Aetheria/tests/Aetheria.Shared.Tests/MiningCut2Tests.cs

523 lines
26 KiB
C#
Raw Normal View History

/* 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, Rarity = 0, 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
{
OrbitPeriod = new ExponentialCurve { Multiplier = 0.6f, Exponent = 1.3f, Constant = 40f },
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 these three.
BodyRadius = new ExponentialCurve(),
GravityRadius = new ExponentialCurve(),
GravityDepth = 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(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);
}
Cut 2 Stryker fix batch: chunk-index bounds, respawn boundary, ChunkId equality Stryker over Zone.cs (concurrency 1, 111 mutants tested) surfaced four real gaps on this cut's own lines, all now killed: - ChunkExists's range check (`index < 0 || index >= length`) survived both an AND weakening and a `>` boundary weakening, because nothing tried an out-of-range index on either side alone. - The broken-until comparisons in ChunkExists/ChunkRadius and the pack-time pruning in IsExpired all survived a `>`/`<` vs `>=`/`<=` boundary flip, because every prior test advanced zone time well past BrokenUntil instead of landing on it exactly. - ChunkId.Equals survived an AND-to-OR weakening: the wear dictionary's own GetHashCode already separates same-field/different-index and same-index/different-field pairs into different buckets, so a bad Equals was never reached through a lookup. Testing equality directly closes that. Two survivors on this cut's lines are recorded as equivalent, not chased: ChunkRadius's `wear.Damage <= 0f` boundary (at damage == 0, DamagedSize and UndamagedSize evaluate the same curve at the same point, so no input distinguishes the two branches), and ChunkId.GetHashCode's exact formula (a hashcode's bit pattern isn't a contract; any deterministic function of the same two fields trivially keeps equal keys hashing equal, and this dictionary is too small for distribution to matter). The remaining survivors (lines 44, 75, 170, 200, 212, 216, 319, 336-412) sit in Update/GetOrbitPosition/GetSecurityLevel/GetHeight/GetNormal/PowerPulse, none of which this cut touches; they predate it and are out of scope here. Mutation score 38.62% -> 42.33% (78/111 killed, 2 timeout, 31 survived, all 31 either pre-existing or one of the two equivalents above). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:05:47 +02:00
// 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);
}
Cut 2 Stryker fix batch: chunk-index bounds, respawn boundary, ChunkId equality Stryker over Zone.cs (concurrency 1, 111 mutants tested) surfaced four real gaps on this cut's own lines, all now killed: - ChunkExists's range check (`index < 0 || index >= length`) survived both an AND weakening and a `>` boundary weakening, because nothing tried an out-of-range index on either side alone. - The broken-until comparisons in ChunkExists/ChunkRadius and the pack-time pruning in IsExpired all survived a `>`/`<` vs `>=`/`<=` boundary flip, because every prior test advanced zone time well past BrokenUntil instead of landing on it exactly. - ChunkId.Equals survived an AND-to-OR weakening: the wear dictionary's own GetHashCode already separates same-field/different-index and same-index/different-field pairs into different buckets, so a bad Equals was never reached through a lookup. Testing equality directly closes that. Two survivors on this cut's lines are recorded as equivalent, not chased: ChunkRadius's `wear.Damage <= 0f` boundary (at damage == 0, DamagedSize and UndamagedSize evaluate the same curve at the same point, so no input distinguishes the two branches), and ChunkId.GetHashCode's exact formula (a hashcode's bit pattern isn't a contract; any deterministic function of the same two fields trivially keeps equal keys hashing equal, and this dictionary is too small for distribution to matter). The remaining survivors (lines 44, 75, 170, 200, 212, 216, 319, 336-412) sit in Update/GetOrbitPosition/GetSecurityLevel/GetHeight/GetNormal/PowerPulse, none of which this cut touches; they predate it and are out of scope here. Mutation score 38.62% -> 42.33% (78/111 killed, 2 timeout, 31 survived, all 31 either pre-existing or one of the two equivalents above). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:05:47 +02:00
[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;
}
}