From a04dac11515185cc86453795cf4c9a6fe10bcaa7 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sun, 9 Aug 2026 20:06:53 +0200 Subject: [PATCH 1/3] feat(economy): low power takes radar first and halves repair (C4) Sprint 16.6, D-030/Economy.md shutdown order: - Radar falls FIRST at a deficit: FogOfWarSystem.GetRadarSignatures returns nothing while the team's balance is low power. The economy becomes the third required constructor dependency (the compiler enumerated every host and rig again). MinimapHud goes dark with it - same balance, same read as the 16.5 radar gate. - Production and construction keep the exact 0.5 Q16.16 speed penalty (existing). - Repair halves exactly: 10 -> 5 HP/tick under low power (Economy.md repair rule; 10/2 is exact, no rounding). - NOT included, by design: the defense shutdown. Whether a tower fires is CombatSystem's call alone, and Simulation/Combat/ has no notion of power - that input would be units-track terrain. Goes to 13B as a finding (Issue #70). This branch REPLICATES the Sprint 16.5 state (PR #77: FogOfWarSystem construction dependency, MinimapHud radar gate, BuildMenuHud radar hint, all host/rig signature updates) as its base, because 16.6 builds directly on the 16.5 radar functions and PR-only flow forbids stacking on a local branch. Identical hunks in both merge directions: whichever of #77 and this PR lands first, the other merges clean. New tests: pings stop at deficit and resume on recovery (Fog), repair halves exactly under low power (Construction). EditMode mirrors synced. No baseline impact: low-power effects read committed balances; the decay of 16.4 is what moves state, and that lands with #73. --- Assets/Tests/EditMode/AI/SkirmishAiTests.cs | 2 +- .../Gameplay/CanonicalMatchSetupTests.cs | 2 +- .../EditMode/Simulation/CombatSystemTests.cs | 36 +++++--- .../Simulation/ConstructionSystemTests.cs | 76 +++++++++++++++++ .../Simulation/EconomyIntegrationTests.cs | 4 +- .../Simulation/FogOfWarSystemTests.cs | 85 ++++++++++++++++--- .../ProductionConstructionIntegrationTests.cs | 2 +- .../EditMode/Simulation/WeaponValuesTests.cs | 6 +- .../Scripts/Gameplay/Match/MatchRunner.cs | 2 +- .../Scripts/Presentation/UI/BuildMenuHud.cs | 15 +++- .../Scripts/Presentation/UI/MinimapHud.cs | 77 ++++++++++++++++- .../Construction/ConstructionSystem.cs | 15 +++- .../Simulation/Vision/FogOfWarSystem.cs | 56 ++++++++++-- CHANGELOG.md | 20 +++++ .../BarracksSpawnMatchConfigTests.cs | 2 +- .../CanonicalMatchSetupTests.cs | 2 +- .../Nova.SimRunner.Tests/CombatSystemTests.cs | 32 ++++--- .../ConstructionSystemTests.cs | 21 +++++ .../EconomyIntegrationTests.cs | 4 +- .../FogOfWarSystemTests.cs | 81 ++++++++++++++++-- .../LockstepNetworkTests.cs | 4 +- .../ProductionConstructionIntegrationTests.cs | 2 +- tools/Nova.SimRunner.Tests/SkirmishAiTests.cs | 2 +- .../Nova.SimRunner.Tests/WeaponValuesTests.cs | 6 +- .../Determinism10000Scenario.cs | 2 +- tools/Nova.SimRunner/Program.cs | 2 +- .../Scale500PrecombatScenario.cs | 2 +- 27 files changed, 485 insertions(+), 75 deletions(-) diff --git a/Assets/Tests/EditMode/AI/SkirmishAiTests.cs b/Assets/Tests/EditMode/AI/SkirmishAiTests.cs index 0239cc7..964d1b2 100644 --- a/Assets/Tests/EditMode/AI/SkirmishAiTests.cs +++ b/Assets/Tests/EditMode/AI/SkirmishAiTests.cs @@ -125,7 +125,7 @@ private static AiHost BuildAiHost(ulong seed) var economy = new EconomySystem(entities, EconomySystem.CanonicalMatchStartingCreditsAE); var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, teamCount: 2, MapWidth, MapHeight); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, MapWidth, MapHeight); var combat = new CombatSystem(entities, fogOfWar, economy); var victory = new VictorySystem(entities, construction); diff --git a/Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs b/Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs index 5447a24..56af0db 100644 --- a/Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs +++ b/Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs @@ -131,7 +131,7 @@ private static ReferenceHost BuildReferenceHost(ulong seed) var economy = new EconomySystem(entities, EconomySystem.CanonicalMatchStartingCreditsAE); var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, teamCount: 2, MapWidth, MapHeight); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, MapWidth, MapHeight); var combat = new Nova.Simulation.Combat.CombatSystem(entities, fogOfWar, economy); var victory = new Nova.Simulation.Victory.VictorySystem(entities, construction); diff --git a/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs b/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs index 9b5e793..9398ac0 100644 --- a/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs @@ -19,10 +19,10 @@ namespace Nova.Simulation.Tests /// pings), the committed-view rule between recomputes, exact cooldown /// cadence, damage/death/order resolution, two-kernel determinism, hash /// sensitivity, replay playback and mid-combat snapshot continuation. - /// Mirror of the EditMode lane CombatSystemTests. + /// Mirror of the .NET lane CombatSystemTests. /// [TestFixture] - public sealed class CombatSystemTests + public class CombatSystemTests { private const ulong Seed = 0xC0BA7UL; private static readonly SimFixed HalfCell = SimFixed.FromRaw(SimFixed.OneRaw / 2); @@ -38,23 +38,29 @@ private sealed class TestHost public FogOfWarSystem Fog { get; } public CombatSystem Combat { get; } - private TestHost(SimulationKernel kernel, EntityManager entities, FogOfWarSystem fog, CombatSystem combat) + private TestHost(SimulationKernel kernel, EntityManager entities, + Nova.Simulation.Construction.ConstructionSystem construction, FogOfWarSystem fog, CombatSystem combat) { Kernel = kernel; Entities = entities; + Construction = construction; Fog = fog; Combat = combat; } + public Nova.Simulation.Construction.ConstructionSystem Construction { get; } + public static TestHost Create(ulong seed, int capacity = 64, ushort width = 64, ushort height = 64) { var entities = new EntityManager(capacity); var pathfinding = new PathfindingSystem(width, height); var movement = new MovementSystem(entities, pathfinding); - var fog = new FogOfWarSystem(entities, teamCount: 2, width, height); // Faction source for the combat weapon table: an unregistered - // economy state (all slots default to Alliance). + // economy state (all slots default to Alliance). 16.5: the + // FoW radar read also requires the placement register. var factions = new EconomySystem(entities); + var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, factions); + var fog = new FogOfWarSystem(entities, construction, factions, teamCount: 2, width, height); var combat = new CombatSystem(entities, fog, factions); var kernel = new SimulationKernel(new SimRandom(seed)); @@ -63,7 +69,7 @@ public static TestHost Create(ulong seed, int capacity = 64, ushort width = 64, kernel.RegisterSystem(fog); kernel.RegisterSystem(combat); kernel.Start(); - return new TestHost(kernel, entities, fog, combat); + return new TestHost(kernel, entities, construction, fog, combat); } public void Step() => Kernel.StepTick(); @@ -246,17 +252,21 @@ public void Cooldown_ExactlyFiveTicksBetweenShots() public void RadarPingOnly_GrantsNoTargetingPermission() { var host = TestHost.Create(Seed); - // Sight 5 (radar 10 provisional x2): the enemy 7 cells out is - // radar-covered and inside weapon range 8 + 0.5, but NOT Visible. - EntityId attacker = SpawnAt(host, 0, 10, 10, sightRadius: 5); - EntityId target = SpawnAt(host, 1, 17, 10, maxHealth: 100); + // 16.5: only a COMPLETED Radar building radiates — centre (10,15), + // coverage sight 10 x 2 = 20. The building also contributes plain + // SIGHT (radius 10), so the target hides from both radii: 16 > 10 + // and 16 > 5 away, inside the 20 coverage, inside weapon range. + EntityId attacker = SpawnAt(host, 0, 20, 10, sightRadius: 5); + Assert.That(host.Construction.PlaceCompletedBuilding(0, 10, 9, 14).IsValid, Is.True, + "Alliance Radar (def 10) as a completed placement"); + EntityId target = SpawnAt(host, 1, 26, 10, maxHealth: 100); host.Entities.GetUnitRef(attacker).AttackTarget = target; host.Step(2); // first commit var pings = new System.Collections.Generic.List(); host.Fog.GetRadarSignatures(0, pings); Assert.That(pings.Count, Is.EqualTo(1), "the hidden in-range enemy pings on radar"); - Assert.That(host.Fog.GetTeamView(0).IsVisible(17, 10), Is.False); + Assert.That(host.Fog.GetTeamView(0).IsVisible(26, 10), Is.False); host.Step(18); // many shot opportunities pass Assert.That(HealthOf(host, target), Is.EqualTo(100), @@ -563,7 +573,9 @@ public static ReplayHost Create(ulong seed) var pathfinding = new PathfindingSystem(64, 64); var movement = new MovementSystem(entities, pathfinding); var economy = new EconomySystem(entities); - var fog = new FogOfWarSystem(entities, teamCount: 2, 64, 64); + // 16.5: the FoW radar read requires the placement register. + var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, economy); + var fog = new FogOfWarSystem(entities, construction, factions, teamCount: 2, 64, 64); var combat = new CombatSystem(entities, fog, economy); var kernel = new SimulationKernel(new SimRandom(seed)); diff --git a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs index 08413a3..0b7b2a5 100644 --- a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs @@ -461,6 +461,61 @@ public void PlaceCompletedBuilding_ResearchLab_UnlocksT2Immediately() Assert.That(f.Construction.IsT2Unlocked(0), Is.True); } + private static int CountUnits(Fixture f, byte slot, UnitRole role) + { + UnitState[] units = f.Entities.RawUnits; + int count = 0; + for (int i = 0; i < f.Entities.Capacity; i++) + { + if (units[i].IsActive && units[i].PlayerId == slot && units[i].Role == role) count++; + } + return count; + } + + [Test] + public void RefineryCompletion_GrantsTheFirstHarvesterFree() + { + // The dead end this closes: the Harvester costs 700 AE and the + // Refinery is its only producer since D-077. A player who spends + // down below 700 before the Refinery finishes can never earn + // again — no Harvester, no Aetherium, no money for a Harvester. + var f = new Fixture(startingCredits: 1000); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + "HQ provides the 30 power the Refinery draws from"); + f.SpawnBuilder(0, 19, 20); + f.Step(1); // commit the balance + + Assert.That(f.Construction.TryPlaceBuilding(0, 4, 20, 20), Is.True, "Refinery def 4 (Alliance, 700 AE, 200 ticks)"); + Assert.That(CountUnits(f, 0, UnitRole.Harvester), Is.EqualTo(0), "none before completion"); + + f.Step(150); + Assert.That(CountUnits(f, 0, UnitRole.Harvester), Is.EqualTo(0), "not while the site is still running"); + long creditsBefore = f.Economy.GetPlayerEconomy(0).AetheriumCredits; + + f.Step(100); // past the 200-tick build time + + Assert.That(CountUnits(f, 0, UnitRole.Harvester), Is.EqualTo(1), + "a finished Refinery hands out its first Harvester"); + Assert.That(CountUnits(f, 1, UnitRole.Harvester), Is.EqualTo(0), + "the grant belongs to the building's owner alone"); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(creditsBefore), + "the grant is free: nothing is charged at completion"); + } + + [Test] + public void PlaceCompletedBuilding_Refinery_GrantsNothing_MatchStartIsUnchanged() + { + // PlaceCompletedBuilding is the match-start path (starting HQ plus + // Refinery). The grant deliberately hangs on finishing a site, not + // on instant placement — otherwise every match would begin with a + // free Harvester, which is a balance change nobody asked for. + var f = new Fixture(startingCredits: 3000); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 20, 20).IsValid, Is.True); + + Assert.That(CountUnits(f, 0, UnitRole.Harvester), Is.EqualTo(0), + "an instantly placed Refinery grants nothing"); + } + [Test] public void CancelConstruction_Refunds75Percent_AndFreesFootprint() { @@ -532,6 +587,27 @@ public void Repair_BuilderRestoresHp_InReachOnly_AndResolvesAtFull() "repair caps at MaxHealth and the order resolves"); } + [Test] + public void Repair_LowPower_ExactlyHalvesTheRate() + { + // 16.6 (C4, Economy.md repair rule): under LOW POWER the repair + // rate halves exactly — 5 HP per tick, no rounding. + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 40, 40).IsValid, Is.True, + "a completed Refinery (20 required, nothing provided) forces low power"); + EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); + f.Entities.GetUnitRef(barracks).CurrentHealth = 100; + + EntityId builder = f.SpawnBuilder(0, 19, 20); + f.Step(1); // commit the balance: refinery + barracks draw 35, nothing provided + Assert.That(f.Economy.GetPlayerEconomy(0).IsLowPower, Is.True); + + f.Construction.AssignRepairOrder(UnitCommandStateView.ToRawEntityId(builder), UnitCommandStateView.ToRawEntityId(barracks)); + f.Step(10); + Assert.That(f.Entities.GetUnitRef(barracks).CurrentHealth, Is.EqualTo(150), + "5 HP per tick under low power — exactly half the provisional rate"); + } + [Test] public void Repair_Validation_RejectsNonBuilder_AndUndamagedTarget() { diff --git a/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs b/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs index f02b8d3..45d2aa7 100644 --- a/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs +++ b/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs @@ -56,7 +56,9 @@ public static EcoHost Create(ulong seed, int capacity = 256, ushort width = 64, var pathfinding = new PathfindingSystem(width, height); var movement = new MovementSystem(entities, pathfinding); var economy = new EconomySystem(entities); - var fogOfWar = new FogOfWarSystem(entities, teamCount: 2, width, height); + // 16.5: the FoW radar read requires the placement register. + var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, economy); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, width, height); var combat = new CombatSystem(entities, fogOfWar, economy); var kernel = new SimulationKernel(new SimRandom(seed)); diff --git a/Assets/Tests/EditMode/Simulation/FogOfWarSystemTests.cs b/Assets/Tests/EditMode/Simulation/FogOfWarSystemTests.cs index a0fc44c..3196a5b 100644 --- a/Assets/Tests/EditMode/Simulation/FogOfWarSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/FogOfWarSystemTests.cs @@ -2,6 +2,9 @@ using NUnit.Framework; using Nova.Core; using Nova.Simulation; +using Nova.Simulation.Construction; +using Nova.Simulation.Definitions; +using Nova.Simulation.Economy; using Nova.Simulation.Movement; using Nova.Simulation.Pathfinding; using Nova.Simulation.Snapshots; @@ -11,12 +14,12 @@ namespace Nova.Simulation.Tests { /// - /// Canonical Fog of War suite (EditMode lane, docs/tech/FogOfWar.md, D-058): + /// Canonical Fog of War suite (.NET lane, docs/tech/FogOfWar.md, D-058): /// state transitions, the 5 Hz commit cadence, exact radius rasterization, /// team separation, radar pings without targeting right, the /// Hidden-World metamorphic of Testing.md section 5, snapshot /// roundtrip/continuation and two-kernel determinism. - /// Mirror of the .NET lane FogOfWarSystemTests with Unity Test Framework asserts. + /// Mirror of the .NET lane FogOfWarSystemTests. /// [TestFixture] public class FogOfWarSystemTests @@ -32,12 +35,16 @@ private sealed class TestHost { public SimulationKernel Kernel { get; } public EntityManager Entities { get; } + public EconomySystem Economy { get; } + public ConstructionSystem Construction { get; } public FogOfWarSystem Fog { get; } - private TestHost(SimulationKernel kernel, EntityManager entities, FogOfWarSystem fog) + private TestHost(SimulationKernel kernel, EntityManager entities, EconomySystem economy, ConstructionSystem construction, FogOfWarSystem fog) { Kernel = kernel; Entities = entities; + Economy = economy; + Construction = construction; Fog = fog; } @@ -46,14 +53,19 @@ public static TestHost Create(ulong seed, int capacity = 64, ushort width = 64, var entities = new EntityManager(capacity); var pathfinding = new PathfindingSystem(width, height); var movement = new MovementSystem(entities, pathfinding); - var fog = new FogOfWarSystem(entities, teamCount: 2, width, height); + // 16.5/16.6: the FoW radar read requires the placement register + // and the power balance — an unregistered economy/construction + // pair answers both queries without ever ticking. + var economy = new EconomySystem(entities); + var construction = new ConstructionSystem(entities, economy); + var fog = new FogOfWarSystem(entities, construction, economy, teamCount: 2, width, height); var kernel = new SimulationKernel(new SimRandom(seed)); kernel.RegisterSystem(pathfinding); kernel.RegisterSystem(movement); kernel.RegisterSystem(fog); kernel.Start(); - return new TestHost(kernel, entities, fog); + return new TestHost(kernel, entities, economy, construction, fog); } public void Step() => Kernel.StepTick(); @@ -235,9 +247,15 @@ public void GetVisibleEntities_OwnAlways_ForeignOnlyInVisibleCells() public void RadarSignature_PingsWithoutTargetingRight() { var host = TestHost.Create(Seed); - SpawnAt(host, 0, 10, 10, sightRadius: 5); // radar coverage 10 (provisional x2) - SpawnAt(host, 1, 19, 10, sightRadius: 5); // inside radar (9 <= 10), outside sight (9 > 5) - SpawnAt(host, 1, 40, 10, sightRadius: 5); // outside radar + SpawnAt(host, 0, 10, 10, sightRadius: 5); // sight observer, NOT a radar source + // 16.5 (#54): only a COMPLETED Radar building radiates — centre + // (10,15), coverage = its sight radius 10 x 2 = 20. NOTE: the + // building also contributes plain SIGHT (radius 10), so a ping + // target must hide from both radii. + Assert.That(host.Construction.PlaceCompletedBuilding(0, 10, 9, 14).IsValid, Is.True, + "Alliance Radar (def 10) as a completed placement"); + SpawnAt(host, 1, 24, 10, sightRadius: 5); // inside radar coverage (dx 14 <= 20), outside both sights (14 > 10, 14 > 5) + SpawnAt(host, 1, 40, 10, sightRadius: 5); // outside radar (dx 30 > 20) SpawnAt(host, 1, 13, 10, sightRadius: 5); // inside sight: a target, not a ping host.Step(2); @@ -245,17 +263,56 @@ public void RadarSignature_PingsWithoutTargetingRight() var pings = new List(); host.Fog.GetRadarSignatures(0, pings); Assert.That(pings.Count, Is.EqualTo(1), "exactly the radar-covered hidden enemy pings"); - Assert.That(pings[0].GridX, Is.EqualTo(19)); + Assert.That(pings[0].GridX, Is.EqualTo(24)); Assert.That(pings[0].GridY, Is.EqualTo(10)); // Contract: the ping grants no targeting permission — the cell is // not Visible, so Combat must not address the pinged object. The // signature struct carries no EntityId by design. TeamView view = host.Fog.GetTeamView(0); - Assert.That(view.IsVisible(19, 10), Is.False, "a pinged cell stays non-targetable"); + Assert.That(view.IsVisible(24, 10), Is.False, "a pinged cell stays non-targetable"); Assert.That(view.IsVisible(13, 10), Is.True, "the in-sight enemy is a target instead"); } + [Test] + public void RadarSignature_WithoutCompletedRadar_NoCoverage() + { + var host = TestHost.Create(Seed); + SpawnAt(host, 0, 10, 10, sightRadius: 5); // a plain unit radiates nothing since 16.5 + SpawnAt(host, 1, 16, 10, sightRadius: 5); // hidden (6 > 5) — the old every-unit x2 rule WOULD have pinged this + + host.Step(2); + + var pings = new List(); + host.Fog.GetRadarSignatures(0, pings); + Assert.That(pings.Count, Is.EqualTo(0), "no finished Radar building, no coverage at all"); + } + + [Test] + public void RadarSignatures_StopAtPowerDeficit_AndResumeWhenBalanceRecovers() + { + // 16.6 (C4, D-030): at a power deficit the radar is the FIRST + // system to fall — no coverage, no pings. The deficit here is set + // directly on the balance (the rig's economy never recomputes). + var host = TestHost.Create(Seed); + Assert.That(host.Construction.PlaceCompletedBuilding(0, 10, 9, 14).IsValid, Is.True); + SpawnAt(host, 1, 24, 10, sightRadius: 5); // radar-covered, hidden + host.Step(2); + + var pings = new List(); + host.Fog.GetRadarSignatures(0, pings); + Assert.That(pings.Count, Is.EqualTo(1), "coverage with a finished Radar and a balanced grid"); + + host.Economy.GetPlayerEconomy(0).PowerRequired = 1; // deficit: 1 required > 0 provided + pings.Clear(); + host.Fog.GetRadarSignatures(0, pings); + Assert.That(pings.Count, Is.EqualTo(0), "LOW POWER takes the radar offline"); + + host.Economy.GetPlayerEconomy(0).PowerRequired = 0; + host.Fog.GetRadarSignatures(0, pings); + Assert.That(pings.Count, Is.EqualTo(1), "the radar comes back with the balance"); + } + [Test] public void HiddenWorldMetamorphic_HiddenEnemyVariation_LeavesTeamZeroViewIdentical() { @@ -269,9 +326,17 @@ public void HiddenWorldMetamorphic_HiddenEnemyVariation_LeavesTeamZeroViewIdenti var hostB = TestHost.Create(Seed); SpawnAt(hostA, 0, 10, 10, sightRadius: 5); SpawnAt(hostB, 0, 10, 10, sightRadius: 5); + // 16.5: both hosts get an identical completed Radar, so the ping + // comparison exercises the radar path rather than two empty lists. + Assert.That(hostA.Construction.PlaceCompletedBuilding(0, 10, 9, 14).IsValid, Is.True); + Assert.That(hostB.Construction.PlaceCompletedBuilding(0, 10, 9, 14).IsValid, Is.True); EntityId hiddenA = SpawnAt(hostA, 1, 50, 50, sightRadius: 5); EntityId hiddenB = SpawnAt(hostB, 1, 55, 52, sightRadius: 5); // hidden variation Assert.That(hiddenA, Is.EqualTo(hiddenB), "same spawn sequence must yield the same id"); + // Identical radar-covered enemy in BOTH hosts (inside coverage + // 20, outside both sight radii 10 and 5): one ping each. + SpawnAt(hostA, 1, 24, 10, sightRadius: 5); + SpawnAt(hostB, 1, 24, 10, sightRadius: 5); for (int i = 0; i < 10; i++) { diff --git a/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs b/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs index b72efac..d6a6a06 100644 --- a/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs +++ b/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs @@ -62,7 +62,7 @@ public static ProdHost Create(ulong seed, int capacity = 256, long startingCredi var economy = new EconomySystem(entities, startingCredits); var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, teamCount: 2, 128, 128); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, 128, 128); var combat = new CombatSystem(entities, fogOfWar, economy); var kernel = new SimulationKernel(new SimRandom(seed)); diff --git a/Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs b/Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs index dcbbeec..5d68e9f 100644 --- a/Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs +++ b/Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs @@ -318,10 +318,12 @@ public static TestHost Create() var entities = new EntityManager(64); var pathfinding = new PathfindingSystem(64, 64); var movement = new MovementSystem(entities, pathfinding); - var fog = new FogOfWarSystem(entities, teamCount: 2, 64, 64); // Faction source for the combat weapon table: an unregistered - // economy state (all slots default to Alliance). + // economy state (all slots default to Alliance). 16.5: the + // FoW radar read also requires the placement register. var factions = new EconomySystem(entities); + var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, factions); + var fog = new FogOfWarSystem(entities, construction, factions, teamCount: 2, 64, 64); var combat = new CombatSystem(entities, fog, factions); var kernel = new SimulationKernel(new SimRandom(Seed)); diff --git a/Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs b/Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs index 2105b63..20a8fb1 100644 --- a/Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs +++ b/Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs @@ -219,7 +219,7 @@ public void InitializeMatch(MatchConfig config) Economy = new EconomySystem(Entities, _config.StartingCredits); Construction = new Simulation.Construction.ConstructionSystem(Entities, Economy, Pathfinding.CostField); Production = new Simulation.Production.ProductionSystem(Entities, Economy, Construction); - FogOfWar = new FogOfWarSystem(Entities, teamCount: 2, _mapWidth, _mapHeight); + FogOfWar = new FogOfWarSystem(Entities, Construction, Economy, teamCount: 2, _mapWidth, _mapHeight); Combat = new CombatSystem(Entities, FogOfWar, Economy); Victory = new Simulation.Victory.VictorySystem(Entities, Construction); diff --git a/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs b/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs index 968a9fb..d1163e6 100644 --- a/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs +++ b/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs @@ -114,6 +114,7 @@ public sealed class BuildMenuHud : MonoBehaviour private bool _siteLacksBuilder; private int _siteLacksBuilderFrame = -1; private string _hoveredBlockerReason; + private string _hoveredRadarHint; private string _transientNotice; private float _transientNoticeUntil; @@ -287,18 +288,27 @@ private void DrawBar() float scale = Mathf.Max(1f, _uiScale); Vector2 guiMouse = HudLayout.RawMouseToGui(Input.mousePosition, scale); _hoveredBlockerReason = null; + _hoveredRadarHint = null; for (int i = 0; i < BuildableRoles.Length; i++) { UnitRole role = BuildableRoles[i]; if (!SimDefinitions.TryGetBuilding(faction, role, out SimBuildingDefinition def)) continue; - if (IsAvailable(in def, slot, credits, construction)) continue; var rect = new Rect( buttonsRect.x + i * (buttonWidth + ButtonSpacing), buttonsRect.y, buttonWidth, _buttonHeight); - if (rect.Contains(guiMouse)) + if (!rect.Contains(guiMouse)) continue; + + if (!IsAvailable(in def, slot, credits, construction)) { _hoveredBlockerReason = BlockerReason(role, in def, slot, credits, construction); } + else if (role == UnitRole.Radar) + { + // 16.5 (#54, C3): the button says in plain text what it + // unlocks — the minimap is a Radar function now, and + // losing the building takes the map away again. + _hoveredRadarHint = "Radar: schaltet die Minimap frei — ohne Radar keine Karte"; + } } // Chrome and status line paint BEFORE the buttons — IMGUI paints @@ -361,6 +371,7 @@ private void DrawChromeAndStatusLine(Rect zone, Rect buttonsRect) private string ResolveStatusLineText() { if (_hoveredBlockerReason != null) return _hoveredBlockerReason; + if (_hoveredRadarHint != null) return _hoveredRadarHint; if (_transientNotice != null && Time.unscaledTime < _transientNoticeUntil) return _transientNotice; if (_siteLacksBuilder) return ConstructionSiteStatus.NoBuilderWarning; if (!_hintDismissed && _runner.IsRunning) return HintText; diff --git a/Assets/_Project/Scripts/Presentation/UI/MinimapHud.cs b/Assets/_Project/Scripts/Presentation/UI/MinimapHud.cs index 7269602..39c0fad 100644 --- a/Assets/_Project/Scripts/Presentation/UI/MinimapHud.cs +++ b/Assets/_Project/Scripts/Presentation/UI/MinimapHud.cs @@ -6,6 +6,7 @@ using Nova.Gameplay.Match; using Nova.Simulation.CommandsV1; using Nova.Simulation.Definitions; +using Nova.Simulation.Economy; using Nova.Simulation.State; using Nova.Simulation.Vision; using EntityId = Nova.Core.EntityId; @@ -18,9 +19,16 @@ namespace Nova.Presentation.UI /// the Glutrinne desert ground, the viewer team's fog state as the same /// terrain silhouette in three brightness bands (visible full, explored /// dimmed, unexplored near-dark but readable — never black), every entity - /// the team may legally see as a faction-coloured dot, and the camera's - /// ground footprint as a white rectangle. A left click inside the map - /// jumps the camera there. + /// the team may legally see as a faction-coloured dot, radar pings as + /// signal-orange dots, and the camera's ground footprint as a white + /// rectangle. A left click inside the map jumps the camera there. + /// + /// RADAR GATE (16.5, #54, C3): the whole panel — draw AND hit area — only + /// exists while the local slot owns a COMPLETED Radar building + /// (). The map the tester always had was + /// never free; the radar button says so in plain text. One read drives + /// both, so the dead rect cannot swallow clicks while nothing is drawn. + /// /// /// NO LEAKS BY CONSTRUCTION: the background is the SAME committed team /// view the renders and the dots come @@ -79,8 +87,11 @@ public sealed class MinimapHud : MonoBehaviour [Header("Dots")] [SerializeField] private float _unitDotSize = 3f; [SerializeField] private float _buildingDotSize = 4.5f; + [Tooltip("Radar pings (16.5): foreign units inside Radar coverage but outside committed sight — a signal, not a target.")] + [SerializeField] private Color _radarPingColor = new Color(1f, 0.55f, 0.1f, 1f); private readonly List _visibleScratch = new List(256); + private readonly List _radarScratch = new List(64); private Texture2D _background; private Color32[] _pixels; private uint _renderedTick; @@ -109,6 +120,10 @@ private void Awake() /// public bool IsPointerOverMinimap(Vector2 mousePosition) { + // 16.5 (#54): no Radar, no map — and no hit area either, so the + // dead rect cannot swallow world clicks while nothing is drawn. + if (!LocalRadarOnline()) return false; + float scale = Mathf.Max(1f, _uiScale); Vector2 gui = HudLayout.RawMouseToGui(mousePosition, scale); Rect map = ComputeMapRect(); @@ -131,6 +146,11 @@ private void OnGUI() { FogOfWarSystem fog = _runner != null ? _runner.FogOfWar : null; if (fog == null) return; // no committed sight, no minimap (the world view renders nothing either) + // 16.5 (#54, C3): the map itself is a Radar function now — no + // completed Radar, no minimap at all. The radar building button + // says so in plain text (BuildMenuHud), and a lost Radar takes + // the map away again. + if (!LocalRadarOnline()) return; byte team = ResolveViewerTeam(fog); RefreshBackground(fog, team); @@ -148,12 +168,30 @@ private void OnGUI() GUI.DrawTexture(map, _background); DrawEntityDots(map, fog, team); + DrawRadarPings(map, fog, team); DrawCameraViewport(map, fog); HandleClick(map, fog); GUI.matrix = previousMatrix; } + /// + /// 16.5 (#54, C3): the minimap unlocks with the local slot's first + /// COMPLETED Radar building and goes dark when it is lost. 16.6 (C4, + /// D-030): at a power deficit the radar is the FIRST system to fall — + /// the map goes dark too, exactly like the sim-side pings stop. One + /// read, shared by the draw and the hit test, so they can never + /// disagree. + /// + private bool LocalRadarOnline() + { + if (_runner == null || _runner.Construction == null || _runner.Session == null) return false; + if (!_runner.Construction.HasFinishedBuilding(_runner.Session.LocalSlot, UnitRole.Radar)) return false; + EconomySystem economy = _runner.Economy; + if (economy == null) return false; + return !economy.GetPlayerEconomy(_runner.Session.LocalSlot).IsLowPower; + } + /// The local viewer team — the same convention as FogOfWarOverlayView/UnitViewManager. private byte ResolveViewerTeam(FogOfWarSystem fog) { @@ -290,6 +328,39 @@ private void DrawEntityDots(Rect map, FogOfWarSystem fog, byte team) GUI.color = previousColor; } + /// + /// Radar pings (16.5, #54): foreign units inside the finished Radar's + /// coverage but OUTSIDE committed sight — a signal, not a target. + /// Drawn as the signal-orange dot, one per cell, on top of the entity + /// dots so a ping is never hidden by explored terrain. First live + /// consumer of . + /// + private void DrawRadarPings(Rect map, FogOfWarSystem fog, byte team) + { + if (FogRevealDebug.RevealAll) return; // the lab reveal already shows everything + + _radarScratch.Clear(); + fog.GetRadarSignatures(team, _radarScratch); + if (_radarScratch.Count == 0) return; + + Color previousColor = GUI.color; + GUI.color = _radarPingColor; + float size = _unitDotSize + 1f; + for (int i = 0; i < _radarScratch.Count; i++) + { + RadarSignature ping = _radarScratch[i]; + // The signature is a GRID cell: draw its centre (the +0.5), + // same boundary as the entity dots. + (float uiX, float uiY) = MinimapRenderer.WorldToMinimapGuiCoordinates( + ping.GridX + 0.5f, ping.GridY + 0.5f, + fog.Width, fog.Height, map.width, map.height); + GUI.DrawTexture( + new Rect(map.x + uiX - size * 0.5f, map.y + uiY - size * 0.5f, size, size), + HudChrome.Pixel); + } + GUI.color = previousColor; + } + /// Owner faction colour via the economy state — the same lookup the world views use; grey fallback, never a throw. private Color ColorForOwner(byte playerId) { diff --git a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs index 1495ef7..3950b52 100644 --- a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs @@ -102,7 +102,8 @@ namespace Nova.Simulation.Construction /// damaged building: in reach (same Chebyshev rule) the target gains /// HP per tick up to its MaxHealth, /// where the order resolves; out of reach the order is HELD, never - /// dropped; Stop clears it. Repair is unaffected by low power. + /// dropped; Stop clears it. Under LOW POWER the rate halves exactly + /// (16.6, C4 — Economy.md repair rule; 10/2 is exact, no rounding). /// /// /// State (snapshot block , @@ -750,7 +751,17 @@ private void ProcessRepairOrders() continue; // held, not dropped } - int repaired = target.CurrentHealth + RepairRateHpPerTick; + // 16.6 (C4, Economy.md repair rule): LOW POWER halves the + // repair rate — 10/2 is exact, no rounding. The last stage of + // the shutdown order still repairs; it just repairs slower. + int rate = RepairRateHpPerTick; + ref readonly PlayerEconomyState repairEco = ref _economy.GetPlayerEconomy(target.PlayerId); + if (repairEco.IsLowPower) + { + rate = RepairRateHpPerTick / 2; + } + + int repaired = target.CurrentHealth + rate; target.CurrentHealth = repaired > target.MaxHealth ? target.MaxHealth : repaired; } } diff --git a/Assets/_Project/Scripts/Simulation/Vision/FogOfWarSystem.cs b/Assets/_Project/Scripts/Simulation/Vision/FogOfWarSystem.cs index 3ebe11c..88348f8 100644 --- a/Assets/_Project/Scripts/Simulation/Vision/FogOfWarSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Vision/FogOfWarSystem.cs @@ -25,8 +25,13 @@ namespace Nova.Simulation.Vision /// MS-1 sight model: pure radii — no blockers, heights, weather, /// stealth/detection or special per-unit sight logic. Radar produces /// minimap pings WITHOUT targeting - /// permission. MS-1 simplification: team index equals the player slot - /// (D-058 activates exactly two slots; allied shared sight is post-MS-1). + /// permission, and only from a COMPLETED Radar building (16.5, #54, C3): + /// the building is the radiator, its own sight radius times + /// is the coverage — no finished + /// Radar, no coverage. At a power deficit the radar is the FIRST system + /// to fall (16.6, C4, D-030): no coverage, no pings. MS-1 simplification: + /// team index equals the player slot (D-058 activates exactly two slots; + /// allied shared sight is post-MS-1). /// /// /// Determinism (FogOfWar.md section 5): integer distance tests in stable @@ -48,14 +53,17 @@ public sealed class FogOfWarSystem : IStatefulSimSystem public const int RecomputeIntervalTicks = 2; /// - /// Provisional radar coverage factor over the sight radius (Q-040 - /// candidate: the MS-1 spec mandates radar pings but defines no radar - /// radius source; until ratification every unit radiates radar at - /// twice its sight radius). + /// Radar coverage factor over the building's sight radius (16.5, + /// #54, C3: only a COMPLETED Radar building radiates — the finished + /// building's own sight radius times this factor is the coverage. + /// The pre-16.5 rule, every unit radiating at twice its sight, is + /// gone together with its last caller contract). /// public const int RadarRadiusMultiplier = 2; private readonly EntityManager _entityManager; + private readonly Construction.ConstructionSystem _construction; + private readonly Economy.EconomySystem _economy; private readonly byte[][] _masks; private readonly HashSet _radarSeenCells = new HashSet(); @@ -73,9 +81,19 @@ public sealed class FogOfWarSystem : IStatefulSimSystem /// False until the first recompute committed a view (all cells Unexplored). public bool HasCommittedView { get; private set; } - public FogOfWarSystem(EntityManager entityManager, int teamCount = 2, ushort width = 128, ushort height = 128) + /// + /// The construction system is a REQUIRED dependency since 16.5 (#54): + /// radar coverage derives from COMPLETED Radar placements, which only + /// its register can tell from sites and corpses. The economy is one + /// since 16.6 (C4, D-030): at a power deficit the radar goes OFFLINE — + /// no pings, no coverage. Both are read-only here (placement queries, + /// balance reads) and never ticked through this system. + /// + public FogOfWarSystem(EntityManager entityManager, Construction.ConstructionSystem construction, Economy.EconomySystem economy, int teamCount = 2, ushort width = 128, ushort height = 128) { _entityManager = entityManager ?? throw new ArgumentNullException(nameof(entityManager)); + _construction = construction ?? throw new ArgumentNullException(nameof(construction)); + _economy = economy ?? throw new ArgumentNullException(nameof(economy)); if (teamCount < 1 || teamCount > MaxTeams) { throw new ArgumentOutOfRangeException(nameof(teamCount), teamCount, $"Team count must be in [1, {MaxTeams}]."); @@ -191,6 +209,15 @@ public int GetVisibleEntities(byte team, List results) /// index, one ping per cell. Derived view over committed sight and /// current positions; not part of the authoritative state. /// + /// Coverage (16.5, #54, C3): only a COMPLETED Radar building of the + /// team radiates, over the building's own sight radius times + /// . A Radar site, a destroyed one + /// and every other entity radiate nothing — without a finished Radar + /// the team has no coverage at all (and MinimapHud draws no map). At + /// a power deficit the radar is the FIRST system to fall (16.6, C4, + /// D-030): this method returns nothing. + /// + /// /// Cadence (Q-040(j), provisional): pings derive from live 10 Hz /// positions and can fire before the first committed view — FogOfWar.md /// section 6.3 pins only the cadence @@ -207,6 +234,14 @@ public int GetRadarSignatures(byte team, List results) throw new ArgumentOutOfRangeException(nameof(team), team, "Unknown team slot."); } + // 16.6 (C4, D-030): at a power deficit the radar is the FIRST + // system to fall — no coverage, no pings. The minimap reads the + // same balance and goes dark with it. + if (_economy.GetPlayerEconomy(team).IsLowPower) + { + return 0; + } + byte[] mask = _masks[team]; UnitState[] units = _entityManager.RawUnits; int capacity = _entityManager.Capacity; @@ -224,12 +259,15 @@ public int GetRadarSignatures(byte team, List results) if (mask[cell] == (byte)VisionState.Visible) continue; // a target, not a ping if (!_radarSeenCells.Add(cell)) continue; // one signature per cell - // Radar coverage: any own unit whose radar radius reaches the - // target's cell center (same cell-center rule as sight). + // Radar coverage: any own COMPLETED Radar building whose + // radar radius reaches the target's cell center (same + // cell-center rule as sight). for (int j = 0; j < capacity; j++) { ref readonly UnitState observer = ref units[j]; if (!observer.IsActive || observer.PlayerId != team) continue; + if (observer.Role != UnitRole.Radar) continue; + if (!_construction.IsCompletedPlacement(UnitCommandStateView.ToRawEntityId(observer.Id))) continue; SimFixed radarRadius = observer.SightRadius * SimFixed.FromInt(RadarRadiusMultiplier); int ox = GridXOf(in observer); diff --git a/CHANGELOG.md b/CHANGELOG.md index d61365d..143e611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,26 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de > erzeugt; MS-0 und MS-1 bleiben offen. ### Behoben +- **Low Power ist eine Waffe (C4/D-030, Sprint 16.6)** — bei Energiedefizit + fällt Radar zuerst: `FogOfWarSystem.GetRadarSignatures` liefert nichts mehr + (Economy als Pflicht-Abhängigkeit), und die Minimap geht mit aus (dieselbe + Bilanz, dieselbe Abfrage). Produktion und Bau behalten den exakten + Tempo-Malus (0.5 in Q16.16), und die Reparatur halbiert jetzt exakt + (10 → 5 HP/Tick, Economy.md-Reparaturregel). Erst damit ist der Angriff + aufs Kraftwerk ein taktischer Zug. **Nicht dabei:** die Verteidigungs- + abschaltung — ob ein Turm feuert, entscheidet `CombatSystem` (Einheiten- + strang), das einen Strombegriff nicht kennt; Befund geht an 13B +- **#54: Das Radar wird ein Gebäude (C3/D-096)** — die Minimap ist jetzt eine + Radar-Funktion: `MinimapHud` zeichnet (Panel und Trefferfläche) nur noch, + solange der lokale Slot ein fertiges Radar besitzt; der Bauknopf sagt es im + Klartext („Radar: schaltet die Minimap frei — ohne Radar keine Karte"). Die + Radar-Abdeckung kommt vom Gebäude statt von jeder Einheit: nur ein fertiges + Radargebäude strahlt (Sichtweite × 2), `FogOfWarSystem` bekommt das + Bau-Register dafür als Pflicht-Abhängigkeit, und `GetRadarSignatures` wird + erstmals von der Präsentation konsumiert (signal-orange Pings: ein Zeichen, + kein Ziel). `GetTeamView` unverändert — Vertragsfläche des Einheitenstrangs. + Kein Baseline-Eingriff: die Pings sind eine abgeleitete Ansicht, kein + autorisierter Zustand - **#49: Auswahlrahmen und Füllung entschärft** — `GroundMarkerVisuals`: Rand von 6/64 auf 2/64 der Quad-Kante, Füll-Alpha von 0.28 auf 0.10; wirkt auf Auswahl-, Platzierungs-, Sammelpunkt- und Baustellenmarker zugleich und nimmt #50 (Einheit im Pulk nicht auffindbar) die verdeckende Füllung ab - **Die drei Laborschalter greifen nicht mehr in einer Netzpartie und nicht mehr im ausgelieferten Build:** `FogRevealDebug` und `MatchSpeedDebug` kamen aus dem diff --git a/tools/Nova.SimRunner.Tests/BarracksSpawnMatchConfigTests.cs b/tools/Nova.SimRunner.Tests/BarracksSpawnMatchConfigTests.cs index caa4942..99fcf52 100644 --- a/tools/Nova.SimRunner.Tests/BarracksSpawnMatchConfigTests.cs +++ b/tools/Nova.SimRunner.Tests/BarracksSpawnMatchConfigTests.cs @@ -99,7 +99,7 @@ private static MatchHost BuildMatchHost() var economy = new EconomySystem(entities, EconomySystem.CanonicalMatchStartingCreditsAE); var construction = new ConstructionSystem(entities, economy); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, teamCount: 2, MapWidth, MapHeight); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, MapWidth, MapHeight); var combat = new Nova.Simulation.Combat.CombatSystem(entities, fogOfWar, economy); var victory = new Nova.Simulation.Victory.VictorySystem(entities, construction); diff --git a/tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs b/tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs index ad90df2..8107ff3 100644 --- a/tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs +++ b/tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs @@ -103,7 +103,7 @@ private static ReferenceHost BuildReferenceHost(ulong seed) var economy = new EconomySystem(entities, EconomySystem.CanonicalMatchStartingCreditsAE); var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, teamCount: 2, MapWidth, MapHeight); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, MapWidth, MapHeight); var combat = new Nova.Simulation.Combat.CombatSystem(entities, fogOfWar, economy); var victory = new Nova.Simulation.Victory.VictorySystem(entities, construction); diff --git a/tools/Nova.SimRunner.Tests/CombatSystemTests.cs b/tools/Nova.SimRunner.Tests/CombatSystemTests.cs index 199c5dd..389d29c 100644 --- a/tools/Nova.SimRunner.Tests/CombatSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/CombatSystemTests.cs @@ -38,23 +38,29 @@ private sealed class TestHost public FogOfWarSystem Fog { get; } public CombatSystem Combat { get; } - private TestHost(SimulationKernel kernel, EntityManager entities, FogOfWarSystem fog, CombatSystem combat) + private TestHost(SimulationKernel kernel, EntityManager entities, + Nova.Simulation.Construction.ConstructionSystem construction, FogOfWarSystem fog, CombatSystem combat) { Kernel = kernel; Entities = entities; + Construction = construction; Fog = fog; Combat = combat; } + public Nova.Simulation.Construction.ConstructionSystem Construction { get; } + public static TestHost Create(ulong seed, int capacity = 64, ushort width = 64, ushort height = 64) { var entities = new EntityManager(capacity); var pathfinding = new PathfindingSystem(width, height); var movement = new MovementSystem(entities, pathfinding); - var fog = new FogOfWarSystem(entities, teamCount: 2, width, height); // Faction source for the combat weapon table: an unregistered - // economy state (all slots default to Alliance). + // economy state (all slots default to Alliance). 16.5: the + // FoW radar read also requires the placement register. var factions = new EconomySystem(entities); + var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, factions); + var fog = new FogOfWarSystem(entities, construction, factions, teamCount: 2, width, height); var combat = new CombatSystem(entities, fog, factions); var kernel = new SimulationKernel(new SimRandom(seed)); @@ -63,7 +69,7 @@ public static TestHost Create(ulong seed, int capacity = 64, ushort width = 64, kernel.RegisterSystem(fog); kernel.RegisterSystem(combat); kernel.Start(); - return new TestHost(kernel, entities, fog, combat); + return new TestHost(kernel, entities, construction, fog, combat); } public void Step() => Kernel.StepTick(); @@ -246,17 +252,21 @@ public void Cooldown_ExactlyFiveTicksBetweenShots() public void RadarPingOnly_GrantsNoTargetingPermission() { var host = TestHost.Create(Seed); - // Sight 5 (radar 10 provisional x2): the enemy 7 cells out is - // radar-covered and inside weapon range 8 + 0.5, but NOT Visible. - EntityId attacker = SpawnAt(host, 0, 10, 10, sightRadius: 5); - EntityId target = SpawnAt(host, 1, 17, 10, maxHealth: 100); + // 16.5: only a COMPLETED Radar building radiates — centre (10,15), + // coverage sight 10 x 2 = 20. The building also contributes plain + // SIGHT (radius 10), so the target hides from both radii: 16 > 10 + // and 16 > 5 away, inside the 20 coverage, inside weapon range. + EntityId attacker = SpawnAt(host, 0, 20, 10, sightRadius: 5); + Assert.That(host.Construction.PlaceCompletedBuilding(0, 10, 9, 14).IsValid, Is.True, + "Alliance Radar (def 10) as a completed placement"); + EntityId target = SpawnAt(host, 1, 26, 10, maxHealth: 100); host.Entities.GetUnitRef(attacker).AttackTarget = target; host.Step(2); // first commit var pings = new System.Collections.Generic.List(); host.Fog.GetRadarSignatures(0, pings); Assert.That(pings.Count, Is.EqualTo(1), "the hidden in-range enemy pings on radar"); - Assert.That(host.Fog.GetTeamView(0).IsVisible(17, 10), Is.False); + Assert.That(host.Fog.GetTeamView(0).IsVisible(26, 10), Is.False); host.Step(18); // many shot opportunities pass Assert.That(HealthOf(host, target), Is.EqualTo(100), @@ -563,7 +573,9 @@ public static ReplayHost Create(ulong seed) var pathfinding = new PathfindingSystem(64, 64); var movement = new MovementSystem(entities, pathfinding); var economy = new EconomySystem(entities); - var fog = new FogOfWarSystem(entities, teamCount: 2, 64, 64); + // 16.5: the FoW radar read requires the placement register. + var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, economy); + var fog = new FogOfWarSystem(entities, construction, factions, teamCount: 2, 64, 64); var combat = new CombatSystem(entities, fog, economy); var kernel = new SimulationKernel(new SimRandom(seed)); diff --git a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs index 286401d..659b28d 100644 --- a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs @@ -587,6 +587,27 @@ public void Repair_BuilderRestoresHp_InReachOnly_AndResolvesAtFull() "repair caps at MaxHealth and the order resolves"); } + [Test] + public void Repair_LowPower_ExactlyHalvesTheRate() + { + // 16.6 (C4, Economy.md repair rule): under LOW POWER the repair + // rate halves exactly — 5 HP per tick, no rounding. + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 40, 40).IsValid, Is.True, + "a completed Refinery (20 required, nothing provided) forces low power"); + EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); + f.Entities.GetUnitRef(barracks).CurrentHealth = 100; + + EntityId builder = f.SpawnBuilder(0, 19, 20); + f.Step(1); // commit the balance: refinery + barracks draw 35, nothing provided + Assert.That(f.Economy.GetPlayerEconomy(0).IsLowPower, Is.True); + + f.Construction.AssignRepairOrder(UnitCommandStateView.ToRawEntityId(builder), UnitCommandStateView.ToRawEntityId(barracks)); + f.Step(10); + Assert.That(f.Entities.GetUnitRef(barracks).CurrentHealth, Is.EqualTo(150), + "5 HP per tick under low power — exactly half the provisional rate"); + } + [Test] public void Repair_Validation_RejectsNonBuilder_AndUndamagedTarget() { diff --git a/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs b/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs index 8f809a8..ef375a0 100644 --- a/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs +++ b/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs @@ -56,7 +56,9 @@ public static EcoHost Create(ulong seed, int capacity = 256, ushort width = 64, var pathfinding = new PathfindingSystem(width, height); var movement = new MovementSystem(entities, pathfinding); var economy = new EconomySystem(entities); - var fogOfWar = new FogOfWarSystem(entities, teamCount: 2, width, height); + // 16.5: the FoW radar read requires the placement register. + var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, economy); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, width, height); var combat = new CombatSystem(entities, fogOfWar, economy); var kernel = new SimulationKernel(new SimRandom(seed)); diff --git a/tools/Nova.SimRunner.Tests/FogOfWarSystemTests.cs b/tools/Nova.SimRunner.Tests/FogOfWarSystemTests.cs index 7eaf8d2..7c1914b 100644 --- a/tools/Nova.SimRunner.Tests/FogOfWarSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/FogOfWarSystemTests.cs @@ -2,6 +2,9 @@ using NUnit.Framework; using Nova.Core; using Nova.Simulation; +using Nova.Simulation.Construction; +using Nova.Simulation.Definitions; +using Nova.Simulation.Economy; using Nova.Simulation.Movement; using Nova.Simulation.Pathfinding; using Nova.Simulation.Snapshots; @@ -32,12 +35,16 @@ private sealed class TestHost { public SimulationKernel Kernel { get; } public EntityManager Entities { get; } + public EconomySystem Economy { get; } + public ConstructionSystem Construction { get; } public FogOfWarSystem Fog { get; } - private TestHost(SimulationKernel kernel, EntityManager entities, FogOfWarSystem fog) + private TestHost(SimulationKernel kernel, EntityManager entities, EconomySystem economy, ConstructionSystem construction, FogOfWarSystem fog) { Kernel = kernel; Entities = entities; + Economy = economy; + Construction = construction; Fog = fog; } @@ -46,14 +53,19 @@ public static TestHost Create(ulong seed, int capacity = 64, ushort width = 64, var entities = new EntityManager(capacity); var pathfinding = new PathfindingSystem(width, height); var movement = new MovementSystem(entities, pathfinding); - var fog = new FogOfWarSystem(entities, teamCount: 2, width, height); + // 16.5/16.6: the FoW radar read requires the placement register + // and the power balance — an unregistered economy/construction + // pair answers both queries without ever ticking. + var economy = new EconomySystem(entities); + var construction = new ConstructionSystem(entities, economy); + var fog = new FogOfWarSystem(entities, construction, economy, teamCount: 2, width, height); var kernel = new SimulationKernel(new SimRandom(seed)); kernel.RegisterSystem(pathfinding); kernel.RegisterSystem(movement); kernel.RegisterSystem(fog); kernel.Start(); - return new TestHost(kernel, entities, fog); + return new TestHost(kernel, entities, economy, construction, fog); } public void Step() => Kernel.StepTick(); @@ -235,9 +247,15 @@ public void GetVisibleEntities_OwnAlways_ForeignOnlyInVisibleCells() public void RadarSignature_PingsWithoutTargetingRight() { var host = TestHost.Create(Seed); - SpawnAt(host, 0, 10, 10, sightRadius: 5); // radar coverage 10 (provisional x2) - SpawnAt(host, 1, 19, 10, sightRadius: 5); // inside radar (9 <= 10), outside sight (9 > 5) - SpawnAt(host, 1, 40, 10, sightRadius: 5); // outside radar + SpawnAt(host, 0, 10, 10, sightRadius: 5); // sight observer, NOT a radar source + // 16.5 (#54): only a COMPLETED Radar building radiates — centre + // (10,15), coverage = its sight radius 10 x 2 = 20. NOTE: the + // building also contributes plain SIGHT (radius 10), so a ping + // target must hide from both radii. + Assert.That(host.Construction.PlaceCompletedBuilding(0, 10, 9, 14).IsValid, Is.True, + "Alliance Radar (def 10) as a completed placement"); + SpawnAt(host, 1, 24, 10, sightRadius: 5); // inside radar coverage (dx 14 <= 20), outside both sights (14 > 10, 14 > 5) + SpawnAt(host, 1, 40, 10, sightRadius: 5); // outside radar (dx 30 > 20) SpawnAt(host, 1, 13, 10, sightRadius: 5); // inside sight: a target, not a ping host.Step(2); @@ -245,17 +263,56 @@ public void RadarSignature_PingsWithoutTargetingRight() var pings = new List(); host.Fog.GetRadarSignatures(0, pings); Assert.That(pings.Count, Is.EqualTo(1), "exactly the radar-covered hidden enemy pings"); - Assert.That(pings[0].GridX, Is.EqualTo(19)); + Assert.That(pings[0].GridX, Is.EqualTo(24)); Assert.That(pings[0].GridY, Is.EqualTo(10)); // Contract: the ping grants no targeting permission — the cell is // not Visible, so Combat must not address the pinged object. The // signature struct carries no EntityId by design. TeamView view = host.Fog.GetTeamView(0); - Assert.That(view.IsVisible(19, 10), Is.False, "a pinged cell stays non-targetable"); + Assert.That(view.IsVisible(24, 10), Is.False, "a pinged cell stays non-targetable"); Assert.That(view.IsVisible(13, 10), Is.True, "the in-sight enemy is a target instead"); } + [Test] + public void RadarSignature_WithoutCompletedRadar_NoCoverage() + { + var host = TestHost.Create(Seed); + SpawnAt(host, 0, 10, 10, sightRadius: 5); // a plain unit radiates nothing since 16.5 + SpawnAt(host, 1, 16, 10, sightRadius: 5); // hidden (6 > 5) — the old every-unit x2 rule WOULD have pinged this + + host.Step(2); + + var pings = new List(); + host.Fog.GetRadarSignatures(0, pings); + Assert.That(pings.Count, Is.EqualTo(0), "no finished Radar building, no coverage at all"); + } + + [Test] + public void RadarSignatures_StopAtPowerDeficit_AndResumeWhenBalanceRecovers() + { + // 16.6 (C4, D-030): at a power deficit the radar is the FIRST + // system to fall — no coverage, no pings. The deficit here is set + // directly on the balance (the rig's economy never recomputes). + var host = TestHost.Create(Seed); + Assert.That(host.Construction.PlaceCompletedBuilding(0, 10, 9, 14).IsValid, Is.True); + SpawnAt(host, 1, 24, 10, sightRadius: 5); // radar-covered, hidden + host.Step(2); + + var pings = new List(); + host.Fog.GetRadarSignatures(0, pings); + Assert.That(pings.Count, Is.EqualTo(1), "coverage with a finished Radar and a balanced grid"); + + host.Economy.GetPlayerEconomy(0).PowerRequired = 1; // deficit: 1 required > 0 provided + pings.Clear(); + host.Fog.GetRadarSignatures(0, pings); + Assert.That(pings.Count, Is.EqualTo(0), "LOW POWER takes the radar offline"); + + host.Economy.GetPlayerEconomy(0).PowerRequired = 0; + host.Fog.GetRadarSignatures(0, pings); + Assert.That(pings.Count, Is.EqualTo(1), "the radar comes back with the balance"); + } + [Test] public void HiddenWorldMetamorphic_HiddenEnemyVariation_LeavesTeamZeroViewIdentical() { @@ -269,9 +326,17 @@ public void HiddenWorldMetamorphic_HiddenEnemyVariation_LeavesTeamZeroViewIdenti var hostB = TestHost.Create(Seed); SpawnAt(hostA, 0, 10, 10, sightRadius: 5); SpawnAt(hostB, 0, 10, 10, sightRadius: 5); + // 16.5: both hosts get an identical completed Radar, so the ping + // comparison exercises the radar path rather than two empty lists. + Assert.That(hostA.Construction.PlaceCompletedBuilding(0, 10, 9, 14).IsValid, Is.True); + Assert.That(hostB.Construction.PlaceCompletedBuilding(0, 10, 9, 14).IsValid, Is.True); EntityId hiddenA = SpawnAt(hostA, 1, 50, 50, sightRadius: 5); EntityId hiddenB = SpawnAt(hostB, 1, 55, 52, sightRadius: 5); // hidden variation Assert.That(hiddenA, Is.EqualTo(hiddenB), "same spawn sequence must yield the same id"); + // Identical radar-covered enemy in BOTH hosts (inside coverage + // 20, outside both sight radii 10 and 5): one ping each. + SpawnAt(hostA, 1, 24, 10, sightRadius: 5); + SpawnAt(hostB, 1, 24, 10, sightRadius: 5); for (int i = 0; i < 10; i++) { diff --git a/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs b/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs index 1defad7..b3dce9d 100644 --- a/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs +++ b/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs @@ -332,7 +332,7 @@ public static ClientHost Create(RelayMatchClient client) var economy = new EconomySystem(entities, EconomySystem.CanonicalMatchStartingCreditsAE); var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, teamCount: 2, 128, 128); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, 128, 128); var combat = new CombatSystem(entities, fogOfWar, economy); var kernel = new SimulationKernel(new SimRandom(Seed)); @@ -407,7 +407,7 @@ public static ClientHost CreatePlayback() var economy = new EconomySystem(entities, EconomySystem.CanonicalMatchStartingCreditsAE); var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, teamCount: 2, 128, 128); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, 128, 128); var combat = new CombatSystem(entities, fogOfWar, economy); var kernel = new SimulationKernel(new SimRandom(Seed)); kernel.RegisterSystem(economy); diff --git a/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs b/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs index 8f51512..116850b 100644 --- a/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs +++ b/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs @@ -62,7 +62,7 @@ public static ProdHost Create(ulong seed, int capacity = 256, long startingCredi var economy = new EconomySystem(entities, startingCredits); var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, teamCount: 2, 128, 128); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, 128, 128); var combat = new CombatSystem(entities, fogOfWar, economy); var kernel = new SimulationKernel(new SimRandom(seed)); diff --git a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs index 92da192..51c559a 100644 --- a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs +++ b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs @@ -161,7 +161,7 @@ private static AiHost BuildAiHost(ulong seed, AiProfile? profile = null) var economy = new EconomySystem(entities, EconomySystem.CanonicalMatchStartingCreditsAE); var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, teamCount: 2, MapWidth, MapHeight); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, MapWidth, MapHeight); var combat = new CombatSystem(entities, fogOfWar, economy); var victory = new VictorySystem(entities, construction); diff --git a/tools/Nova.SimRunner.Tests/WeaponValuesTests.cs b/tools/Nova.SimRunner.Tests/WeaponValuesTests.cs index da4af82..eb7095b 100644 --- a/tools/Nova.SimRunner.Tests/WeaponValuesTests.cs +++ b/tools/Nova.SimRunner.Tests/WeaponValuesTests.cs @@ -318,10 +318,12 @@ public static TestHost Create() var entities = new EntityManager(64); var pathfinding = new PathfindingSystem(64, 64); var movement = new MovementSystem(entities, pathfinding); - var fog = new FogOfWarSystem(entities, teamCount: 2, 64, 64); // Faction source for the combat weapon table: an unregistered - // economy state (all slots default to Alliance). + // economy state (all slots default to Alliance). 16.5: the + // FoW radar read also requires the placement register. var factions = new EconomySystem(entities); + var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, factions); + var fog = new FogOfWarSystem(entities, construction, factions, teamCount: 2, 64, 64); var combat = new CombatSystem(entities, fog, factions); var kernel = new SimulationKernel(new SimRandom(Seed)); diff --git a/tools/Nova.SimRunner/Determinism10000Scenario.cs b/tools/Nova.SimRunner/Determinism10000Scenario.cs index 0a43a89..e96a735 100644 --- a/tools/Nova.SimRunner/Determinism10000Scenario.cs +++ b/tools/Nova.SimRunner/Determinism10000Scenario.cs @@ -668,7 +668,7 @@ private static Host BuildHost(ulong seed, INovaLogger logger) var economy = new EconomySystem(entities, EconomySystem.CanonicalMatchStartingCreditsAE); var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, teamCount: 2, MapWidth, MapHeight); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, MapWidth, MapHeight); var combat = new Nova.Simulation.Combat.CombatSystem(entities, fogOfWar, economy); var victory = new Nova.Simulation.Victory.VictorySystem(entities, construction); diff --git a/tools/Nova.SimRunner/Program.cs b/tools/Nova.SimRunner/Program.cs index 5ebc2f7..bf9682a 100644 --- a/tools/Nova.SimRunner/Program.cs +++ b/tools/Nova.SimRunner/Program.cs @@ -369,7 +369,7 @@ private static ulong RunOnce(string runLabel, INovaLogger logger) var economy = new EconomySystem(entities); var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, economy, pathfinding.CostField); var production = new Nova.Simulation.Production.ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, teamCount: 2, 128, 128); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, 128, 128); var combat = new CombatSystem(entities, fogOfWar, economy); // Canonical tick order (SimulationCore.md section 2): economy diff --git a/tools/Nova.SimRunner/Scale500PrecombatScenario.cs b/tools/Nova.SimRunner/Scale500PrecombatScenario.cs index cc8761c..97cf418 100644 --- a/tools/Nova.SimRunner/Scale500PrecombatScenario.cs +++ b/tools/Nova.SimRunner/Scale500PrecombatScenario.cs @@ -535,7 +535,7 @@ private static Host BuildHost(ScenarioOptions options, INovaLogger logger) var economy = new EconomySystem(entities); var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); - var fogOfWar = new FogOfWarSystem(entities, teamCount: 2, MapWidth, MapHeight); + var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, MapWidth, MapHeight); var host = new Host { From 5c240cd0fcf665c9298c264923f587249e173477 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sun, 9 Aug 2026 20:39:19 +0200 Subject: [PATCH 2/3] fix(test): der zweite Kampf-Fixture reicht economy weiter, nicht factions Der Wiedergabe-Fixture in CombatSystemTests nennt seine Wirtschaft economy; beim Nachziehen der 16.5-Signatur blieb an einer Stelle der Name des ersten Fixtures (factions) stehen. Die CI brach mit CS0103 ab, bevor ein Test lief. Beide Spiegel gleich gezogen. Co-Authored-By: Claude Opus 5 --- Assets/Tests/EditMode/Simulation/CombatSystemTests.cs | 2 +- tools/Nova.SimRunner.Tests/CombatSystemTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs b/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs index 9398ac0..3325da1 100644 --- a/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs @@ -575,7 +575,7 @@ public static ReplayHost Create(ulong seed) var economy = new EconomySystem(entities); // 16.5: the FoW radar read requires the placement register. var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, economy); - var fog = new FogOfWarSystem(entities, construction, factions, teamCount: 2, 64, 64); + var fog = new FogOfWarSystem(entities, construction, economy, teamCount: 2, 64, 64); var combat = new CombatSystem(entities, fog, economy); var kernel = new SimulationKernel(new SimRandom(seed)); diff --git a/tools/Nova.SimRunner.Tests/CombatSystemTests.cs b/tools/Nova.SimRunner.Tests/CombatSystemTests.cs index 389d29c..2bec8fb 100644 --- a/tools/Nova.SimRunner.Tests/CombatSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/CombatSystemTests.cs @@ -575,7 +575,7 @@ public static ReplayHost Create(ulong seed) var economy = new EconomySystem(entities); // 16.5: the FoW radar read requires the placement register. var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, economy); - var fog = new FogOfWarSystem(entities, construction, factions, teamCount: 2, 64, 64); + var fog = new FogOfWarSystem(entities, construction, economy, teamCount: 2, 64, 64); var combat = new CombatSystem(entities, fog, economy); var kernel = new SimulationKernel(new SimRandom(seed)); From ff946748e36bb6f44abdd98df48ca40474c6b7cb Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sun, 9 Aug 2026 21:29:17 +0200 Subject: [PATCH 3/3] test(construction): keep normal repair fixture powered --- Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs | 2 ++ tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs index 8c9df21..d07b795 100644 --- a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs @@ -679,6 +679,8 @@ public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable() public void Repair_BuilderRestoresHp_InReachOnly_AndResolvesAtFull() { var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, + "a completed power plant keeps the normal repair rate active"); EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); uint raw = UnitCommandStateView.ToRawEntityId(barracks); f.Entities.GetUnitRef(barracks).CurrentHealth = 100; diff --git a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs index 0182d5d..9fea674 100644 --- a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs @@ -679,6 +679,8 @@ public void Sell_CompletedBuilding_Refunds50Percent_SiteIsNotSellable() public void Repair_BuilderRestoresHp_InReachOnly_AndResolvesAtFull() { var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 5, 40, 40).IsValid, Is.True, + "a completed power plant keeps the normal repair rate active"); EntityId barracks = f.Construction.PlaceCompletedBuilding(0, 7, 20, 20); uint raw = UnitCommandStateView.ToRawEntityId(barracks); f.Entities.GetUnitRef(barracks).CurrentHealth = 100;