From 817549be19cbfe64bb2a0f2ba5dedcd803f736a0 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sun, 9 Aug 2026 18:44:18 +0200 Subject: [PATCH 1/4] feat(production): units spawn at the footprint and walk to rally (#46) Sprint 16.2: TryFindSpawnCell anchors its ring scan at the producing building's center cell instead of the rally cell; the footprint always loses to the occupancy rule, so units spawn on the first ring outside the building and immediately receive a standing move order to the rally point (direct SetTarget write, same class as the construction push-out - no command record, no new command kind). The rally point is a destination again, not a teleporter. Tests updated to the new contract in both lanes (kept in sync): - ProductionSystemTests: spawn cell (9,9) from center (11,11), rally as order target, occupied-cell skip shifted to the ring scan. - ProductionConstructionIntegrationTests: footprint neighbourhood plus GoalGridPos/IsMoving instead of exact end positions (movement runs in these rigs). - BarracksSpawnMatchConfigTests: same assert shift; the pause-path wall now covers the centre-anchored search square (x origins 12..30). Expected: golden-byte baselines move (the Determinism10000 scenario queues Harvesters at the Refinery; spawn position and order fields change). Baseline reset lands in a SEPARATE PR per the standing rule and needs an SDK-8 environment (this machine has 10.0.302 against the 8.0.318 pin). Side effect to note in the GrayboxLog, not in code: units driving out of the building make the hollow building assets (#57) more visible. --- .../ProductionConstructionIntegrationTests.cs | 32 ++++++++-- .../Simulation/ProductionSystemTests.cs | 45 +++++++++----- .../Simulation/Production/ProductionSystem.cs | 61 +++++++++++++------ CHANGELOG.md | 8 +++ .../BarracksSpawnMatchConfigTests.cs | 43 ++++++++----- .../ProductionConstructionIntegrationTests.cs | 32 ++++++++-- .../ProductionSystemTests.cs | 45 +++++++++----- 7 files changed, 192 insertions(+), 74 deletions(-) diff --git a/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs b/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs index b72efac..c026bb5 100644 --- a/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs +++ b/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs @@ -269,7 +269,7 @@ public void QueueUnit_ThroughSealedCommands_T2GatingAndProducerRules() } [Test] - public void FullLoop_BuildBarracks_QueueInfantry_SpawnsAtRally() + public void FullLoop_BuildBarracks_QueueInfantry_SpawnsAtFootprint_OrderedToRally() { var host = ProdHost.Create(Seed); EntityId builder = host.SpawnBaseFixture(0, 4, 4); @@ -311,8 +311,19 @@ public void FullLoop_BuildBarracks_QueueInfantry_SpawnsAtRally() for (int i = 0; i < 100; i++) host.StepTick(); Assert.That(host.CountRole(0, UnitRole.BasicInfantry), Is.EqualTo(1)); EntityId infantry = FindRole(host, 0, UnitRole.BasicInfantry); - Assert.That(host.Entities.GetUnitRef(infantry).Transform.PositionX, Is.EqualTo(SimFixed.FromInt(30))); - Assert.That(host.Entities.GetUnitRef(infantry).Transform.PositionY, Is.EqualTo(SimFixed.FromInt(30))); + ref readonly UnitState spawned = ref host.Entities.GetUnitRef(infantry); + // 16.2 (#46): the infantry spawns at the Barracks' footprint ring + // (center (21,21); first free ring-2 cell (19,19)) and walks to + // the rally point. The spawn happened inside this loop's last + // tick, so movement has carried it at most a fraction of a cell — + // assert the footprint neighbourhood and the standing order. + int gx = System.Math.Max(0, SimFixed.WorldToGrid(spawned.Transform.PositionX)); + int gy = System.Math.Max(0, SimFixed.WorldToGrid(spawned.Transform.PositionY)); + Assert.That(System.Math.Max(System.Math.Abs(gx - 21), System.Math.Abs(gy - 21)), Is.LessThanOrEqualTo(2), + "spawns at the footprint ring, no longer teleports to the rally cell"); + Assert.That(spawned.GoalGridPos.X, Is.EqualTo(30)); + Assert.That(spawned.GoalGridPos.Y, Is.EqualTo(30)); + Assert.That(spawned.IsMoving, Is.True, "ordered at the rally cell (30,30)"); } [Test] @@ -524,15 +535,24 @@ public void SetRallyPoint_OffMapCommand_IsRejected_ProductionContinuesNormally() "no producer row was created by the rejected command"); // Production continues normally: queue applies and the unit - // spawns at the DEFAULT rally (two cells east of the center). + // spawns at the footprint ring, ordered at the DEFAULT rally + // (two cells east of the center). host.Submit(new QueueUnitPayload(barracksRaw, 12, 1)); host.StepTick(); Assert.That(host.Kernel.LastTickResults[0].Code, Is.EqualTo(CommandResultCode.Applied)); for (int i = 0; i < 100; i++) host.StepTick(); Assert.That(host.CountRole(0, UnitRole.BasicInfantry), Is.EqualTo(1)); EntityId infantry = FindRole(host, 0, UnitRole.BasicInfantry); - Assert.That(host.Entities.GetUnitRef(infantry).Transform.PositionX, Is.EqualTo(SimFixed.FromInt(23))); - Assert.That(host.Entities.GetUnitRef(infantry).Transform.PositionY, Is.EqualTo(SimFixed.FromInt(21))); + ref readonly UnitState spawned = ref host.Entities.GetUnitRef(infantry); + // 16.2 (#46): spawn at the footprint ring of center (21,21) — + // the rejected off-map rally changed nothing, the standing order + // targets the default rally cell (23,21). + int gx = System.Math.Max(0, SimFixed.WorldToGrid(spawned.Transform.PositionX)); + int gy = System.Math.Max(0, SimFixed.WorldToGrid(spawned.Transform.PositionY)); + Assert.That(System.Math.Max(System.Math.Abs(gx - 21), System.Math.Abs(gy - 21)), Is.LessThanOrEqualTo(2), + "spawns at the footprint ring, no longer teleports to the rally cell"); + Assert.That(spawned.GoalGridPos.X, Is.EqualTo(23)); + Assert.That(spawned.GoalGridPos.Y, Is.EqualTo(21)); } private static uint BarracksRaw(ProdHost host) diff --git a/Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs b/Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs index cfe4bbd..b4e2a02 100644 --- a/Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs @@ -166,10 +166,10 @@ public void QueueUnit_InsufficientFunds_IsRejectedInsufficientResources() } [Test] - public void Production_SpawnsAtDefaultRally_AfterExactBuildTicks() + public void Production_SpawnsAtFootprint_ThenOrdersToRally() { var f = new Fixture(); - uint barracks = f.SpawnBarracks(0); // center cell (11,11) -> default rally (13,11) + uint barracks = f.SpawnBarracks(0); // footprint (10,10)-(12,12), center (11,11) -> default rally (13,11) Assert.That(f.Production.TryQueueUnit(0, barracks, 12, 1), Is.True); f.Step(99); @@ -178,9 +178,17 @@ public void Production_SpawnsAtDefaultRally_AfterExactBuildTicks() Assert.That(CountRole(f, UnitRole.BasicInfantry), Is.EqualTo(1), "spawned after exactly 100 full-power ticks"); EntityId unit = FindRole(f, UnitRole.BasicInfantry); - Assert.That(f.Entities.GetUnitRef(unit).Transform.PositionX, Is.EqualTo(SimFixed.FromInt(13))); - Assert.That(f.Entities.GetUnitRef(unit).Transform.PositionY, Is.EqualTo(SimFixed.FromInt(11))); - Assert.That(f.Entities.GetUnitRef(unit).MaxHealth, Is.EqualTo(90)); + ref readonly UnitState spawned = ref f.Entities.GetUnitRef(unit); + // 16.2 (#46): the ring scan anchors at the building's CENTER cell — + // the footprint loses to the occupancy rule, so the first free + // cell in ascending (y, x) is (9,9). The rally point is the ORDER + // target the unit walks to, no longer the spawn anchor. + Assert.That(spawned.Transform.PositionX, Is.EqualTo(SimFixed.FromInt(9))); + Assert.That(spawned.Transform.PositionY, Is.EqualTo(SimFixed.FromInt(9))); + Assert.That(spawned.IsMoving, Is.True, "a standing move order to the rally cell is issued at spawn"); + Assert.That(spawned.GoalGridPos.X, Is.EqualTo(13)); + Assert.That(spawned.GoalGridPos.Y, Is.EqualTo(11)); + Assert.That(spawned.MaxHealth, Is.EqualTo(90)); Assert.That(f.Production.TotalQueuedUnits, Is.EqualTo(0)); } @@ -206,7 +214,7 @@ public void Production_LowPower_ExactlyDoublesDuration() } [Test] - public void SetRallyPoint_MovesTheSpawnLocation() + public void SetRallyPoint_MovesTheOrderTarget_NotTheSpawnCell() { var f = new Fixture(); uint barracks = f.SpawnBarracks(0); @@ -218,8 +226,13 @@ public void SetRallyPoint_MovesTheSpawnLocation() Assert.That(f.Production.TryQueueUnit(0, barracks, 12, 1), Is.True); f.Step(100); EntityId unit = FindRole(f, UnitRole.BasicInfantry); - Assert.That(f.Entities.GetUnitRef(unit).Transform.PositionX, Is.EqualTo(SimFixed.FromInt(30))); - Assert.That(f.Entities.GetUnitRef(unit).Transform.PositionY, Is.EqualTo(SimFixed.FromInt(30))); + ref readonly UnitState spawned = ref f.Entities.GetUnitRef(unit); + // 16.2 (#46): the spawn cell stays at the footprint ring (9,9); + // SetRallyPoint moves the ORDER target, not the spawn anchor. + Assert.That(spawned.Transform.PositionX, Is.EqualTo(SimFixed.FromInt(9)), "still spawns at the footprint"); + Assert.That(spawned.Transform.PositionY, Is.EqualTo(SimFixed.FromInt(9))); + Assert.That(spawned.GoalGridPos.X, Is.EqualTo(30)); + Assert.That(spawned.GoalGridPos.Y, Is.EqualTo(30)); } [Test] @@ -245,18 +258,20 @@ public void SetRallyPoint_Accepted_OnRefinery() public void SpawnSearch_SkipsOccupiedCells_Deterministically() { var f = new Fixture(); - uint barracks = f.SpawnBarracks(0); // default rally (13,11) - // Occupy the rally cell with a completed Storage at (13,11). - Assert.That(f.Construction.PlaceCompletedBuilding(0, 6, 13, 11).IsValid, Is.True); + uint barracks = f.SpawnBarracks(0); // center (11,11) + // Occupy the first ring-2 candidate (9,9): a completed Storage at + // origin (7,7) covers (7,7)-(9,9) without touching the Barracks + // footprint (10,10)-(12,12). + Assert.That(f.Construction.PlaceCompletedBuilding(0, 6, 7, 7).IsValid, Is.True); Assert.That(f.Production.TryQueueUnit(0, barracks, 12, 1), Is.True); f.Step(100); - // Ring-1 scan in ascending (y, x): (12,10) is Barracks footprint, - // (13,10) is the first free cell. + // Ring-2 scan in ascending (y, x): (9,9) is Storage footprint, + // (10,9) is the first free cell. EntityId unit = FindRole(f, UnitRole.BasicInfantry); - Assert.That(f.Entities.GetUnitRef(unit).Transform.PositionX, Is.EqualTo(SimFixed.FromInt(13))); - Assert.That(f.Entities.GetUnitRef(unit).Transform.PositionY, Is.EqualTo(SimFixed.FromInt(10)), + Assert.That(f.Entities.GetUnitRef(unit).Transform.PositionX, Is.EqualTo(SimFixed.FromInt(10))); + Assert.That(f.Entities.GetUnitRef(unit).Transform.PositionY, Is.EqualTo(SimFixed.FromInt(9)), "the documented ring scan skips occupied cells deterministically"); } diff --git a/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs b/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs index 29762c1..091b9ec 100644 --- a/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs @@ -50,10 +50,17 @@ namespace Nova.Simulation.Production /// Q16.16 — /// raw per tick (1.0 at full power, exactly 0.5 under low power; no /// rounding, 0.5 is exact in Q16.16). On completion the unit spawns at - /// the building's rally point: the rally CELL is tried first, then - /// expanding Chebyshev rings 1.., within - /// a ring in ascending (y, x) order; a cell is free when no placement - /// footprint covers it and no active unit stands on it. + /// the building's FOOTPRINT (16.2, #46: the rally point is a destination, + /// not a teleporter): expanding Chebyshev rings 0.. + /// from the building's center cell, within a ring in ascending (y, x) + /// order; a cell is free when no placement footprint covers it and no + /// active unit stands on it. The footprint itself always loses to the + /// occupancy rule, so the first hits are the ring just outside it, and + /// freshly produced units walk OUT of the building instead of appearing + /// at the rally point. A spawned unit immediately gets a standing move + /// order to the rally cell (a direct SetTarget write, the same + /// write class as the construction push-out's — no command record, no + /// new command kind; movement then drives the walk). /// When no free cell exists inside the search range or the entity store /// is full (MS-1 cap 1.024, mvp-v1.json capacity.entityStoreCap), the /// finished unit waits: progress stays clamped at the threshold and the @@ -359,7 +366,8 @@ public void SetRallyPoint(uint buildingRaw, SimFixed targetX, SimFixed targetY) /// Phase 4: drops rows whose building died (queue lost without /// refund), then progresses every producer's first entry by the /// owner's exact Q16.16 speed multiplier and spawns finished units at - /// the rally point — in strict ascending row order. + /// the building's footprint with a standing move order to the rally + /// point — in strict ascending row order. /// public void ExecuteTick(Tick tick) { @@ -393,19 +401,33 @@ public void ExecuteTick(Tick tick) entry.ProgressRaw = thresholdRaw; break; } - if (!TryFindSpawnCell(row, out int cellX, out int cellY)) + if (!TryFindSpawnCell(in building, out int cellX, out int cellY)) { // No free cell inside the search range: same documented pause. entry.ProgressRaw = thresholdRaw; break; } - _entityManager.SpawnUnit( + EntityId spawned = _entityManager.SpawnUnit( building.PlayerId, new Transform2D(SimFixed.FromInt(cellX), SimFixed.FromInt(cellY)), def.MoveSpeed, maxHealth: def.MaxHealth, role: def.Role); + + // 16.2 (#46): the unit walks OUT of the building to the + // rally point instead of appearing there. Direct state + // write, same class as the construction push-out's + // SetTarget — movement drives the walk from the next + // phase on. A rally cell coinciding with the spawn cell + // needs no order at all. + int rallyCellX = Math.Max(0, SimFixed.WorldToGrid(SimFixed.FromRaw(row.RallyXRaw))); + int rallyCellY = Math.Max(0, SimFixed.WorldToGrid(SimFixed.FromRaw(row.RallyYRaw))); + if (rallyCellX != cellX || rallyCellY != cellY) + { + _entityManager.GetUnitRef(spawned).SetTarget(new GridPos2D(rallyCellX, rallyCellY)); + } + entry.RemainingCount--; entry.ProgressRaw -= thresholdRaw; } @@ -422,17 +444,20 @@ public void ExecuteTick(Tick tick) // ------------------------------------------------------------------ /// - /// Spawn cell search (documented deterministic algorithm): the rally - /// cell first, then expanding Chebyshev rings 1..SpawnSearchMaxRing, - /// within a ring ascending (y, x); a cell is free when no placement - /// footprint covers it AND no active unit stands on it — freshly - /// produced units form a line in front of the building instead of - /// stacking on the rally cell. + /// Spawn cell search (documented deterministic algorithm, 16.2/#46): + /// expanding Chebyshev rings 0..SpawnSearchMaxRing from the + /// building's CENTER CELL — the footprint always loses to the + /// occupancy rule, so the first hits are the ring just outside the + /// building. Within a ring ascending (y, x); a cell is free when no + /// placement footprint covers it AND no active unit stands on it — + /// freshly produced units form a line at the building's edge instead + /// of stacking on one cell. The rally point is the ORDER target the + /// spawned unit walks to, no longer the spawn anchor. /// - private bool TryFindSpawnCell(ProducerRow row, out int cellX, out int cellY) + private bool TryFindSpawnCell(in UnitState building, out int cellX, out int cellY) { - int rallyCellX = Math.Max(0, SimFixed.WorldToGrid(SimFixed.FromRaw(row.RallyXRaw))); - int rallyCellY = Math.Max(0, SimFixed.WorldToGrid(SimFixed.FromRaw(row.RallyYRaw))); + int centerX = Math.Max(0, SimFixed.WorldToGrid(building.Transform.PositionX)); + int centerY = Math.Max(0, SimFixed.WorldToGrid(building.Transform.PositionY)); for (int ring = 0; ring <= SpawnSearchMaxRing; ring++) { @@ -441,8 +466,8 @@ private bool TryFindSpawnCell(ProducerRow row, out int cellX, out int cellY) for (int dx = -ring; dx <= ring; dx++) { if (Math.Max(Math.Abs(dx), Math.Abs(dy)) != ring) continue; - int x = rallyCellX + dx; - int y = rallyCellY + dy; + int x = centerX + dx; + int y = centerY + dy; if (_construction.IsCellFree(x, y) && !_entityManager.HasActiveUnitOnCell(x, y)) { cellX = x; diff --git a/CHANGELOG.md b/CHANGELOG.md index d61365d..6699b77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,14 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de > erzeugt; MS-0 und MS-1 bleiben offen. ### Behoben +- **#46: Produzierte Einheiten verlassen das Gebäude** — die Spawn-Suche des + `ProductionSystem` verankert am Footprint-Zentrum des Produktionsgebäudes + statt am Sammelpunkt; die fertige Einheit bekommt einen stehenden + Bewegungsbefehl auf den Sammelpunkt (direkter `SetTarget`-Schreibzugriff, + gleiche Klasse wie der Push-out — kein neuer Befehlstyp). Der Sammelpunkt + ist wieder ein Ziel statt eines Teleporters; Einheiten fahren aus dem + Gebäude heraus. Macht #57 (hohle Gebäude-Assets) sichtbarer — Art-Befund + für den GrayboxLog, kein Code-Eingriff - **#49: Auswahlrahmen und Füllung entschärft** — `GroundMarkerVisuals`: Rand von 6/64 auf 2/64 der Quad-Kante, Füll-Alpha von 0.28 auf 0.10; wirkt auf Auswahl-, Platzierungs-, Sammelpunkt- und Baustellenmarker zugleich und nimmt #50 (Einheit im Pulk nicht auffindbar) die verdeckende Füllung ab - **Die drei Laborschalter greifen nicht mehr in einer Netzpartie und nicht mehr im ausgelieferten Build:** `FogRevealDebug` und `MatchSpeedDebug` kamen aus dem diff --git a/tools/Nova.SimRunner.Tests/BarracksSpawnMatchConfigTests.cs b/tools/Nova.SimRunner.Tests/BarracksSpawnMatchConfigTests.cs index caa4942..df46cf5 100644 --- a/tools/Nova.SimRunner.Tests/BarracksSpawnMatchConfigTests.cs +++ b/tools/Nova.SimRunner.Tests/BarracksSpawnMatchConfigTests.cs @@ -145,8 +145,10 @@ private static MatchHost BuildMatchHost() /// The report's exact situation: a completed Alliance barracks, one /// infantry queued through the sealed command path. The bar's data /// source must show the progressing entry (the owner's "bar runs"), - /// and after BuildTicks the entity must stand at the default rally - /// cell — if it does, the simulation side of the defect is closed. + /// and after BuildTicks the entity must stand at the barracks' + /// footprint ring with a standing order to the default rally cell + /// (16.2, #46: spawns at the building, walks to the rally point) — + /// if it does, the simulation side of the defect is closed. /// [Test] public void MatchConfig_BarracksQueueViaCommandPath_SpawnsInfantryAtDefaultRally() @@ -188,9 +190,14 @@ public void MatchConfig_BarracksQueueViaCommandPath_SpawnsInfantryAtDefaultRally "SIM VERDICT: in the real match configuration the infantry MUST spawn — if this fails, " + "one of the two silent ProductionSystem pause paths is reachable in an ordinary base"); - // The spawned infantryman stands at the default rally cell (13,11) - // and is part of the viewer team's committed fog view (the feed - // UnitViewManager renders — own entities always). + // 16.2 (#46): the spawned infantryman stands at the barracks' + // footprint ring (no longer AT the default rally cell) and walks + // there under a standing order. The spawn tick plus the few + // remaining loop ticks carry him at most ~1.5 cells, so the + // asserts pin the order and the footprint neighbourhood, not an + // exact en-route position. He is part of the viewer team's + // committed fog view (the feed UnitViewManager renders — own + // entities always). UnitState[] units = host.Entities.RawUnits; EntityId infantry = EntityId.Invalid; for (int i = 0; i < host.Entities.Capacity; i++) @@ -199,8 +206,15 @@ public void MatchConfig_BarracksQueueViaCommandPath_SpawnsInfantryAtDefaultRally if (u.IsActive && u.PlayerId == 0 && u.Role == UnitRole.BasicInfantry) { infantry = u.Id; - Assert.That(u.Transform.PositionX, Is.EqualTo(SimFixed.FromInt(13))); - Assert.That(u.Transform.PositionY, Is.EqualTo(SimFixed.FromInt(11))); + int gx = System.Math.Max(0, SimFixed.WorldToGrid(u.Transform.PositionX)); + int gy = System.Math.Max(0, SimFixed.WorldToGrid(u.Transform.PositionY)); + Assert.That(gx == 13 && gy == 11, Is.False, + "no longer teleports to the default rally cell (13,11)"); + Assert.That(System.Math.Max(System.Math.Abs(gx - 11), System.Math.Abs(gy - 11)), Is.LessThanOrEqualTo(3), + "spawns at the footprint ring of the (11,11) barracks and walks"); + Assert.That(u.GoalGridPos.X, Is.EqualTo(13)); + Assert.That(u.GoalGridPos.Y, Is.EqualTo(11)); + Assert.That(u.IsMoving, Is.True, "ordered at the default rally cell (13,11)"); } } Assert.That(infantry.IsValid, Is.True); @@ -215,28 +229,29 @@ public void MatchConfig_BarracksQueueViaCommandPath_SpawnsInfantryAtDefaultRally /// Companion verdict for the second silent pause path: with the /// manifest's 1024-entity store the only way production can hang in an /// ordinary match is a fully blocked spawn search — proven here by - /// walling the default rally cell's entire eight-ring search area with - /// placements, which must pause the finished unit at the threshold - /// (progress clamped, nothing spawned, nothing lost). + /// walling the entire eight-ring search area around the FOOTPRINT + /// CENTRE (16.2: the search anchors there, not at the rally cell) + /// with placements, which must pause the finished unit at the + /// threshold (progress clamped, nothing spawned, nothing lost). /// [Test] public void MatchConfig_NoFreeSpawnCell_PausesAtThreshold_Silently() { MatchHost host = BuildMatchHost(); - // Barracks at origin (21,21) -> centre (22,22) -> default rally - // (24,22); the eight-ring spawn search covers (16..32, 14..30). + // Barracks at origin (21,21) -> centre (22,22); the eight-ring + // spawn search anchored at the centre covers (14..30, 14..30). EntityId barracks = host.Construction.PlaceCompletedBuilding(0, DefBarracksAlliance, 21, 21); Assert.That(barracks.IsValid, Is.True); uint rawBarracks = UnitCommandStateView.ToRawEntityId(barracks); // Wall the ENTIRE search area with exactly tiling 3x3 footprints: - // origins every three cells covering (15..32, 12..32) — the + // origins every three cells covering (12..32, 12..32) — the // barracks itself fills the (21,21) slot, so every placement is // free and no cell of the search square stays uncovered. for (int y = 12; y <= 30; y += 3) { - for (int x = 15; x <= 30; x += 3) + for (int x = 12; x <= 30; x += 3) { if (x == 21 && y == 21) continue; // the barracks slot itself Assert.That( diff --git a/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs b/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs index 8f51512..d4f54ef 100644 --- a/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs +++ b/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs @@ -269,7 +269,7 @@ public void QueueUnit_ThroughSealedCommands_T2GatingAndProducerRules() } [Test] - public void FullLoop_BuildBarracks_QueueInfantry_SpawnsAtRally() + public void FullLoop_BuildBarracks_QueueInfantry_SpawnsAtFootprint_OrderedToRally() { var host = ProdHost.Create(Seed); EntityId builder = host.SpawnBaseFixture(0, 4, 4); @@ -311,8 +311,19 @@ public void FullLoop_BuildBarracks_QueueInfantry_SpawnsAtRally() for (int i = 0; i < 100; i++) host.StepTick(); Assert.That(host.CountRole(0, UnitRole.BasicInfantry), Is.EqualTo(1)); EntityId infantry = FindRole(host, 0, UnitRole.BasicInfantry); - Assert.That(host.Entities.GetUnitRef(infantry).Transform.PositionX, Is.EqualTo(SimFixed.FromInt(30))); - Assert.That(host.Entities.GetUnitRef(infantry).Transform.PositionY, Is.EqualTo(SimFixed.FromInt(30))); + ref readonly UnitState spawned = ref host.Entities.GetUnitRef(infantry); + // 16.2 (#46): the infantry spawns at the Barracks' footprint ring + // (center (21,21); first free ring-2 cell (19,19)) and walks to + // the rally point. The spawn happened inside this loop's last + // tick, so movement has carried it at most a fraction of a cell — + // assert the footprint neighbourhood and the standing order. + int gx = System.Math.Max(0, SimFixed.WorldToGrid(spawned.Transform.PositionX)); + int gy = System.Math.Max(0, SimFixed.WorldToGrid(spawned.Transform.PositionY)); + Assert.That(System.Math.Max(System.Math.Abs(gx - 21), System.Math.Abs(gy - 21)), Is.LessThanOrEqualTo(2), + "spawns at the footprint ring, no longer teleports to the rally cell"); + Assert.That(spawned.GoalGridPos.X, Is.EqualTo(30)); + Assert.That(spawned.GoalGridPos.Y, Is.EqualTo(30)); + Assert.That(spawned.IsMoving, Is.True, "ordered at the rally cell (30,30)"); } [Test] @@ -524,15 +535,24 @@ public void SetRallyPoint_OffMapCommand_IsRejected_ProductionContinuesNormally() "no producer row was created by the rejected command"); // Production continues normally: queue applies and the unit - // spawns at the DEFAULT rally (two cells east of the center). + // spawns at the footprint ring, ordered at the DEFAULT rally + // (two cells east of the center). host.Submit(new QueueUnitPayload(barracksRaw, 12, 1)); host.StepTick(); Assert.That(host.Kernel.LastTickResults[0].Code, Is.EqualTo(CommandResultCode.Applied)); for (int i = 0; i < 100; i++) host.StepTick(); Assert.That(host.CountRole(0, UnitRole.BasicInfantry), Is.EqualTo(1)); EntityId infantry = FindRole(host, 0, UnitRole.BasicInfantry); - Assert.That(host.Entities.GetUnitRef(infantry).Transform.PositionX, Is.EqualTo(SimFixed.FromInt(23))); - Assert.That(host.Entities.GetUnitRef(infantry).Transform.PositionY, Is.EqualTo(SimFixed.FromInt(21))); + ref readonly UnitState spawned = ref host.Entities.GetUnitRef(infantry); + // 16.2 (#46): spawn at the footprint ring of center (21,21) — + // the rejected off-map rally changed nothing, the standing order + // targets the default rally cell (23,21). + int gx = System.Math.Max(0, SimFixed.WorldToGrid(spawned.Transform.PositionX)); + int gy = System.Math.Max(0, SimFixed.WorldToGrid(spawned.Transform.PositionY)); + Assert.That(System.Math.Max(System.Math.Abs(gx - 21), System.Math.Abs(gy - 21)), Is.LessThanOrEqualTo(2), + "spawns at the footprint ring, no longer teleports to the rally cell"); + Assert.That(spawned.GoalGridPos.X, Is.EqualTo(23)); + Assert.That(spawned.GoalGridPos.Y, Is.EqualTo(21)); } private static uint BarracksRaw(ProdHost host) diff --git a/tools/Nova.SimRunner.Tests/ProductionSystemTests.cs b/tools/Nova.SimRunner.Tests/ProductionSystemTests.cs index 797bb42..fb03d25 100644 --- a/tools/Nova.SimRunner.Tests/ProductionSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/ProductionSystemTests.cs @@ -166,10 +166,10 @@ public void QueueUnit_InsufficientFunds_IsRejectedInsufficientResources() } [Test] - public void Production_SpawnsAtDefaultRally_AfterExactBuildTicks() + public void Production_SpawnsAtFootprint_ThenOrdersToRally() { var f = new Fixture(); - uint barracks = f.SpawnBarracks(0); // center cell (11,11) -> default rally (13,11) + uint barracks = f.SpawnBarracks(0); // footprint (10,10)-(12,12), center (11,11) -> default rally (13,11) Assert.That(f.Production.TryQueueUnit(0, barracks, 12, 1), Is.True); f.Step(99); @@ -178,9 +178,17 @@ public void Production_SpawnsAtDefaultRally_AfterExactBuildTicks() Assert.That(CountRole(f, UnitRole.BasicInfantry), Is.EqualTo(1), "spawned after exactly 100 full-power ticks"); EntityId unit = FindRole(f, UnitRole.BasicInfantry); - Assert.That(f.Entities.GetUnitRef(unit).Transform.PositionX, Is.EqualTo(SimFixed.FromInt(13))); - Assert.That(f.Entities.GetUnitRef(unit).Transform.PositionY, Is.EqualTo(SimFixed.FromInt(11))); - Assert.That(f.Entities.GetUnitRef(unit).MaxHealth, Is.EqualTo(90)); + ref readonly UnitState spawned = ref f.Entities.GetUnitRef(unit); + // 16.2 (#46): the ring scan anchors at the building's CENTER cell — + // the footprint loses to the occupancy rule, so the first free + // cell in ascending (y, x) is (9,9). The rally point is the ORDER + // target the unit walks to, no longer the spawn anchor. + Assert.That(spawned.Transform.PositionX, Is.EqualTo(SimFixed.FromInt(9))); + Assert.That(spawned.Transform.PositionY, Is.EqualTo(SimFixed.FromInt(9))); + Assert.That(spawned.IsMoving, Is.True, "a standing move order to the rally cell is issued at spawn"); + Assert.That(spawned.GoalGridPos.X, Is.EqualTo(13)); + Assert.That(spawned.GoalGridPos.Y, Is.EqualTo(11)); + Assert.That(spawned.MaxHealth, Is.EqualTo(90)); Assert.That(f.Production.TotalQueuedUnits, Is.EqualTo(0)); } @@ -206,7 +214,7 @@ public void Production_LowPower_ExactlyDoublesDuration() } [Test] - public void SetRallyPoint_MovesTheSpawnLocation() + public void SetRallyPoint_MovesTheOrderTarget_NotTheSpawnCell() { var f = new Fixture(); uint barracks = f.SpawnBarracks(0); @@ -218,8 +226,13 @@ public void SetRallyPoint_MovesTheSpawnLocation() Assert.That(f.Production.TryQueueUnit(0, barracks, 12, 1), Is.True); f.Step(100); EntityId unit = FindRole(f, UnitRole.BasicInfantry); - Assert.That(f.Entities.GetUnitRef(unit).Transform.PositionX, Is.EqualTo(SimFixed.FromInt(30))); - Assert.That(f.Entities.GetUnitRef(unit).Transform.PositionY, Is.EqualTo(SimFixed.FromInt(30))); + ref readonly UnitState spawned = ref f.Entities.GetUnitRef(unit); + // 16.2 (#46): the spawn cell stays at the footprint ring (9,9); + // SetRallyPoint moves the ORDER target, not the spawn anchor. + Assert.That(spawned.Transform.PositionX, Is.EqualTo(SimFixed.FromInt(9)), "still spawns at the footprint"); + Assert.That(spawned.Transform.PositionY, Is.EqualTo(SimFixed.FromInt(9))); + Assert.That(spawned.GoalGridPos.X, Is.EqualTo(30)); + Assert.That(spawned.GoalGridPos.Y, Is.EqualTo(30)); } [Test] @@ -245,18 +258,20 @@ public void SetRallyPoint_Accepted_OnRefinery() public void SpawnSearch_SkipsOccupiedCells_Deterministically() { var f = new Fixture(); - uint barracks = f.SpawnBarracks(0); // default rally (13,11) - // Occupy the rally cell with a completed Storage at (13,11). - Assert.That(f.Construction.PlaceCompletedBuilding(0, 6, 13, 11).IsValid, Is.True); + uint barracks = f.SpawnBarracks(0); // center (11,11) + // Occupy the first ring-2 candidate (9,9): a completed Storage at + // origin (7,7) covers (7,7)-(9,9) without touching the Barracks + // footprint (10,10)-(12,12). + Assert.That(f.Construction.PlaceCompletedBuilding(0, 6, 7, 7).IsValid, Is.True); Assert.That(f.Production.TryQueueUnit(0, barracks, 12, 1), Is.True); f.Step(100); - // Ring-1 scan in ascending (y, x): (12,10) is Barracks footprint, - // (13,10) is the first free cell. + // Ring-2 scan in ascending (y, x): (9,9) is Storage footprint, + // (10,9) is the first free cell. EntityId unit = FindRole(f, UnitRole.BasicInfantry); - Assert.That(f.Entities.GetUnitRef(unit).Transform.PositionX, Is.EqualTo(SimFixed.FromInt(13))); - Assert.That(f.Entities.GetUnitRef(unit).Transform.PositionY, Is.EqualTo(SimFixed.FromInt(10)), + Assert.That(f.Entities.GetUnitRef(unit).Transform.PositionX, Is.EqualTo(SimFixed.FromInt(10))); + Assert.That(f.Entities.GetUnitRef(unit).Transform.PositionY, Is.EqualTo(SimFixed.FromInt(9)), "the documented ring scan skips occupied cells deterministically"); } From 15c17d57a5a0037871449c9331870fd877a2a6db Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sun, 9 Aug 2026 20:38:43 +0200 Subject: [PATCH 2/4] fix(sim): GridPos2D braucht sein using im ProductionSystem Seit 16.2 schickt die fertige Einheit sich per SetTarget zum Sammelpunkt. GridPos2D liegt in Nova.Simulation.Pathfinding, und das using fehlte - die CI brach mit CS0246 ab, bevor ein einziger Test lief. Co-Authored-By: Claude Opus 5 --- .../_Project/Scripts/Simulation/Production/ProductionSystem.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs b/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs index 091b9ec..d19d0af 100644 --- a/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs @@ -3,6 +3,7 @@ using Nova.Simulation.CommandsV1; using Nova.Simulation.Definitions; using Nova.Simulation.Economy; +using Nova.Simulation.Pathfinding; using Nova.Simulation.Snapshots; using Nova.Simulation.State; From 3fa53c52b9cff824e235fcb87ff0f0761aeee7d8 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sun, 9 Aug 2026 21:08:17 +0200 Subject: [PATCH 3/4] test(production): Spawnort am Erscheinungstick messen, Sammelpunkt am Ziel pruefen Zwei Tests hielten das Verhalten vor 16.2 fest und wurden nach der Umstellung an der falschen Stelle rot. BarracksSpawnMatchConfigTests lief 110 Ticks weiter und pruefte dann, dass der Soldat NICHT am Sammelpunkt steht. Nach 110 Ticks ist er dort angekommen - das ist der Zweck der Aenderung. Die Schleife haelt jetzt am Erscheinungstick an; dort greift die Aussage, und die Ankunft bleibt erlaubt. ProductionSystemTests pruefte die Endposition gegen den Sammelpunkt. Der Fixture registriert kein MovementSystem, die Einheit bleibt also am Footprint stehen. Geprueft wird jetzt der stehende Befehl auf (30,30) - genau das, was belegt, dass der abgelehnte Sammelpunkt den alten nicht ueberschrieben hat. Co-Authored-By: Claude Opus 5 --- .../Simulation/ProductionSystemTests.cs | 18 ++++++++++++++++-- .../BarracksSpawnMatchConfigTests.cs | 11 ++++++++--- .../ProductionSystemTests.cs | 18 ++++++++++++++++-- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs b/Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs index b4e2a02..8b6d0fd 100644 --- a/Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs +++ b/Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs @@ -403,8 +403,22 @@ public void SetRallyPoint_OffMap_IsRejected_RallyUnchanged_QueueContinues() Assert.That(f.Production.TryQueueUnit(0, barracks, 12, 1), Is.True); f.Step(100); EntityId unit = FindRole(f, UnitRole.BasicInfantry); - Assert.That(f.Entities.GetUnitRef(unit).Transform.PositionX, Is.EqualTo(SimFixed.FromInt(30))); - Assert.That(f.Entities.GetUnitRef(unit).Transform.PositionY, Is.EqualTo(SimFixed.FromInt(30))); + ref readonly UnitState spawned = ref f.Entities.GetUnitRef(unit); + + // 16.2 (#46): the unit is born at the barracks footprint and is + // ORDERED to the rally cell — it no longer materialises there. + // This fixture registers no MovementSystem, so it stays at the + // spawn cell; the standing order is what proves the rally + // survived the rejected update. + Assert.That(spawned.GoalGridPos.X, Is.EqualTo(30), + "the surviving rally (30,30) is what the finished unit is sent to"); + Assert.That(spawned.GoalGridPos.Y, Is.EqualTo(30)); + + int spawnX = System.Math.Max(0, SimFixed.WorldToGrid(spawned.Transform.PositionX)); + int spawnY = System.Math.Max(0, SimFixed.WorldToGrid(spawned.Transform.PositionY)); + Assert.That(System.Math.Max(System.Math.Abs(spawnX - 11), System.Math.Abs(spawnY - 11)), + Is.LessThanOrEqualTo(3), + "born at the footprint ring of the (11,11) barracks, not at the rally"); } [Test] diff --git a/tools/Nova.SimRunner.Tests/BarracksSpawnMatchConfigTests.cs b/tools/Nova.SimRunner.Tests/BarracksSpawnMatchConfigTests.cs index 613a3a8..3e20570 100644 --- a/tools/Nova.SimRunner.Tests/BarracksSpawnMatchConfigTests.cs +++ b/tools/Nova.SimRunner.Tests/BarracksSpawnMatchConfigTests.cs @@ -181,7 +181,12 @@ public void MatchConfig_BarracksQueueViaCommandPath_SpawnsInfantryAtDefaultRally // 100 build ticks at full power (HQ 30 provided, barracks 15 // required), plus the ticks already spent — 110 is ample. - for (int i = 0; i < 110; i++) + // Stop AT the spawn tick: the no-teleport assert below reads the + // birth position, and with a MovementSystem in the host the + // infantryman reaches the rally cell within a few more ticks — + // which is exactly the intended behaviour and would make a + // fixed-length loop assert the opposite of what it means. + for (int i = 0; i < 110 && host.CountRole(0, UnitRole.BasicInfantry) == 0; i++) { host.Step(); } @@ -208,10 +213,10 @@ public void MatchConfig_BarracksQueueViaCommandPath_SpawnsInfantryAtDefaultRally infantry = u.Id; int gx = System.Math.Max(0, SimFixed.WorldToGrid(u.Transform.PositionX)); int gy = System.Math.Max(0, SimFixed.WorldToGrid(u.Transform.PositionY)); - Assert.That(gx == 13 && gy == 11, Is.False, - "no longer teleports to the default rally cell (13,11)"); Assert.That(System.Math.Max(System.Math.Abs(gx - 11), System.Math.Abs(gy - 11)), Is.LessThanOrEqualTo(3), "spawns at the footprint ring of the (11,11) barracks and walks"); + Assert.That(gx == 13 && gy == 11, Is.False, + "at the spawn tick he is not yet at the rally cell — he walks there"); Assert.That(u.GoalGridPos.X, Is.EqualTo(13)); Assert.That(u.GoalGridPos.Y, Is.EqualTo(11)); Assert.That(u.IsMoving, Is.True, "ordered at the default rally cell (13,11)"); diff --git a/tools/Nova.SimRunner.Tests/ProductionSystemTests.cs b/tools/Nova.SimRunner.Tests/ProductionSystemTests.cs index fb03d25..228c555 100644 --- a/tools/Nova.SimRunner.Tests/ProductionSystemTests.cs +++ b/tools/Nova.SimRunner.Tests/ProductionSystemTests.cs @@ -403,8 +403,22 @@ public void SetRallyPoint_OffMap_IsRejected_RallyUnchanged_QueueContinues() Assert.That(f.Production.TryQueueUnit(0, barracks, 12, 1), Is.True); f.Step(100); EntityId unit = FindRole(f, UnitRole.BasicInfantry); - Assert.That(f.Entities.GetUnitRef(unit).Transform.PositionX, Is.EqualTo(SimFixed.FromInt(30))); - Assert.That(f.Entities.GetUnitRef(unit).Transform.PositionY, Is.EqualTo(SimFixed.FromInt(30))); + ref readonly UnitState spawned = ref f.Entities.GetUnitRef(unit); + + // 16.2 (#46): the unit is born at the barracks footprint and is + // ORDERED to the rally cell — it no longer materialises there. + // This fixture registers no MovementSystem, so it stays at the + // spawn cell; the standing order is what proves the rally + // survived the rejected update. + Assert.That(spawned.GoalGridPos.X, Is.EqualTo(30), + "the surviving rally (30,30) is what the finished unit is sent to"); + Assert.That(spawned.GoalGridPos.Y, Is.EqualTo(30)); + + int spawnX = System.Math.Max(0, SimFixed.WorldToGrid(spawned.Transform.PositionX)); + int spawnY = System.Math.Max(0, SimFixed.WorldToGrid(spawned.Transform.PositionY)); + Assert.That(System.Math.Max(System.Math.Abs(spawnX - 11), System.Math.Abs(spawnY - 11)), + Is.LessThanOrEqualTo(3), + "born at the footprint ring of the (11,11) barracks, not at the rally"); } [Test] From 1f294eddbc77d6767dba7613139237103d3d6e34 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sun, 9 Aug 2026 21:10:45 +0200 Subject: [PATCH 4/4] test(ai): Ausgangspin auf 16.2 nachziehen Einheiten erscheinen seit 16.2 am Footprint und laufen zum Sammelpunkt, statt dort zu materialisieren. Die kanonische KI-Partie entscheidet dadurch zwei Ticks frueher und endet in einem anderen Zustand. Entscheidungstick: 2548 -> 2546 Endzustand: 0x8C0B54F31F2986B7 -> 0x9F93097AD526B6F7 AiBehaviorId bleibt r5.779A1B5B - es hat sich die Simulation bewegt, nicht die KI (D-101). Co-Authored-By: Claude Opus 5 --- .../CanonicalAiOutcomeTests.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs b/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs index 6461776..7908432 100644 --- a/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs +++ b/tools/Nova.SimRunner.Tests/CanonicalAiOutcomeTests.cs @@ -43,17 +43,17 @@ namespace Nova.SimRunner.Tests [TestFixture] public sealed class CanonicalAiOutcomeTests { - /// Decided tick of the canonical AI match, last moved by: Sprint 15 (r5) — unchanged by 16.1. - private const uint PinnedDecidedTick = 2548u; + /// Decided tick of the canonical AI match, last moved by: Sprint 16.2 (#46). Previous value: 2548 (Sprint 16.1). + private const uint PinnedDecidedTick = 2546u; /// - /// End-state hash of the canonical AI match, last moved by: Sprint 16 package - /// 16.1 (#43) — the founding Harvester is born with a standing harvest - /// order, so the AI match ends with a different economy. The AI itself is - /// unchanged: AiBehaviorId stayed r5.779A1B5B. - /// Previous value: 0x14472B2B943ED2BB (Sprint 15, r5). + /// End-state hash of the canonical AI match, last moved by: Sprint 16 + /// package 16.2 (#46) — produced units spawn at the building footprint + /// and walk to their rally point. The AI itself is unchanged: + /// AiBehaviorId stayed r5.779A1B5B. + /// Previous value: 0x8C0B54F31F2986B7 (Sprint 16.1). /// - private const string PinnedEndState = "0x8C0B54F31F2986B7"; + private const string PinnedEndState = "0x9F93097AD526B6F7"; [Test] public void CanonicalAiMatch_DecidesOnThePinnedTick_WithThePinnedEndState()