Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)
Expand Down
63 changes: 46 additions & 17 deletions Assets/Tests/EditMode/Simulation/ProductionSystemTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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));
}

Expand All @@ -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);
Expand All @@ -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]
Expand All @@ -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");
}

Expand Down Expand Up @@ -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]
Expand Down
62 changes: 44 additions & 18 deletions Assets/_Project/Scripts/Simulation/Production/ProductionSystem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -50,10 +51,17 @@ namespace Nova.Simulation.Production
/// Q16.16 — <see cref="PlayerEconomyState.ProductionSpeedMultiplierQ16"/>
/// 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..<see cref="SpawnSearchMaxRing"/>, 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..<see cref="SpawnSearchMaxRing"/>
/// 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 <c>SetTarget</c> 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
Expand Down Expand Up @@ -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.
/// </summary>
public void ExecuteTick(Tick tick)
{
Expand Down Expand Up @@ -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;
}
Expand All @@ -422,17 +445,20 @@ public void ExecuteTick(Tick tick)
// ------------------------------------------------------------------

/// <summary>
/// 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.
/// </summary>
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++)
{
Expand All @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading