Aetheria/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs
soul cc0c164574 determinism-ledger: every ItemManager states its seed; scatter on the zone's dice
- 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>
2026-10-21 19:01:41 +02:00

522 lines
26 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.Buffers;
using System.Collections.Generic;
using System.IO;
using CultMath;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using Xunit;
using static CultMath.math;
// Cut 2 (docs/mining-cut.md): the dead per-tick "hit it with a tool" behavior and its per-tick accumulator are
// gone, along with the scanner's dead survey half, and chunk wear gets one owner: Zone. These tests pin that
// owner's behaviour -- accumulation, breaking, lazy respawn, and the persistence round trip through ZonePack's
// new nullable key 6 -- never by reaching into AsteroidBelt (it no longer holds wear at all).
public sealed class MiningCut2Tests : IDisposable
{
private int _fixtureCount;
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-miningcut2-" + Guid.NewGuid().ToString("N"));
public MiningCut2Tests() => Directory.CreateDirectory(_root);
public void Dispose() => Directory.Delete(_root, true);
private static readonly MessagePackSerializerOptions PackOptions =
CultDocumentMessagePackSerialization.OptionsFor(typeof(ZonePack).Assembly);
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()
};
// Authored-shape curves, not flat defaults: hitpoints and respawn time both vary with size over the
// fixture's range, so a test that pins "breaks at the authored hitpoints" or "respawns at the authored
// curve" is exercising real curvature, not a constant that any wrong formula would also satisfy.
private static PlanetSettings Settings() => new PlanetSettings
{
WellStrength = new ExponentialCurve { Constant = 25f },
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 },
// The zone builds a Planet for the fixture's non-belt body; its constructor reads this.
BodyRadius = new ExponentialCurve()
};
private sealed class Fixture
{
public Zone Zone;
public ItemManager Items;
public PlanetSettings Settings;
public CultRecordKey BeltA;
public CultRecordKey BeltB;
public CultRecordKey Planet;
public Asteroid[] AsteroidsA;
public Asteroid[] AsteroidsB;
}
// Two belts of different sizes, asteroids at distinct sizes within each -- so a bug that keys wear by field
// alone (ignoring index), or by index alone (ignoring field), shows up as cross-talk between chunks that
// share an index or a belt.
private Fixture BuildFixture()
{
var dir = Path.Combine(_root, $"cat{_fixtureCount++}");
Directory.CreateDirectory(dir);
var cache = AetheriaStores.Open(Path.Combine(dir, "Aetheria.cc"), Path.Combine(dir, "run.cc"), catalogWritable: true);
var items = new ItemManager(1u, cache, new ProvenanceLedger(), GameSettings(), _ => { });
var settings = Settings();
var rootOrbit = new OrbitData { Distance = 3000f, Phase = 0.05f };
var rootKey = cache.Upsert(rootOrbit).Key;
CultRecordKey MakeBelt(int count, float baseDistance, float sizeStart)
{
var beltOrbit = new OrbitData { Parent = new CultRecordRef<OrbitData>(rootKey), Distance = baseDistance, Phase = 0.11f };
var beltOrbitKey = cache.Upsert(beltOrbit).Key;
var asteroids = new Asteroid[count];
for (var i = 0; i < count; i++)
{
asteroids[i] = new Asteroid
{
Distance = 100f + i * 9.7f,
Phase = frac(i * 0.618f),
Size = frac(sizeStart + i * 0.29f),
RotationSpeed = 0.1f + i * 0.02f
};
}
var beltData = new AsteroidBeltData { Orbit = new CultRecordRef<OrbitData>(beltOrbitKey), Asteroids = asteroids };
return cache.Upsert(beltData).Key;
}
var beltAKey = MakeBelt(9, 700f, 0.05f);
var beltBKey = MakeBelt(5, 1600f, 0.61f);
// A body that is not a debris field: a chunk of it must not exist, whatever its index.
var planetKey = cache.Upsert(new PlanetData { Orbit = new CultRecordRef<OrbitData>(rootKey) }).Key;
var beltAData = (AsteroidBeltData) cache.Get(beltAKey);
var beltBData = (AsteroidBeltData) cache.Get(beltBKey);
var pack = new ZonePack
{
Orbits = { new CultRecordRef<OrbitData>(rootKey) },
Planets = { new CultRecordRef<BodyData>(beltAKey), new CultRecordRef<BodyData>(beltBKey), new CultRecordRef<BodyData>(planetKey) },
Radius = 5000f,
Mass = 10000f,
Time = 0
};
// Both belt orbits share the root as parent and are already registered against the cache; the Zone
// constructor discovers each belt's own orbit key through AsteroidBeltData.Orbit, but it also needs
// every orbit key it will walk listed in pack.Orbits.
pack.Orbits.Add(new CultRecordRef<OrbitData>(beltAData.Orbit.Key));
pack.Orbits.Add(new CultRecordRef<OrbitData>(beltBData.Orbit.Key));
var zone = new Zone(items, settings, pack, new GalaxyZone { Name = "MiningCut2", Owner = null }, null);
return new Fixture
{
Zone = zone, Items = items, Settings = settings,
BeltA = beltAKey, BeltB = beltBKey, Planet = planetKey,
AsteroidsA = beltAData.Asteroids, AsteroidsB = beltBData.Asteroids
};
}
[Fact]
public void WearAccumulatesAndDoesNotBreakUntilStrictlyOverHitpoints()
{
var f = BuildFixture();
const int index = 3;
var chunk = new ChunkId(f.BeltA, index);
var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size);
// Several partial hits under the threshold: still there, still shrinking.
Assert.False(f.Zone.Wear(chunk, hp * 0.3f));
Assert.True(f.Zone.ChunkExists(chunk));
Assert.False(f.Zone.Wear(chunk, hp * 0.3f));
Assert.True(f.Zone.ChunkExists(chunk));
var before = f.Zone.ChunkRadius(chunk);
Assert.True(before < f.Zone.AsteroidBelts[f.BeltA].UndamagedSize(index, f.Settings),
"accumulated but unbroken damage must have already shrunk the chunk");
// The remaining exact amount lands accumulated damage exactly on the hitpoint threshold: pins the
// break condition as strictly greater-than, not greater-or-equal (today's behaviour, kept deliberately
// -- see Zone.Wear's own comment). Equal-to-hitpoints must not break the chunk.
var remaining = hp - hp * 0.6f;
Assert.False(f.Zone.Wear(chunk, remaining));
Assert.True(f.Zone.ChunkExists(chunk), "damage exactly equal to hitpoints must not break the chunk");
// One more point of damage tips it over.
Assert.True(f.Zone.Wear(chunk, 0.01f));
Assert.False(f.Zone.ChunkExists(chunk));
Assert.Equal(0f, f.Zone.ChunkRadius(chunk));
}
[Fact]
public void ABrokenChunkReturnsAtItsRespawnTimeFullyHealed()
{
var f = BuildFixture();
const int index = 1;
var chunk = new ChunkId(f.BeltA, index);
var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size);
var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[index].Size);
Assert.True(f.Zone.Wear(chunk, hp + 1f));
Assert.False(f.Zone.ChunkExists(chunk));
// Uneven steps that sum to just under the authored respawn time: still broken.
var dts = new float[] { respawn * 0.2f, respawn * 0.31f, respawn * 0.1f, respawn * 0.15f };
foreach (var dt in dts) f.Zone.Update(dt);
Assert.False(f.Zone.ChunkExists(chunk), "must still be broken just under the authored respawn time");
// The remaining sliver plus a margin crosses the respawn time.
f.Zone.Update(respawn * 0.3f);
Assert.True(f.Zone.ChunkExists(chunk), "must have respawned once zone time reaches the authored respawn time");
Assert.Equal(f.Zone.AsteroidBelts[f.BeltA].UndamagedSize(index, f.Settings), f.Zone.ChunkRadius(chunk), 4);
}
[Fact]
public void BrokenThenRespawnedThenWornAgainStartsFromZeroDamage()
{
var f = BuildFixture();
const int index = 2;
var chunk = new ChunkId(f.BeltB, index);
var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsB[index].Size);
var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsB[index].Size);
Assert.True(f.Zone.Wear(chunk, hp * 1.5f));
f.Zone.Update(respawn + 1f);
Assert.True(f.Zone.ChunkExists(chunk));
// A hit well under hitpoints, on the freshly respawned chunk, must not break it -- if the old damage
// (or the old broken state) leaked through, this hit alone would already be over any stale threshold.
Assert.False(f.Zone.Wear(chunk, hp * 0.4f));
Assert.True(f.Zone.ChunkExists(chunk));
var radius = f.Zone.ChunkRadius(chunk);
Assert.True(radius > 0f && radius < f.Zone.AsteroidBelts[f.BeltB].UndamagedSize(index, f.Settings));
}
[Fact]
public void WearIsIndependentPerChunk()
{
var f = BuildFixture();
// Same index in both belts, and a different index in the same belt, so a key collision on either half
// of ChunkId would show up as cross-talk.
var chunkA0 = new ChunkId(f.BeltA, 0);
var chunkB0 = new ChunkId(f.BeltB, 0);
var chunkA1 = new ChunkId(f.BeltA, 1);
var hpA0 = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[0].Size);
Assert.True(f.Zone.Wear(chunkA0, hpA0 + 1f));
Assert.False(f.Zone.ChunkExists(chunkA0));
Assert.True(f.Zone.ChunkExists(chunkB0), "a different field at the same index must be unaffected");
Assert.True(f.Zone.ChunkExists(chunkA1), "a different index in the same field must be unaffected");
Assert.Equal(f.Zone.AsteroidBelts[f.BeltB].UndamagedSize(0, f.Settings), f.Zone.ChunkRadius(chunkB0), 4);
}
[Fact]
public void WearSurvivesPackAndUnpack()
{
var f = BuildFixture();
var damaged = new ChunkId(f.BeltA, 4);
var broken = new ChunkId(f.BeltB, 3);
var untouched = new ChunkId(f.BeltA, 5);
var hpDamaged = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[4].Size);
var hpBroken = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsB[3].Size);
Assert.False(f.Zone.Wear(damaged, hpDamaged * 0.45f));
Assert.True(f.Zone.Wear(broken, hpBroken + 5f));
f.Zone.Update(1.3f);
var damagedRadiusBefore = f.Zone.ChunkRadius(damaged);
var pack = f.Zone.PackZone();
Assert.NotNull(pack.ChunkWear);
Assert.True(pack.ChunkWear.Count >= 2);
var reloaded = new Zone(f.Items, f.Settings, pack, new GalaxyZone { Name = "MiningCut2Reload", Owner = null }, null);
Assert.True(reloaded.ChunkExists(untouched));
Assert.False(reloaded.ChunkExists(broken), "a still-broken chunk must stay broken across save/continue");
Assert.True(reloaded.ChunkExists(damaged));
Assert.Equal(damagedRadiusBefore, reloaded.ChunkRadius(damaged), 4);
}
[Fact]
public void APackOmitsExpiredWear()
{
var f = BuildFixture();
var chunk = new ChunkId(f.BeltA, 6);
var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[6].Size);
var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[6].Size);
Assert.True(f.Zone.Wear(chunk, hp + 1f));
f.Zone.Update(respawn + 2f);
Assert.True(f.Zone.ChunkExists(chunk), "sanity: this chunk must have respawned before packing");
var pack = f.Zone.PackZone();
var stillPacked = pack.ChunkWear?.Exists(w => w.Field.Equals(chunk.Field) && w.Index == chunk.Index) ?? false;
Assert.False(stillPacked, "a fully healed, no-longer-broken entry carries no live information and must be pruned");
}
// Persistence rule (docs/mining-cut.md): ZonePack key 6 is nullable so a record from before it existed --
// built here as raw MessagePack bytes with only the six original keys, not a default-constructed ZonePack
// -- deserializes with ChunkWear == null and loads as a zone with no wear, rather than throwing or reading
// some default non-null instance.
[Fact]
public void ARunWithoutKeySixLoadsWhole()
{
var f = BuildFixture();
var oldShapePack = new ZonePack
{
Planets = { new CultRecordRef<BodyData>(f.BeltA), new CultRecordRef<BodyData>(f.BeltB) },
Orbits = f.Zone.Orbits.Keys.ConvertToRefList(),
Entities = new List<EntityPack>(),
Radius = 5000f,
Mass = 10000f,
Time = 12.5
};
var buffer = new ArrayBufferWriter<byte>();
var writer = new MessagePackWriter(buffer);
writer.WriteArrayHeader(6); // the pre-Cut-2 shape: keys 0-5 only, no slot for key 6 at all.
MessagePackSerializer.Serialize(ref writer, oldShapePack.Planets, PackOptions);
MessagePackSerializer.Serialize(ref writer, oldShapePack.Orbits, PackOptions);
MessagePackSerializer.Serialize(ref writer, oldShapePack.Entities, PackOptions);
MessagePackSerializer.Serialize(ref writer, oldShapePack.Radius, PackOptions);
MessagePackSerializer.Serialize(ref writer, oldShapePack.Mass, PackOptions);
MessagePackSerializer.Serialize(ref writer, oldShapePack.Time, PackOptions);
writer.Flush();
var loadedPack = MessagePackSerializer.Deserialize<ZonePack>(buffer.WrittenMemory, PackOptions);
Assert.Null(loadedPack.ChunkWear);
var zone = new Zone(f.Items, f.Settings, loadedPack, new GalaxyZone { Name = "MiningCut2Old", Owner = null }, null);
Assert.True(zone.ChunkExists(new ChunkId(f.BeltA, 0)));
Assert.Equal(zone.AsteroidBelts[f.BeltA].UndamagedSize(0, f.Settings), zone.ChunkRadius(new ChunkId(f.BeltA, 0)), 4);
}
// Stryker gap (2026-09-25 pass on this cut): ChunkExists's range check reads `chunk.Index < 0 ||
// chunk.Index >= length`; nothing exercised either side of that OR alone, or the >= boundary at exactly
// `length`, so a mutant weakening it to AND (which can never be true, since an index cannot be both
// negative and past the end at once) and a mutant weakening >= to > both survived.
[Fact]
public void OutOfRangeChunkIndicesDoNotExist()
{
var f = BuildFixture();
Assert.False(f.Zone.ChunkExists(new ChunkId(f.BeltA, -1)), "a negative index alone must already fail");
Assert.False(f.Zone.ChunkExists(new ChunkId(f.BeltA, f.AsteroidsA.Length)),
"the index exactly at the asteroid count is already past the end (0-based), not the first one out");
}
// Stryker gap: ChunkId.Equals was reachable only through the wear dictionary, whose GetHashCode already
// separates a same-field-different-index or same-index-different-field pair into different buckets, so a
// mutant weakening Equals from `&&` to `||` never got exercised by a lookup. This tests the value equality
// directly, the way the operator's ruling actually describes chunk identity: same field AND same index.
// Bug (found dormant, confirmed by Soul, on Cut 4's path): Wear did not check whether the chunk was already
// broken. A later hit computed newDamage from a fresh 0f (ChunkWear's default Damage after breaking), stayed
// under hitpoints, and wrote BrokenUntil = null -- un-breaking a chunk mid-respawn. The rule: damage to a
// currently-broken chunk (zone time < BrokenUntil) changes nothing. It stays broken until its original
// respawn time regardless of whether the re-hit is above or below hitpoints, and its wear starts from zero
// only once it actually respawns.
[Fact]
public void HittingABrokenChunkDoesNotUnbreakItEitherBelowOrAboveHitpoints()
{
var f = BuildFixture();
const int index = 4;
var chunk = new ChunkId(f.BeltA, index);
var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size);
var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[index].Size);
Assert.True(f.Zone.Wear(chunk, hp + 1f));
Assert.False(f.Zone.ChunkExists(chunk));
// A hit well under hitpoints, inside the respawn window: must not break it (it is already broken) and
// must not touch BrokenUntil.
f.Zone.Update(respawn * 0.2f);
Assert.False(f.Zone.Wear(chunk, hp * 0.1f), "a hit on an already-broken chunk did not break it");
Assert.False(f.Zone.ChunkExists(chunk), "must still be absent -- the old bug un-broke it here");
Assert.Equal(0f, f.Zone.ChunkRadius(chunk));
// A second hit, this one alone well over hitpoints, inside the same window: still must not break it
// (it is already broken) and must not push BrokenUntil further out.
f.Zone.Update(respawn * 0.3f);
Assert.False(f.Zone.Wear(chunk, hp + 50f), "a hit on an already-broken chunk did not break it, even when large");
Assert.False(f.Zone.ChunkExists(chunk));
// Just under the original respawn time (measured from the original break, not from either re-hit):
// still broken.
f.Zone.Update(respawn * 0.45f);
Assert.False(f.Zone.ChunkExists(chunk), "must still be broken just under the ORIGINAL respawn time");
// Crossing the original respawn time: present again, and unworn -- the re-hits left no residue.
f.Zone.Update(respawn * 0.1f);
Assert.True(f.Zone.ChunkExists(chunk), "must respawn at its original time despite the re-hits");
Assert.Equal(f.Zone.AsteroidBelts[f.BeltA].UndamagedSize(index, f.Settings), f.Zone.ChunkRadius(chunk), 4);
// Worn again from fresh: breaks on the normal threshold, not one warped by the earlier no-op hits.
Assert.False(f.Zone.Wear(chunk, hp * 0.9f));
Assert.True(f.Zone.ChunkExists(chunk));
Assert.True(f.Zone.Wear(chunk, hp * 0.2f));
Assert.False(f.Zone.ChunkExists(chunk));
}
// Same rule, checked across a save/reload: a re-hit taken while a chunk is broken must not move BrokenUntil,
// and that must survive the pack/unpack round trip -- not just live in the same Zone instance.
[Fact]
public void ReHitDuringRespawnSurvivesSaveRoundTripAtTheSameAbsoluteTime()
{
var f = BuildFixture();
const int idx = 3;
var chunk = new ChunkId(f.BeltB, idx);
var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsB[idx].Size);
var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsB[idx].Size);
Assert.True(f.Zone.Wear(chunk, hp + 1f));
f.Zone.Update(respawn * 0.4f);
Assert.False(f.Zone.Wear(chunk, hp * 0.5f), "re-hit inside the window did not break an already-broken chunk");
var pack = f.Zone.PackZone();
var reloaded = new Zone(f.Items, f.Settings, pack, new GalaxyZone { Name = "MiningCut2ReHitReload", Owner = null }, null);
Assert.False(reloaded.ChunkExists(chunk), "still broken immediately after reload");
// Just under the original respawn time (measured from the original break at zone time 0): still broken.
reloaded.Update(respawn * 0.59f);
Assert.False(reloaded.ChunkExists(chunk), "must still be broken just under the ORIGINAL respawn time after reload");
reloaded.Update(respawn * 0.02f);
Assert.True(reloaded.ChunkExists(chunk), "must respawn at its original absolute time after reload, unmoved by the re-hit");
Assert.Equal(f.Zone.AsteroidBelts[f.BeltB].UndamagedSize(idx, f.Settings), reloaded.ChunkRadius(chunk), 4);
}
[Fact]
public void ChunkIdEqualityRequiresBothFieldAndIndex()
{
var f = BuildFixture();
var a0 = new ChunkId(f.BeltA, 0);
var a0Again = new ChunkId(f.BeltA, 0);
var a1 = new ChunkId(f.BeltA, 1);
var b0 = new ChunkId(f.BeltB, 0);
Assert.Equal(a0, a0Again);
Assert.NotEqual(a0, a1);
Assert.NotEqual(a0, b0);
}
// Stryker gap: the broken-until comparisons (ChunkExists, ChunkRadius) and the pack-time pruning rule
// (IsExpired) all compare BrokenUntil against zone time at their own boundary, and no existing test landed
// exactly on it (ABrokenChunkReturnsAtItsRespawnTimeFullyHealed and APackOmitsExpiredWear both advance well
// past it). Breaking at time 0 and stepping by exactly the authored respawn time lands zone time on
// BrokenUntil bit-for-bit (0.0 + respawnTime, then 0.0 += respawnTime): the chunk must already read as
// respawned at that exact instant, not one tick later, and packing at that instant must already prune it.
[Fact]
public void RespawnAndPruneBoundaryIsInclusiveOfExactlyNow()
{
var f = BuildFixture();
const int index = 7;
var chunk = new ChunkId(f.BeltA, index);
var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size);
var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[index].Size);
Assert.True(f.Zone.Wear(chunk, hp + 1f));
f.Zone.Update(respawn); // zone time now equals BrokenUntil exactly, not "respawn plus a margin".
Assert.True(f.Zone.ChunkExists(chunk), "must read as respawned the instant zone time reaches BrokenUntil, not strictly after it");
Assert.Equal(f.Zone.AsteroidBelts[f.BeltA].UndamagedSize(index, f.Settings), f.Zone.ChunkRadius(chunk), 4);
var pack = f.Zone.PackZone();
var stillPacked = pack.ChunkWear?.Exists(w => w.Field.Equals(chunk.Field) && w.Index == chunk.Index) ?? false;
Assert.False(stillPacked, "at exactly the respawn instant the entry already carries no live information");
}
// Soul-debt pass (mining-cut-refresh A.1 item 11): the rules below were unpinned when every mutant of the
// chunk surface was run against the Cut 1-2 tests. Each fails under its own mutation.
[Fact]
public void OnlyADebrisFieldHasChunks()
{
var f = BuildFixture();
Assert.True(f.Zone.Planets.ContainsKey(f.Planet), "sanity: the fixture's planet is a body of the zone");
Assert.False(f.Zone.ChunkExists(new ChunkId(f.Planet, 0)), "a planet is a body, not a field of chunks");
Assert.False(f.Zone.ChunkExists(new ChunkId(new CultRecordKey(Guid.NewGuid().ToString("N")), 0)), "a key that names no body has no chunks");
}
// The respawn instant belongs to the live chunk on every reader: ChunkExists and ChunkRadius already say so
// (RespawnAndPruneBoundaryIsInclusiveOfExactlyNow); Wear, the writer, must agree, or a hit landing on the
// instant is swallowed by a chunk every reader shows.
[Fact]
public void AHitOnTheExactRespawnInstantLandsOnTheLiveChunk()
{
var f = BuildFixture();
const int index = 2;
var chunk = new ChunkId(f.BeltA, index);
var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size);
var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[index].Size);
Assert.True(f.Zone.Wear(chunk, hp + 1f));
f.Zone.Update(respawn);
Assert.True(f.Zone.ChunkExists(chunk));
Assert.True(f.Zone.Wear(chunk, hp + 1f), "a hit on the instant of respawn breaks the respawned chunk again");
Assert.False(f.Zone.ChunkExists(chunk));
}
// A chunk that has respawned and been worn again is damaged and not broken. Its saved wear says so: no
// respawn time is carried over from the break it already recovered from.
[Fact]
public void WearAfterRespawnPacksWithoutTheOldRespawnTime()
{
var f = BuildFixture();
const int index = 1;
var chunk = new ChunkId(f.BeltA, index);
var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size);
var respawn = f.Settings.AsteroidRespawnTime.Evaluate(f.AsteroidsA[index].Size);
Assert.True(f.Zone.Wear(chunk, hp + 1f));
f.Zone.Update(respawn + 1f);
Assert.False(f.Zone.Wear(chunk, hp * 0.3f));
var packed = f.Zone.PackZone().ChunkWear.Find(w => w.Field.Equals(chunk.Field) && w.Index == index);
Assert.NotNull(packed);
Assert.Equal(hp * 0.3f, packed.Damage, 4);
Assert.Null(packed.BrokenUntil);
}
// ChunkPose is the sim's read of a chunk's size as well as its place: worn chunks are smaller, broken ones
// have none. Reading the undamaged size here would show a shot-out rock at full size to everything that
// asks through ChunkPose.
[Fact]
public void ChunkPoseCarriesTheChunksWornSize()
{
var f = BuildFixture();
const int index = 3;
var chunk = new ChunkId(f.BeltA, index);
var hp = f.Settings.AsteroidHitpoints.Evaluate(f.AsteroidsA[index].Size);
var undamaged = f.Zone.ChunkPose(f.BeltA, index).w;
Assert.Equal(f.Zone.AsteroidBelts[f.BeltA].UndamagedSize(index, f.Settings), undamaged, 4);
Assert.False(f.Zone.Wear(chunk, hp * 0.6f));
var worn = f.Zone.ChunkPose(f.BeltA, index).w;
Assert.Equal(f.Zone.ChunkRadius(chunk), worn, 4);
Assert.True(worn < undamaged, "a worn chunk is smaller than an untouched one");
Assert.True(f.Zone.Wear(chunk, hp));
Assert.Equal(0f, f.Zone.ChunkPose(f.BeltA, index).w);
}
}
internal static class MiningCut2TestExtensions
{
public static List<CultRecordRef<OrbitData>> ConvertToRefList(this IEnumerable<CultRecordKey> keys)
{
var list = new List<CultRecordRef<OrbitData>>();
foreach (var key in keys) list.Add(new CultRecordRef<OrbitData>(key));
return list;
}
}