diff --git a/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs b/Assets/Tests/EditMode/Simulation/ProductionConstructionIntegrationTests.cs index 0580040..2a1ae16 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..8b6d0fd 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"); } @@ -388,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/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs b/Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs index 29762c1..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; @@ -50,10 +51,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 +367,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 +402,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 +445,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 +467,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 1d030c6..e689787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,14 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de protokolliert statt still verschluckt. Die Fahrt zum Feld übernehmen die bestehenden Eskorten (Client `UpdateHarvesterEscort`, KI `SkirmishAiSystem`): kein neuer Befehlstyp, kein neues Zustandsfeld +- **#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 c268ecf..3e20570 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() @@ -179,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(); } @@ -188,9 +195,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 +211,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(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)"); } } Assert.That(infantry.IsValid, Is.True); @@ -215,28 +234,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/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() diff --git a/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs b/tools/Nova.SimRunner.Tests/ProductionConstructionIntegrationTests.cs index e209e16..93172c9 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..228c555 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"); } @@ -388,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]