- ItemManager(uint seed, ...) refuses seed 0 and seeds Random from it. - RunStart.ItemSeed(galaxy, step) is public (formula unchanged), refuses a zero result; named steps ArenaStep, StageStep, ContinueStep and NoRunSeed. - Continue seeds the item stream from the saved run's seed (ContinueStep); a scene with no galaxy uses NoRunSeed. - Zone's unpack scatter draws SimulationDice.For(CombatSeed, ScatterStream, 1-based pack ordinal), never the item stream. - Loadout's scratch manager and AetherDb's catalog command take fixed seeds; every test and Editor construction gains 1u (mechanical rewrite). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1065 lines
52 KiB
C#
1065 lines
52 KiB
C#
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Security.Cryptography;
|
||
using CultMath;
|
||
using GameCult.Caching;
|
||
using Xunit;
|
||
using static CultMath.math;
|
||
using float2 = CultMath.float2;
|
||
using float3 = CultMath.float3;
|
||
|
||
// Cut 3 (docs/mining-cut-refresh.md): a chunk is a target, detected by reflected light. One detection gain rule
|
||
// (Sensor.Gain) serves both the per-tick entity loop and the on-demand chunk query, and the chunk query returns
|
||
// the value the per-tick rule settles at (Q14 A). A belt's field kind is chosen once and saved (Q16 B).
|
||
public sealed partial class MiningCut3Tests : IDisposable
|
||
{
|
||
private const float Dt = .05f;
|
||
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-miningcut3-" + Guid.NewGuid().ToString("N"));
|
||
private readonly List<CultCache> _caches = new List<CultCache>();
|
||
private int _fixtureCount;
|
||
|
||
public MiningCut3Tests() => Directory.CreateDirectory(_root);
|
||
|
||
public void Dispose()
|
||
{
|
||
foreach (var cache in _caches) cache.Dispose();
|
||
Directory.Delete(_root, true);
|
||
}
|
||
|
||
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
|
||
|
||
private static GameplaySettings GameSettings() => new GameplaySettings
|
||
{
|
||
DefaultEntitySettings = new EntitySettings(),
|
||
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Color = new float3(1, 1, 1) } },
|
||
QualityPriceModifier = new ExponentialLerp(),
|
||
// Settings.asset's own values for the three detection tunables.
|
||
VisibilityDecay = .5f,
|
||
TargetInfoDecay = .5f,
|
||
TargetDetectionInfoThreshold = .1f,
|
||
SchematicCellSize = 2f,
|
||
FiringArc = 90f
|
||
};
|
||
|
||
// Every sun carries a well (the belts circle in it, c = 9), one sun whose light reaches 1000 units, and the chunk
|
||
// curves MiningCut2Tests uses, so sizes and wear vary over the fixture's range.
|
||
private static PlanetSettings PlanetSettings(float lightRadius = 1000f) => new PlanetSettings
|
||
{
|
||
WellStrength = new ExponentialCurve { Constant = 9f },
|
||
RocheLimit = new ExponentialCurve { Constant = 1f },
|
||
AsteroidSize = new ExponentialLerp { Minimum = 2f, Maximum = 9f, Exponent = 1.7f },
|
||
AsteroidHitpoints = new ExponentialLerp { Minimum = 20f, Maximum = 220f, Exponent = 1.6f },
|
||
AsteroidRespawnTime = new ExponentialLerp { Minimum = 8f, Maximum = 96f, Exponent = 1.2f },
|
||
BodyRadius = new ExponentialCurve(),
|
||
LightRadius = new ExponentialCurve { Constant = lightRadius }
|
||
};
|
||
|
||
// A sensitivity that falls off away from the sensor's facing, so the angle term is exercised, not a constant.
|
||
private static BezierCurve Falloff() => new BezierCurve
|
||
{
|
||
Keys = new[] { new float4(0, 1, 0, 0), new float4(1, .3f, 0, 0) }
|
||
};
|
||
|
||
private sealed class Scene
|
||
{
|
||
public CultCache Cache;
|
||
public ItemManager Items;
|
||
public Zone Zone;
|
||
public HullData Hull;
|
||
public GearData Eye;
|
||
public GearData DimEye;
|
||
public ZonePack Pack;
|
||
public List<CultRecordKey> Belts = new List<CultRecordKey>();
|
||
public List<FieldKindData> Kinds = new List<FieldKindData>();
|
||
}
|
||
|
||
// A belt around the sun: its chunks orbit the origin at their own distances, so at zone time 0 a chunk of
|
||
// phase p sits at distance × (cos 2πp, sin 2πp). `kind` null leaves the belt kindless, for the zone to assign.
|
||
private sealed class BeltSpec
|
||
{
|
||
public Asteroid[] Asteroids;
|
||
public FieldKindData Kind;
|
||
}
|
||
|
||
private static Asteroid Rock(float distance, float phase = 0f, float size = .5f) =>
|
||
new Asteroid { Distance = distance, Phase = phase, Size = size, RotationSpeed = .1f };
|
||
|
||
private Scene BuildScene(FieldKindData[] kinds = null, params BeltSpec[] belts) => BuildSceneAt(float2.zero, kinds, belts);
|
||
|
||
// The same scene with its sun, and so every belt centre, at `sunAt`.
|
||
private Scene BuildSceneAt(float2 sunAt, FieldKindData[] kinds, params BeltSpec[] belts) =>
|
||
BuildSceneAt(sunAt, 1000f, 0, null, kinds, belts);
|
||
|
||
// The same, with the suns' light radius, the zone's starting time and more suns (fixed, belt-less) chosen.
|
||
private Scene BuildSceneAt(float2 sunAt, float lightRadius, double time, float2[] moreSuns, FieldKindData[] kinds, params BeltSpec[] belts)
|
||
{
|
||
var dir = Path.Combine(_root, $"scene{_fixtureCount++}");
|
||
Directory.CreateDirectory(dir);
|
||
var cache = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true);
|
||
_caches.Add(cache);
|
||
cache.Upsert(new VerseGrammar { Revision = 1 });
|
||
var hullShape = new Shape(5, 5);
|
||
foreach (var cell in hullShape.AllCoordinates) hullShape[cell] = true;
|
||
var hull = new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = hullShape, Durability = 10, Mass = 1000 };
|
||
cache.Upsert(hull);
|
||
var eye = Gear("Eye", new SensorData
|
||
{
|
||
Sensitivity = Constant(3f),
|
||
SensitivityCurve = Falloff(),
|
||
PingBoost = Constant(.02f),
|
||
PingEnergy = Constant(0f),
|
||
PingRange = Constant(300f),
|
||
PingCooldown = Constant(4f)
|
||
});
|
||
cache.Upsert(eye);
|
||
// A far weaker sensor, for scenes of ships: a hull's own heat makes it hundreds of times brighter than a rock,
|
||
// and the ordinary sensor would saturate on it at once.
|
||
var dimEye = Gear("Dim eye", new SensorData
|
||
{
|
||
Sensitivity = Constant(.01f),
|
||
SensitivityCurve = Falloff(),
|
||
PingBoost = Constant(.02f),
|
||
PingEnergy = Constant(0f),
|
||
PingRange = Constant(300f),
|
||
PingCooldown = Constant(4f)
|
||
});
|
||
cache.Upsert(dimEye);
|
||
foreach (var kind in kinds ?? new FieldKindData[0]) cache.Upsert(kind);
|
||
var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { });
|
||
|
||
var sunOrbit = cache.Upsert(new OrbitData { FixedPosition = sunAt }).Key;
|
||
var sun = cache.Upsert(new SunData { Orbit = new CultRecordRef<OrbitData>(sunOrbit), Mass = 1f }).Key;
|
||
var pack = new ZonePack
|
||
{
|
||
Orbits = { new CultRecordRef<OrbitData>(sunOrbit) },
|
||
Planets = { new CultRecordRef<BodyData>(sun) },
|
||
Radius = 5000f,
|
||
Mass = 10000f,
|
||
Time = time
|
||
};
|
||
foreach (var at in moreSuns ?? new float2[0])
|
||
{
|
||
var orbit = cache.Upsert(new OrbitData { FixedPosition = at }).Key;
|
||
pack.Orbits.Add(new CultRecordRef<OrbitData>(orbit));
|
||
pack.Planets.Add(new CultRecordRef<BodyData>(cache.Upsert(new SunData { Orbit = new CultRecordRef<OrbitData>(orbit), Mass = 1f }).Key));
|
||
}
|
||
var scene = new Scene { Cache = cache, Items = items, Hull = hull, Eye = eye, DimEye = dimEye, Pack = pack };
|
||
scene.Kinds.AddRange(kinds ?? new FieldKindData[0]);
|
||
foreach (var spec in belts)
|
||
{
|
||
var beltOrbit = cache.Upsert(new OrbitData { Parent = new CultRecordRef<OrbitData>(sunOrbit), Distance = 150f }).Key;
|
||
var belt = new AsteroidBeltData { Orbit = new CultRecordRef<OrbitData>(beltOrbit), Asteroids = spec.Asteroids };
|
||
if (spec.Kind != null) belt.Kind = cache.RefOf(spec.Kind);
|
||
var key = cache.Upsert(belt).Key;
|
||
pack.Orbits.Add(new CultRecordRef<OrbitData>(beltOrbit));
|
||
pack.Planets.Add(new CultRecordRef<BodyData>(key));
|
||
scene.Belts.Add(key);
|
||
}
|
||
scene.Zone = new Zone(items, PlanetSettings(lightRadius), pack, new GalaxyZone { Name = "MiningCut3", Owner = null }, null);
|
||
return scene;
|
||
}
|
||
|
||
private static FieldKindData Kind(string name, float crossSection, float weight = 1f) =>
|
||
new FieldKindData { Name = name, CrossSection = crossSection, GenerationWeight = weight };
|
||
|
||
private static BeltSpec Belt(FieldKindData kind, params Asteroid[] asteroids) => new BeltSpec { Kind = kind, Asteroids = asteroids };
|
||
|
||
// A chunk's planar position now (zone time never advances in these tests).
|
||
private static float2 At(Scene s, ChunkId chunk) => s.Zone.ChunkPose(chunk.Field, chunk.Index).xy;
|
||
|
||
// The rule as the map states it: kind cross-section × area in schematic cells (cell 2) × the light on it.
|
||
private static float ExpectedVisibility(Scene s, ChunkId chunk, FieldKindData kind)
|
||
{
|
||
var cells = s.Zone.ChunkRadius(chunk) / 2f;
|
||
return kind.CrossSection * PI * cells * cells * s.Zone.GetLight(At(s, chunk));
|
||
}
|
||
|
||
private static GearData Gear(string name, BehaviorData behavior) => new GearData
|
||
{
|
||
Name = name, Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1,
|
||
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
||
Behaviors = { behavior }
|
||
};
|
||
|
||
private static EquippableItem Mint(Scene s, ItemData design) => new EquippableItem
|
||
{
|
||
Data = s.Cache.RefOf<ItemData>(design),
|
||
Durability = design is EquippableItemData equippable ? equippable.Durability : 0,
|
||
Lot = s.Items.Lots.Add(new Lot { Design = s.Cache.RefOf<ItemData>(design), Origin = new Attributed(), Quality = .5f, Roles = new List<RoleFill>() })
|
||
};
|
||
|
||
// A ship held at a planar position, facing +z. `sensor` fits the scene's sensor; `crossSection` fits a
|
||
// reflector of that cross-section (its own design, so ships in one scene can differ); each of `weaponRanges`
|
||
// fits a gun of that range.
|
||
private Ship SpawnShip(Scene s, float2 at, bool sensor = false, float crossSection = 0f, bool secondSensor = false, bool dimSensor = false, params float[] weaponRanges)
|
||
{
|
||
var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings());
|
||
if (sensor) Assert.True(ship.TryEquip(Mint(s, s.Eye)));
|
||
if (secondSensor) Assert.True(ship.TryEquip(Mint(s, s.Eye)));
|
||
if (dimSensor) Assert.True(ship.TryEquip(Mint(s, s.DimEye)));
|
||
if (crossSection > 0f)
|
||
{
|
||
var mirror = Gear($"Mirror {crossSection}", new ReflectorData { CrossSection = Constant(crossSection) });
|
||
s.Cache.Upsert(mirror);
|
||
Assert.True(ship.TryEquip(Mint(s, mirror)));
|
||
}
|
||
foreach (var range in weaponRanges)
|
||
{
|
||
var gun = Gear($"Gun {range}", new InstantWeaponData { Range = Constant(range) });
|
||
s.Cache.Upsert(gun);
|
||
Assert.True(ship.TryEquip(Mint(s, gun)));
|
||
}
|
||
s.Zone.Admit(ship, piloted: false);
|
||
Hold(ship, at);
|
||
ship.Aim = float3(0, 0, 1);
|
||
return ship;
|
||
}
|
||
|
||
private static void Hold(Entity entity, float2 at)
|
||
{
|
||
entity.Position = float3(at.x, entity.Position.y, at.y);
|
||
entity.Velocity = float2.zero;
|
||
entity.Direction = float2(0, 1);
|
||
}
|
||
|
||
// One simulation tick for the given entities, each held in place, in order. Zone time does not advance, so
|
||
// chunk poses and sun positions stay where they are.
|
||
private static void Tick(params (Entity entity, float2 at)[] held)
|
||
{
|
||
foreach (var (entity, at) in held)
|
||
{
|
||
Hold(entity, at);
|
||
entity.Update(Dt);
|
||
}
|
||
}
|
||
|
||
private static uint Bits(float value) => BitConverter.ToUInt32(BitConverter.GetBytes(value), 0);
|
||
|
||
// The per-tick entity detection loop is unchanged by the extraction of its gain rule: a fixed scene (two ships
|
||
// at different ranges and bearings, seen by a weak sensor so neither saturates, a ping part way through)
|
||
// yields the same EntityInfoGathered trace, bit for bit, as it did before Sensor.Gain existed. The golden hash
|
||
// was recorded against the pre-extraction Sensor.Execute (cb4a1dbd with this scene patched in).
|
||
private const string Golden = "919B7EE7C5D1B40F774EE34283D62DE016862FE799B3EF16362D7FC75FD73C79";
|
||
|
||
[Fact]
|
||
public void SensorGainExtractionChangesNothing()
|
||
{
|
||
var s = BuildScene();
|
||
var eyeAt = float2(0, 50);
|
||
var nearAt = float2(30, 120);
|
||
var farAt = float2(-150, 90);
|
||
var observer = SpawnShip(s, eyeAt, dimSensor: true);
|
||
var near = SpawnShip(s, nearAt, crossSection: .2f);
|
||
var far = SpawnShip(s, farAt, crossSection: 9f);
|
||
|
||
var trace = new List<byte>();
|
||
var nearTrace = new List<float>();
|
||
for (var tick = 0; tick < 60; tick++)
|
||
{
|
||
if (tick == 5) observer.Sensor.Ping();
|
||
Tick((near, nearAt), (far, farAt), (observer, eyeAt));
|
||
trace.AddRange(BitConverter.GetBytes(Bits(observer.EntityInfoGathered[near])));
|
||
trace.AddRange(BitConverter.GetBytes(Bits(observer.EntityInfoGathered[far])));
|
||
nearTrace.Add(observer.EntityInfoGathered[near]);
|
||
}
|
||
|
||
// Not a degenerate scene: both targets settle well short of saturation, and the ping visibly lifts the
|
||
// near one above where it settles, so the trace carries the passive rule, the bearing and the ping.
|
||
Assert.InRange(observer.EntityInfoGathered[near], .05f, .8f);
|
||
Assert.InRange(observer.EntityInfoGathered[far], .05f, .8f);
|
||
Assert.True(nearTrace.Max() > observer.EntityInfoGathered[near] + .1f, "the ping left no mark on the trace");
|
||
var hash = Convert.ToHexString(SHA256.HashData(trace.ToArray()));
|
||
Assert.True(hash == Golden, $"the EntityInfoGathered trace changed: hash {hash}");
|
||
}
|
||
|
||
// Q14 A's parity: one rule, two integrators. A ship carrying a reflector of the chunk's own cross-section
|
||
// (the kind's per-cell reflectivity × the chunk's area in cells), held still where the chunk is, settles under
|
||
// the per-tick loop at the value ChunkInfo returns on demand. The discrete loop's fixed point sits one decay
|
||
// step below the continuous one, exactly: x* (1 - k dt). The observer is off the sensor's axis, so the
|
||
// bearing term is live.
|
||
[Fact]
|
||
public void ChunkInfoMatchesAnEntityHeldStill()
|
||
{
|
||
var kind = Kind("Asteroid", 1f);
|
||
var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f)));
|
||
var chunk = new ChunkId(s.Belts[0], 0);
|
||
var chunkAt = At(s, chunk);
|
||
var eyeAt = float2(50, -150);
|
||
var observer = SpawnShip(s, eyeAt, sensor: true);
|
||
var cells = s.Zone.ChunkRadius(chunk) / 2f;
|
||
var rock = SpawnShip(s, chunkAt, crossSection: kind.CrossSection * PI * cells * cells);
|
||
|
||
for (var tick = 0; tick < 600; tick++) Tick((rock, chunkAt), (observer, eyeAt));
|
||
|
||
var settled = observer.ChunkInfo(chunk);
|
||
var perTick = observer.EntityInfoGathered[rock];
|
||
Assert.InRange(settled, .12f, .9f); // visible, unsaturated: the balance, not the cap, decides it
|
||
var discrete = settled * (1f - GameSettings().TargetInfoDecay * Dt);
|
||
Assert.True(abs(perTick - discrete) <= 1e-3f * settled,
|
||
$"per-tick loop settled at {perTick}, the on-demand value implies {discrete} (settled {settled})");
|
||
}
|
||
|
||
// ChunkInfo's value is not changed by moving its arithmetic under PassiveInfo: six observers at different
|
||
// places and bearings, one with a second sensor so the average is live, read the same chunk, and every value
|
||
// matches the bits recorded at base 12dfcef5, before the extraction (a different order of the multiplication
|
||
// by the chunk's brightness moves them in the last bits).
|
||
private static readonly uint[] ChunkInfoAtBase = new uint[] { 1045120030u, 1039274086u, 1030543422u, 1029120970u, 1036363351u, 1035122917u };
|
||
|
||
[Fact]
|
||
public void ChunkInfoIsBitIdenticalToItsValueBeforeTheExtraction()
|
||
{
|
||
var kind = Kind("Asteroid", 1f);
|
||
var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f)));
|
||
var chunk = new ChunkId(s.Belts[0], 0);
|
||
var places = new[] { float2(50, -150), float2(-80, -60), float2(10, 200), float2(-300, 40), float2(120, 120), float2(0, -400) };
|
||
var values = new List<float>();
|
||
for (var i = 0; i < places.Length; i++)
|
||
{
|
||
var observer = SpawnShip(s, places[i], sensor: true, secondSensor: i == 2);
|
||
Tick((observer, places[i])); // brings the sensors online
|
||
values.Add(observer.ChunkInfo(chunk));
|
||
}
|
||
|
||
Assert.All(values, v => Assert.InRange(v, 1e-4f, .99f)); // a live, unsaturated reading at every place
|
||
var actual = values.Select(Bits).ToArray();
|
||
Assert.True(actual.SequenceEqual(ChunkInfoAtBase), "ChunkInfo bits: " + string.Join(",", actual));
|
||
}
|
||
|
||
// A chunk outside every sun's light is dark: no visibility, no info, and it cannot be picked, while a lit
|
||
// chunk of the same belt and kind can.
|
||
[Fact]
|
||
public void AChunkInDarknessIsNotVisible()
|
||
{
|
||
var kind = Kind("Asteroid", 4f);
|
||
var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f), Rock(700f, .5f)));
|
||
var lit = new ChunkId(s.Belts[0], 0);
|
||
var dark = new ChunkId(s.Belts[0], 1);
|
||
Assert.Equal(0f, s.Zone.GetLight(At(s, dark)));
|
||
|
||
var inTheDark = SpawnShip(s, At(s, dark) + float2(0, -30), sensor: true, weaponRanges: 300f);
|
||
Tick((inTheDark, At(s, dark) + float2(0, -30)));
|
||
Assert.Equal(0f, s.Zone.ChunkVisibility(dark));
|
||
Assert.Equal(0f, inTheDark.ChunkInfo(dark));
|
||
Assert.False(inTheDark.SetTarget(dark));
|
||
Assert.True(inTheDark.Target.Value.IsNone);
|
||
var reachable = new List<ChunkId>();
|
||
inTheDark.VisibleChunksInReach(reachable);
|
||
Assert.DoesNotContain(dark, reachable);
|
||
|
||
var inTheLight = SpawnShip(s, At(s, lit) + float2(0, -30), sensor: true);
|
||
Tick((inTheLight, At(s, lit) + float2(0, -30))); // equipment comes online on its first tick
|
||
Assert.True(inTheLight.SetTarget(lit));
|
||
Assert.True(inTheLight.Target.Value.Equals(new TargetRef(lit)));
|
||
}
|
||
|
||
// Visibility is the kind's reflectivity × the chunk's area in cells × the light on it: linear in
|
||
// cross-section, quadratic in radius, linear in light. Info follows visibility, so a brighter chunk is seen
|
||
// further off.
|
||
[Fact]
|
||
public void ABrighterOrBiggerChunkIsSeenFurther()
|
||
{
|
||
var dim = Kind("Dim", 1f);
|
||
var bright = Kind("Bright", 2f);
|
||
var s = BuildScene(new[] { dim, bright },
|
||
Belt(dim, Rock(150f, 0f, .2f), Rock(150f, .25f, .8f), Rock(250f, .5f, .2f)),
|
||
Belt(bright, Rock(150f, 0f, .2f)));
|
||
var small = new ChunkId(s.Belts[0], 0);
|
||
var big = new ChunkId(s.Belts[0], 1);
|
||
var far = new ChunkId(s.Belts[0], 2);
|
||
var brightTwin = new ChunkId(s.Belts[1], 0);
|
||
|
||
foreach (var (chunk, kind) in new[] { (small, dim), (big, dim), (far, dim), (brightTwin, bright) })
|
||
Assert.Equal(ExpectedVisibility(s, chunk, kind), s.Zone.ChunkVisibility(chunk), 4);
|
||
|
||
var vSmall = s.Zone.ChunkVisibility(small);
|
||
Assert.Equal(2f, s.Zone.ChunkVisibility(brightTwin) / vSmall, 4);
|
||
var radiusRatio = s.Zone.ChunkRadius(big) / s.Zone.ChunkRadius(small);
|
||
Assert.True(radiusRatio > 1.5f);
|
||
Assert.Equal(radiusRatio * radiusRatio, s.Zone.ChunkVisibility(big) / vSmall, 3);
|
||
var lightRatio = s.Zone.GetLight(At(s, far)) / s.Zone.GetLight(At(s, small));
|
||
Assert.InRange(lightRatio, .01f, .9f);
|
||
Assert.Equal(lightRatio, s.Zone.ChunkVisibility(far) / vSmall, 3);
|
||
|
||
// Seen further: from 180 away the dim twin is below the detection threshold and the bright one above it.
|
||
var eyeAt = At(s, small) + float2(0, -180);
|
||
var observer = SpawnShip(s, eyeAt, sensor: true);
|
||
Tick((observer, eyeAt));
|
||
Assert.Equal(2f, observer.ChunkInfo(brightTwin) / observer.ChunkInfo(small), 3);
|
||
Assert.False(observer.ChunkVisible(small));
|
||
Assert.True(observer.ChunkVisible(brightTwin));
|
||
}
|
||
|
||
// A worn chunk shrinks, so it dims by exactly its smaller area; a broken chunk has no size and is dark.
|
||
[Fact]
|
||
public void AWornChunkDims()
|
||
{
|
||
var kind = Kind("Asteroid", 4f);
|
||
var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f)));
|
||
var chunk = new ChunkId(s.Belts[0], 0);
|
||
var whole = s.Zone.ChunkVisibility(chunk);
|
||
var hitpoints = PlanetSettings().AsteroidHitpoints.Evaluate(.5f);
|
||
|
||
Assert.False(s.Zone.Wear(chunk, hitpoints * .5f));
|
||
var worn = s.Zone.ChunkVisibility(chunk);
|
||
Assert.InRange(worn, whole * .05f, whole * .95f);
|
||
Assert.Equal(ExpectedVisibility(s, chunk, kind), worn, 4);
|
||
}
|
||
|
||
[Fact]
|
||
public void ABrokenChunkIsInvisible()
|
||
{
|
||
var kind = Kind("Asteroid", 4f);
|
||
var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f)));
|
||
var chunk = new ChunkId(s.Belts[0], 0);
|
||
var eyeAt = At(s, chunk) + float2(0, -100);
|
||
var observer = SpawnShip(s, eyeAt, sensor: true);
|
||
Tick((observer, eyeAt));
|
||
Assert.True(observer.ChunkVisible(chunk));
|
||
|
||
Assert.True(s.Zone.Wear(chunk, PlanetSettings().AsteroidHitpoints.Evaluate(.5f) * 2f));
|
||
Assert.Equal(0f, s.Zone.ChunkVisibility(chunk));
|
||
Assert.Equal(0f, observer.ChunkInfo(chunk));
|
||
Assert.False(observer.SetTarget(chunk));
|
||
}
|
||
|
||
// Q13 A: the reach for offering a chunk is the longest range among the entity's ACTIVE weapons. Two visible
|
||
// chunks, 200 and about 300 away; guns of 250, 120 and 350 fitted in that order, the last switched off -- so the
|
||
// last active gun is not the longest one.
|
||
[Fact]
|
||
public void CyclingReachIsTheLongestActiveWeaponRange()
|
||
{
|
||
var kind = Kind("Asteroid", 4f);
|
||
var s = BuildScene(new[] { kind },
|
||
Belt(kind, Rock(150f), Rock(length(float2(150, 100)), atan2(100f, 150f) / (2 * PI))));
|
||
var near = new ChunkId(s.Belts[0], 0);
|
||
var farther = new ChunkId(s.Belts[0], 1);
|
||
var eyeAt = float2(150, -200);
|
||
var observer = SpawnShip(s, eyeAt, sensor: true, weaponRanges: new[] { 250f, 120f, 350f });
|
||
Tick((observer, eyeAt)); // weapons read their ranges when they run
|
||
Assert.Equal(200f, length(At(s, near) - eyeAt), 2);
|
||
Assert.Equal(300f, length(At(s, farther) - eyeAt), 2);
|
||
Assert.True(observer.ChunkVisible(near));
|
||
Assert.True(observer.ChunkVisible(farther));
|
||
|
||
EquippedItem Gun(float range) => observer.Equipment.Single(item => item.Data.Name == $"Gun {range}");
|
||
var offered = new List<ChunkId>();
|
||
|
||
Gun(350f).Enabled.Value = false;
|
||
observer.VisibleChunksInReach(offered);
|
||
Assert.Equal(new[] { near }, offered);
|
||
|
||
Gun(250f).Enabled.Value = false;
|
||
observer.VisibleChunksInReach(offered);
|
||
Assert.Empty(offered);
|
||
|
||
Gun(350f).Enabled.Value = true;
|
||
observer.VisibleChunksInReach(offered);
|
||
Assert.Equal(2, offered.Count);
|
||
Assert.Contains(near, offered);
|
||
Assert.Contains(farther, offered);
|
||
}
|
||
|
||
// The chunk query returns exactly the chunks within range, from inside the belt's hole, from within the
|
||
// annulus and from outside it -- the belt skip never drops a chunk the brute-force answer would keep -- and it
|
||
// leaves out a broken chunk.
|
||
[Fact]
|
||
public void TheChunkQueryFindsExactlyTheChunksInRange()
|
||
{
|
||
var kind = Kind("Asteroid", 4f);
|
||
var rocks = Enumerable.Range(0, 12).Select(i => Rock(200f + i * 15f, i / 12f)).ToArray();
|
||
var centre = float2(400, -250); // off the origin, so a query that ignored the belt centre would disagree
|
||
var s = BuildSceneAt(centre, new[] { kind }, Belt(kind, rocks));
|
||
var belt = s.Belts[0];
|
||
Assert.True(s.Zone.Wear(new ChunkId(belt, 5), 1e6f)); // broken
|
||
|
||
var found = new List<ChunkId>();
|
||
var sawSome = false;
|
||
foreach (var (from, range) in new[]
|
||
{
|
||
(centre + float2(0, 0), 150f), (centre + float2(0, 0), 230f), (centre + float2(0, 0), 400f),
|
||
(centre + float2(220, 0), 40f), (centre + float2(-300, 50), 120f), (centre + float2(600, 0), 360f), (centre + float2(600, 0), 200f)
|
||
})
|
||
{
|
||
ChunksWithin(s, from, range, found);
|
||
var expected = Enumerable.Range(0, rocks.Length)
|
||
.Where(i => i != 5 && length(At(s, new ChunkId(belt, i)) - from) <= range)
|
||
.Select(i => new ChunkId(belt, i)).ToArray();
|
||
Assert.Equal(expected.ToHashSet(), found.ToHashSet());
|
||
Assert.Equal(expected.Length, found.Count);
|
||
sawSome |= expected.Length > 0;
|
||
}
|
||
Assert.True(sawSome);
|
||
}
|
||
|
||
// The negative for Activate's "still tracked" reconciliation (Entity.Activate) and the per-tick lost-track
|
||
// check: a visible chunk target survives ticks and a dock/undock, and reads its range through the chunk.
|
||
[Fact]
|
||
public void AChunkTargetSurvivesTheInfoPassWhileSeen()
|
||
{
|
||
var kind = Kind("Asteroid", 4f);
|
||
var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f)));
|
||
var chunk = new ChunkId(s.Belts[0], 0);
|
||
var eyeAt = At(s, chunk) + float2(30, -100);
|
||
var observer = SpawnShip(s, eyeAt, sensor: true);
|
||
Tick((observer, eyeAt));
|
||
Assert.True(observer.SetTarget(chunk));
|
||
|
||
for (var tick = 0; tick < 5; tick++) Tick((observer, eyeAt));
|
||
Assert.True(observer.Target.Value.Equals(new TargetRef(chunk)));
|
||
Assert.Equal(length(float2(30, -100)), observer.TargetRange, 3);
|
||
|
||
observer.Deactivate();
|
||
observer.Activate();
|
||
Assert.True(observer.Target.Value.Equals(new TargetRef(chunk)));
|
||
}
|
||
|
||
// The lost-track rule through the chunk path: a chunk target is dropped once this entity cannot see it --
|
||
// on the next tick, or at Activate when it went dark while docked.
|
||
[Fact]
|
||
public void AChunkTargetIsDroppedWhenItGoesDark()
|
||
{
|
||
var kind = Kind("Asteroid", 4f);
|
||
var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f), Rock(150f, .25f), Rock(150f, .5f)));
|
||
var eyeAt = float2(0, 0);
|
||
|
||
// Its own sensor goes off: nothing sees the chunk, and the next tick drops it.
|
||
var blinded = SpawnShip(s, eyeAt, sensor: true);
|
||
Tick((blinded, eyeAt));
|
||
var first = new ChunkId(s.Belts[0], 0);
|
||
Assert.True(blinded.SetTarget(first));
|
||
blinded.Equipment.Single(item => item.Data.Name == "Eye").Enabled.Value = false;
|
||
Tick((blinded, eyeAt));
|
||
Assert.True(blinded.Target.Value.IsNone);
|
||
|
||
// The chunk breaks: it is dark to everyone, and the next tick drops it.
|
||
var watcher = SpawnShip(s, eyeAt, sensor: true);
|
||
Tick((watcher, eyeAt));
|
||
var second = new ChunkId(s.Belts[0], 1);
|
||
Assert.True(watcher.SetTarget(second));
|
||
Tick((watcher, eyeAt));
|
||
Assert.False(watcher.Target.Value.IsNone);
|
||
Assert.True(s.Zone.Wear(second, 1e6f));
|
||
Tick((watcher, eyeAt));
|
||
Assert.True(watcher.Target.Value.IsNone);
|
||
|
||
// It breaks while the holder is docked: Activate drops it before any tick runs.
|
||
var docked = SpawnShip(s, eyeAt, sensor: true);
|
||
Tick((docked, eyeAt));
|
||
var third = new ChunkId(s.Belts[0], 2);
|
||
Assert.True(docked.SetTarget(third));
|
||
docked.Deactivate();
|
||
Assert.True(s.Zone.Wear(third, 1e6f));
|
||
docked.Activate();
|
||
Assert.True(docked.Target.Value.IsNone);
|
||
}
|
||
|
||
// One slot, two kinds, value equality: the same chunk is the same target, a chunk is never an entity, and an
|
||
// empty target is None whichever way it was made.
|
||
[Fact]
|
||
public void TargetRefEquality()
|
||
{
|
||
var s = BuildScene();
|
||
var ship = SpawnShip(s, float2(0, 0));
|
||
var other = SpawnShip(s, float2(10, 0));
|
||
var field = new CultRecordKey("field");
|
||
var chunk = new ChunkId(field, 3);
|
||
|
||
Assert.True(new TargetRef(chunk).Equals(new TargetRef(new ChunkId(new CultRecordKey("field"), 3))));
|
||
Assert.Equal(new TargetRef(chunk).GetHashCode(), new TargetRef(new ChunkId(new CultRecordKey("field"), 3)).GetHashCode());
|
||
Assert.False(new TargetRef(chunk).Equals(new TargetRef(new ChunkId(field, 4))));
|
||
Assert.False(new TargetRef(chunk).Equals(new TargetRef(new ChunkId(new CultRecordKey("other"), 3))));
|
||
|
||
Assert.True(new TargetRef(ship).Equals(new TargetRef(ship)));
|
||
Assert.False(new TargetRef(ship).Equals(new TargetRef(other)));
|
||
Assert.False(new TargetRef(ship).Equals(new TargetRef(chunk)));
|
||
Assert.False(new TargetRef(chunk).Equals(new TargetRef(ship)));
|
||
Assert.False(new TargetRef(ship).Equals(TargetRef.None));
|
||
|
||
Assert.True(TargetRef.None.IsNone);
|
||
Assert.True(new TargetRef((Entity) null).IsNone);
|
||
Assert.True(new TargetRef((Entity) null).Equals(TargetRef.None));
|
||
Assert.False(new TargetRef(default(ChunkId)).IsNone);
|
||
Assert.False(new TargetRef(default(ChunkId)).Equals(TargetRef.None));
|
||
Assert.False(new TargetRef(ship).IsNone);
|
||
Assert.True(((object) new TargetRef(chunk)).Equals(new TargetRef(chunk)));
|
||
Assert.Equal(new TargetRef(ship).GetHashCode(), new TargetRef(ship).GetHashCode());
|
||
Assert.Equal(TargetRef.None.GetHashCode(), new TargetRef((Entity) null).GetHashCode());
|
||
}
|
||
|
||
// Q12 A: a launcher never locks a chunk. The same lock weapon, geometry and detection build lock on a
|
||
// hostile ship; switched to a chunk, the lock drops to zero and stays there.
|
||
[Fact]
|
||
public void ChunkTargetIsNeverLocked()
|
||
{
|
||
var kind = Kind("Asteroid", 4f);
|
||
var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f)));
|
||
var chunk = new ChunkId(s.Belts[0], 0);
|
||
var eyeAt = At(s, chunk) + float2(0, -100);
|
||
var shooter = SpawnShip(s, eyeAt, sensor: true, weaponRanges: 300f);
|
||
Tick((shooter, eyeAt));
|
||
var enemy = SpawnShip(s, eyeAt + float2(0, 50));
|
||
shooter.SetIff(enemy, true);
|
||
shooter.EntityInfoGathered[enemy] = 1;
|
||
shooter.Aim = float3(0, 0, 1);
|
||
var launcher = new LockWeapon(new LockWeaponData
|
||
{
|
||
LockSpeed = Constant(.5f),
|
||
LockAngle = Constant(180f),
|
||
DirectionImpact = Constant(1f)
|
||
}, shooter.Equipment.Single(item => item.Behaviors.Any(b => b is Weapon)));
|
||
|
||
Assert.True(shooter.SetTarget(enemy));
|
||
launcher.Execute(1f);
|
||
Assert.True(launcher.Lock > 0f, "positive control: the launcher locks a hostile ship");
|
||
|
||
Assert.True(shooter.SetTarget(chunk));
|
||
launcher.Execute(1f);
|
||
Assert.Equal(0f, launcher.Lock);
|
||
launcher.Execute(1f);
|
||
Assert.Equal(0f, launcher.Lock);
|
||
}
|
||
|
||
// Q16 B, the pick itself: a weighted choice by the belt's key -- the same key always gives the same kind, a
|
||
// kind of weight zero is never chosen, and the others come up in proportion to their weights.
|
||
[Fact]
|
||
public void FieldKindAssignmentIsAWeightedPickByKey()
|
||
{
|
||
var light = Kind("Light", 1f, 1f);
|
||
var never = Kind("Never", 1f, 0f);
|
||
var middle = Kind("Middle", 1f, 2f);
|
||
var heavy = Kind("Heavy", 1f, 3f);
|
||
var s = BuildScene(new[] { light, never, middle, heavy });
|
||
var counts = new Dictionary<string, int> { ["Light"] = 0, ["Never"] = 0, ["Middle"] = 0, ["Heavy"] = 0 };
|
||
for (var i = 0; i < 2000; i++)
|
||
{
|
||
var key = new CultRecordKey($"belt-{i}");
|
||
var picked = FieldKinds.Assign(key, s.Cache);
|
||
Assert.True(picked.Key.Equals(FieldKinds.Assign(key, s.Cache).Key));
|
||
counts[s.Cache.Get(picked).Name]++;
|
||
}
|
||
Assert.Equal(0, counts["Never"]);
|
||
Assert.InRange(counts["Light"] / 2000f, 1f / 6 - .04f, 1f / 6 + .04f);
|
||
Assert.InRange(counts["Middle"] / 2000f, 2f / 6 - .04f, 2f / 6 + .04f);
|
||
Assert.InRange(counts["Heavy"] / 2000f, 3f / 6 - .04f, 3f / 6 + .04f);
|
||
|
||
var empty = BuildScene(new[] { never });
|
||
Assert.False(FieldKinds.Assign(new CultRecordKey("belt-0"), empty.Cache).IsSet());
|
||
}
|
||
|
||
// Q16 B, first load: a belt saved before field kinds existed gets its kind when a zone first loads it, by the
|
||
// same function generation uses, and the assignment is saved -- so a later catalog edit that would pick a
|
||
// different kind for that belt changes nothing about it.
|
||
[Fact]
|
||
public void FieldKindAssignmentIsOneFunction()
|
||
{
|
||
var a = Kind("A", 1f, 1f);
|
||
var b = Kind("B", 2f, 1f);
|
||
var s = BuildScene(new[] { a, b }, Belt(null, Rock(150f)));
|
||
var beltKey = s.Belts[0];
|
||
var belt = (AsteroidBeltData) s.Zone.Planets[beltKey];
|
||
Assert.True(belt.Kind.IsSet());
|
||
Assert.True(belt.Kind.Key.Equals(FieldKinds.Assign(beltKey, s.Cache).Key));
|
||
var assigned = belt.Kind.Key;
|
||
|
||
// Saved: a fresh open of the same stores reads the kind back.
|
||
s.Cache.FlushAsync().Wait();
|
||
s.Cache.Dispose();
|
||
_caches.Remove(s.Cache);
|
||
var dir = Path.Combine(_root, $"scene{_fixtureCount - 1}");
|
||
var reopened = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true);
|
||
_caches.Add(reopened);
|
||
Assert.True(((AsteroidBeltData) reopened.Get(beltKey)).Kind.Key.Equals(assigned));
|
||
|
||
// Kept: a new, overwhelmingly weighted kind now wins the pick for this belt, yet loading it again leaves it.
|
||
reopened.Upsert(Kind("Overwhelming", 9f, 1e6f));
|
||
Assert.False(FieldKinds.Assign(beltKey, reopened).Key.Equals(assigned));
|
||
var items = new ItemManager(1u, reopened, new ProvenanceLedger(), GameSettings(), _ => { });
|
||
var reloaded = new Zone(items, PlanetSettings(), s.Pack, new GalaxyZone { Name = "MiningCut3Reload", Owner = null }, null);
|
||
Assert.True(((AsteroidBeltData) reloaded.Planets[beltKey]).Kind.Key.Equals(assigned));
|
||
}
|
||
|
||
// Q14 A with two sensors: each runs the per-tick rule on the same info, so each adds its gain and each applies
|
||
// the decay. Two identical sensors settle exactly where one does -- the on-demand value divides the summed rate
|
||
// by the number of sensors as well as by the decay.
|
||
[Fact]
|
||
public void ChunkInfoMatchesAnEntityHeldStillUnderTwoSensors()
|
||
{
|
||
var kind = Kind("Asteroid", 1f);
|
||
var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f)));
|
||
var chunk = new ChunkId(s.Belts[0], 0);
|
||
var chunkAt = At(s, chunk);
|
||
var eyeAt = float2(50, -150);
|
||
var observer = SpawnShip(s, eyeAt, sensor: true, secondSensor: true);
|
||
var cells = s.Zone.ChunkRadius(chunk) / 2f;
|
||
var rock = SpawnShip(s, chunkAt, crossSection: kind.CrossSection * PI * cells * cells);
|
||
|
||
for (var tick = 0; tick < 600; tick++) Tick((rock, chunkAt), (observer, eyeAt));
|
||
|
||
var settled = observer.ChunkInfo(chunk);
|
||
var perTick = observer.EntityInfoGathered[rock];
|
||
Assert.InRange(settled, .12f, .9f);
|
||
var discrete = settled * (1f - GameSettings().TargetInfoDecay * Dt);
|
||
Assert.True(abs(perTick - discrete) <= 2e-3f * settled,
|
||
$"per-tick loop settled at {perTick}, the on-demand value implies {discrete} (settled {settled})");
|
||
}
|
||
|
||
// The per-tick rule saturates info at 1, so the settled value is capped there too: a bright chunk close by is
|
||
// fully known, not more than fully.
|
||
[Fact]
|
||
public void ChunkInfoIsCappedAtOne()
|
||
{
|
||
var kind = Kind("Asteroid", 4f);
|
||
var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f)));
|
||
var chunk = new ChunkId(s.Belts[0], 0);
|
||
var eyeAt = At(s, chunk) + float2(0, -30);
|
||
var observer = SpawnShip(s, eyeAt, sensor: true);
|
||
Tick((observer, eyeAt));
|
||
Assert.Equal(1f, observer.ChunkInfo(chunk));
|
||
}
|
||
|
||
// A chunk target bears through its own planar position: the arc gate passes a chunk ahead and refuses one off
|
||
// to the side (a 90-degree arc facing +z), and a chunk has no stance to safe a weapon against.
|
||
[Fact]
|
||
public void AChunkTargetBearsThroughItsOwnPosition()
|
||
{
|
||
var kind = Kind("Asteroid", 4f);
|
||
var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f, .25f), Rock(150f)));
|
||
var ahead = new ChunkId(s.Belts[0], 0); // (0, 150)
|
||
var aside = new ChunkId(s.Belts[0], 1); // (150, 0)
|
||
var eyeAt = float2(-10, 50); // ahead is at (10, 100) from here, aside at (160, -50)
|
||
var shooter = SpawnShip(s, eyeAt, sensor: true, weaponRanges: 300f);
|
||
Tick((shooter, eyeAt));
|
||
var gun = shooter.Weapons.Single();
|
||
|
||
shooter.Aim = float3(0, 0, -1); // astern: only a solution can fire the gun
|
||
Assert.True(shooter.SetTarget(ahead));
|
||
Assert.True(gun.ArcAllowsFire);
|
||
Assert.True(gun.StanceAllowsFire);
|
||
|
||
Assert.True(shooter.SetTarget(aside));
|
||
Assert.False(gun.ArcAllowsFire);
|
||
Assert.True(gun.StanceAllowsFire);
|
||
}
|
||
|
||
// The same bearing through the solution: a chunk ahead is a solution, one to the side is not, and a chunk is a
|
||
// solution without a subject entity (its round flies the aim, as an untargeted one).
|
||
[Fact]
|
||
public void AChunkTargetBearsByItsPose()
|
||
{
|
||
var kind = Kind("Asteroid", 4f);
|
||
var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f, .25f), Rock(150f)));
|
||
var eyeAt = float2(-10, 50);
|
||
var shooter = SpawnShip(s, eyeAt, sensor: true, weaponRanges: 300f);
|
||
Tick((shooter, eyeAt));
|
||
var gun = shooter.Weapons.Single();
|
||
|
||
var ahead = FireControl.Solution(gun, shooter, new ChunkId(s.Belts[0], 0));
|
||
var aside = FireControl.Solution(gun, shooter, new ChunkId(s.Belts[0], 1));
|
||
|
||
Assert.True(ahead.Bears);
|
||
Assert.Null(ahead.Subject);
|
||
Assert.False(aside.Bears);
|
||
|
||
// The bearing is the chunk's pose minus the shooter's position: from (100, -100) the side chunk at (150, 0)
|
||
// is 26 degrees off the nose, though its pose sum with the shooter's position would be far abeam.
|
||
var nearAt = float2(100, -100);
|
||
var near = SpawnShip(s, nearAt, sensor: true, weaponRanges: 300f);
|
||
Tick((shooter, eyeAt), (near, nearAt));
|
||
Assert.True(FireControl.Solution(near.Weapons.Single(), near, new ChunkId(s.Belts[0], 1)).Bears);
|
||
}
|
||
|
||
// A writable copy of the shipped catalog plus a scratch run store, through a registry scoped to the shipped
|
||
// assembly's own document types (RestoredHullsTests' composition): this test assembly's own catalog global
|
||
// would otherwise be demanded of a catalog that was never authored with it.
|
||
private static CultCache OpenShippedCatalogCopy(string catalogPath, string runPath)
|
||
{
|
||
var registry = TestCatalog.Registry();
|
||
var cache = new CultCache(registry);
|
||
cache.AddBackingStore(new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(catalogPath, false), AetheriaStores.CatalogTypes);
|
||
cache.AddBackingStore(new GameCult.Caching.MessagePack.SingleFileMessagePackBackingStore(runPath), AetheriaStores.RunTypes);
|
||
return cache;
|
||
}
|
||
|
||
// Q16 B, generation: every belt ZoneGenerator makes carries its kind already, chosen by FieldKinds.Assign on
|
||
// the belt's own key. Runs against a scratch copy of the shipped catalog with three kinds of different
|
||
// weights, so a pick by any other rule would disagree on some belt.
|
||
[Fact]
|
||
public void GenerationChoosesTheKindByTheSameFunction()
|
||
{
|
||
var shipped = TestCatalog.Repo;
|
||
var belts = 0;
|
||
for (uint seed = 1; seed <= 25 && belts < 3; seed++)
|
||
{
|
||
var dir = Path.Combine(_root, $"gen{seed}");
|
||
Directory.CreateDirectory(dir);
|
||
var catalog = Path.Combine(dir, "Aetheria.cc");
|
||
File.Copy(shipped, catalog);
|
||
using var cache = OpenShippedCatalogCopy(catalog, Path.Combine(dir, "run.cc"));
|
||
foreach (var existing in cache.GetAll<FieldKindData>().ToArray())
|
||
{
|
||
existing.GenerationWeight = 0f;
|
||
cache.Upsert(existing);
|
||
}
|
||
cache.Upsert(Kind("Gen A", 1f, 1f));
|
||
cache.Upsert(Kind("Gen B", 1f, 2f));
|
||
cache.Upsert(Kind("Gen C", 1f, 4f));
|
||
|
||
Galaxy galaxy;
|
||
try
|
||
{
|
||
galaxy = new Galaxy(RestoredHullsTests.TutorialGalaxySettings(), RestoredHullsTests.TutorialBackgroundSettings(),
|
||
cache, new PlayerSettings(), new DirectoryInfo(dir), _ => { }, null, seed);
|
||
}
|
||
catch (Exception) { continue; } // Galaxy's own seed-dependent flakiness (RestoredHullsTests)
|
||
var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { });
|
||
foreach (var zone in galaxy.Zones.Take(4))
|
||
{
|
||
var pack = ZoneGenerator.GenerateZone(items, RestoredHullsTests.TutorialZoneSettings(), RestoredHullsTests.AuthoredPlanetSettings(), galaxy, zone, isTutorial: zone == galaxy.Entrance);
|
||
foreach (var body in pack.Planets)
|
||
{
|
||
if (!(cache.Get(body) is AsteroidBeltData belt)) continue;
|
||
belts++;
|
||
Assert.True(belt.Kind.IsSet());
|
||
Assert.True(belt.Kind.Key.Equals(FieldKinds.Assign(body.Key, cache).Key));
|
||
}
|
||
}
|
||
}
|
||
Assert.True(belts > 0, "no seed generated a belt; the generation path went unexercised");
|
||
}
|
||
|
||
// ==== Mining Cut 3 fix batch (Soul's findings) ====
|
||
|
||
// Q12 A ("launchers cannot mine") in the reach rule: reach counts only weapons that can mine (Weapon.CanMine).
|
||
// Two visible rocks, 150 and 250 away. A launcher of range 400 alone is offered neither; a gun of range 200
|
||
// beside it is offered the near one only, so the launcher's range still counts for nothing.
|
||
[Fact]
|
||
public void LaunchersDoNotCountTowardChunkReach()
|
||
{
|
||
var kind = Kind("Asteroid", 40f);
|
||
var eyeAt = float2(150, -250);
|
||
var nearAt = float2(150, -100);
|
||
var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f), Rock(length(nearAt), 1f + atan2(nearAt.y, nearAt.x) / (2 * PI))));
|
||
var far = new ChunkId(s.Belts[0], 0);
|
||
var near = new ChunkId(s.Belts[0], 1);
|
||
Assert.Equal(250f, length(At(s, far) - eyeAt), 2);
|
||
Assert.Equal(150f, length(At(s, near) - eyeAt), 2);
|
||
var launcher = Gear("Launcher", new LockWeaponData { Range = Constant(400f), LockSpeed = Constant(.5f), LockAngle = Constant(30f), DirectionImpact = Constant(1f) });
|
||
s.Cache.Upsert(launcher);
|
||
var gun = Gear("Gun 200", new InstantWeaponData { Range = Constant(200f) });
|
||
s.Cache.Upsert(gun);
|
||
|
||
Ship Fit(params GearData[] weapons)
|
||
{
|
||
var ship = new Ship(s.Items, s.Zone, Mint(s, s.Hull), new EntitySettings());
|
||
Assert.True(ship.TryEquip(Mint(s, s.Eye)));
|
||
foreach (var weapon in weapons) Assert.True(ship.TryEquip(Mint(s, weapon)));
|
||
s.Zone.Admit(ship, piloted: false);
|
||
Hold(ship, eyeAt);
|
||
ship.Aim = float3(0, 0, 1);
|
||
Tick((ship, eyeAt)); // weapons read their ranges when they run
|
||
return ship;
|
||
}
|
||
|
||
var offered = new List<ChunkId>();
|
||
var launcherOnly = Fit(launcher);
|
||
Assert.Equal(400f, launcherOnly.Weapons.Single().Range);
|
||
Assert.True(launcherOnly.ChunkVisible(far));
|
||
Assert.True(launcherOnly.ChunkVisible(near));
|
||
launcherOnly.VisibleChunksInReach(offered);
|
||
Assert.Empty(offered);
|
||
|
||
var withGun = Fit(launcher, gun);
|
||
withGun.VisibleChunksInReach(offered);
|
||
Assert.Equal(new[] { near }, offered);
|
||
}
|
||
|
||
// Q13 A reads Active, not Enabled (Soul F8): a gun that is switched on but offline (worn out) adds no reach.
|
||
[Fact]
|
||
public void AnOfflineWeaponAddsNoReach()
|
||
{
|
||
var kind = Kind("Asteroid", 4f);
|
||
var s = BuildScene(new[] { kind },
|
||
Belt(kind, Rock(150f), Rock(length(float2(150, 100)), atan2(100f, 150f) / (2 * PI))));
|
||
var near = new ChunkId(s.Belts[0], 0);
|
||
var farther = new ChunkId(s.Belts[0], 1);
|
||
var eyeAt = float2(150, -200);
|
||
var observer = SpawnShip(s, eyeAt, sensor: true, weaponRanges: new[] { 250f, 350f });
|
||
Tick((observer, eyeAt));
|
||
var offered = new List<ChunkId>();
|
||
observer.VisibleChunksInReach(offered);
|
||
Assert.Equal(new[] { near, farther }, offered);
|
||
|
||
var longGun = observer.Equipment.Single(item => item.Data.Name == "Gun 350");
|
||
longGun.EquippableItem.Durability = 0f;
|
||
Tick((observer, eyeAt));
|
||
Assert.True(longGun.Enabled.Value);
|
||
Assert.False(longGun.Active.Value);
|
||
observer.VisibleChunksInReach(offered);
|
||
Assert.Equal(new[] { near }, offered);
|
||
}
|
||
|
||
// Q16 B on the clean-store path (Soul P2): a belt saved kindless, reopened from disk with nothing dirty, and
|
||
// loaded by a Zone, has its first-load kind saved by the next flush.
|
||
[Fact]
|
||
public void AFirstLoadKindIsSavedFromACleanStore()
|
||
{
|
||
var kind = Kind("Asteroid", 4f);
|
||
var s = BuildScene(new[] { kind });
|
||
var beltOrbit = s.Cache.Upsert(new OrbitData { Parent = s.Pack.Orbits[0], Distance = 150f }).Key;
|
||
var beltKey = s.Cache.Upsert(new AsteroidBeltData { Orbit = new CultRecordRef<OrbitData>(beltOrbit), Asteroids = new[] { Rock(150f) } }).Key;
|
||
s.Pack.Orbits.Add(new CultRecordRef<OrbitData>(beltOrbit));
|
||
s.Pack.Planets.Add(new CultRecordRef<BodyData>(beltKey));
|
||
s.Cache.FlushAsync().Wait();
|
||
s.Cache.Dispose();
|
||
_caches.Remove(s.Cache);
|
||
var dir = Path.Combine(_root, $"scene{_fixtureCount - 1}");
|
||
|
||
var clean = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true);
|
||
_caches.Add(clean);
|
||
Assert.False(clean.IsDirty);
|
||
Assert.False(((AsteroidBeltData) clean.Get(beltKey)).Kind.IsSet());
|
||
var zone = new Zone(new ItemManager(1u, clean, new ProvenanceLedger(), GameSettings(), _ => { }), PlanetSettings(), s.Pack,
|
||
new GalaxyZone { Name = "Clean", Owner = null }, null);
|
||
var assigned = ((AsteroidBeltData) zone.Planets[beltKey]).Kind;
|
||
Assert.True(assigned.IsSet());
|
||
clean.FlushAsync().Wait();
|
||
clean.Dispose();
|
||
_caches.Remove(clean);
|
||
|
||
var reopened = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: false);
|
||
_caches.Add(reopened);
|
||
Assert.True(((AsteroidBeltData) reopened.Get(beltKey)).Kind.Key.Equals(assigned.Key), "the first-load kind was not saved");
|
||
}
|
||
|
||
// SetTarget refuses a lit chunk the entity cannot see because it is too far away, not only a dark one (Soul P9).
|
||
[Fact]
|
||
public void SetTargetRefusesALitChunkBeyondSight()
|
||
{
|
||
var kind = Kind("Asteroid", 1f);
|
||
var s = BuildScene(new[] { kind }, Belt(kind, Rock(150f)));
|
||
var chunk = new ChunkId(s.Belts[0], 0);
|
||
var eyeAt = At(s, chunk) + float2(0, -900);
|
||
var ship = SpawnShip(s, eyeAt, sensor: true);
|
||
Tick((ship, eyeAt));
|
||
Assert.True(s.Zone.ChunkVisibility(chunk) > 0f);
|
||
Assert.InRange(ship.ChunkInfo(chunk), 1e-3f, .1f); // seen a little, below the detection threshold
|
||
Assert.False(ship.ChunkVisible(chunk));
|
||
Assert.False(ship.SetTarget(chunk));
|
||
Assert.True(ship.Target.Value.IsNone);
|
||
}
|
||
|
||
// The chunk query equals brute force (Soul P10): 3000 random queries off the origin, over two belts, many of
|
||
// them from beyond a belt's outer edge where the belt skip decides.
|
||
[Fact]
|
||
public void TheChunkQueryMatchesBruteForce()
|
||
{
|
||
var kind = Kind("Asteroid", 4f);
|
||
var rng = new System.Random(11);
|
||
var rocks = Enumerable.Range(0, 400).Select(i => Rock(200f + (float) rng.NextDouble() * 150f, (float) rng.NextDouble())).ToArray();
|
||
var centre = float2(-700, 333);
|
||
var s = BuildSceneAt(centre, new[] { kind }, Belt(kind, rocks), Belt(kind, Rock(600f), Rock(640f, .3f)));
|
||
var found = new List<ChunkId>();
|
||
var mismatches = 0;
|
||
var nonEmpty = 0;
|
||
var acrossOuterEdge = 0;
|
||
for (var q = 0; q < 3000; q++)
|
||
{
|
||
var angle = (float) (rng.NextDouble() * 2 * PI);
|
||
var fromCentre = (float) rng.NextDouble() * 900f;
|
||
var from = centre + fromCentre * float2(cos(angle), sin(angle));
|
||
var range = (float) rng.NextDouble() * 300f;
|
||
ChunksWithin(s, from, range, found);
|
||
var expected = s.Belts
|
||
.SelectMany(b => Enumerable.Range(0, ((AsteroidBeltData) s.Zone.Planets[b]).Asteroids.Length).Select(i => new ChunkId(b, i)))
|
||
.Where(c => length(At(s, c) - from) <= range).ToArray();
|
||
if (expected.Length != found.Count || !expected.ToHashSet().SetEquals(found)) mismatches++;
|
||
if (expected.Length > 0) nonEmpty++;
|
||
if (fromCentre > 350f && fromCentre - range < 350f) acrossOuterEdge++;
|
||
}
|
||
Assert.Equal(0, mismatches);
|
||
Assert.True(nonEmpty > 300 && acrossOuterEdge > 300, $"degenerate queries: {nonEmpty} non-empty, {acrossOuterEdge} across the outer edge");
|
||
}
|
||
|
||
// The detection loop's ping arm is unchanged by the gain extraction (Soul's ping sweep): the EntityInfoGathered
|
||
// trace of 18 sensor configurations, some with a ping that lands unsaturated, matches the trace recorded against
|
||
// the pre-extraction Sensor.Execute (cb4a1dbd). SensorGainExtractionChangesNothing does not see a ping's size;
|
||
// this sweep does.
|
||
[Fact]
|
||
public void ThePingArmIsUnchangedByTheGainExtraction()
|
||
{
|
||
var bytes = new List<byte>();
|
||
var unsaturatedPings = 0;
|
||
foreach (var sensitivity in new[] { 1e-5f, 3e-5f, 1e-4f, 3e-4f, 1e-3f, 1e-2f })
|
||
foreach (var boost in new[] { 1e-4f, 1e-3f, 2e-2f })
|
||
{
|
||
var (near, far) = PingTrace(sensitivity, boost, true);
|
||
var (nearQuiet, farQuiet) = PingTrace(sensitivity, boost, false);
|
||
foreach (var x in near.Concat(far)) bytes.AddRange(BitConverter.GetBytes(x));
|
||
if (near.Max() < .999f && near.Zip(nearQuiet, (a, b) => abs(a - b)).Max() > .01f) unsaturatedPings++;
|
||
if (far.Max() < .999f && far.Zip(farQuiet, (a, b) => abs(a - b)).Max() > .01f) unsaturatedPings++;
|
||
}
|
||
Assert.True(unsaturatedPings > 0, "no configuration saw a ping land unsaturated");
|
||
Assert.Equal("35DE0E26ACC5BD51F6B6E170AB890C3FE5D8BB2BE3A9DC3F3EB66CDA0FA23728", Convert.ToHexString(SHA256.HashData(bytes.ToArray())));
|
||
}
|
||
|
||
// One observer and two reflecting ships in an empty zone, 80 ticks, a ping at tick 20 when `ping`.
|
||
private (float[] near, float[] far) PingTrace(float sensitivity, float boost, bool ping)
|
||
{
|
||
var dir = Path.Combine(_root, $"ping{_fixtureCount++}");
|
||
Directory.CreateDirectory(dir);
|
||
var cache = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true);
|
||
_caches.Add(cache);
|
||
cache.Upsert(new VerseGrammar { Revision = 1 });
|
||
var shape = new Shape(5, 5);
|
||
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
|
||
var hull = new HullData { Name = "Skiff", HullType = HullType.Ship, Shape = shape, Durability = 10, Mass = 1000 };
|
||
cache.Upsert(hull);
|
||
var eye = Gear("Eye", new SensorData
|
||
{
|
||
Sensitivity = Constant(sensitivity), SensitivityCurve = Falloff(), PingBoost = Constant(boost),
|
||
PingEnergy = Constant(0f), PingRange = Constant(300f), PingCooldown = Constant(4f)
|
||
});
|
||
cache.Upsert(eye);
|
||
var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { });
|
||
var s = new Scene
|
||
{
|
||
Cache = cache, Items = items, Hull = hull, Eye = eye,
|
||
Zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Ping", Owner = null }, null)
|
||
};
|
||
var eyeAt = float2(0, 50);
|
||
var nearAt = float2(30, 120);
|
||
var farAt = float2(-150, 90);
|
||
var observer = SpawnShip(s, eyeAt, sensor: true);
|
||
var near = SpawnShip(s, nearAt, crossSection: .2f);
|
||
var far = SpawnShip(s, farAt, crossSection: 9f);
|
||
var nearTrace = new float[80];
|
||
var farTrace = new float[80];
|
||
for (var tick = 0; tick < 80; tick++)
|
||
{
|
||
if (tick == 20 && ping) observer.Sensor.Ping();
|
||
Tick((near, nearAt), (far, farAt), (observer, eyeAt));
|
||
nearTrace[tick] = observer.EntityInfoGathered[near];
|
||
farTrace[tick] = observer.EntityInfoGathered[far];
|
||
}
|
||
return (nearTrace, farTrace);
|
||
}
|
||
|
||
// Soul F2: the shipped catalog authors at least one field kind that generation can pick, so a generated belt is
|
||
// never left kindless and dark.
|
||
[Fact]
|
||
public void TheShippedCatalogHasAPickableFieldKind()
|
||
{
|
||
var dir = Path.Combine(_root, "shipped");
|
||
Directory.CreateDirectory(dir);
|
||
var catalog = Path.Combine(dir, "Aetheria.cc");
|
||
File.Copy(TestCatalog.Repo, catalog);
|
||
using var cache = OpenShippedCatalogCopy(catalog, Path.Combine(dir, "run.cc"));
|
||
Assert.Contains(cache.GetAll<FieldKindData>(), kind => kind.GenerationWeight > 0f);
|
||
}
|
||
}
|