TestCatalogGlobal existed only to give the test host one [CultGlobal] routed to the catalog. VerseGrammar is the game's first, so the test-only type is deleted and every fixture authors a VerseGrammar instead (63 call sites; the missing-global tests assert on aetheria.verse_grammar). Stale comments about the test type are rewritten. Builds (not yet run when committed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
343 lines
18 KiB
C#
343 lines
18 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 CultMath;
|
|
using GameCult.Caching;
|
|
using UniRx;
|
|
using Xunit;
|
|
using static CultMath.math;
|
|
using float3 = CultMath.float3;
|
|
using int2 = CultMath.int2;
|
|
using Random = CultMath.Random;
|
|
|
|
// Cut 6b (docs/fire-control-cut.md, "the dice stop being shared, and the flak cannon works again"): pins 6.1
|
|
// (a shot's dice are a pure function of zone identity and shot id, not a shared stream) and 6.2 (airburst
|
|
// resolves in the simulation, via Splash instead of Apply, never both). Builds its own fixtures, the same
|
|
// convention every earlier cut's test file establishes.
|
|
public sealed class FireControlCut6Tests : IDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut6-" + Guid.NewGuid().ToString("N"));
|
|
private string Catalog => Path.Combine(_root, "Aetheria.cc");
|
|
|
|
public FireControlCut6Tests() => Directory.CreateDirectory(_root);
|
|
|
|
private readonly List<CultCache> _openCaches = new List<CultCache>();
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var c in _openCaches) c.Dispose();
|
|
Directory.Delete(_root, true);
|
|
}
|
|
|
|
private static GameplaySettings TestSettings() => new GameplaySettings
|
|
{
|
|
DefaultEntitySettings = new EntitySettings(),
|
|
Tiers = new[] { new RarityTier { Name = "Common", Quality = .5f, Rarity = 0, Color = new float3(1, 1, 1) } },
|
|
QualityPriceModifier = new ExponentialLerp(),
|
|
TargetDetectionInfoThreshold = .1f,
|
|
TargetArmorInfoThreshold = .2f,
|
|
TargetGearInfoThreshold = .8f,
|
|
FiringArc = 170,
|
|
CommitHorizon = .5f,
|
|
SchematicCellSize = 1f,
|
|
UnaidedAccuracy = .05f,
|
|
UnaidedTracking = 10f,
|
|
AgentMinHitProbability = 0f,
|
|
BeamResolveInterval = .1f
|
|
};
|
|
|
|
private static PerformanceStat Constant(float v) => new PerformanceStat { Min = v, Max = v };
|
|
|
|
private static Shape SolidShape(int w, int h)
|
|
{
|
|
var shape = new Shape(w, h);
|
|
foreach (var cell in shape.AllCoordinates) shape[cell] = true;
|
|
return shape;
|
|
}
|
|
|
|
// ---- A discrete-weapon shooter/target engagement, the same shape FireControlCut5Tests.Build uses. Every
|
|
// engagement shares the fixed GalaxyZone name "Test", so every Zone built from this fixture shares one
|
|
// CombatSeed (R1's stable identity) -- exactly the substrate 6.1's determinism claim depends on. ----
|
|
|
|
private sealed class Engagement
|
|
{
|
|
public ItemManager Items;
|
|
public Zone Zone;
|
|
public Ship Shooter;
|
|
public Ship Target;
|
|
public EquippedItem WeaponItem;
|
|
public InstantWeapon Weapon;
|
|
}
|
|
|
|
private Engagement Build(
|
|
GameplaySettings settings,
|
|
float damage = 10, float range = 1000, float velocity = 0, float spread = 0,
|
|
float accuracy = 1, float resolution = 1, float tracking = 100000,
|
|
float targetRange = 100, WeaponFuse? fuse = null, float blastRadius = 0)
|
|
{
|
|
var hullShape = SolidShape(5, 5);
|
|
var hullData = new HullData
|
|
{
|
|
Name = "Hull", HullType = HullType.Ship, Shape = hullShape, Durability = 100000, Mass = 1000,
|
|
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(0, 0), Shape = new Shape() } }
|
|
};
|
|
|
|
var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true);
|
|
_openCaches.Add(cache);
|
|
cache.Upsert(new VerseGrammar { Revision = 1 });
|
|
cache.Upsert(hullData);
|
|
// Cut 6b, 6.2 (renamed 12.4(a)): a real WeaponItemData (not the bare GearData earlier cuts' fixtures
|
|
// use) -- it is the one catalog type carrying Fuse and BlastRadius. WeaponModifiers no longer decides
|
|
// behaviour (Q12-6), so the fixture does not bother setting it.
|
|
cache.Upsert(new WeaponItemData
|
|
{
|
|
Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
|
|
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
|
Fuse = fuse,
|
|
BlastRadius = blastRadius,
|
|
Behaviors = { new InstantWeaponData
|
|
{
|
|
Damage = Constant(damage), Range = Constant(range), MinRange = Constant(0),
|
|
Velocity = Constant(velocity), Spread = Constant(spread), DamageSpread = Constant(0),
|
|
Penetration = Constant(0), Count = Constant(1), BurstTime = Constant(0), Cooldown = Constant(1000),
|
|
DamageCurve = new BezierCurve { Keys = new[] { float4(0, 1, 0, 0), float4(1, 1, 0, 0) } }
|
|
} }
|
|
});
|
|
cache.Upsert(new GearData
|
|
{
|
|
Name = "Targeting", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 1,
|
|
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
|
Behaviors = { new TargetingSystemData
|
|
{
|
|
// Cut 6d (docs/fire-control-cut.md): Precision now feeds pOnHull (the dart-throw kernel's
|
|
// sigma) for every shot, aimed or not -- 0 would balloon sigma and starve this fixture's hit
|
|
// tests, none of which care about aim placement. See FireAuthorityTests.Build's own comment.
|
|
Accuracy = Constant(accuracy), Resolution = Constant(resolution), Precision = Constant(1), Tracking = Constant(tracking)
|
|
} }
|
|
});
|
|
cache.FlushAsync().Wait();
|
|
|
|
var ledger = new ProvenanceLedger();
|
|
ledger.Lots[1] = new Lot { Origin = new Attributed(), Quality = 1 };
|
|
ledger.Lots[2] = new Lot { Origin = new Attributed(), Quality = 1 };
|
|
ledger.Lots[3] = new Lot { Origin = new Attributed(), Quality = 1 };
|
|
ledger.Lots[4] = new Lot { Origin = new Attributed(), Quality = 1 };
|
|
ledger.Lots[5] = new Lot { Origin = new Attributed(), Quality = 1 };
|
|
var items = new ItemManager(cache, ledger, settings, _ => { });
|
|
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = "Test", Owner = null }, null);
|
|
|
|
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Hull"));
|
|
var shooterHull = new EquippableItem { Data = hullRef, Durability = 100000, Lot = 1 };
|
|
var shooter = new Ship(items, zone, shooterHull, new EntitySettings());
|
|
|
|
var gunRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<WeaponItemData>("Gun"));
|
|
var gun = new EquippableItem { Data = gunRef, Durability = 1, Lot = 2 };
|
|
Assert.True(shooter.TryEquip(gun, new int2(0, 0)));
|
|
var weaponItem = shooter.Equipment.Single(e => e.EquippableItem == gun);
|
|
var weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon);
|
|
|
|
var targetingRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>("Targeting"));
|
|
var targeting = new EquippableItem { Data = targetingRef, Durability = 1, Lot = 3 };
|
|
Assert.True(shooter.TryFindSpace(targeting, out var tpos));
|
|
Assert.True(shooter.TryEquip(targeting, tpos));
|
|
|
|
var targetHull = new EquippableItem { Data = hullRef, Durability = 100000, Lot = 4 };
|
|
var target = new Ship(items, zone, targetHull, new EntitySettings());
|
|
// A target with no non-hull equipment never runs TryEquip, so Entity's _orderedEquipment (only ever
|
|
// populated there) stays null and Entity.Update NREs on the very first tick -- give it an inert item
|
|
// purely so that array gets initialized, the same reason FireControlCut5Tests' fixtures always equip
|
|
// a target gun.
|
|
var targetGunRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<WeaponItemData>("Gun"));
|
|
var targetGun = new EquippableItem { Data = targetGunRef, Durability = 1, Lot = 5 };
|
|
Assert.True(target.TryEquip(targetGun, new int2(0, 0)));
|
|
|
|
zone.Entities.Add(shooter);
|
|
zone.Entities.Add(target);
|
|
shooter.Activate();
|
|
target.Activate();
|
|
|
|
shooter.Position = float3.zero;
|
|
target.Position = float3(0, 0, targetRange);
|
|
shooter.SetTarget(target);
|
|
shooter.EntityInfoGathered[target] = 1f;
|
|
shooter.SetIff(target, true);
|
|
|
|
zone.Update(0f); // warm up resolved stats
|
|
|
|
return new Engagement { Items = items, Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem, Weapon = weapon };
|
|
}
|
|
|
|
// ---- 6.1: a shot's dice belong to the shot (Soul finding 6). ----
|
|
|
|
// A fixed fight, run twice from the same zone identity (CombatSeed comes from the fixed GalaxyZone name
|
|
// "Test", shared by both engagements), with an arbitrary number of unrelated draws from ItemManager.Random
|
|
// between shots in the second run. Outcomes must be identical shot-for-shot. Mutation: restore
|
|
// `shot.Source.ItemManager.Random` as Commit's generator -- the unrelated draws below then advance the
|
|
// shared stream Commit reads from, so the second run's hit/cell sequence diverges from the first's. This
|
|
// is the test Soul's own hermetic-fixture probe could never be (docs/fire-control-cut.md, 6.1): a fixture
|
|
// that never draws anything unrelated cannot see a shared-stream defect.
|
|
[Fact]
|
|
public void SameFightRollsSameThroughUnrelatedDraws()
|
|
{
|
|
var settings = TestSettings();
|
|
|
|
List<(bool hit, int2 cell)> RunFight(Action<ItemManager, int> unrelatedDraws)
|
|
{
|
|
var e = Build(settings, damage: 10, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, targetRange: 100);
|
|
var outcomes = new List<(bool, int2)>();
|
|
e.Zone.ShotResolved.Subscribe(o => outcomes.Add((o.Hit, o.Cell)));
|
|
for (var i = 0; i < 8; i++)
|
|
{
|
|
unrelatedDraws?.Invoke(e.Items, i);
|
|
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
|
e.Zone.Update(.01f); // flight time 0 (velocity 0) -> commits and resolves in this one call
|
|
}
|
|
return outcomes;
|
|
}
|
|
|
|
var baseline = RunFight(null);
|
|
// A different, growing number of unrelated draws before each shot -- exactly "how many draws came
|
|
// before it" perturbing a shared stream would be sensitive to.
|
|
var perturbed = RunFight((items, i) => { for (var k = 0; k < i * 3 + 1; k++) items.Random.NextFloat(); });
|
|
|
|
Assert.Equal(baseline.Count, perturbed.Count);
|
|
Assert.True(baseline.Count > 0);
|
|
Assert.Equal(baseline, perturbed);
|
|
}
|
|
|
|
// A shot's outcome is a function of its own id: firing many shots at an identical, unmoving target (same
|
|
// frozen payload in every way but ShotId, which increments per shot) must not always land on the same
|
|
// cell. Mutation: drop ShotId from the seed -- every shot in a zone would then build the exact same local
|
|
// generator, so every one of these draws the same cell every time (no variety at all across the run).
|
|
[Fact]
|
|
public void ShotIdDecidesTheDie()
|
|
{
|
|
var settings = TestSettings();
|
|
var e = Build(settings, damage: 10, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000, targetRange: 100);
|
|
|
|
var cells = new List<int2>();
|
|
e.Zone.ShotResolved.Subscribe(o => { if (o.Hit) cells.Add(o.Cell); });
|
|
|
|
for (var i = 0; i < 12; i++)
|
|
{
|
|
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
|
e.Zone.Update(.01f);
|
|
}
|
|
|
|
Assert.True(cells.Count > 1);
|
|
Assert.True(cells.Distinct().Count() > 1, "Every shot landed on the same cell -- ShotId is not reaching the seed.");
|
|
}
|
|
|
|
// ---- 6.2: airburst resolves in the simulation (Soul finding 5). ----
|
|
|
|
// A proximity-fuse shot detonates unconditionally, at arrival, with no roll (12.4(b)'s "Which model a shot
|
|
// uses") -- it damages everything in radius regardless of the discrete hit roll (Cut 4's rule, kept).
|
|
// Accuracy 0 pins PBase (and so Commit's hit) to false with no dependence on the random draw, so a direct
|
|
// hit would apply no damage whatsoever (Apply's own gate on shot.Outcome.Hit). A proximity shot must still
|
|
// deal its area damage. Mutation: route a proximity fuse through the direct-hit branch instead -- harmless
|
|
// here (that branch still no-ops on a miss), so this test alone does not catch that mutation;
|
|
// AirburstAndDiscreteNeverDoubleUp below does, with a guaranteed hit where an extra direct-hit application
|
|
// is not a no-op.
|
|
[Fact]
|
|
public void ProximityDetonatesEvenOnAGuaranteedMiss()
|
|
{
|
|
var settings = TestSettings();
|
|
var e = Build(settings, damage: 500, velocity: 0, accuracy: 0, resolution: 1, tracking: 100000,
|
|
targetRange: 50, fuse: WeaponFuse.Proximity, blastRadius: 100);
|
|
|
|
var before = e.Target.Hull.Durability;
|
|
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
|
e.Zone.Update(.01f);
|
|
|
|
Assert.True(e.Target.Hull.Durability < before, "An airburst shot must splash regardless of the discrete hit roll.");
|
|
}
|
|
|
|
// The double-application Soul was told to hunt for: with a guaranteed hit (accuracy 1), a proximity shot's
|
|
// total damage must equal a bare Detonate call's own damage -- not more. Mutation: route the shot through
|
|
// the direct-hit branch as well as Detonate at arrival -- the direct-hit cell would land on top of
|
|
// Detonate's area damage, so the shot's total damage exceeds a lone Detonate call's.
|
|
[Fact]
|
|
public void AirburstAndDiscreteNeverDoubleUp()
|
|
{
|
|
var settings = TestSettings();
|
|
|
|
// 12.4(b)'s area model spreads damage over pi*r^2, so a radius this much larger than the 5x5 target
|
|
// needs enough damage that the covered share still clears DamageHull's .1f threshold (matching
|
|
// ProximityDetonatesEvenOnAGuaranteedMiss's own damage, above).
|
|
var e = Build(settings, damage: 500, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000,
|
|
targetRange: 50, fuse: WeaponFuse.Proximity, blastRadius: 100);
|
|
var before = e.Target.Hull.Durability;
|
|
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
|
e.Zone.Update(.01f);
|
|
var actualDelta = before - e.Target.Hull.Durability;
|
|
|
|
// A fresh, identically-shaped target, damaged by one bare Detonate call with the same parameters
|
|
// FireControl.Fire freezes for this weapon (BurstPosition, for a stationary velocity-0 target, is
|
|
// exactly its own position -- PredictedIntercept falls back to target.Position below weapon.Velocity's
|
|
// .01f floor).
|
|
var e2 = Build(settings, damage: 500, velocity: 0, accuracy: 1, resolution: 1, tracking: 100000,
|
|
targetRange: 50, fuse: WeaponFuse.Proximity, blastRadius: 100);
|
|
var before2 = e2.Target.Hull.Durability;
|
|
FireControl.Detonate(e2.Zone, e2.Target.Position.xz, 100, 500, DamageType.Kinetic);
|
|
var expectedDelta = before2 - e2.Target.Hull.Durability;
|
|
|
|
Assert.True(expectedDelta > 0);
|
|
Assert.Equal(expectedDelta, actualDelta, 3);
|
|
}
|
|
|
|
// The other side: a shot with no fuse must never detonate, even one whose frozen burst position (unread,
|
|
// for a null-fuse shot) would land exactly at zero range on a stationary target -- the zero-radius edge
|
|
// case a careless "detonate unconditionally" mutant would still pass through, since distance 0 is never
|
|
// greater than radius 0. Accuracy 0 pins the discrete roll to a guaranteed miss, so the correct path
|
|
// (Apply's null-fuse branch, gated on Outcome.Hit) deals no damage at all. Mutation: detonate unconditionally
|
|
// at arrival instead of switching on the frozen Fuse -- Detonate does not check Hit, so it would still deal
|
|
// damage even though this shot never froze a fuse.
|
|
[Fact]
|
|
public void NoFuseNeverDetonates()
|
|
{
|
|
var settings = TestSettings();
|
|
var e = Build(settings, damage: 1000, velocity: 0, accuracy: 0, resolution: 1, tracking: 100000,
|
|
targetRange: 50);
|
|
|
|
var before = e.Target.Hull.Durability;
|
|
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
|
e.Zone.Update(.01f);
|
|
|
|
Assert.Equal(before, e.Target.Hull.Durability);
|
|
}
|
|
|
|
// ---- Cut 12.4(a): Fire decides "detonates" once, from the weapon's own data fields, never from a label
|
|
// (Q12-6, "WeaponModifiers are labels, not behaviour"). A fuse without a radius is inert: it freezes null,
|
|
// not the weapon's Fuse. ----
|
|
|
|
// Mutation: freeze `weaponItemData.Fuse` unconditionally instead of gating it on `blastRadius > 0f` -- a
|
|
// weapon that carries a fuse but no radius would then freeze a fuse it should not, and Apply's future
|
|
// fuse switch (12.4(b)) would try to detonate a shot with nothing to detonate with.
|
|
[Fact]
|
|
public void FireFreezesFuseOnlyWhenBlastRadiusIsPositive()
|
|
{
|
|
var settings = TestSettings();
|
|
|
|
var withRadius = Build(settings, fuse: WeaponFuse.Delayed, blastRadius: 40);
|
|
FireControl.Fire(withRadius.Weapon, withRadius.WeaponItem, withRadius.Shooter);
|
|
var shotWithRadius = withRadius.Zone.PendingShots[0];
|
|
Assert.Equal(WeaponFuse.Delayed, shotWithRadius.Fuse);
|
|
Assert.Equal(40f, shotWithRadius.BlastRadius);
|
|
|
|
var withoutRadius = Build(settings, fuse: WeaponFuse.Delayed, blastRadius: 0);
|
|
FireControl.Fire(withoutRadius.Weapon, withoutRadius.WeaponItem, withoutRadius.Shooter);
|
|
var shotWithoutRadius = withoutRadius.Zone.PendingShots[0];
|
|
Assert.Null(shotWithoutRadius.Fuse);
|
|
Assert.Equal(0f, shotWithoutRadius.BlastRadius);
|
|
}
|
|
|
|
// ---- negative: ItemManager.Random no longer appears in FireControl.cs; Airburst no longer appears
|
|
// anywhere under Assets/Scripts/Gameplay (Projectile.cs's fields and ProjectileManager.cs's write-back are
|
|
// deleted, not merely unused). Enforced by tests/mutation_tests_fire_control_cut6.sh's negative-grep step,
|
|
// not restated here as a source-scanning unit test.
|
|
}
|