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]