diff --git a/Assets/Tests/EditMode/AI/SkirmishAiTests.cs b/Assets/Tests/EditMode/AI/SkirmishAiTests.cs index 5e0f1db..900163b 100644 --- a/Assets/Tests/EditMode/AI/SkirmishAiTests.cs +++ b/Assets/Tests/EditMode/AI/SkirmishAiTests.cs @@ -126,7 +126,7 @@ private static AiHost BuildAiHost(ulong seed) var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, MapWidth, MapHeight); - var combat = new CombatSystem(entities, fogOfWar, economy); + var combat = new CombatSystem(entities, fogOfWar, economy, construction); var victory = new VictorySystem(entities, construction); var session = new MatchSession(HumanSlot, activeSlots: new byte[] { HumanSlot, AiSlot }, inputDelayTicks: 1); @@ -290,6 +290,35 @@ public void SkirmishAi_PlacesRefineryThenBarracks_ThroughTheSealedCommandPath() "AI orders must enter through the canonical session/ingress intent path, not direct system calls"); } + [Test] + public void SkirmishAi_DefinitionRoleSite_DoesNotCountAsCompletedOrAdvanceBuildOrder() + { + AiHost host = BuildMatch(Seed); + + // The tick-20 decision submits the Refinery, tick 21 creates its + // site, and tick 40 is the first decision that must classify that + // definition-role entity through the site register. A bare role + // check queues a second (Barracks) site for tick 41. + host.Run(41); + + Assert.That(host.Construction.SiteCount, Is.EqualTo(1), + "an unfinished Refinery is the active build, not a completed producer that unlocks Barracks"); + Assert.That(host.Construction.HasFinishedBuilding(AiSlot, UnitRole.Refinery), Is.False); + Assert.That(host.Construction.HasFinishedBuilding(AiSlot, UnitRole.Barracks), Is.False); + + UnitState[] units = host.Entities.RawUnits; + int definitionRoleSites = 0; + for (int i = 0; i < host.Entities.Capacity; i++) + { + ref readonly UnitState unit = ref units[i]; + if (!unit.IsActive || unit.PlayerId != AiSlot || !host.Construction.IsActiveSite(unit.Id)) continue; + definitionRoleSites++; + Assert.That(unit.Role, Is.EqualTo(UnitRole.Refinery), + "the sole site carries the Refinery role without becoming a finished Refinery"); + } + Assert.That(definitionRoleSites, Is.EqualTo(1)); + } + // ---------------------------------------------------------------- // (b) Economy: harvesters work the field, credits recover // ---------------------------------------------------------------- diff --git a/Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs b/Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs index 024f32c..72eb349 100644 --- a/Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs +++ b/Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs @@ -132,7 +132,7 @@ private static ReferenceHost BuildReferenceHost(ulong seed) var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, MapWidth, MapHeight); - var combat = new Nova.Simulation.Combat.CombatSystem(entities, fogOfWar, economy); + var combat = new Nova.Simulation.Combat.CombatSystem(entities, fogOfWar, economy, construction); var victory = new Nova.Simulation.Victory.VictorySystem(entities, construction); kernel.RegisterSystem(economy); diff --git a/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs b/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs index 7fd4fd0..b96676e 100644 --- a/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/CombatSystemTests.cs @@ -3,6 +3,7 @@ using Nova.Simulation; using Nova.Simulation.Combat; using Nova.Simulation.CommandsV1; +using Nova.Simulation.Definitions; using Nova.Simulation.Economy; using Nova.Simulation.Movement; using Nova.Simulation.Pathfinding; @@ -35,14 +36,17 @@ private sealed class TestHost { public SimulationKernel Kernel { get; } public EntityManager Entities { get; } + public EconomySystem Economy { get; } public FogOfWarSystem Fog { get; } public CombatSystem Combat { get; } private TestHost(SimulationKernel kernel, EntityManager entities, - Nova.Simulation.Construction.ConstructionSystem construction, FogOfWarSystem fog, CombatSystem combat) + EconomySystem economy, Nova.Simulation.Construction.ConstructionSystem construction, + FogOfWarSystem fog, CombatSystem combat) { Kernel = kernel; Entities = entities; + Economy = economy; Construction = construction; Fog = fog; Combat = combat; @@ -61,7 +65,7 @@ public static TestHost Create(ulong seed, int capacity = 64, ushort width = 64, var factions = new EconomySystem(entities); var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, factions); var fog = new FogOfWarSystem(entities, construction, teamCount: 2, width, height); - var combat = new CombatSystem(entities, fog, factions); + var combat = new CombatSystem(entities, fog, factions, construction); var kernel = new SimulationKernel(new SimRandom(seed)); kernel.RegisterSystem(pathfinding); @@ -69,7 +73,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, construction, fog, combat); + return new TestHost(kernel, entities, factions, construction, fog, combat); } public void Step() => Kernel.StepTick(); @@ -107,6 +111,31 @@ private static int HealthOf(TestHost host, EntityId id) return u.CurrentHealth; } + private static EntityId PlaceActiveDefensePlatformSite(TestHost host, byte team, int originX, int originY) + { + FactionId faction = host.Economy.GetSlotFaction(team); + ushort powerDefId = SimDefinitions.ToDefinitionId(faction, UnitRole.Power); + ushort platformDefId = SimDefinitions.ToDefinitionId(faction, UnitRole.DefensePlatform); + Assert.That(host.Construction.PlaceCompletedBuilding(team, powerDefId, 50, 50).IsValid, Is.True, + "a completed Power plant unlocks the DefensePlatform site"); + host.Economy.ExecuteTick(Tick.Zero); + Assert.That(host.Construction.TryPlaceBuilding(team, platformDefId, originX, originY), Is.True); + + UnitState[] units = host.Entities.RawUnits; + for (int i = 0; i < host.Entities.Capacity; i++) + { + ref readonly UnitState unit = ref units[i]; + if (unit.IsActive && unit.PlayerId == team && unit.Role == UnitRole.DefensePlatform + && host.Construction.IsActiveSite(unit.Id)) + { + return unit.Id; + } + } + + Assert.Fail("the active DefensePlatform site was not found in the entity store"); + return EntityId.Invalid; + } + [Test] public void Fires_WhenTargetAliveInRangeAndVisible_OnlyAfterFirstCommit() { @@ -212,6 +241,46 @@ public void AutoAcquire_DefensePlatform_FiresOnItsOwn() Assert.That(HealthOf(host, attacker), Is.LessThan(200), "the platform actually fires"); } + [Test] + public void ActiveDefensePlatformSite_NeitherAcquiresNorExecutesExplicitAttack() + { + var host = TestHost.Create(Seed); + EntityId site = PlaceActiveDefensePlatformSite(host, 0, 10, 10); + EntityId hostile = SpawnAt(host, 1, 14, 11, maxHealth: 200); + + host.Step(2); + Assert.That(host.Construction.IsActiveSite(site), Is.True); + Assert.That(host.Entities.GetUnitRef(site).AttackTarget.IsValid, Is.False, + "an armed building role must stay inert while its entity is a site"); + Assert.That(HealthOf(host, hostile), Is.EqualTo(200)); + + host.Entities.GetUnitRef(site).AttackTarget = hostile; + host.Step(); + Assert.That(host.Entities.GetUnitRef(site).AttackTarget.IsValid, Is.False, + "an explicit or stale site order is cleared instead of becoming a completion-time free shot"); + Assert.That(HealthOf(host, hostile), Is.EqualTo(200)); + } + + [Test] + public void ActiveConstructionSite_CannotBeAutoAcquiredOrExplicitlyEngaged() + { + var host = TestHost.Create(Seed); + EntityId site = PlaceActiveDefensePlatformSite(host, 1, 10, 10); + EntityId attacker = SpawnAt(host, 0, 14, 11, maxHealth: 200); + + host.Step(2); + Assert.That(host.Entities.GetUnitRef(attacker).AttackTarget.IsValid, Is.False, + "auto-acquisition must exclude unfinished sites"); + Assert.That(host.Entities.GetUnitRef(site).CurrentHealth, Is.EqualTo(1)); + + host.Entities.GetUnitRef(attacker).AttackTarget = site; + host.Step(); + Assert.That(host.Entities.GetUnitRef(attacker).AttackTarget.IsValid, Is.False, + "an explicit order on an illegal site target is cleared"); + Assert.That(host.Construction.IsActiveSite(site), Is.True); + Assert.That(host.Entities.GetUnitRef(site).CurrentHealth, Is.EqualTo(1)); + } + [Test] public void AutoAcquire_UnarmedRoles_NeverAcquire() { @@ -576,7 +645,7 @@ public static ReplayHost Create(ulong seed) // 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, teamCount: 2, 64, 64); - var combat = new CombatSystem(entities, fog, economy); + var combat = new CombatSystem(entities, fog, economy, construction); var kernel = new SimulationKernel(new SimRandom(seed)); kernel.RegisterSystem(economy); diff --git a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs index e6b6b74..3c0c95e 100644 --- a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs @@ -264,12 +264,13 @@ public void PlaceBuilding_ChargesExactCost_AndCreatesSiteEntity() "Barracks costs exactly 500 AE (provisional)"); Assert.That(f.Construction.SiteCount, Is.EqualTo(1)); - // The site entity sits at the footprint center with role Unit and 1 HP. + // The site entity sits at the footprint center carrying its + // DEFINITION role (16.3, #44) with 1 HP. bool found = false; UnitState[] units = f.Entities.RawUnits; for (int i = 0; i < f.Entities.Capacity; i++) { - if (!units[i].IsActive || units[i].Role != UnitRole.Unit) continue; + if (!units[i].IsActive || units[i].Role != UnitRole.Barracks) continue; found = true; Assert.That(units[i].Transform.PositionX, Is.EqualTo(SimFixed.FromInt(21))); Assert.That(units[i].Transform.PositionY, Is.EqualTo(SimFixed.FromInt(21))); @@ -407,7 +408,10 @@ public void SiteProgress_LowPower_ExactlyHalvesProgress() f.Step(279); // 289 ticks total: still short of 150 effective Assert.That(f.Construction.TryGetSite(siteRaw, out _, out progressRaw, out _), Is.True); Assert.That(progressRaw, Is.EqualTo(289 * (SimFixed.OneRaw / 2))); - Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).Role, Is.EqualTo(UnitRole.Unit)); + // 16.3 (#44): the role no longer tells "unfinished" — the site + // register and the 1 HP do. + Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).CurrentHealth, Is.EqualTo(1), + "still unfinished: site HP stays 1 until completion"); f.Step(11); // 300 ticks = exactly 150 effective ticks Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).Role, Is.EqualTo(UnitRole.Power), @@ -417,7 +421,7 @@ public void SiteProgress_LowPower_ExactlyHalvesProgress() } [Test] - public void Completion_BecomesRoleEntity_PowerAppliesFromNextTick() + public void Completion_NormalizesLegacySiteRole_AndPowerAppliesFromNextTick() { var f = new Fixture(); f.SpawnBuilder(0, 19, 20); @@ -425,9 +429,14 @@ public void Completion_BecomesRoleEntity_PowerAppliesFromNextTick() uint siteRaw = UnitCommandStateView.ToRawEntityId(SiteEntity(f)); f.Step(149); - Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).Role, Is.EqualTo(UnitRole.Unit)); + Assert.That(f.Construction.TryGetSite(siteRaw, out _, out _, out _), Is.True, "still a site one tick short"); + // Emulate a pre-16.3 mid-construction snapshot: its site entity + // restores with the legacy generic role while the site table still + // names the Power definition. + f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).Role = UnitRole.Unit; f.Step(1); // tick 150: completion in phase 4 - Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).Role, Is.EqualTo(UnitRole.Power)); + Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).Role, Is.EqualTo(UnitRole.Power), + "completion normalizes legacy snapshot entities to their definition role"); Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).CurrentHealth, Is.EqualTo(400), "completion restores full HP"); Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(0), @@ -630,6 +639,59 @@ public void PlaceCompletedBuilding_Refinery_GrantsNothing_MatchStartIsUnchanged( "an instantly placed Refinery grants nothing"); } + [Test] + public void Site_CarriesDefinitionRole_ButDrawsAndProvidesNoPower_UntilCompletion() + { + // 16.3 (#44): the site carries its definition role so the armed + // generic-slot fallback dies — and the power recompute must not + // read that role. A Refinery site drains nothing, a Power site + // feeds nothing, until the site register flips at completion. + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + "the completed HQ supplies the 30 power needed to permit the Refinery"); + f.SpawnBuilder(0, 19, 20); + f.Step(1); // commit: HQ provides 30, nothing required + + Assert.That(f.Construction.TryPlaceBuilding(0, 4, 20, 20), Is.True, "Refinery def 4 (draws 20 completed)"); + uint siteRaw = UnitCommandStateView.ToRawEntityId(SiteEntity(f)); + EntityId siteId = UnitCommandStateView.ToEntityId(siteRaw); + f.Step(1); + Assert.That(f.Entities.GetUnitRef(siteId).Role, Is.EqualTo(UnitRole.Refinery), + "the site carries its definition role"); + Assert.That(f.Construction.IsActiveSite(siteId), Is.True); + Assert.That(f.Construction.IsCompletedPlacement(siteRaw), Is.False); + Assert.That(f.Construction.HasFinishedBuilding(0, UnitRole.Refinery), Is.False, + "definition role is not completion; producer scans must use the placement register"); + Assert.That(f.Economy.GetPlayerEconomy(0).PowerRequired, Is.EqualTo(0), + "the unfinished site draws nothing"); + Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(30), + "the unfinished site neither adds nor removes power from the completed-HQ baseline"); + + f.Step(200); // completion (200 full-power ticks) + Assert.That(f.Construction.TryGetSite(siteRaw, out _, out _, out _), Is.False, "completed: no longer a site"); + f.Step(1); // next economy recompute + Assert.That(f.Economy.GetPlayerEconomy(0).PowerRequired, Is.EqualTo(20), + "the completed Refinery draws its 20"); + } + + [Test] + public void PowerSite_ProvidesNothing_UntilCompletion() + { + var f = new Fixture(); + f.SpawnBuilder(0, 19, 20); + f.Step(1); + + Assert.That(f.Construction.TryPlaceBuilding(0, 5, 20, 20), Is.True, "Power plant def 5 (feeds 100 completed)"); + f.Step(1); + Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(0), + "a Power site must not power itself up mid-build"); + + f.Step(150); // completion (150 full-power ticks) + f.Step(1); // next economy recompute + Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(100), + "the completed plant feeds its 100"); + } + [Test] public void CancelConstruction_Refunds75Percent_AndFreesFootprint() { @@ -840,13 +902,14 @@ public void ProgressSites_ReassignsNonBuilderAssignment_DefenseInDepth() "the site pauses — the non-builder never progressed it"); } - /// Returns the single active site entity of the fixture. + /// Returns the single active site entity of the fixture (16.3: via the site register — the role is the definition's now). private static EntityId SiteEntity(Fixture f) { UnitState[] units = f.Entities.RawUnits; for (int i = 0; i < f.Entities.Capacity; i++) { - if (units[i].IsActive && units[i].Role == UnitRole.Unit) + if (units[i].IsActive + && f.Construction.TryGetSite(UnitCommandStateView.ToRawEntityId(units[i].Id), out _, out _, out _)) { return units[i].Id; } diff --git a/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs b/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs index 9a7ea07..4c8b3c0 100644 --- a/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs +++ b/Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs @@ -3,6 +3,7 @@ using Nova.Simulation; using Nova.Simulation.Combat; using Nova.Simulation.CommandsV1; +using Nova.Simulation.Construction; using Nova.Simulation.Economy; using Nova.Simulation.Movement; using Nova.Simulation.Pathfinding; @@ -36,16 +37,18 @@ private sealed class EcoHost public SimulationKernel Kernel { get; } public EntityManager Entities { get; } public EconomySystem Economy { get; } + public ConstructionSystem Construction { get; } public MatchSession Session { get; } public CommandIngress Ingress { get; } private EcoHost( - SimulationKernel kernel, EntityManager entities, EconomySystem economy, + SimulationKernel kernel, EntityManager entities, EconomySystem economy, ConstructionSystem construction, MatchSession session, CommandIngress ingress) { Kernel = kernel; Entities = entities; Economy = economy; + Construction = construction; Session = session; Ingress = ingress; } @@ -59,7 +62,7 @@ public static EcoHost Create(ulong seed, int capacity = 256, ushort width = 64, // 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, teamCount: 2, width, height); - var combat = new CombatSystem(entities, fogOfWar, economy); + var combat = new CombatSystem(entities, fogOfWar, economy, construction); var kernel = new SimulationKernel(new SimRandom(seed)); // Canonical tick order (SimulationCore.md section 2): economy @@ -77,7 +80,7 @@ public static EcoHost Create(ulong seed, int capacity = 256, ushort width = 64, kernel.BindCommands(new UnitCommandStateView(entities, pathfinding, economy), ingress); kernel.Start(); - return new EcoHost(kernel, entities, economy, session, ingress); + return new EcoHost(kernel, entities, economy, construction, session, ingress); } /// One host lockstep iteration: seal the due batch, submit it, step, advance the session. @@ -164,6 +167,58 @@ public void HarvestThenReturn_ThroughSealedCommands_RaisesCreditsExactly() "credits rise by exactly the delivered cargo"); } + [Test] + public void ReturnCargo_HoldsAtRefinerySite_ThenDepositsAtCompletedRefinery() + { + var host = EcoHost.Create(Seed); + Assert.That(host.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + "the completed HQ supplies the placement power budget"); + host.StepTick(); + Assert.That(host.Construction.TryPlaceBuilding(0, 4, 10, 10), Is.True, + "the nearby definition-role Refinery is still only a site"); + + EntityId site = EntityId.Invalid; + UnitState[] units = host.Entities.RawUnits; + for (int i = 0; i < host.Entities.Capacity; i++) + { + if (units[i].IsActive && units[i].Role == UnitRole.Refinery + && host.Construction.IsActiveSite(units[i].Id)) + { + site = units[i].Id; + break; + } + } + Assert.That(site.IsValid, Is.True); + + EntityId harvester = host.Entities.SpawnUnit( + 0, + new Transform2D(SimFixed.FromInt(13), SimFixed.FromInt(11)), + SimFixed.FromInt(4), + role: UnitRole.Harvester); + ref UnitState returning = ref host.Entities.GetUnitRef(harvester); + returning.CargoAE = 20; + returning.IsReturningCargo = true; + long creditsBefore = host.Economy.GetPlayerEconomy(0).AetheriumCredits; + + host.StepTick(); + Assert.That(host.Entities.GetUnitRef(harvester).CargoAE, Is.EqualTo(20), + "a definition-role site is not a cargo drop-off"); + Assert.That(host.Entities.GetUnitRef(harvester).IsReturningCargo, Is.True, + "the return order is held until a completed Refinery is reachable"); + Assert.That(host.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(creditsBefore)); + + uint siteRaw = UnitCommandStateView.ToRawEntityId(site); + Assert.That(host.Construction.CancelConstruction(siteRaw), Is.True); + Assert.That(host.Construction.PlaceCompletedBuilding(0, 4, 10, 10).IsValid, Is.True); + host.StepTick(); + + Assert.That(host.Entities.GetUnitRef(harvester).CargoAE, Is.EqualTo(0)); + Assert.That(host.Entities.GetUnitRef(harvester).IsReturningCargo, Is.False); + Assert.That(host.Economy.GetPlayerEconomy(0).AetheriumCredits, + Is.EqualTo(creditsBefore + 525 + 20), + "cancellation refunds 75 percent and the now-legal drop-off adds the held cargo"); + } + [Test] public void TwoKernels_HarvestAndReturnCommands_300Ticks_ProduceIdenticalHashes() { diff --git a/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs b/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs index 2a1ae16..be64315 100644 --- a/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs +++ b/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs @@ -63,7 +63,7 @@ public static ProdHost Create(ulong seed, int capacity = 256, long startingCredi var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, 128, 128); - var combat = new CombatSystem(entities, fogOfWar, economy); + var combat = new CombatSystem(entities, fogOfWar, economy, construction); var kernel = new SimulationKernel(new SimRandom(seed)); // Canonical tick order (SimulationCore.md section 2): economy diff --git a/Assets/Tests/EditMode/Simulation/VictorySystemTests.cs b/Assets/Tests/EditMode/Simulation/VictorySystemTests.cs index 45502b7..c84fd11 100644 --- a/Assets/Tests/EditMode/Simulation/VictorySystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/VictorySystemTests.cs @@ -14,7 +14,7 @@ namespace Nova.Simulation.Tests { /// - /// Canonical MS-1 victory suite (EditMode lane, docs/gamedesign/VictoryConditions.md + /// Canonical MS-1 victory suite (.NET lane, docs/gamedesign/VictoryConditions.md /// section "MS-1-Override (D-056)" plus the D-077 second defeat trigger): /// the three decided outcomes (elimination, mutual annihilation, time /// limit), elimination's two triggers (total annihilation per D-056 and @@ -22,11 +22,10 @@ namespace Nova.Simulation.Tests /// final" property across later ticks AND snapshot save/restore, /// construction sites counting as buildings, the last-unit reveal hold /// with its reset rule, block hardening (v2 format, clean break from v1) - /// and determinism. Mirror of the .NET lane VictorySystemTests with Unity - /// Test Framework asserts. + /// and determinism. Mirror of the .NET lane VictorySystemTests. /// [TestFixture] - public sealed class VictorySystemTests + public class VictorySystemTests { private const ulong Seed = 0x5EED0056UL; private const int Capacity = 64; @@ -76,13 +75,14 @@ public void WipeSlot(byte slot) } } - /// Despawns every living HQ of a slot (the D-077 "HQ sniped" state, other entities survive). + /// Despawns every living COMPLETED HQ of a slot (the D-077 "HQ sniped" state, other entities survive; sites excluded — they carry the HQ role since 16.3 but are not a headquarters). public void SnipeHq(byte slot) { UnitState[] units = Entities.RawUnits; for (int i = 0; i < Entities.Capacity; i++) { - if (units[i].IsActive && units[i].PlayerId == slot && units[i].Role == UnitRole.HQ) + if (units[i].IsActive && units[i].PlayerId == slot && units[i].Role == UnitRole.HQ + && !Construction.IsActiveSite(units[i].Id)) { Entities.DespawnUnit(units[i].Id); } @@ -235,6 +235,36 @@ public void HqSnipedWithOtherEntitiesLeft_IsDefeated_TheOtherSlotWins() "the defeat lands immediately, on the tick the HQ died"); } + [Test] + public void HqSite_DoesNotSaveTheSlot_FromTheHqLossElimination() + { + // 16.3 (#44): a site carries its definition role, so a half-built + // HQ would read as a headquarters to the bare role check and mask + // the D-077 elimination after the real HQ falls. The site + // register is excluded from the HQ scan, exactly like the generic + // role was before. + TestHost host = NewHost(startingCredits: 6000); + host.SpawnUnit(0, 10, 10, UnitRole.HQ); + host.SpawnUnit(0, 16, 10, UnitRole.Builder); + host.SpawnUnit(1, 50, 50, UnitRole.HQ); + host.SpawnUnit(1, 52, 50); + host.Step(1); // both slots engage and latch their HQs + + // Slot 0 starts a second HQ as a SITE — definition role HQ since + // 16.3, 1 HP, never completed in this test (the builder stands + // out of reach, so the site pauses). + Assert.That(host.Construction.TryPlaceBuilding(0, 3, 30, 30), Is.True, "HQ def 3 (Alliance)"); + Assert.That(host.Construction.SiteCount, Is.EqualTo(1)); + + // The real HQ falls: the D-077 elimination must fire despite the + // open site — a half-built HQ is not a headquarters. + host.SnipeHq(0); + host.Step(1); + + Assert.That(host.Victory.Outcome, Is.EqualTo(MatchOutcome.VictoryElimination)); + Assert.That(host.Victory.WinnerSlot, Is.EqualTo((byte)1)); + } + [Test] public void BothHqsSnipedInTheSameTick_IsMutualAnnihilationDraw() { diff --git a/Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs b/Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs index adf6bbb..3ee34c6 100644 --- a/Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs +++ b/Assets/Tests/EditMode/Simulation/WeaponValuesTests.cs @@ -324,7 +324,7 @@ public static TestHost Create() var factions = new EconomySystem(entities); var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, factions); var fog = new FogOfWarSystem(entities, construction, teamCount: 2, 64, 64); - var combat = new CombatSystem(entities, fog, factions); + var combat = new CombatSystem(entities, fog, factions, construction); var kernel = new SimulationKernel(new SimRandom(Seed)); kernel.RegisterSystem(pathfinding); diff --git a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs index 8b09305..b80f4fe 100644 --- a/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs +++ b/Assets/_Project/Scripts/AI/SkirmishAiSystem.cs @@ -225,6 +225,21 @@ private void Decide() uint raw = UnitCommandStateView.ToRawEntityId(u.Id); if (raw == 0) continue; + // 16.3 (#44): a site already carries its definition role. + // Classify through the site register BEFORE building roles so + // an unfinished Refinery/HQ/etc. never becomes a completed + // producer or prerequisite in the planner. + if (_construction.TryGetSite(raw, out _, out _, out uint assignedBuilder)) + { + sites.Add(new SiteInfo + { + CellX = GridCellOf(u.Transform.PositionX), + CellY = GridCellOf(u.Transform.PositionY), + AssignedBuilderRaw = assignedBuilder, + }); + continue; + } + if (SimDefinitions.IsBuildingRole(u.Role)) { switch (u.Role) @@ -263,19 +278,6 @@ private void Decide() idleHarvesterRaws.Add(raw); } break; - case UnitRole.Unit: - // A construction site carries the generic role; the - // site table tells it from a plain unit. - if (_construction.TryGetSite(raw, out _, out _, out uint assignedBuilder)) - { - sites.Add(new SiteInfo - { - CellX = GridCellOf(u.Transform.PositionX), - CellY = GridCellOf(u.Transform.PositionY), - AssignedBuilderRaw = assignedBuilder, - }); - } - break; default: if (IsCombatRole(u.Role)) { @@ -837,6 +839,7 @@ private void CollectVisibleThreats(List cells, List raws) { if (!_entityManager.TryGetUnit(visible[i], out UnitState u)) continue; if (u.PlayerId == _aiPlayerId) continue; + if (_construction.IsActiveSite(u.Id)) continue; if (WeaponProfiles.Get(_economy.GetSlotFaction(u.PlayerId), u.Role).AttackDamage <= 0) continue; long x = GridCellOf(u.Transform.PositionX); @@ -1362,6 +1365,10 @@ private uint FindBestVisibleEnemyByScore(List army, out int cellX, ou // own unit would actually fire. The auto-acquisition filters // hostile strictly; the command path does not. if (u.PlayerId == _aiPlayerId) continue; + // Combat rejects every active site as a target. Excluding it + // here keeps the AI from repeatedly choosing an invulnerable + // definition-role site (especially an HQ site). + if (_construction.IsActiveSite(u.Id)) continue; uint raw = UnitCommandStateView.ToRawEntityId(u.Id); if (raw == 0) continue; diff --git a/Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs b/Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs index bf3b4a4..cb8f4fb 100644 --- a/Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs +++ b/Assets/_Project/Scripts/Gameplay/Match/MatchRunner.cs @@ -220,7 +220,7 @@ public void InitializeMatch(MatchConfig config) Construction = new Simulation.Construction.ConstructionSystem(Entities, Economy, Pathfinding.CostField); Production = new Simulation.Production.ProductionSystem(Entities, Economy, Construction); FogOfWar = new FogOfWarSystem(Entities, Construction, teamCount: 2, _mapWidth, _mapHeight); - Combat = new CombatSystem(Entities, FogOfWar, Economy); + Combat = new CombatSystem(Entities, FogOfWar, Economy, Construction); Victory = new Simulation.Victory.VictorySystem(Entities, Construction); Session = new MatchSession(_config.LocalSlot, _config.ActiveSlots, _config.InputDelayTicks); diff --git a/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs b/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs index bceefcc..1fc4505 100644 --- a/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs +++ b/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs @@ -6,6 +6,7 @@ using Nova.Gameplay.CombatFeedback; using Nova.Simulation.CommandsV1; using Nova.Simulation.Combat; +using Nova.Simulation.Construction; using Nova.Simulation.Definitions; using Nova.Simulation.Economy; using Nova.Simulation.State; @@ -344,11 +345,13 @@ private void LateUpdate() if (slot < 0 || slot >= _viewInstances.Length) continue; // A rebind is required for a recycled slot (new version) and - // when the role changed in place — a construction site carries - // UnitRole.Unit until ConstructionSystem promotes it to the - // finished building role, and the shape must follow. + // when the EFFECTIVE view role changed in place: a site + // carries its definition role since 16.3 (#44), so the + // site-register flip at completion (not a role change) is + // what promotes the view from the site pad to the finished + // building look. bool spawned = false; - if (_viewInstances[slot] == null || _boundIds[slot] != id || _viewRoles[slot] != unit.Role) + if (_viewInstances[slot] == null || _boundIds[slot] != id || _viewRoles[slot] != EffectiveViewRole(in unit)) { ReleaseView(slot); AcquireView(slot, in unit); @@ -460,6 +463,27 @@ private void EnsureBuffers() _combatDiffer.Reset(capacity); } + /// + /// The role every shape decision is made with (16.3, #44): an + /// unfinished site carries its definition role in the simulation now, + /// but it must KEEP the site look — the low generic pad, no art + /// prefab — until completion. Sites therefore map back to + /// here; one read drives the rebind + /// trigger, the prefab lookup and the primitive table alike, so the + /// completion flip (site register, not role) rebinds the view to the + /// finished building. stores this effective + /// role, which is also what the building-rotation lock reads. + /// + private UnitRole EffectiveViewRole(in UnitState unit) + { + ConstructionSystem construction = _matchRunner != null ? _matchRunner.Construction : null; + if (construction != null && SimDefinitions.IsBuildingRole(unit.Role) && construction.IsActiveSite(unit.Id)) + { + return UnitRole.Unit; + } + return unit.Role; + } + private void AcquireView(int slot, in UnitState unit) { GameObject instance; @@ -487,7 +511,7 @@ private void AcquireView(int slot, in UnitState unit) } else { - GetRoleShape(unit.Role, out PrimitiveType primitive, out Vector3 scale); + GetRoleShape(EffectiveViewRole(in unit), out PrimitiveType primitive, out Vector3 scale); shapeKey = (int)primitive; groundOffset = GroundOffset(primitive, scale); @@ -523,7 +547,7 @@ private void AcquireView(int slot, in UnitState unit) _viewInstances[slot] = instance; _viewRenderers[slot] = instance.GetComponentInChildren(true); _boundIds[slot] = unit.Id; - _viewRoles[slot] = unit.Role; + _viewRoles[slot] = EffectiveViewRole(in unit); _viewShapeKeys[slot] = shapeKey; _viewSourcePrefabs[slot] = sourcePrefab; _viewGroundOffsets[slot] = groundOffset; @@ -544,8 +568,11 @@ private void AcquireView(int slot, in UnitState unit) /// the entity's own faction definition id (the same lookup combat and /// economy resolve through — a Legion LightTank gets the Legion prefab, /// never the Alliance one), then the single legacy - /// override. UnitRole.Unit (the construction site) maps to the invalid - /// definition id 0 and therefore always falls through to the primitive. + /// override. The effective view role decides (16.3, #44): a site maps + /// back to , which resolves to the invalid + /// definition id 0. Active sites also bypass the optional legacy unit + /// fallback, so they always use the graybox site primitive and never + /// the finished building's art. /// private GameObject ResolveViewPrefab(in UnitState unit) { @@ -555,7 +582,7 @@ private GameObject ResolveViewPrefab(in UnitState unit) if (economy != null && unit.PlayerId < EconomySystem.MaxPlayers) { FactionId faction = economy.GetSlotFaction(unit.PlayerId); - int definitionId = SimDefinitions.ToDefinitionId(faction, unit.Role); + int definitionId = SimDefinitions.ToDefinitionId(faction, EffectiveViewRole(in unit)); if (definitionId != 0) { GameObject prefab = _assetMappings.GetUnitPrefab(definitionId); @@ -570,6 +597,11 @@ private GameObject ResolveViewPrefab(in UnitState unit) } } } + ConstructionSystem construction = _matchRunner != null ? _matchRunner.Construction : null; + if (construction != null && construction.IsActiveSite(unit.Id)) + { + return null; + } return _unitPrefab; } @@ -596,7 +628,7 @@ private float NormalizePrefabScale(GameObject sourcePrefab, GameObject instance, bounds.Encapsulate(renderers[i].bounds); } - float target = TargetViewSize(unit.Role); + float target = TargetViewSize(EffectiveViewRole(in unit)); float current = Mathf.Max(bounds.size.x, bounds.size.z); if (current > target && current > 1e-4f) { @@ -1004,8 +1036,9 @@ private static void GetRoleShape(UnitRole role, out PrimitiveType primitive, out { switch (role) { - // Generic entity and, until ConstructionSystem promotes it, the - // unfinished construction site: a low ground pad. + // Generic entity — and the unfinished construction site, + // which EffectiveViewRole maps back here until completion + // (16.3): a low ground pad. case UnitRole.Unit: primitive = PrimitiveType.Cube; scale = new Vector3(1.0f, 0.30f, 1.0f); diff --git a/Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs b/Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs index 2885b47..f3db7dc 100644 --- a/Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs +++ b/Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs @@ -138,7 +138,7 @@ public CommandButtonType GetUnitCommands(FactionId faction, UnitRole leadRole) return commands; } - /// The command buttons of a CONSTRUCTION SITE (role Unit with an active site): only cancelling is meaningful. + /// The command buttons of a CONSTRUCTION SITE (definition role with an active site-register row): only cancelling is meaningful. public CommandButtonType GetSiteCommands() { return CommandButtonType.CancelConstruction; diff --git a/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs b/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs index d1163e6..64d543d 100644 --- a/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs +++ b/Assets/_Project/Scripts/Presentation/UI/BuildMenuHud.cs @@ -209,7 +209,7 @@ private bool ComputeSiteLacksBuilder() for (int i = 0; i < capacity; i++) { ref readonly UnitState unit = ref units[i]; - if (!unit.IsActive || unit.PlayerId != slot || unit.Role != UnitRole.Unit) continue; + if (!unit.IsActive || unit.PlayerId != slot) continue; uint raw = UnitCommandStateView.ToRawEntityId(unit.Id); if (raw != 0 && construction.TryGetSite(raw, out _, out _, out uint assignedBuilderRaw) diff --git a/Assets/_Project/Scripts/Presentation/UI/ConstructionSiteMarkerView.cs b/Assets/_Project/Scripts/Presentation/UI/ConstructionSiteMarkerView.cs index 0a13856..6356b6e 100644 --- a/Assets/_Project/Scripts/Presentation/UI/ConstructionSiteMarkerView.cs +++ b/Assets/_Project/Scripts/Presentation/UI/ConstructionSiteMarkerView.cs @@ -105,7 +105,7 @@ private void CollectSites() for (int i = 0; i < capacity; i++) { ref readonly UnitState unit = ref units[i]; - if (!unit.IsActive || unit.PlayerId != slot || unit.Role != UnitRole.Unit) continue; + if (!unit.IsActive || unit.PlayerId != slot) continue; uint raw = UnitCommandStateView.ToRawEntityId(unit.Id); if (raw == 0) continue; if (!construction.TryGetSite(raw, out _, out _, out uint assignedBuilderRaw)) continue; diff --git a/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs b/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs index 2b1ac54..7366252 100644 --- a/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs +++ b/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs @@ -1061,7 +1061,7 @@ private ReadOnlySpan BuilderSelection(out int count) private bool TryGetLeadProducer(out EntityId producer) { producer = EntityId.Invalid; - if (_runner.Entities == null) return false; + if (_runner.Entities == null || _runner.Construction == null) return false; ReadOnlySpan selected = _selection.SelectedEntities; if (selected.Length == 0) return false; @@ -1072,7 +1072,9 @@ private bool TryGetLeadProducer(out EntityId producer) if (candidate.PlayerId != _dispatcher.LocalSlot) return false; if (SimDefinitions.IsBuildingRole(candidate.Role)) { - if (!producer.IsValid && ProducerBuildingRoles.IsProducerRole(candidate.Role)) + uint raw = UnitCommandStateView.ToRawEntityId(candidate.Id); + if (!producer.IsValid && ProducerBuildingRoles.IsProducerRole(candidate.Role) + && _runner.Construction.IsCompletedPlacement(raw)) { producer = candidate.Id; } @@ -1249,17 +1251,18 @@ private void DispatchMoveAndRepair(ReadOnlySpan builders, Vector3 appr } /// - /// Nearest own active entity with a building role to a ground point — - /// the repair pick's target search. A construction site (role Unit) - /// is excluded by construction; the completed-placement check the - /// executor runs stays authoritative at the target tick. + /// Nearest own completed placement with a building role to a ground + /// point — the repair pick's target search. A site carries the same + /// role since 16.3, so presentation prevalidation checks the placement + /// register; the executor remains authoritative at the target tick. /// private bool TryPickBuilding(Vector3 world, out EntityId picked, out UnitState building) { picked = EntityId.Invalid; building = default; EntityManager entities = _runner.Entities; - if (entities == null) return false; + ConstructionSystem construction = _runner.Construction; + if (entities == null || construction == null) return false; byte slot = _dispatcher.LocalSlot; UnitState[] units = entities.RawUnits; @@ -1271,6 +1274,7 @@ private bool TryPickBuilding(Vector3 world, out EntityId picked, out UnitState b ref readonly UnitState unit = ref units[i]; if (!unit.IsActive || unit.PlayerId != slot) continue; if (!SimDefinitions.IsBuildingRole(unit.Role)) continue; + if (!construction.IsCompletedPlacement(UnitCommandStateView.ToRawEntityId(unit.Id))) continue; // Presentation-side boundary conversion (picking is UI, not sim). float dx = unit.Transform.PositionX.ToFloat() - world.x; @@ -1412,14 +1416,16 @@ private bool TryResolveNearestField(Vector3 world, out ushort fieldId) /// /// Producer for a unit definition: a selected own building of the /// definition's producer role wins, otherwise the first own building of - /// that role in entity order. Construction sites carry role Unit until - /// completion, so they are excluded by construction. + /// that role in entity order. A site carries its definition role since + /// 16.3, so both scans require a completed placement explicitly. /// private bool TryResolveProducer(ushort unitDefId, out EntityId building) { building = EntityId.Invalid; EntityManager entities = _runner.Entities; - if (entities == null || !SimDefinitions.TryGetUnit(unitDefId, out SimUnitDefinition definition)) + ConstructionSystem construction = _runner.Construction; + if (entities == null || construction == null + || !SimDefinitions.TryGetUnit(unitDefId, out SimUnitDefinition definition)) { return false; } @@ -1430,6 +1436,8 @@ private bool TryResolveProducer(ushort unitDefId, out EntityId building) { if (!entities.TryGetUnit(selected[i], out UnitState candidate)) continue; if (candidate.PlayerId != slot || candidate.Role != definition.ProducerRole) continue; + uint raw = UnitCommandStateView.ToRawEntityId(candidate.Id); + if (!construction.IsCompletedPlacement(raw)) continue; building = candidate.Id; return true; } @@ -1440,6 +1448,8 @@ private bool TryResolveProducer(ushort unitDefId, out EntityId building) { ref readonly UnitState unit = ref units[i]; if (!unit.IsActive || unit.PlayerId != slot || unit.Role != definition.ProducerRole) continue; + uint raw = UnitCommandStateView.ToRawEntityId(unit.Id); + if (!construction.IsCompletedPlacement(raw)) continue; building = unit.Id; return true; } diff --git a/Assets/_Project/Scripts/Simulation/Combat/CombatSystem.cs b/Assets/_Project/Scripts/Simulation/Combat/CombatSystem.cs index 1420e5a..1c2604b 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/CombatSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/CombatSystem.cs @@ -1,5 +1,6 @@ using System; using Nova.Core; +using Nova.Simulation.Construction; using Nova.Simulation.State; using Nova.Simulation.Vision; @@ -16,7 +17,8 @@ namespace Nova.Simulation.Combat /// (1) every living unit's weapon cooldown decrements by one tick; /// (2) AUTO-ACQUISITION (D-087): every armed entity without a valid /// attack order picks the nearest hostile, visible, in-range target — - /// buildings included; explicit orders are never retargeted; + /// completed buildings included, active construction sites excluded; + /// explicit orders are never retargeted; /// (3) every unit with an validates /// its target — a dead/despawned target is cleared from the order; a /// living target must be in range AND @@ -38,8 +40,10 @@ namespace Nova.Simulation.Combat /// targetProfile.ArmorClass) — an integer percent multiplier, no /// floats, no fixed-point multiply. A role with base damage 0 is unarmed /// and never fires at all: Builder, Harvester and the eight non-defensive - /// buildings hold their attack order forever, while the DefensePlatform - /// shoots like any unit because buildings CAN shoot. + /// buildings hold their attack order forever, while a completed + /// DefensePlatform shoots like any unit because buildings CAN shoot. + /// Active construction sites are neither attackers nor targets, even + /// when they already carry the DefensePlatform role (16.3, #44). /// /// /// Duel asymmetry (review finding): because engagements run in ascending @@ -126,6 +130,7 @@ public sealed class CombatSystem : ISimSystem private readonly EntityManager _entityManager; private readonly FogOfWarSystem _fogOfWar; private readonly ISlotFactionLookup _factions; + private readonly ConstructionSystem _construction; public string Name => "CombatSystem"; @@ -136,11 +141,16 @@ public sealed class CombatSystem : ISimSystem /// The lookup is the economy state — the single home of the faction /// assignment — injected at construction like the FoW reference. /// - public CombatSystem(EntityManager entityManager, FogOfWarSystem fogOfWar, ISlotFactionLookup factions) + public CombatSystem( + EntityManager entityManager, + FogOfWarSystem fogOfWar, + ISlotFactionLookup factions, + ConstructionSystem construction) { _entityManager = entityManager ?? throw new ArgumentNullException(nameof(entityManager)); _fogOfWar = fogOfWar ?? throw new ArgumentNullException(nameof(fogOfWar)); _factions = factions ?? throw new ArgumentNullException(nameof(factions)); + _construction = construction ?? throw new ArgumentNullException(nameof(construction)); } public void Initialize(SimulationKernel kernel) @@ -166,9 +176,10 @@ public void ExecuteTick(Tick tick) // Phase 2 (D-087): auto-acquisition. Every active entity WITHOUT // a valid attack order picks the NEAREST hostile, visible, - // in-range target — buildings included, so the DefensePlatform - // finally fires (it is armed by definition but could never - // receive an explicit order). Unarmed roles (damage 0) skip. + // in-range target — completed buildings included, so the + // DefensePlatform finally fires (it is armed by definition but + // could never receive an explicit order). Active sites are + // excluded on both sides; unarmed roles (damage 0) skip. // Deterministic: strict ascending scans, squared fixed-point // distances in widened long arithmetic, lowest entity index wins // ties (strict less-than keeps the earliest candidate). A unit @@ -177,7 +188,8 @@ public void ExecuteTick(Tick tick) for (int i = 0; i < capacity; i++) { ref UnitState attacker = ref units[i]; - if (!attacker.IsActive || attacker.AttackTarget.IsValid) continue; + if (!attacker.IsActive || attacker.AttackTarget.IsValid + || _construction.IsActiveSite(attacker.Id)) continue; WeaponProfile weapon = WeaponProfiles.Get(_factions.GetSlotFaction(attacker.PlayerId), attacker.Role); if (!weapon.IsArmed) continue; @@ -190,6 +202,7 @@ public void ExecuteTick(Tick tick) { ref readonly UnitState candidate = ref units[c]; if (!candidate.IsActive || candidate.PlayerId == attacker.PlayerId) continue; + if (_construction.IsActiveSite(candidate.Id)) continue; if (!IsInRange(in attacker, in candidate, weapon.AttackRange)) continue; if (!IsVisibleToAttacker(view, in candidate)) continue; @@ -214,6 +227,15 @@ public void ExecuteTick(Tick tick) ref UnitState attacker = ref units[i]; if (!attacker.IsActive || !attacker.AttackTarget.IsValid) continue; + // A site may carry an armed building role, but construction + // progress is not a combatant. Clear stale/explicit orders so + // it cannot fire while unfinished. + if (_construction.IsActiveSite(attacker.Id)) + { + attacker.AttackTarget = EntityId.Invalid; + continue; + } + EntityId targetId = attacker.AttackTarget; // A dead/despawned target drops out of the order immediately. @@ -223,6 +245,15 @@ public void ExecuteTick(Tick tick) continue; } + // Sites are not legal combat targets in this slice. A held + // order would otherwise let an explicit command destroy the + // 1-HP placeholder or auto-acquisition lock onto it. + if (_construction.IsActiveSite(targetId)) + { + attacker.AttackTarget = EntityId.Invalid; + continue; + } + ref UnitState target = ref _entityManager.GetUnitRef(targetId); // The attacker's own faction and role decide damage, type, diff --git a/Assets/_Project/Scripts/Simulation/Combat/WeaponProfiles.cs b/Assets/_Project/Scripts/Simulation/Combat/WeaponProfiles.cs index a712f39..6ad2734 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/WeaponProfiles.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/WeaponProfiles.cs @@ -76,8 +76,8 @@ public WeaponProfile( /// scores at exactly 1.00, so a generic-on-generic engagement still /// applies its 15 damage unscaled. This is deliberate and load-bearing: /// is the fallback role of a directly spawned - /// entity and of an unfinished construction site, it carries no content - /// definition of its own, and the canonical combat suites exercise combat + /// roleless entity; sites carry their definition role since 16.3 (#44). + /// The generic role has no content definition of its own, and the canonical combat suites exercise combat /// through exactly such roleless units. Every entity that comes out of /// production or construction carries a real role and is therefore /// governed by the real table — nothing on the content path resolves to diff --git a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs index 669e01e..d4eb2dd 100644 --- a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs @@ -69,11 +69,18 @@ namespace Nova.Simulation.Construction /// later. /// /// - /// Sites: a site is a live entity carrying role - /// (so the economy's power recompute - /// ignores it) at the footprint center with 1 HP of its definition's + /// Sites: a site is a live entity carrying its DEFINITION role (since + /// 16.3, #44 — the generic slot was armed by + /// the weapon-table fallback, so every site shot). Combat also consults + /// explicitly: even a DefensePlatform site is + /// neither an attacker nor a target before completion. The entity sits + /// at the footprint center with 1 HP of its definition's /// MaxHealth — construction HP interpolation is deliberately NOT - /// modeled (provisional, Q-040 candidate). Builder assignment: the + /// modeled (provisional, Q-040 candidate). The readers that resolved a + /// site by its old generic role are compensated at the source: the + /// economy's power recompute skips sites through the bound + /// lookup, and the view layer maps sites back + /// to the site look until completion. Builder assignment: the /// PlaceBuilding payload names no builder, so the site auto-assigns the /// own Builder with the lowest entity index (ascending-index scan, /// deterministic); when the assigned builder dies the next tick @@ -85,12 +92,13 @@ namespace Nova.Simulation.Construction /// raw per /// progressed tick (1.0 at full power, exactly 0.5 under low power — /// no rounding, 0.5 is exact in Q16.16, so low power means exactly one - /// tick of progress per two ticks). Completion sets the entity's role - /// to the definition's building role, restores full HP and — for a - /// ResearchLab — sets the owner's T2 unlock (phase 5; + /// tick of progress per two ticks). Completion restores full HP (the + /// role is already the definition's) and — for a ResearchLab — sets the + /// owner's T2 unlock (phase 5; /// mvp-v1.json technology.researchLabCompletionUnlocksTier2; there are /// no research upgrades, no research queue and no tier 3 in MS-1). - /// A site entity destroyed by combat aborts the site without refund. + /// A site missing from the entity store is swept without refund; active + /// sites themselves are excluded as combat targets since 16.3 (#44). /// /// /// Cancel/sell/repair (documented provisional economy rules, Q-040 @@ -214,6 +222,10 @@ public ConstructionSystem(EntityManager entityManager, EconomySystem economy, Co _t2Unlocked = new bool[EconomySystem.MaxPlayers]; _occupied = new byte[GridSize * GridSize]; _costField = costField; + // 16.3 (#44): a site carries its definition role, so the power + // recompute can no longer skip sites by role — it skips them via + // this register instead. Bound here so no host can forget it. + _economy.BindSiteLookup(IsActiveSite); } public void Initialize(SimulationKernel kernel) @@ -289,6 +301,18 @@ public bool IsCompletedPlacement(uint rawEntityId) return IndexOfBuilding(rawEntityId) >= 0; } + /// + /// True while the entity is an unfinished site (16.3, #44: sites now + /// carry their definition role, so role alone no longer tells a site + /// apart). Bound into the economy's power recompute via + /// ; also the read the + /// presentation layer needs to keep the site look until completion. + /// + public bool IsActiveSite(EntityId id) + { + return IndexOfSite(UnitCommandStateView.ToRawEntityId(id)) >= 0; + } + /// True when the slot owns a COMPLETED building of the given role (prerequisite scans). public bool HasFinishedBuilding(byte playerSlot, UnitRole role) { @@ -425,9 +449,8 @@ public CommandResultCode ValidateRepair(byte playerSlot, uint[] actorRaws, uint /// Programmatic placement (command Apply path, AI): validates exactly /// as plus the credit spend, then /// charges the full cost, occupies the footprint, spawns the site - /// entity (role , 1 HP) and auto-assigns - /// the lowest-index own Builder. Returns false without mutating when - /// any check fails. + /// entity (definition role, 1 HP) and auto-assigns the lowest-index + /// own Builder. Returns false without mutating when any check fails. /// public bool TryPlaceBuilding(byte playerSlot, ushort buildingDefId, int originX, int originY) { @@ -647,6 +670,10 @@ private void CompleteSite(int siteIndex, in SimBuildingDefinition def, byte owne EntityId id = UnitCommandStateView.ToEntityId(rawEntityId); ref UnitState unit = ref _entityManager.GetUnitRef(id); + // New sites already carry the definition role (16.3), while an + // active site restored from a pre-16.3 snapshot can still carry + // UnitRole.Unit. Normalize idempotently at completion so the old + // snapshot becomes a valid powered/producing building. unit.Role = def.Role; unit.CurrentHealth = def.MaxHealth; @@ -839,8 +866,18 @@ private void CreateSite(byte playerSlot, in SimBuildingDefinition def, int origi /// /// Spawns the building entity at the footprint center cell. A site - /// carries role and 1 HP; a completed - /// building carries its definition role at full HP. + /// carries its DEFINITION role since 16.3 (#44) — the generic + /// slot made every site an armed combatant + /// through the weapon-table fallback (15 damage, D-087 auto-acquires + /// for it). Unarmed building roles carry AttackDamage 0, so the + /// fallback shot disappears for unarmed roles; Combat additionally + /// excludes every active site, including DefensePlatform, as attacker + /// and target. The readers that resolved a site BY its generic role + /// are compensated at the source: the economy's power recompute skips sites through + /// (a site neither provides nor draws), + /// and UnitViewManager keeps the site look until completion through + /// the same read. A completed building carries the same definition + /// role at full HP — completion no longer mutates the role at all. /// private EntityId SpawnBuildingEntity(byte playerSlot, in SimBuildingDefinition def, int originX, int originY, bool completed) { @@ -849,7 +886,7 @@ private EntityId SpawnBuildingEntity(byte playerSlot, in SimBuildingDefinition d new Transform2D(SimFixed.FromInt(originX + 1), SimFixed.FromInt(originY + 1)), SimFixed.Zero, maxHealth: def.MaxHealth, - role: completed ? def.Role : UnitRole.Unit); + role: def.Role); if (!completed) { _entityManager.GetUnitRef(id).CurrentHealth = 1; diff --git a/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs b/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs index 7f6359f..8a05268 100644 --- a/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs +++ b/Assets/_Project/Scripts/Simulation/Economy/EconomySystem.cs @@ -147,6 +147,15 @@ public sealed class EconomySystem : IStatefulSimSystem, ISlotFactionLookup private int _fieldCount; private SimulationKernel _kernel; + /// + /// Construction-site lookup bound by the ConstructionSystem + /// constructor (16.3, #44): a site entity carries its definition role + /// now, so the power recompute needs the site's own register to tell + /// "unfinished" from "completed". Null in a rig without construction + /// — every building-role entity then counts, the pre-16.3 behaviour. + /// + private Func _isSiteLookup; + public string Name => "EconomySystem"; public ushort StateBlockId => SnapshotBlockIds.Economy; @@ -178,6 +187,18 @@ public void Initialize(SimulationKernel kernel) $"[{Name}] Initialized canonical economy ({MaxPlayers} slots, harvest rate {HarvestRateAE} AE/tick)."); } + /// + /// Binds the construction site's own register as the "is this entity + /// an unfinished site" lookup (16.3, #44). Called ONCE by the + /// ConstructionSystem constructor — hosts never wire this themselves. + /// The lookup is read-only against the site table and moves no state + /// into the economy, so the snapshot layout is untouched. + /// + public void BindSiteLookup(Func isSiteLookup) + { + _isSiteLookup = isSiteLookup; + } + /// Mutable access to one slot's economy state (slot must be in [0, MaxPlayers)). public ref PlayerEconomyState GetPlayerEconomy(byte playerId) { @@ -334,8 +355,10 @@ public void Shutdown() /// canonical definition table () /// and are FACTION-RESOLVED: the entity's owner slot selects the row /// (a Legion Schwerer Generator feeds 80, an Alliance Fusionsreaktor - /// 100). Mobile roles and construction sites (role - /// ) draw nothing. + /// 100). Mobile roles draw nothing, and an unfinished construction + /// site — which carries its definition role since 16.3 (#44) — is + /// skipped exactly like the generic role before it: it neither + /// provides nor draws power until completion. /// private void RecomputePower() { @@ -355,6 +378,14 @@ private void RecomputePower() if (Definitions.SimDefinitions.TryGetBuilding( _players[unit.PlayerId].Faction, unit.Role, out Definitions.SimBuildingDefinition building)) { + // A site must not power itself up (a Power site feeding + // its own grid) or drain the grid it is only starting to + // join. The lookup knows the site's own register; without + // it (construction-free rigs) every role entity counts. + if (_isSiteLookup != null && _isSiteLookup(unit.Id)) + { + continue; + } _players[unit.PlayerId].PowerProvided += building.PowerProvided; _players[unit.PlayerId].PowerRequired += building.PowerRequired; } @@ -487,7 +518,7 @@ private static bool IsInReach(in UnitState unit, GridPos2D target) } /// - /// True when an active own refinery stands in reach of the unit + /// True when a completed own refinery stands in reach of the unit /// (ascending entity-index scan, first hit decides). Reach is measured /// against the building FOOTPRINT, not its entity cell: the entity /// sits at the footprint centre (ConstructionSystem.SpawnBuildingEntity @@ -512,6 +543,9 @@ private bool HasOwnRefineryInReach(in UnitState unit) ref readonly UnitState candidate = ref units[i]; if (!candidate.IsActive || candidate.Role != UnitRole.Refinery) continue; if (candidate.PlayerId != unit.PlayerId) continue; + // 16.3 (#44): a site already carries the Refinery role, but + // it is not a cargo drop-off until completion. + if (_isSiteLookup != null && _isSiteLookup(candidate.Id)) continue; int rx = Math.Max(0, SimFixed.WorldToGrid(candidate.Transform.PositionX)); int ry = Math.Max(0, SimFixed.WorldToGrid(candidate.Transform.PositionY)); diff --git a/Assets/_Project/Scripts/Simulation/State/UnitRole.cs b/Assets/_Project/Scripts/Simulation/State/UnitRole.cs index 15635c4..6998b23 100644 --- a/Assets/_Project/Scripts/Simulation/State/UnitRole.cs +++ b/Assets/_Project/Scripts/Simulation/State/UnitRole.cs @@ -7,9 +7,9 @@ namespace Nova.Simulation.State /// documented minimal variant of this slice models buildings as entities /// carrying one of these roles. The economy derives power provided / /// required from the building roles; harvest orders are only effective - /// for ; construction output becomes the building - /// role entity on completion (before that a site carries - /// ). Values are stable wire identifiers of the entity + /// for ; a construction site carries its target + /// building role from creation and is distinguished from a completed + /// placement by the construction register. Values are stable wire identifiers of the entity /// store block v4 — renaming a member never changes the wire value. /// /// MS-1 roles (mvp-v1.json): nine building roles @@ -21,8 +21,8 @@ namespace Nova.Simulation.State /// , , /// , , /// , ). - /// stays the generic fallback role and the role of an - /// unfinished construction site. + /// stays the generic fallback role; unfinished sites + /// have carried their definition role since 16.3 (#44). /// /// /// The seventeen content roles are deliberately numbered 1..17: the @@ -33,7 +33,7 @@ namespace Nova.Simulation.State /// public enum UnitRole : byte { - /// Plain mobile unit without an economic or building function; also the role of a construction site until completion. + /// Plain mobile unit without an economic or building function; sites use their definition role and the construction register. Unit = 0, /// Construction unit; the only role that can repair buildings and progress construction sites. diff --git a/Assets/_Project/Scripts/Simulation/Victory/VictorySystem.cs b/Assets/_Project/Scripts/Simulation/Victory/VictorySystem.cs index 053ac0e..c8edf5b 100644 --- a/Assets/_Project/Scripts/Simulation/Victory/VictorySystem.cs +++ b/Assets/_Project/Scripts/Simulation/Victory/VictorySystem.cs @@ -62,9 +62,9 @@ namespace Nova.Simulation.Victory /// stable) and covers /// any same-tick mix of them. A slot that never owned an HQ — fixtures /// that spawn only units — is unaffected by the HQ trigger and is judged - /// by the D-056 rule alone. Only COMPLETED buildings carry - /// ; a construction site carries - /// and never counts, so an unfinished HQ + /// by the D-056 rule alone. An HQ construction site carries + /// since 16.3 (#44), but the construction + /// register excludes it from the completed-HQ count, so an unfinished HQ /// rebuild does not postpone the defeat. /// /// @@ -397,9 +397,12 @@ private void Recount() ref readonly UnitState u = ref units[i]; if (!u.IsActive || u.PlayerId >= MaxSlots) continue; - // Only COMPLETED buildings carry UnitRole.HQ (construction - // sites carry UnitRole.Unit), so a bare role check counts HQs. - if (u.Role == UnitRole.HQ) + // Only COMPLETED buildings count as HQs: a site carries its + // definition role since 16.3 (#44), so the bare role check + // alone would promote a half-built HQ to a headquarters — + // the site's own register is excluded here exactly like the + // generic role excluded it before. + if (u.Role == UnitRole.HQ && !_construction.IsActiveSite(u.Id)) { _scratchHq[u.PlayerId]++; } @@ -418,9 +421,10 @@ private void Recount() /// /// D-056 building classification: one of the nine MS-1 building roles, /// or an active construction site ("einschließlich Baustellen"). A - /// site is a live entity carrying (see the - /// ConstructionSystem remarks), so the role alone cannot tell it from - /// a mobile unit and the site table is consulted for exactly that role. + /// site carries its definition role since 16.3 (#44), so the building + /// role check catches it directly; the site-table consultation below + /// remains as the defensive answer for the generic role, which no + /// canonical path assigns to a site any more. /// /// Known limitation, inherited from the command wire format: entity /// indices above 1023 have no packed raw id diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e21cef..7e6e160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,15 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de sie belegt keine Verbesserung. ### Behoben +- **#44: Baustellen schiessen nicht mehr** — die Baustelle trägt jetzt ihre + Definitionsrolle statt `UnitRole.Unit`; der bewaffnete Fallback-Slot der + Waffentabelle greift nicht mehr, und `CombatSystem` schliesst zusätzlich jede aktive + Baustelle als Angreifer und Ziel aus — auch die Verteidigungsplattform feuert + vor Fertigstellung nicht. Strombilanz, Skirmish-KI, kanonisches + Determinismus-Szenario und HQ-Siegprüfung behandeln Sites ebenfalls nicht als + fertige Gebäude; `UnitViewManager` behält die Baustellenoptik bis zur + Fertigstellung. Die minimale AI-/Combat-Integrationsreparatur ist nach D-105 + dokumentiert und ändert die dauerhafte Stranghoheit nicht - **#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 diff --git a/docs/production/hashkrieg/16_Sprint_Wirtschaft.md b/docs/production/hashkrieg/16_Sprint_Wirtschaft.md index 8b02f2d..5a2499f 100644 --- a/docs/production/hashkrieg/16_Sprint_Wirtschaft.md +++ b/docs/production/hashkrieg/16_Sprint_Wirtschaft.md @@ -1,6 +1,6 @@ # Sprint 16: Die Wirtschaft trägt sich selbst — kein Gebäude kostet Geld, ohne etwas zu tun -**Version:** 1.0.0 | **Status:** geplant | **Verantwortungsbereich:** Netzstrang (Maintainer) | **Sprint:** 16 | **Vorgänger:** [12_Sprint_Zu_Zweit.md](12_Sprint_Zu_Zweit.md) Strang C | **Parallel zu:** [13B](13B_Sprint_Einheitenverhalten.md) | **Regelwerk:** [13-15_Parallelbetrieb.md](13-15_Parallelbetrieb.md) | **UX-Gate:** human | **Leitsatz:** ein Gebäude, das Strom zieht und nichts tut, ist kein Platzhalter, sondern ein Schaden +**Version:** 1.1.0 | **Status:** in Umsetzung | **Verantwortungsbereich:** Netzstrang (Maintainer) | **Sprint:** 16 | **Vorgänger:** [12_Sprint_Zu_Zweit.md](12_Sprint_Zu_Zweit.md) Strang C | **Parallel zu:** [13B](13B_Sprint_Einheitenverhalten.md) | **Regelwerk:** [13-15_Parallelbetrieb.md](13-15_Parallelbetrieb.md) | **UX-Gate:** human | **Leitsatz:** ein Gebäude, das Strom zieht und nichts tut, ist kein Platzhalter, sondern ein Schaden ## Zweck @@ -67,13 +67,22 @@ sondern ein Platzierungsfehler.** Das ändert den Aufwand, nicht die Dringlichke | `Scripts/Gameplay/UI/CommandCardPresenter.cs` | 16.10 — Strombedarf am angeklickten Gebäude | | `tools/Nova.SimRunner/Determinism10000Scenario.cs`, `tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs`, `Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs` | 16.7 — Drehbuch und beide Spiegel | -**Keine Datei unter** `Scripts/Simulation/Combat/`, `Movement/`, `Factions/`, +**Grundsätzlich keine Datei unter** `Scripts/Simulation/Combat/`, `Movement/`, `Factions/`, `Pathfinding/`, `Scripts/AI/`, `AI.Data/`, `Presentation/UI/DebugHud.cs`. Das ist der Einheitenstrang. Disjunkt gegen [13B](13B_Sprint_Einheitenverhalten.md) — **ausser an zwei Vertragsflächen:** `Simulation/Definitions/` ist geteilt (Absprache vor 16.8), und der `WeaponProfiles`-Slot `UnitRole.Unit`, den 16.3 faktisch umwidmet, gehört 13B. **Beides wird vor dem PR angesagt, nicht danach.** +**D-105-Integrationsausnahme für 16.3:** Die geänderte Rollendarstellung hat +beim Zusammenführen zwei Fehler ausserhalb des ursprünglichen Schreibbereichs +offengelegt. Der Projektinhaber darf dafür den kleinsten gebundenen Reparaturdiff +führen: `CombatSystem` schliesst aktive Sites als Angreifer und Ziel aus; +`SkirmishAiSystem` und das kanonische 10.000-Tick-Szenario unterscheiden Sites +vor der Gebäuderolle von fertigen Gebäuden. Gespiegelte Regressionstests und +die Benachrichtigung im PR sind Pflicht; die dauerhafte Stranghoheit ändert sich +nicht. + **Kein neuer `CommandKind`.** Das Register `Simulation/CommandsV1/` bleibt eingefroren; kein Paket dieses Sprints braucht einen neuen Befehlstyp. @@ -125,7 +134,9 @@ nicht in eine Behebung. Die Baustelle bekommt bei `SpawnBuildingEntity(completed: false)` **`def.Role` statt `UnitRole.Unit`**. Unbewaffnete Gebäuderollen tragen `AttackDamage = 0`; -damit fällt der Fallback-Schuss weg, ohne dass eine Zeile in `Combat/` nötig ist. +damit fällt der Fallback-Schuss weg. Weil eine Verteidigungsplattform selbst +als Gebäuderolle bewaffnet ist, schliesst `CombatSystem` zusätzlich jede aktive +Site als Angreifer und Ziel aus. Drei Stellen, die das mitzieht: @@ -136,6 +147,11 @@ Drei Stellen, die das mitzieht: | `SelectionManager.CopyMobileSelection` | fällt in die andere Richtung: die Baustelle verschwindet aus dem Versand mobiler Befehle. Auswählbar ist sie heute schon — `SelectSingle` und `SelectBoxAdditive` prüfen nur `PlayerId`. Die Befehlskarte ist unbetroffen, `TryGetSite` greift vor `IsBuildingRole` | | `VictorySystem.IsBuilding` | prüft `IsBuildingRole` zuerst und liefert weiterhin `true` — hier ändert sich nichts | +Zusätzlich müssen `SkirmishAiSystem` und das kanonische +`Determinism10000Scenario` Sites vor jeder Rollenauswertung über das +Baustellenregister ausfiltern. Sonst gilt eine unfertige Raffinerie bereits als +Produzent und die KI reicht Folgeaufträge zu früh ein. + `ConstructionSystem.HasFinishedBuilding` ist **nicht** betroffen: es iteriert `_buildings[]`, das nur `CompleteSite` und `PlaceCompletedBuilding` schreiben. Bauvoraussetzungen bleiben korrekt. @@ -354,4 +370,5 @@ Die Baseline-Neusetzung ist Zweck der Tests, kein Bruch. | Version | Datum | Änderung | Autor | |---|---|---|---| +| 1.1.0 | 2026-08-10 | D-105-Integrationsausnahme für 16.3 dokumentiert: aktive Sites sind keine Kampfteilnehmer oder fertigen KI-Produzenten | Codex / Dennis Westermann | | 1.0.0 | 2026-08-09 | Erstfassung: Strang C aus Sprint 12 und die acht Betatest-Befunde im selben Schreibbereich zu einem Sprint zusammengeführt, am Code geprüft und nach Kosten sortiert | Orchestrator | diff --git a/tools/Nova.SimRunner.Tests/BarracksSpawnMatchConfigTests.cs b/tools/Nova.SimRunner.Tests/BarracksSpawnMatchConfigTests.cs index 3e20570..56ac90f 100644 --- a/tools/Nova.SimRunner.Tests/BarracksSpawnMatchConfigTests.cs +++ b/tools/Nova.SimRunner.Tests/BarracksSpawnMatchConfigTests.cs @@ -100,7 +100,7 @@ private static MatchHost BuildMatchHost() var construction = new ConstructionSystem(entities, economy); var production = new ProductionSystem(entities, economy, construction); var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, MapWidth, MapHeight); - var combat = new Nova.Simulation.Combat.CombatSystem(entities, fogOfWar, economy); + var combat = new Nova.Simulation.Combat.CombatSystem(entities, fogOfWar, economy, construction); var victory = new Nova.Simulation.Victory.VictorySystem(entities, construction); kernel.RegisterSystem(economy); diff --git a/tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs b/tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs index 213e8a2..4646772 100644 --- a/tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs +++ b/tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs @@ -104,7 +104,7 @@ private static ReferenceHost BuildReferenceHost(ulong seed) var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, MapWidth, MapHeight); - var combat = new Nova.Simulation.Combat.CombatSystem(entities, fogOfWar, economy); + var combat = new Nova.Simulation.Combat.CombatSystem(entities, fogOfWar, economy, construction); var victory = new Nova.Simulation.Victory.VictorySystem(entities, construction); kernel.RegisterSystem(economy); diff --git a/tools/Nova.SimRunner.Tests/CombatSystemTests.cs b/tools/Nova.SimRunner.Tests/CombatSystemTests.cs index f8c5df3..1464f72 100644 --- a/tools/Nova.SimRunner.Tests/CombatSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/CombatSystemTests.cs @@ -3,6 +3,7 @@ using Nova.Simulation; using Nova.Simulation.Combat; using Nova.Simulation.CommandsV1; +using Nova.Simulation.Definitions; using Nova.Simulation.Economy; using Nova.Simulation.Movement; using Nova.Simulation.Pathfinding; @@ -35,14 +36,17 @@ private sealed class TestHost { public SimulationKernel Kernel { get; } public EntityManager Entities { get; } + public EconomySystem Economy { get; } public FogOfWarSystem Fog { get; } public CombatSystem Combat { get; } private TestHost(SimulationKernel kernel, EntityManager entities, - Nova.Simulation.Construction.ConstructionSystem construction, FogOfWarSystem fog, CombatSystem combat) + EconomySystem economy, Nova.Simulation.Construction.ConstructionSystem construction, + FogOfWarSystem fog, CombatSystem combat) { Kernel = kernel; Entities = entities; + Economy = economy; Construction = construction; Fog = fog; Combat = combat; @@ -61,7 +65,7 @@ public static TestHost Create(ulong seed, int capacity = 64, ushort width = 64, var factions = new EconomySystem(entities); var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, factions); var fog = new FogOfWarSystem(entities, construction, teamCount: 2, width, height); - var combat = new CombatSystem(entities, fog, factions); + var combat = new CombatSystem(entities, fog, factions, construction); var kernel = new SimulationKernel(new SimRandom(seed)); kernel.RegisterSystem(pathfinding); @@ -69,7 +73,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, construction, fog, combat); + return new TestHost(kernel, entities, factions, construction, fog, combat); } public void Step() => Kernel.StepTick(); @@ -107,6 +111,31 @@ private static int HealthOf(TestHost host, EntityId id) return u.CurrentHealth; } + private static EntityId PlaceActiveDefensePlatformSite(TestHost host, byte team, int originX, int originY) + { + FactionId faction = host.Economy.GetSlotFaction(team); + ushort powerDefId = SimDefinitions.ToDefinitionId(faction, UnitRole.Power); + ushort platformDefId = SimDefinitions.ToDefinitionId(faction, UnitRole.DefensePlatform); + Assert.That(host.Construction.PlaceCompletedBuilding(team, powerDefId, 50, 50).IsValid, Is.True, + "a completed Power plant unlocks the DefensePlatform site"); + host.Economy.ExecuteTick(Tick.Zero); + Assert.That(host.Construction.TryPlaceBuilding(team, platformDefId, originX, originY), Is.True); + + UnitState[] units = host.Entities.RawUnits; + for (int i = 0; i < host.Entities.Capacity; i++) + { + ref readonly UnitState unit = ref units[i]; + if (unit.IsActive && unit.PlayerId == team && unit.Role == UnitRole.DefensePlatform + && host.Construction.IsActiveSite(unit.Id)) + { + return unit.Id; + } + } + + Assert.Fail("the active DefensePlatform site was not found in the entity store"); + return EntityId.Invalid; + } + [Test] public void Fires_WhenTargetAliveInRangeAndVisible_OnlyAfterFirstCommit() { @@ -212,6 +241,46 @@ public void AutoAcquire_DefensePlatform_FiresOnItsOwn() Assert.That(HealthOf(host, attacker), Is.LessThan(200), "the platform actually fires"); } + [Test] + public void ActiveDefensePlatformSite_NeitherAcquiresNorExecutesExplicitAttack() + { + var host = TestHost.Create(Seed); + EntityId site = PlaceActiveDefensePlatformSite(host, 0, 10, 10); + EntityId hostile = SpawnAt(host, 1, 14, 11, maxHealth: 200); + + host.Step(2); + Assert.That(host.Construction.IsActiveSite(site), Is.True); + Assert.That(host.Entities.GetUnitRef(site).AttackTarget.IsValid, Is.False, + "an armed building role must stay inert while its entity is a site"); + Assert.That(HealthOf(host, hostile), Is.EqualTo(200)); + + host.Entities.GetUnitRef(site).AttackTarget = hostile; + host.Step(); + Assert.That(host.Entities.GetUnitRef(site).AttackTarget.IsValid, Is.False, + "an explicit or stale site order is cleared instead of becoming a completion-time free shot"); + Assert.That(HealthOf(host, hostile), Is.EqualTo(200)); + } + + [Test] + public void ActiveConstructionSite_CannotBeAutoAcquiredOrExplicitlyEngaged() + { + var host = TestHost.Create(Seed); + EntityId site = PlaceActiveDefensePlatformSite(host, 1, 10, 10); + EntityId attacker = SpawnAt(host, 0, 14, 11, maxHealth: 200); + + host.Step(2); + Assert.That(host.Entities.GetUnitRef(attacker).AttackTarget.IsValid, Is.False, + "auto-acquisition must exclude unfinished sites"); + Assert.That(host.Entities.GetUnitRef(site).CurrentHealth, Is.EqualTo(1)); + + host.Entities.GetUnitRef(attacker).AttackTarget = site; + host.Step(); + Assert.That(host.Entities.GetUnitRef(attacker).AttackTarget.IsValid, Is.False, + "an explicit order on an illegal site target is cleared"); + Assert.That(host.Construction.IsActiveSite(site), Is.True); + Assert.That(host.Entities.GetUnitRef(site).CurrentHealth, Is.EqualTo(1)); + } + [Test] public void AutoAcquire_UnarmedRoles_NeverAcquire() { @@ -576,7 +645,7 @@ public static ReplayHost Create(ulong seed) // 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, teamCount: 2, 64, 64); - var combat = new CombatSystem(entities, fog, economy); + var combat = new CombatSystem(entities, fog, economy, construction); var kernel = new SimulationKernel(new SimRandom(seed)); kernel.RegisterSystem(economy); diff --git a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs index e1c380a..bbda9a3 100644 --- a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs @@ -264,12 +264,13 @@ public void PlaceBuilding_ChargesExactCost_AndCreatesSiteEntity() "Barracks costs exactly 500 AE (provisional)"); Assert.That(f.Construction.SiteCount, Is.EqualTo(1)); - // The site entity sits at the footprint center with role Unit and 1 HP. + // The site entity sits at the footprint center carrying its + // DEFINITION role (16.3, #44) with 1 HP. bool found = false; UnitState[] units = f.Entities.RawUnits; for (int i = 0; i < f.Entities.Capacity; i++) { - if (!units[i].IsActive || units[i].Role != UnitRole.Unit) continue; + if (!units[i].IsActive || units[i].Role != UnitRole.Barracks) continue; found = true; Assert.That(units[i].Transform.PositionX, Is.EqualTo(SimFixed.FromInt(21))); Assert.That(units[i].Transform.PositionY, Is.EqualTo(SimFixed.FromInt(21))); @@ -407,7 +408,10 @@ public void SiteProgress_LowPower_ExactlyHalvesProgress() f.Step(279); // 289 ticks total: still short of 150 effective Assert.That(f.Construction.TryGetSite(siteRaw, out _, out progressRaw, out _), Is.True); Assert.That(progressRaw, Is.EqualTo(289 * (SimFixed.OneRaw / 2))); - Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).Role, Is.EqualTo(UnitRole.Unit)); + // 16.3 (#44): the role no longer tells "unfinished" — the site + // register and the 1 HP do. + Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).CurrentHealth, Is.EqualTo(1), + "still unfinished: site HP stays 1 until completion"); f.Step(11); // 300 ticks = exactly 150 effective ticks Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).Role, Is.EqualTo(UnitRole.Power), @@ -417,7 +421,7 @@ public void SiteProgress_LowPower_ExactlyHalvesProgress() } [Test] - public void Completion_BecomesRoleEntity_PowerAppliesFromNextTick() + public void Completion_NormalizesLegacySiteRole_AndPowerAppliesFromNextTick() { var f = new Fixture(); f.SpawnBuilder(0, 19, 20); @@ -425,9 +429,14 @@ public void Completion_BecomesRoleEntity_PowerAppliesFromNextTick() uint siteRaw = UnitCommandStateView.ToRawEntityId(SiteEntity(f)); f.Step(149); - Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).Role, Is.EqualTo(UnitRole.Unit)); + Assert.That(f.Construction.TryGetSite(siteRaw, out _, out _, out _), Is.True, "still a site one tick short"); + // Emulate a pre-16.3 mid-construction snapshot: its site entity + // restores with the legacy generic role while the site table still + // names the Power definition. + f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).Role = UnitRole.Unit; f.Step(1); // tick 150: completion in phase 4 - Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).Role, Is.EqualTo(UnitRole.Power)); + Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).Role, Is.EqualTo(UnitRole.Power), + "completion normalizes legacy snapshot entities to their definition role"); Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).CurrentHealth, Is.EqualTo(400), "completion restores full HP"); Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(0), @@ -462,6 +471,59 @@ public void PlaceCompletedBuilding_ResearchLab_UnlocksT2Immediately() Assert.That(f.Construction.IsT2Unlocked(0), Is.True); } + [Test] + public void Site_CarriesDefinitionRole_ButDrawsAndProvidesNoPower_UntilCompletion() + { + // 16.3 (#44): the site carries its definition role so the armed + // generic-slot fallback dies — and the power recompute must not + // read that role. A Refinery site drains nothing, a Power site + // feeds nothing, until the site register flips at completion. + var f = new Fixture(); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + "the completed HQ supplies the 30 power needed to permit the Refinery"); + f.SpawnBuilder(0, 19, 20); + f.Step(1); // commit: HQ provides 30, nothing required + + Assert.That(f.Construction.TryPlaceBuilding(0, 4, 20, 20), Is.True, "Refinery def 4 (draws 20 completed)"); + uint siteRaw = UnitCommandStateView.ToRawEntityId(SiteEntity(f)); + EntityId siteId = UnitCommandStateView.ToEntityId(siteRaw); + f.Step(1); + Assert.That(f.Entities.GetUnitRef(siteId).Role, Is.EqualTo(UnitRole.Refinery), + "the site carries its definition role"); + Assert.That(f.Construction.IsActiveSite(siteId), Is.True); + Assert.That(f.Construction.IsCompletedPlacement(siteRaw), Is.False); + Assert.That(f.Construction.HasFinishedBuilding(0, UnitRole.Refinery), Is.False, + "definition role is not completion; producer scans must use the placement register"); + Assert.That(f.Economy.GetPlayerEconomy(0).PowerRequired, Is.EqualTo(0), + "the unfinished site draws nothing"); + Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(30), + "the unfinished site neither adds nor removes power from the completed-HQ baseline"); + + f.Step(200); // completion (200 full-power ticks) + Assert.That(f.Construction.TryGetSite(siteRaw, out _, out _, out _), Is.False, "completed: no longer a site"); + f.Step(1); // next economy recompute + Assert.That(f.Economy.GetPlayerEconomy(0).PowerRequired, Is.EqualTo(20), + "the completed Refinery draws its 20"); + } + + [Test] + public void PowerSite_ProvidesNothing_UntilCompletion() + { + var f = new Fixture(); + f.SpawnBuilder(0, 19, 20); + f.Step(1); + + Assert.That(f.Construction.TryPlaceBuilding(0, 5, 20, 20), Is.True, "Power plant def 5 (feeds 100 completed)"); + f.Step(1); + Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(0), + "a Power site must not power itself up mid-build"); + + f.Step(150); // completion (150 full-power ticks) + f.Step(1); // next economy recompute + Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(100), + "the completed plant feeds its 100"); + } + private static int CountUnits(Fixture f, byte slot, UnitRole role) { UnitState[] units = f.Entities.RawUnits; @@ -840,13 +902,14 @@ public void ProgressSites_ReassignsNonBuilderAssignment_DefenseInDepth() "the site pauses — the non-builder never progressed it"); } - /// Returns the single active site entity of the fixture. + /// Returns the single active site entity of the fixture (16.3: via the site register — the role is the definition's now). private static EntityId SiteEntity(Fixture f) { UnitState[] units = f.Entities.RawUnits; for (int i = 0; i < f.Entities.Capacity; i++) { - if (units[i].IsActive && units[i].Role == UnitRole.Unit) + if (units[i].IsActive + && f.Construction.TryGetSite(UnitCommandStateView.ToRawEntityId(units[i].Id), out _, out _, out _)) { return units[i].Id; } diff --git a/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs b/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs index 9dbea90..1a4aad1 100644 --- a/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs +++ b/tools/Nova.SimRunner.Tests/EconomyIntegrationTests.cs @@ -3,6 +3,7 @@ using Nova.Simulation; using Nova.Simulation.Combat; using Nova.Simulation.CommandsV1; +using Nova.Simulation.Construction; using Nova.Simulation.Economy; using Nova.Simulation.Movement; using Nova.Simulation.Pathfinding; @@ -36,16 +37,18 @@ private sealed class EcoHost public SimulationKernel Kernel { get; } public EntityManager Entities { get; } public EconomySystem Economy { get; } + public ConstructionSystem Construction { get; } public MatchSession Session { get; } public CommandIngress Ingress { get; } private EcoHost( - SimulationKernel kernel, EntityManager entities, EconomySystem economy, + SimulationKernel kernel, EntityManager entities, EconomySystem economy, ConstructionSystem construction, MatchSession session, CommandIngress ingress) { Kernel = kernel; Entities = entities; Economy = economy; + Construction = construction; Session = session; Ingress = ingress; } @@ -59,7 +62,7 @@ public static EcoHost Create(ulong seed, int capacity = 256, ushort width = 64, // 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, teamCount: 2, width, height); - var combat = new CombatSystem(entities, fogOfWar, economy); + var combat = new CombatSystem(entities, fogOfWar, economy, construction); var kernel = new SimulationKernel(new SimRandom(seed)); // Canonical tick order (SimulationCore.md section 2): economy @@ -77,7 +80,7 @@ public static EcoHost Create(ulong seed, int capacity = 256, ushort width = 64, kernel.BindCommands(new UnitCommandStateView(entities, pathfinding, economy), ingress); kernel.Start(); - return new EcoHost(kernel, entities, economy, session, ingress); + return new EcoHost(kernel, entities, economy, construction, session, ingress); } /// One host lockstep iteration: seal the due batch, submit it, step, advance the session. @@ -164,6 +167,58 @@ public void HarvestThenReturn_ThroughSealedCommands_RaisesCreditsExactly() "credits rise by exactly the delivered cargo"); } + [Test] + public void ReturnCargo_HoldsAtRefinerySite_ThenDepositsAtCompletedRefinery() + { + var host = EcoHost.Create(Seed); + Assert.That(host.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + "the completed HQ supplies the placement power budget"); + host.StepTick(); + Assert.That(host.Construction.TryPlaceBuilding(0, 4, 10, 10), Is.True, + "the nearby definition-role Refinery is still only a site"); + + EntityId site = EntityId.Invalid; + UnitState[] units = host.Entities.RawUnits; + for (int i = 0; i < host.Entities.Capacity; i++) + { + if (units[i].IsActive && units[i].Role == UnitRole.Refinery + && host.Construction.IsActiveSite(units[i].Id)) + { + site = units[i].Id; + break; + } + } + Assert.That(site.IsValid, Is.True); + + EntityId harvester = host.Entities.SpawnUnit( + 0, + new Transform2D(SimFixed.FromInt(13), SimFixed.FromInt(11)), + SimFixed.FromInt(4), + role: UnitRole.Harvester); + ref UnitState returning = ref host.Entities.GetUnitRef(harvester); + returning.CargoAE = 20; + returning.IsReturningCargo = true; + long creditsBefore = host.Economy.GetPlayerEconomy(0).AetheriumCredits; + + host.StepTick(); + Assert.That(host.Entities.GetUnitRef(harvester).CargoAE, Is.EqualTo(20), + "a definition-role site is not a cargo drop-off"); + Assert.That(host.Entities.GetUnitRef(harvester).IsReturningCargo, Is.True, + "the return order is held until a completed Refinery is reachable"); + Assert.That(host.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(creditsBefore)); + + uint siteRaw = UnitCommandStateView.ToRawEntityId(site); + Assert.That(host.Construction.CancelConstruction(siteRaw), Is.True); + Assert.That(host.Construction.PlaceCompletedBuilding(0, 4, 10, 10).IsValid, Is.True); + host.StepTick(); + + Assert.That(host.Entities.GetUnitRef(harvester).CargoAE, Is.EqualTo(0)); + Assert.That(host.Entities.GetUnitRef(harvester).IsReturningCargo, Is.False); + Assert.That(host.Economy.GetPlayerEconomy(0).AetheriumCredits, + Is.EqualTo(creditsBefore + 525 + 20), + "cancellation refunds 75 percent and the now-legal drop-off adds the held cargo"); + } + [Test] public void TwoKernels_HarvestAndReturnCommands_300Ticks_ProduceIdenticalHashes() { diff --git a/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs b/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs index bd2690c..2b17aa2 100644 --- a/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs +++ b/tools/Nova.SimRunner.Tests/LockstepNetworkTests.cs @@ -333,7 +333,7 @@ public static ClientHost Create(RelayMatchClient client) var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, 128, 128); - var combat = new CombatSystem(entities, fogOfWar, economy); + var combat = new CombatSystem(entities, fogOfWar, economy, construction); var kernel = new SimulationKernel(new SimRandom(Seed)); kernel.RegisterSystem(economy); @@ -408,7 +408,7 @@ public static ClientHost CreatePlayback() var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, 128, 128); - var combat = new CombatSystem(entities, fogOfWar, economy); + var combat = new CombatSystem(entities, fogOfWar, economy, construction); var kernel = new SimulationKernel(new SimRandom(Seed)); kernel.RegisterSystem(economy); kernel.RegisterSystem(construction); diff --git a/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs b/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs index 93172c9..bde945b 100644 --- a/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs +++ b/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs @@ -63,7 +63,7 @@ public static ProdHost Create(ulong seed, int capacity = 256, long startingCredi var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, 128, 128); - var combat = new CombatSystem(entities, fogOfWar, economy); + var combat = new CombatSystem(entities, fogOfWar, economy, construction); var kernel = new SimulationKernel(new SimRandom(seed)); // Canonical tick order (SimulationCore.md section 2): economy diff --git a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs index 345182f..65172ae 100644 --- a/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs +++ b/tools/Nova.SimRunner.Tests/SkirmishAiTests.cs @@ -166,7 +166,7 @@ private static AiHost BuildAiHost(ulong seed, AiProfile? profile = null) var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, MapWidth, MapHeight); - var combat = new CombatSystem(entities, fogOfWar, economy); + var combat = new CombatSystem(entities, fogOfWar, economy, construction); var victory = new VictorySystem(entities, construction); var session = new MatchSession(HumanSlot, activeSlots: new byte[] { HumanSlot, AiSlot }, inputDelayTicks: 1); @@ -332,6 +332,35 @@ public void SkirmishAi_PlacesRefineryThenBarracks_ThroughTheSealedCommandPath() "AI orders must enter through the canonical session/ingress intent path, not direct system calls"); } + [Test] + public void SkirmishAi_DefinitionRoleSite_DoesNotCountAsCompletedOrAdvanceBuildOrder() + { + AiHost host = BuildMatch(Seed); + + // The tick-20 decision submits the Refinery, tick 21 creates its + // site, and tick 40 is the first decision that must classify that + // definition-role entity through the site register. A bare role + // check queues a second (Barracks) site for tick 41. + host.Run(41); + + Assert.That(host.Construction.SiteCount, Is.EqualTo(1), + "an unfinished Refinery is the active build, not a completed producer that unlocks Barracks"); + Assert.That(host.Construction.HasFinishedBuilding(AiSlot, UnitRole.Refinery), Is.False); + Assert.That(host.Construction.HasFinishedBuilding(AiSlot, UnitRole.Barracks), Is.False); + + UnitState[] units = host.Entities.RawUnits; + int definitionRoleSites = 0; + for (int i = 0; i < host.Entities.Capacity; i++) + { + ref readonly UnitState unit = ref units[i]; + if (!unit.IsActive || unit.PlayerId != AiSlot || !host.Construction.IsActiveSite(unit.Id)) continue; + definitionRoleSites++; + Assert.That(unit.Role, Is.EqualTo(UnitRole.Refinery), + "the sole site carries the Refinery role without becoming a finished Refinery"); + } + Assert.That(definitionRoleSites, Is.EqualTo(1)); + } + // ---------------------------------------------------------------- // (b) Economy: harvesters work the field, credits recover // ---------------------------------------------------------------- @@ -543,6 +572,29 @@ public void SkirmishAi_ShootsTheDangerousTarget_NotTheFirstOneInTheVisibleList() "the army must shoot what actually threatens it, not what it happened to see first"); } + [Test] + public void SkirmishAi_IgnoresDefinitionRoleHqSite_AndTargetsLegalEnemy() + { + AiHost host = BuildMatch(Seed, WavesOff()); + const int SquadThreshold = 6; + int budget = EndToEndBudgetTicks; + while (budget-- > 0 && CountUnits(host, AiSlot, UnitRole.BasicInfantry) < SquadThreshold) + { + host.Step(); + } + Assert.That(TryFirstCombatCell(host, AiSlot, out int armyX, out int armyY), Is.True); + List army = CombatUnitIds(host, AiSlot); + + EntityId hqSite = PlaceEnemySiteNear(host, UnitRole.HQ, armyX + 3, armyY); + EntityId legalTarget = SpawnEnemyUnit(host, UnitRole.BattleTank, armyX + 3, armyY + 1); + RunToDecisionWithSquad(host, SquadThreshold); + + Assert.That(host.Construction.IsActiveSite(hqSite), Is.True, + "the HQ-role entity is still only a site"); + Assert.That(ArmyAttackTarget(host, army), Is.EqualTo(legalTarget), + "the HQ short-circuit must not select a site that Combat rejects"); + } + // ---------------------------------------------------------------- // (g) Waves: reinforcements wait, the army marches at full strength // ---------------------------------------------------------------- @@ -926,6 +978,11 @@ public void SkirmishAi_AimsARetreatingUnitAtItsPursuer_NotAtWhatItWalkedAwayFrom Assert.That(FarthestCombatDistance(host, AiSlot, hqX, hqY), Is.GreaterThan(ring), "the army never marched, so nothing could turn back"); + ushort powerDefId = SimDefinitions.ToDefinitionId( + host.Economy.GetSlotFaction(HumanSlot), UnitRole.Power); + Assert.That(host.Construction.PlaceCompletedBuilding(HumanSlot, powerDefId, 60, 60).IsValid, Is.True); + host.Step(); // commit the new completed Power plant to the energy balance + Assert.That(TryFirstCombatUnit(host, AiSlot, out EntityId woundedId, out int armyX, out int armyY), Is.True); Assert.That(host.Entities.TryGetUnit(woundedId, out UnitState marching), Is.True); @@ -933,7 +990,11 @@ public void SkirmishAi_AimsARetreatingUnitAtItsPursuer_NotAtWhatItWalkedAwayFrom int aheadX = armyX + System.Math.Sign(marching.TargetGridPos.X - armyX) * shipped.RetreatDangerCells; int aheadY = armyY + System.Math.Sign(marching.TargetGridPos.Y - armyY) * shipped.RetreatDangerCells; - EntityId pursuer = SpawnEnemyUnit(host, UnitRole.BasicInfantry, aheadX, aheadY); + EntityId inertSite = PlaceEnemySiteNear(host, UnitRole.DefensePlatform, aheadX, aheadY); + Assert.That(host.Entities.TryGetUnit(inertSite, out UnitState siteState), Is.True); + int siteX = SimFixed.WorldToGrid(siteState.Transform.PositionX); + int siteY = SimFixed.WorldToGrid(siteState.Transform.PositionY); + EntityId pursuer = SpawnEnemyUnit(host, UnitRole.BasicInfantry, siteX, siteY); ref UnitState target = ref host.Entities.GetUnitRef(woundedId); target.CurrentHealth = target.MaxHealth * (shipped.RetreatHealthPercent - 20) / 100; @@ -946,6 +1007,8 @@ public void SkirmishAi_AimsARetreatingUnitAtItsPursuer_NotAtWhatItWalkedAwayFrom "a retreating unit is still aimed at the target it marched away from. It cannot reach it, " + "and holding a valid target is exactly what makes the D-087 auto-acquisition skip the unit — " + "so it fires at nothing the whole way home and defends nothing once there"); + Assert.That(after.AttackTarget, Is.Not.EqualTo(inertSite), + "an armed DefensePlatform role is not a threat while its entity remains a site"); } /// The slot's combat units standing outside the staging ring around the given cell. @@ -1131,6 +1194,42 @@ private static EntityId SpawnEnemyUnit(AiHost host, UnitRole role, int cellX, in role: role); } + private static EntityId PlaceEnemySiteNear(AiHost host, UnitRole role, int centreX, int centreY) + { + FactionId faction = host.Economy.GetSlotFaction(HumanSlot); + ushort definitionId = SimDefinitions.ToDefinitionId(faction, role); + + for (int radius = 0; radius <= 6; radius++) + { + for (int dy = -radius; dy <= radius; dy++) + { + for (int dx = -radius; dx <= radius; dx++) + { + if (radius > 0 && System.Math.Abs(dx) != radius && System.Math.Abs(dy) != radius) continue; + int originX = centreX + dx - 1; + int originY = centreY + dy - 1; + if (host.Construction.ValidatePlacement(HumanSlot, definitionId, originX, originY) + != CommandResultCode.Applied) continue; + Assert.That(host.Construction.TryPlaceBuilding(HumanSlot, definitionId, originX, originY), Is.True); + + UnitState[] units = host.Entities.RawUnits; + for (int i = 0; i < host.Entities.Capacity; i++) + { + ref readonly UnitState unit = ref units[i]; + if (unit.IsActive && unit.PlayerId == HumanSlot && unit.Role == role + && host.Construction.IsActiveSite(unit.Id)) + { + return unit.Id; + } + } + } + } + } + + Assert.Fail($"no legal {role} site near ({centreX},{centreY})"); + return EntityId.Invalid; + } + /// /// The target the army agrees on. The AI hands ONE target to every /// combat unit, so a split would itself be the failure — the assertion diff --git a/tools/Nova.SimRunner.Tests/VictorySystemTests.cs b/tools/Nova.SimRunner.Tests/VictorySystemTests.cs index 9d546bd..4cd2e54 100644 --- a/tools/Nova.SimRunner.Tests/VictorySystemTests.cs +++ b/tools/Nova.SimRunner.Tests/VictorySystemTests.cs @@ -75,13 +75,14 @@ public void WipeSlot(byte slot) } } - /// Despawns every living HQ of a slot (the D-077 "HQ sniped" state, other entities survive). + /// Despawns every living COMPLETED HQ of a slot (the D-077 "HQ sniped" state, other entities survive; sites excluded — they carry the HQ role since 16.3 but are not a headquarters). public void SnipeHq(byte slot) { UnitState[] units = Entities.RawUnits; for (int i = 0; i < Entities.Capacity; i++) { - if (units[i].IsActive && units[i].PlayerId == slot && units[i].Role == UnitRole.HQ) + if (units[i].IsActive && units[i].PlayerId == slot && units[i].Role == UnitRole.HQ + && !Construction.IsActiveSite(units[i].Id)) { Entities.DespawnUnit(units[i].Id); } @@ -234,6 +235,36 @@ public void HqSnipedWithOtherEntitiesLeft_IsDefeated_TheOtherSlotWins() "the defeat lands immediately, on the tick the HQ died"); } + [Test] + public void HqSite_DoesNotSaveTheSlot_FromTheHqLossElimination() + { + // 16.3 (#44): a site carries its definition role, so a half-built + // HQ would read as a headquarters to the bare role check and mask + // the D-077 elimination after the real HQ falls. The site + // register is excluded from the HQ scan, exactly like the generic + // role was before. + TestHost host = NewHost(startingCredits: 6000); + host.SpawnUnit(0, 10, 10, UnitRole.HQ); + host.SpawnUnit(0, 16, 10, UnitRole.Builder); + host.SpawnUnit(1, 50, 50, UnitRole.HQ); + host.SpawnUnit(1, 52, 50); + host.Step(1); // both slots engage and latch their HQs + + // Slot 0 starts a second HQ as a SITE — definition role HQ since + // 16.3, 1 HP, never completed in this test (the builder stands + // out of reach, so the site pauses). + Assert.That(host.Construction.TryPlaceBuilding(0, 3, 30, 30), Is.True, "HQ def 3 (Alliance)"); + Assert.That(host.Construction.SiteCount, Is.EqualTo(1)); + + // The real HQ falls: the D-077 elimination must fire despite the + // open site — a half-built HQ is not a headquarters. + host.SnipeHq(0); + host.Step(1); + + Assert.That(host.Victory.Outcome, Is.EqualTo(MatchOutcome.VictoryElimination)); + Assert.That(host.Victory.WinnerSlot, Is.EqualTo((byte)1)); + } + [Test] public void BothHqsSnipedInTheSameTick_IsMutualAnnihilationDraw() { diff --git a/tools/Nova.SimRunner.Tests/WeaponValuesTests.cs b/tools/Nova.SimRunner.Tests/WeaponValuesTests.cs index 9514b51..6b4d70b 100644 --- a/tools/Nova.SimRunner.Tests/WeaponValuesTests.cs +++ b/tools/Nova.SimRunner.Tests/WeaponValuesTests.cs @@ -324,7 +324,7 @@ public static TestHost Create() var factions = new EconomySystem(entities); var construction = new Nova.Simulation.Construction.ConstructionSystem(entities, factions); var fog = new FogOfWarSystem(entities, construction, teamCount: 2, 64, 64); - var combat = new CombatSystem(entities, fog, factions); + var combat = new CombatSystem(entities, fog, factions, construction); var kernel = new SimulationKernel(new SimRandom(Seed)); kernel.RegisterSystem(pathfinding); diff --git a/tools/Nova.SimRunner/Determinism10000Scenario.cs b/tools/Nova.SimRunner/Determinism10000Scenario.cs index 49db807..689040d 100644 --- a/tools/Nova.SimRunner/Determinism10000Scenario.cs +++ b/tools/Nova.SimRunner/Determinism10000Scenario.cs @@ -669,7 +669,7 @@ private static Host BuildHost(ulong seed, INovaLogger logger) var construction = new ConstructionSystem(entities, economy, pathfinding.CostField); var production = new ProductionSystem(entities, economy, construction); var fogOfWar = new FogOfWarSystem(entities, construction, teamCount: 2, MapWidth, MapHeight); - var combat = new Nova.Simulation.Combat.CombatSystem(entities, fogOfWar, economy); + var combat = new Nova.Simulation.Combat.CombatSystem(entities, fogOfWar, economy, construction); var victory = new Nova.Simulation.Victory.VictorySystem(entities, construction); kernel.RegisterSystem(economy); @@ -826,13 +826,14 @@ private static byte[] CraftRecord( // Deterministic host scans (ascending entity index) // ---------------------------------------------------------------- - /// Raw id of the first active entity of with the role, else 0. + /// Raw id of the first active completed/non-site entity of with the role, else 0. private static uint FindRoleRaw(Host host, byte slot, UnitRole role) { UnitState[] units = host.Entities.RawUnits; for (int i = 0; i < host.Entities.Capacity; i++) { - if (units[i].IsActive && units[i].PlayerId == slot && units[i].Role == role) + if (units[i].IsActive && units[i].PlayerId == slot && units[i].Role == role + && !host.Construction.IsActiveSite(units[i].Id)) { return UnitCommandStateView.ToRawEntityId(units[i].Id); } diff --git a/tools/Nova.SimRunner/Program.cs b/tools/Nova.SimRunner/Program.cs index 6f84c89..d4a5238 100644 --- a/tools/Nova.SimRunner/Program.cs +++ b/tools/Nova.SimRunner/Program.cs @@ -370,7 +370,7 @@ private static ulong RunOnce(string runLabel, INovaLogger logger) 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, construction, teamCount: 2, 128, 128); - var combat = new CombatSystem(entities, fogOfWar, economy); + var combat = new CombatSystem(entities, fogOfWar, economy, construction); // Canonical tick order (SimulationCore.md section 2): economy // (phases 2/3), construction and production (phases 4/5) BEFORE