- Entity.Target is one slot of TargetRef, read-only from outside (IReadOnlyReactiveProperty); Entity.SetTarget is its one writer. A chunk is taken only while the entity can see it, so a rock in darkness cannot be picked. Every handler, agent, turret and test writes through SetTarget. - Entity.ChunkInfo (Q14 A): the value the per-tick sensor rule settles at, computed on demand through Sensor.PassiveRate/Sensor.Gain -- sum of the active sensors' passive rates over (sensors x TargetInfoDecay), capped at 1. Stored nowhere; no per-tick loop over chunks. ChunkVisible is the detection threshold through the chunk path. - The lost-track rule reads through the chunk path: Activate's reconciliation and a per-tick check of the one slot drop a chunk target that has gone dark. - Entity.VisibleChunksInReach (Q13 A): visible chunks within the longest range among active weapons -- the one reach rule for cycling and programmatic picks. - Entity-only readers take .Entity: TargetedBy/TargetedByCount, target items, the stance gate (a chunk has no stance), lock (Q12 A: never on a chunk), turrets, Combat, Minion, FireControl (a chunk fires untargeted until Cut 4). TargetRange and the arc gate read a chunk's planar position. - ActionGameManager: the reticle and next/previous offer visible ships and visible chunks in reach; nearest stays enemies-only; the target panel, indicator and fire-control debug take TargetRef. EntityInstance reads .Entity. - MiningCut3Tests pin parity, darkness, monotone brightness, wear and break, reach, the chunk query, target survival and loss, TargetRef equality, lock, and field-kind assignment at generation and first load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
425 lines
21 KiB
C#
425 lines
21 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 float2 = CultMath.float2;
|
|
using float3 = CultMath.float3;
|
|
using int2 = CultMath.int2;
|
|
|
|
// Cut 11 (docs/fire-control-cut.md): behavioural tests for what a Stryker run against FireControl.cs found
|
|
// undefended. Every test here states a rule as behaviour, observed through the public surface, and is the
|
|
// test whose absence let a real mutant survive. Builds its own fixtures, the convention every cut's test
|
|
// file follows.
|
|
public sealed class FireControlCut11Tests : IDisposable
|
|
{
|
|
private readonly string _root = Path.Combine(Path.GetTempPath(), "aetheria-firecontrolcut11-" + Guid.NewGuid().ToString("N"));
|
|
private string Catalog => Path.Combine(_root, "Aetheria.cc");
|
|
|
|
public FireControlCut11Tests() => 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(float schematicCellSize = 1f) => 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 = schematicCellSize,
|
|
UnaidedAccuracy = .05f,
|
|
UnaidedTracking = 10f,
|
|
UnaidedPrecision = 2f,
|
|
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;
|
|
}
|
|
|
|
private sealed class Engagement
|
|
{
|
|
public Zone Zone;
|
|
public Ship Shooter;
|
|
public Ship Target;
|
|
public EquippedItem WeaponItem;
|
|
public InstantWeapon Weapon;
|
|
public EquippedItem PortMarker;
|
|
public EquippedItem StarboardMarker;
|
|
}
|
|
|
|
// A shooter and a target. The shooter sits OFF the origin on purpose: a shooter at float3.zero makes
|
|
// `target - source` and `target + source` the same vector, which is exactly how four position mutants in
|
|
// FireControl.cs survived every earlier suite.
|
|
private Engagement Build(
|
|
string zoneName = "Cut11",
|
|
float damage = 5, float velocity = 0, float accuracy = 1,
|
|
bool equipShield = false, float shieldCapacity = 30,
|
|
bool markers = false, float spread = 0, float schematicCellSize = 1f)
|
|
{
|
|
// 5 wide x 3 tall: schematic x runs port (0) to starboard (4). Tool gear may only occupy interior
|
|
// cells, which on this hull is the middle row x = 1..3, so the markers sit at (1, 1) and (3, 1) --
|
|
// one cell either side of the centre of mass (2, 1).
|
|
var hullData = new HullData
|
|
{
|
|
Name = "Hull", HullType = HullType.Ship, Shape = SolidShape(5, 3), Durability = 100000, Mass = 1000,
|
|
Hardpoints = { new HardpointData { Type = HardpointType.Sensors, Position = new int2(2, 2), Shape = new Shape() } }
|
|
};
|
|
|
|
var cache = AetheriaStores.Open(Catalog + Guid.NewGuid().ToString("N"), catalogWritable: true);
|
|
_openCaches.Add(cache);
|
|
cache.Upsert(new TestCatalogGlobal { Name = "Temperament" });
|
|
cache.Upsert(hullData);
|
|
cache.Upsert(new GearData
|
|
{
|
|
Name = "Gun", Hardpoint = HardpointType.Sensors, Shape = new Shape(), Durability = 1,
|
|
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
|
Behaviors = { new InstantWeaponData
|
|
{
|
|
Damage = Constant(damage), Range = Constant(1000), 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
|
|
{
|
|
Accuracy = Constant(accuracy), Resolution = Constant(1000), Precision = Constant(1), Tracking = Constant(100000)
|
|
} }
|
|
});
|
|
cache.Upsert(new GearData
|
|
{
|
|
Name = "Reactor", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
|
|
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
|
Behaviors = { new ReactorData { Charge = Constant(1000), Efficiency = Constant(1), OverloadEfficiency = Constant(1), ThrottlingFactor = Constant(2) } }
|
|
});
|
|
cache.Upsert(new GearData
|
|
{
|
|
Name = "Shield", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 10,
|
|
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000,
|
|
Behaviors = { new ShieldData { Efficiency = Constant(1), EnergyUsage = Constant(1), Capacity = Constant(shieldCapacity), RefillDuration = Constant(.01f), RestoreDuration = Constant(.01f) } }
|
|
});
|
|
cache.Upsert(new GearData
|
|
{
|
|
Name = "Marker", Hardpoint = HardpointType.Tool, Shape = new Shape(), Durability = 100,
|
|
MinimumTemperature = -1000, MaximumTemperature = 1000, OptimalTemperature = 0, PlateauWidth = 2000
|
|
});
|
|
cache.FlushAsync().Wait();
|
|
|
|
var ledger = new ProvenanceLedger();
|
|
for (var i = 1; i <= 16; i++) ledger.Lots[i] = new Lot { Origin = new Attributed(), Quality = 1 };
|
|
var items = new ItemManager(cache, ledger, TestSettings(schematicCellSize), _ => { });
|
|
var zone = new Zone(items, new PlanetSettings(), new ZonePack(), new GalaxyZone { Name = zoneName, Owner = null }, null);
|
|
|
|
EquippableItem Make(string name, int lot, float durability) => new EquippableItem
|
|
{
|
|
Data = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<GearData>(name)), Durability = durability, Lot = lot
|
|
};
|
|
|
|
var hullRef = items.ItemData.RefOf<ItemData>(items.ItemData.GetByName<HullData>("Hull"));
|
|
var shooter = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 100000, Lot = 1 }, new EntitySettings());
|
|
var gun = Make("Gun", 2, 1);
|
|
Assert.True(shooter.TryEquip(gun, new int2(2, 2)));
|
|
var weaponItem = shooter.Equipment.Single(x => x.EquippableItem == gun);
|
|
var targeting = Make("Targeting", 3, 1);
|
|
Assert.True(shooter.TryFindSpace(targeting, out var tpos));
|
|
Assert.True(shooter.TryEquip(targeting, tpos));
|
|
|
|
var target = new Ship(items, zone, new EquippableItem { Data = hullRef, Durability = 100000, Lot = 4 }, new EntitySettings());
|
|
Assert.True(target.TryEquip(Make("Gun", 5, 1), new int2(2, 2)));
|
|
|
|
EquippedItem port = null, starboard = null;
|
|
if (markers)
|
|
{
|
|
var p = Make("Marker", 6, 100);
|
|
var s = Make("Marker", 7, 100);
|
|
Assert.True(target.TryEquip(p, new int2(1, 1)), "port marker must fit an interior cell");
|
|
Assert.True(target.TryEquip(s, new int2(3, 1)), "starboard marker must fit an interior cell");
|
|
port = target.Equipment.Single(x => x.EquippableItem == p);
|
|
starboard = target.Equipment.Single(x => x.EquippableItem == s);
|
|
}
|
|
|
|
if (equipShield)
|
|
{
|
|
var reactor = Make("Reactor", 8, 10);
|
|
Assert.True(target.TryFindSpace(reactor, out var rpos));
|
|
Assert.True(target.TryEquip(reactor, rpos));
|
|
var shield = Make("Shield", 9, 10);
|
|
Assert.True(target.TryFindSpace(shield, out var spos));
|
|
Assert.True(target.TryEquip(shield, spos));
|
|
}
|
|
|
|
zone.Entities.Add(shooter);
|
|
zone.Entities.Add(target);
|
|
shooter.Activate();
|
|
target.Activate();
|
|
|
|
shooter.Position = float3(37, 0, -11);
|
|
target.Position = float3(37, 0, 89); // 100 units straight ahead of the shooter
|
|
shooter.SetTarget(target);
|
|
shooter.EntityInfoGathered[target] = 1f;
|
|
shooter.SetIff(target, true);
|
|
if (equipShield) target.Shield.Item.Enabled.Value = true;
|
|
|
|
zone.Update(0f);
|
|
|
|
return new Engagement
|
|
{
|
|
Zone = zone, Shooter = shooter, Target = target, WeaponItem = weaponItem,
|
|
Weapon = (InstantWeapon) weaponItem.Behaviors.Single(b => b is Weapon),
|
|
PortMarker = port, StarboardMarker = starboard
|
|
};
|
|
}
|
|
|
|
private static void Charge(Engagement e)
|
|
{
|
|
for (var i = 0; i < 20; i++) e.Zone.Update(.1f);
|
|
}
|
|
|
|
// ---- The shield pays for what it absorbs. ----
|
|
|
|
// Deleting Shield.TakeHit from FireControl.Apply survived every earlier suite, because they asserted only
|
|
// that an absorbed hit left the hull untouched -- never that the shield paid for it. A shield that absorbs
|
|
// must eventually run dry. Stepped with dt = 0 so the (near-instant) refill cannot top the reserve back up
|
|
// between shots. Mutation: delete TakeHit from the discrete path; the reserve never drains.
|
|
[Fact]
|
|
public void DiscreteShieldAbsorptionDrainsTheReserve()
|
|
{
|
|
var e = Build(damage: 5, equipShield: true, shieldCapacity: 30);
|
|
Charge(e);
|
|
Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5));
|
|
var hull = e.Target.Hull.Durability;
|
|
|
|
var shots = 0;
|
|
while (e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5) && shots < 200)
|
|
{
|
|
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
|
e.Zone.Update(0f);
|
|
shots++;
|
|
}
|
|
|
|
Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5)); // the reserve ran dry
|
|
Assert.False(e.Target.Shield.Broken); // by paying, not by breaking
|
|
Assert.Equal(hull, e.Target.Hull.Durability); // and every hit was absorbed
|
|
}
|
|
|
|
// The same rule on a blast's path (12.4(b) rewrites this to Detonate, docs/fire-control-cut.md
|
|
// "ShieldPaysForWhatReachesIt": "the reserve drops by the entity's covered share, not by the whole
|
|
// blast"), whose absorb branch had no test coverage at all. The radius sits well inside the hull's own
|
|
// solid interior (the 5x3 shape's centre of mass, at least 1 cell from every edge) so the disc never spills
|
|
// off the hull -- the covered share equals the whole damage exactly, the same amount Splash always charged,
|
|
// so this expectation is not loosened. Mutation: delete TakeHit from Detonate's absorb branch.
|
|
[Fact]
|
|
public void ABlastShieldAbsorptionDrainsTheReserve()
|
|
{
|
|
var e = Build(equipShield: true, shieldCapacity: 30);
|
|
Charge(e);
|
|
Assert.True(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5));
|
|
var hull = e.Target.Hull.Durability;
|
|
|
|
var blasts = 0;
|
|
while (e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5) && blasts < 200)
|
|
{
|
|
FireControl.Detonate(e.Zone, e.Target.Position.xz, radius: 1, damage: 5, damageType: DamageType.Kinetic);
|
|
blasts++;
|
|
}
|
|
|
|
Assert.False(e.Target.Shield.CanTakeHit(DamageType.Kinetic, 5));
|
|
Assert.False(e.Target.Shield.Broken);
|
|
Assert.Equal(hull, e.Target.Hull.Durability);
|
|
}
|
|
|
|
// ---- A shot whose target is gone resolves as a miss (Soul C4). ----
|
|
|
|
// The 0b table says a shot whose target has left the zone resolves as a miss "whichever stage it is at."
|
|
// Step only rewrote the outcome on the uncommitted branch, so a hit committed inside the horizon and then
|
|
// orphaned by the target dying was republished as a hit on ShotResolved -- a hit marker on a corpse. The
|
|
// committed outcome is not mutated (R4 keeps it immutable); the resolution is published as its own miss.
|
|
[Fact]
|
|
public void ShotAtTargetThatLeavesResolvesAsMiss()
|
|
{
|
|
var e = Build(velocity: 20); // 100 units at 20 u/s: flight 5 s, commits at 4.5 s
|
|
ShotOutcome committed = null, resolved = null;
|
|
var commits = 0;
|
|
using var c = e.Zone.ShotCommitted.Subscribe(o => { committed = o; commits++; });
|
|
using var r = e.Zone.ShotResolved.Subscribe(o => resolved = o);
|
|
|
|
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
|
e.Zone.Update(4.7f); // past the commit, short of arrival
|
|
Assert.NotNull(committed);
|
|
Assert.True(committed.Hit, "precondition: this fixture's shot must commit as a hit");
|
|
Assert.Null(resolved);
|
|
// What the HUD's arrival countdown reads: time left in flight at commit, 5 s - 4.7 s.
|
|
Assert.Equal(.3f, committed.ArrivalIn, 3);
|
|
|
|
e.Zone.Entities.Remove(e.Target); // the target is gone before the shot arrives
|
|
e.Zone.Update(1f);
|
|
|
|
Assert.NotNull(resolved);
|
|
Assert.Equal(committed.ShotId, resolved.ShotId);
|
|
Assert.False(resolved.Hit, "a shot whose target left the zone must resolve as a miss, not publish its committed hit");
|
|
Assert.True(committed.Hit, "the committed outcome itself is not rewritten (R4)");
|
|
Assert.Equal(1, commits); // a shot commits once; its orphaned resolution is not a second commit
|
|
}
|
|
|
|
// The other stage of the same rule: a target gone before the shot even commits. The shot never got its
|
|
// commit, so the miss is published as the commit too -- a listener waiting on ShotCommitted for this
|
|
// ShotId must not wait forever -- and it is a plain miss, with no shield hit or shield break attached.
|
|
[Fact]
|
|
public void ShotAtTargetThatLeavesBeforeCommitCommitsAsAMiss()
|
|
{
|
|
var e = Build(velocity: 20, equipShield: true); // commits at 4.5 s
|
|
Charge(e);
|
|
var committed = new List<ShotOutcome>();
|
|
var resolved = new List<ShotOutcome>();
|
|
using var c = e.Zone.ShotCommitted.Subscribe(committed.Add);
|
|
using var r = e.Zone.ShotResolved.Subscribe(resolved.Add);
|
|
|
|
var shotId = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
|
e.Zone.Update(1f);
|
|
SafeAssert.Empty(committed);
|
|
|
|
e.Zone.Entities.Remove(e.Target);
|
|
e.Zone.Update(1f);
|
|
|
|
foreach (var outcome in new[] { SafeAssert.Only(committed), SafeAssert.Only(resolved) })
|
|
{
|
|
Assert.Equal(shotId, outcome.ShotId);
|
|
Assert.False(outcome.Hit);
|
|
Assert.False(outcome.Shielded, "a shot at nothing did not hit a shield");
|
|
Assert.False(outcome.ShieldBroken, "a shot at nothing did not break a shield");
|
|
}
|
|
SafeAssert.NoShots(e.Zone);
|
|
}
|
|
|
|
// ---- A targeting system's Tracking is what forgives a target that moves off its predicted line. ----
|
|
|
|
// Tracking fell back to GameplaySettings.UnaidedTracking under a mutant that ignored the fitted system,
|
|
// and nothing noticed: every fixture's target sat still, so deviation was 0 and Tracking divided nothing.
|
|
// Here the target jinks 5 units sideways mid-flight. The fitted system (Tracking 100000) shrugs it off;
|
|
// unaided Tracking (10) would halve the shot's chance.
|
|
[Fact]
|
|
public void AFittedTargetingSystemForgivesAJink()
|
|
{
|
|
var e = Build(velocity: 20);
|
|
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
|
e.Zone.Update(1f);
|
|
e.Target.Position += float3(5, 0, 0);
|
|
|
|
var shot = e.Zone.PendingShots.Single();
|
|
var p = FireControl.DeviationProbability(shot, e.Zone.Time, out var deviation);
|
|
Assert.Equal(5f, deviation, 3);
|
|
Assert.True(p > .99f, $"a 5-unit jink against Tracking 100000 must barely matter, got {p}");
|
|
}
|
|
|
|
// ---- A beam's shot carries the interval's damage, not the weapon's. ----
|
|
|
|
// ConstantWeapon fires one shot per BeamResolveInterval for Damage * interval, through Fire's
|
|
// damageOverride. Dropping the override froze the weapon's full Damage into every one of those shots --
|
|
// a beam doing ten times its damage at the default interval -- and survived, because the beam test
|
|
// counted its rolls and multiplied the count by the expected damage itself.
|
|
[Fact]
|
|
public void ADamageOverrideIsWhatTheShotCarries()
|
|
{
|
|
var e = Build(damage: 5, velocity: 20);
|
|
var full = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
|
var partial = FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter, damageOverride: .5f);
|
|
|
|
Assert.Equal(5f, e.Zone.PendingShots.Single(s => s.ShotId == full).Damage);
|
|
Assert.Equal(.5f, e.Zone.PendingShots.Single(s => s.ShotId == partial).Damage);
|
|
}
|
|
|
|
// ---- Splash damages the side the blast came from. ----
|
|
//
|
|
// Superseded (docs/fire-control-cut.md, "Symmetry and frame"): ABlastDamagesTheCellsNearestIt in
|
|
// FireControlCut124Tests.cs succeeds this test and Cut4Tests.SplashIsDirectional -- a blast is now an area
|
|
// (12.4(b)), not a directional half-hull split, so "the side the blast came from" is pinned by which cells
|
|
// the disc actually covers, not by a facing-rotated half-shape.
|
|
|
|
// ---- pSpread is the share of the spread cone the target's silhouette covers. ----
|
|
|
|
// Every earlier fixture used zero spread (pSpread is then 1 regardless) and a cell size of exactly 1 (so
|
|
// multiplying and dividing by it agree), which left the whole spread term untested. This pins what the
|
|
// number MEANS rather than how it is computed: a silhouette that exactly fills the cone scores 1, one half
|
|
// the cone's angular size scores .5, and one bigger than the cone saturates at 1. Here the 5-wide hull at a
|
|
// cell size of 2 has a half-extent of 5 world units, against a 10-degree cone (5-degree half-angle).
|
|
[Fact]
|
|
public void PSpreadIsTheSilhouettesShareOfTheSpreadCone()
|
|
{
|
|
const float halfExtent = 5f, halfCone = 5f;
|
|
float At(float range)
|
|
{
|
|
var e = Build(spread: 2 * halfCone, schematicCellSize: 2f);
|
|
e.Target.Position = e.Shooter.Position + float3(0, 0, range);
|
|
return FireControl.Inspect(e.Weapon, e.Shooter, e.Target).PSpread;
|
|
}
|
|
|
|
var fills = halfExtent / (float) Math.Tan(halfCone * Math.PI / 180); // silhouette == cone
|
|
var half = halfExtent / (float) Math.Tan(halfCone / 2 * Math.PI / 180); // silhouette == half the cone
|
|
Assert.Equal(1f, At(fills), 2);
|
|
Assert.Equal(.5f, At(half), 2);
|
|
Assert.Equal(1f, At(fills / 3), 2); // bigger than the cone: saturates, never exceeds 1
|
|
Assert.True(At(half * 2) < At(half)); // and falls away with range
|
|
}
|
|
|
|
// ---- Zones roll differently. ----
|
|
|
|
// The roll is a function of (zone, shot id). Replacing `CombatSeed *` with `/` in Commit's seed drops the
|
|
// zone out of it -- integer division by a large constant is zero for almost every seed -- so every zone
|
|
// rolls the same sequence. That survived, because the die test only checks uniformity within one zone.
|
|
// Same shot ids in two zones must not produce the same hit sequence.
|
|
[Fact]
|
|
public void DifferentZonesRollDifferently()
|
|
{
|
|
bool[] Sequence(string zoneName)
|
|
{
|
|
var e = Build(zoneName: zoneName, accuracy: .5f);
|
|
var p = FireControl.HitProbability(e.Weapon, e.Shooter, e.Target); // accuracy times the other factors
|
|
var hits = new List<bool>();
|
|
using var r = e.Zone.ShotResolved.Subscribe(o => hits.Add(o.Hit));
|
|
for (var i = 0; i < 400; i++)
|
|
{
|
|
FireControl.Fire(e.Weapon, e.WeaponItem, e.Shooter);
|
|
e.Zone.Update(0f);
|
|
}
|
|
Assert.Equal(400, hits.Count);
|
|
var fraction = hits.Count(h => h) / 400f;
|
|
Assert.True(Math.Abs(fraction - p) < .08f, $"zone {zoneName}: hit fraction {fraction:F3} is not near p = {p:F3}");
|
|
return hits.ToArray();
|
|
}
|
|
|
|
var a = Sequence("Rhea");
|
|
var b = Sequence("Mimas");
|
|
var differ = a.Zip(b, (x, y) => x != y).Count(d => d) / 400f;
|
|
// Two independent rolls at p ~ .4 disagree 2p(1-p) ~ 48% of the time; identical seeds disagree never.
|
|
Assert.True(differ > .3f, $"only {differ:P1} of shots differ between zones -- the zone is not reaching the roll");
|
|
}
|
|
}
|