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