From 5c90d72de7ef6365dccd5a11a17941030f884eac Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sun, 9 Aug 2026 19:04:46 +0200 Subject: [PATCH] fix(construction): sites carry the definition role, not Unit (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint 16.3: SpawnBuildingEntity assigns def.Role to unfinished sites. The generic UnitRole.Unit slot is armed by the weapon-table fallback (15 damage) and D-087 auto-acquisition fires it — every site shot. Unarmed building roles carry AttackDamage 0, so the fallback dies without a line in Combat/. The three readers that resolved a site by its generic role are compensated at the source: - EconomySystem.RecomputePower skips sites through a bound IsActiveSite lookup (Func, wired once by the ConstructionSystem constructor so no host can forget it; null-tolerant for construction-free rigs). A site neither provides nor draws power — otherwise a Power site would power up its own grid mid-build. - UnitViewManager maps sites back to the effective view role UnitRole.Unit: the site keeps its low pad and no art prefab until completion, and the rebind trigger now follows the site-register flip (the role no longer changes at completion). - VictorySystem excludes sites from the HQ scan: a half-built HQ would otherwise mask the D-077 last-HQ elimination. Building classification itself is untouched (sites counted as buildings before, via the site table). SelectionManager.CopyMobileSelection now drops sites from mobile command dispatch (they read as buildings) — intended: sites take no move orders; selection itself is unchanged (player-id only), and the command card resolves sites before the role check. Known remainder, announced to the units track before this PR: a DefensePlatform SITE stays armed (20 damage from the building definition). A site filter in auto-acquisition lives in Simulation/Combat/ and belongs to the units track. Expected: golden-byte baselines move (site entities serialize the definition role now). Baseline reset lands in a SEPARATE PR per the standing rule and needs an SDK-8 environment. --- .../Simulation/ConstructionSystemTests.cs | 117 +++++++++++++++++- .../EditMode/Simulation/VictorySystemTests.cs | 42 ++++++- .../Scripts/Gameplay/Match/UnitViewManager.cs | 49 ++++++-- .../Construction/ConstructionSystem.cs | 56 +++++++-- .../Simulation/Economy/EconomySystem.cs | 35 +++++- .../Simulation/Victory/VictorySystem.cs | 16 ++- CHANGELOG.md | 14 +++ .../ConstructionSystemTests.cs | 62 +++++++++- .../VictorySystemTests.cs | 35 +++++- 9 files changed, 376 insertions(+), 50 deletions(-) diff --git a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs index 08413a3..3271423 100644 --- a/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/ConstructionSystemTests.cs @@ -263,12 +263,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))); @@ -406,7 +407,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), @@ -424,7 +428,7 @@ 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"); 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)).CurrentHealth, Is.EqualTo(400), @@ -461,6 +465,106 @@ 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(); + f.SpawnBuilder(0, 19, 20); + f.Step(1); // commit: nothing provided, 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)); + f.Step(1); + Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).Role, Is.EqualTo(UnitRole.Refinery), + "the site carries its definition role"); + Assert.That(f.Economy.GetPlayerEconomy(0).PowerRequired, Is.EqualTo(0), + "the unfinished site draws nothing"); + Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(0)); + + 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; + int count = 0; + for (int i = 0; i < f.Entities.Capacity; i++) + { + if (units[i].IsActive && units[i].PlayerId == slot && units[i].Role == role) count++; + } + return count; + } + + [Test] + public void RefineryCompletion_GrantsTheFirstHarvesterFree() + { + // The dead end this closes: the Harvester costs 700 AE and the + // Refinery is its only producer since D-077. A player who spends + // down below 700 before the Refinery finishes can never earn + // again — no Harvester, no Aetherium, no money for a Harvester. + var f = new Fixture(startingCredits: 1000); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, + "HQ provides the 30 power the Refinery draws from"); + f.SpawnBuilder(0, 19, 20); + f.Step(1); // commit the balance + + Assert.That(f.Construction.TryPlaceBuilding(0, 4, 20, 20), Is.True, "Refinery def 4 (Alliance, 700 AE, 200 ticks)"); + Assert.That(CountUnits(f, 0, UnitRole.Harvester), Is.EqualTo(0), "none before completion"); + + f.Step(150); + Assert.That(CountUnits(f, 0, UnitRole.Harvester), Is.EqualTo(0), "not while the site is still running"); + long creditsBefore = f.Economy.GetPlayerEconomy(0).AetheriumCredits; + + f.Step(100); // past the 200-tick build time + + Assert.That(CountUnits(f, 0, UnitRole.Harvester), Is.EqualTo(1), + "a finished Refinery hands out its first Harvester"); + Assert.That(CountUnits(f, 1, UnitRole.Harvester), Is.EqualTo(0), + "the grant belongs to the building's owner alone"); + Assert.That(f.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(creditsBefore), + "the grant is free: nothing is charged at completion"); + } + + [Test] + public void PlaceCompletedBuilding_Refinery_GrantsNothing_MatchStartIsUnchanged() + { + // PlaceCompletedBuilding is the match-start path (starting HQ plus + // Refinery). The grant deliberately hangs on finishing a site, not + // on instant placement — otherwise every match would begin with a + // free Harvester, which is a balance change nobody asked for. + var f = new Fixture(startingCredits: 3000); + Assert.That(f.Construction.PlaceCompletedBuilding(0, 4, 20, 20).IsValid, Is.True); + + Assert.That(CountUnits(f, 0, UnitRole.Harvester), Is.EqualTo(0), + "an instantly placed Refinery grants nothing"); + } + [Test] public void CancelConstruction_Refunds75Percent_AndFreesFootprint() { @@ -671,13 +775,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/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/_Project/Scripts/Gameplay/Match/UnitViewManager.cs b/Assets/_Project/Scripts/Gameplay/Match/UnitViewManager.cs index bceefcc..43cac13 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,10 @@ 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 and therefore always falls through to the primitive — + /// a site never gets the finished building's art. /// private GameObject ResolveViewPrefab(in UnitState unit) { @@ -555,7 +581,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); @@ -1004,8 +1030,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/Simulation/Construction/ConstructionSystem.cs b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs index 1495ef7..02c04f7 100644 --- a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs @@ -69,11 +69,17 @@ 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; unarmed building + /// roles carry AttackDamage 0 and the shot dies without a line in + /// Combat/) 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,9 +91,9 @@ 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. @@ -213,6 +219,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) @@ -287,6 +297,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) { @@ -645,7 +667,10 @@ private void CompleteSite(int siteIndex, in SimBuildingDefinition def, byte owne EntityId id = UnitCommandStateView.ToEntityId(rawEntityId); ref UnitState unit = ref _entityManager.GetUnitRef(id); - unit.Role = def.Role; + // The role is already the definition's (sites carry it since + // 16.3): completion restores full HP and nothing else about the + // entity — the view layer sees the role unchanged and switches + // the look on the site-register flip instead. unit.CurrentHealth = def.MaxHealth; int slot = FreeBuildingIndex(); @@ -778,8 +803,17 @@ 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 dies without a line in Combat/. The two 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) { @@ -788,7 +822,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 2e62ef2..edcaa11 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) { @@ -302,8 +323,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() { @@ -323,6 +346,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; } diff --git a/Assets/_Project/Scripts/Simulation/Victory/VictorySystem.cs b/Assets/_Project/Scripts/Simulation/Victory/VictorySystem.cs index 053ac0e..66e44af 100644 --- a/Assets/_Project/Scripts/Simulation/Victory/VictorySystem.cs +++ b/Assets/_Project/Scripts/Simulation/Victory/VictorySystem.cs @@ -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 d61365d..701ca5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,20 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de > erzeugt; MS-0 und MS-1 bleiben offen. ### Behoben +- **#44: Baustellen schiessen nicht mehr** — die Baustelle trägt jetzt ihre + Definitionsrolle statt `UnitRole.Unit`; der bewaffnete Fallback-Slot der + Waffentabelle (15 Schaden, D-087-Zielerfassung) trifft sie nicht mehr, und + unbewaffnete Gebäuderollen tragen `AttackDamage = 0`. Keine Zeile in + `Combat/` nötig. Die drei Leser der alten Generikrolle sind an der Quelle + mitgezogen: die Strombilanz überspringt Baustellen über das Baustellenregister + (`IsActiveSite`, vom `ConstructionSystem`-Konstruktor in die Economy gebunden + — eine Baustelle liefert und zieht nichts), `UnitViewManager` behält die + Baustellenoptik bis zur Fertigstellung (kein Gebäude-Prefab für Sites), und + `VictorySystem` zählt eine halbfertige HQ-Baustelle nicht als Hauptquartier + (D-077-Eliminierung bleibt exakt). **Restbefund:** eine Baustelle der + Verteidigungsplattform bleibt bewaffnet (20 Schaden aus der Gebäudedefinition) + — ein Site-Filter im Auto-Targeting liegt in `Simulation/Combat/` und gehört + dem Einheitenstrang; Ansage per Issue erfolgt - **#49: Auswahlrahmen und Füllung entschärft** — `GroundMarkerVisuals`: Rand von 6/64 auf 2/64 der Quad-Kante, Füll-Alpha von 0.28 auf 0.10; wirkt auf Auswahl-, Platzierungs-, Sammelpunkt- und Baustellenmarker zugleich und nimmt #50 (Einheit im Pulk nicht auffindbar) die verdeckende Füllung ab - **Die drei Laborschalter greifen nicht mehr in einer Netzpartie und nicht mehr im ausgelieferten Build:** `FogRevealDebug` und `MatchSpeedDebug` kamen aus dem diff --git a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs index 286401d..ff2fee3 100644 --- a/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/ConstructionSystemTests.cs @@ -263,12 +263,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))); @@ -406,7 +407,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), @@ -424,7 +428,7 @@ 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"); 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)).CurrentHealth, Is.EqualTo(400), @@ -461,6 +465,51 @@ 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(); + f.SpawnBuilder(0, 19, 20); + f.Step(1); // commit: nothing provided, 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)); + f.Step(1); + Assert.That(f.Entities.GetUnitRef(UnitCommandStateView.ToEntityId(siteRaw)).Role, Is.EqualTo(UnitRole.Refinery), + "the site carries its definition role"); + Assert.That(f.Economy.GetPlayerEconomy(0).PowerRequired, Is.EqualTo(0), + "the unfinished site draws nothing"); + Assert.That(f.Economy.GetPlayerEconomy(0).PowerProvided, Is.EqualTo(0)); + + 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; @@ -726,13 +775,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/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() {