Skip to content
Draft
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
61 changes: 61 additions & 0 deletions Assets/Tests/EditMode/AI/SkirmishAiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,17 @@ private static bool AnyHarvesterWorking(AiHost host, byte slot)
return false;
}

private static EntityId FirstHarvester(AiHost host, byte slot)
{
UnitState[] units = host.Entities.RawUnits;
for (int i = 0; i < host.Entities.Capacity; i++)
{
ref readonly UnitState u = ref units[i];
if (u.IsActive && u.PlayerId == slot && u.Role == UnitRole.Harvester) return u.Id;
}
return EntityId.Invalid;
}

private static int MinCombatCellX(AiHost host, byte slot)
{
int min = int.MaxValue;
Expand Down Expand Up @@ -356,6 +367,56 @@ public void SkirmishAi_QueuesHarvesters_AndHarvestIncomeRefillsTheTreasury()
"idle harvesters must receive explicit Harvest intents on the own field");
}

/// <summary>
/// Regression for the hang behaviour revision 10 closes (#134, D-121).
/// Production and the founding grant pick a harvester's field as the
/// nearest one to the Refinery; this AI picks the nearest one to its
/// HQ. Where the two disagree, the r9 gather escort walked the
/// harvester to the AI's field while its order named the other one —
/// and the economy HOLDS an out-of-reach order instead of dropping it,
/// so the harvester stood there with frozen cargo for good. The
/// two-field map of this suite makes both rules agree, so the test
/// creates the disagreement directly: a third field away from the AI
/// base, and the AI's first harvester ordered onto it the tick it
/// appears. Artificial on purpose; the mechanism is the real one.
/// </summary>
[Test]
public void SkirmishAi_RetasksAHarvesterOrderedOntoAForeignField_AndItMines()
{
AiHost host = BuildMatch(Seed);

EntityId harvester = EntityId.Invalid;
for (int i = 0; i < 2000 && !harvester.IsValid; i++)
{
host.Step();
harvester = FirstHarvester(host, AiSlot);
}
Assert.That(harvester.IsValid, Is.True, "the AI's Refinery hands out its first harvester");

const ushort ownFieldId = 2; // slot 1's field at (117,117), see ApplyOpeningPosition
const ushort foreignFieldId = 3; // far from the AI's HQ and Refinery alike
Assert.That(host.Economy.TryAddField(foreignFieldId, new GridPos2D(95, 100), FieldReserveAE), Is.True);
ref UnitState forced = ref host.Entities.GetUnitRef(harvester);
forced.HarvestFieldId = foreignFieldId;
forced.IsReturningCargo = false;
Assert.That(forced.CargoAE, Is.EqualTo(0), "a fresh harvester carries nothing yet");

bool retasked = false;
bool mined = false;
for (int i = 0; i < 1000 && !mined; i++)
{
host.Step();
ref readonly UnitState h = ref host.Entities.GetUnitRef(harvester);
if (h.HarvestFieldId == ownFieldId) retasked = true;
if (h.CargoAE > 0 || h.IsReturningCargo) mined = true;
}

Assert.That(retasked, Is.True,
"the AI re-tasks a harvester whose order names a field other than its own (r10)");
Assert.That(mined, Is.True,
"the re-tasked harvester mines instead of standing in reach of the wrong field with frozen cargo");
}

// ----------------------------------------------------------------
// (c) Army: infantry production up to the threshold, then the march
// ----------------------------------------------------------------
Expand Down
147 changes: 146 additions & 1 deletion Assets/Tests/EditMode/Simulation/EconomyIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Nova.Simulation.Economy;
using Nova.Simulation.Movement;
using Nova.Simulation.Pathfinding;
using Nova.Simulation.Production;
using Nova.Simulation.Replays;
using Nova.Simulation.State;
using Nova.Simulation.Vision;
Expand All @@ -19,7 +20,8 @@ namespace Nova.Simulation.Tests
/// (economy registered BEFORE movement — SimulationCore.md section 2,
/// phases 2/3 before 6), two-kernel determinism, hash sensitivity,
/// snapshot continuation and replay playback with economy orders, plus
/// the low-power transition through a combat kill.
/// the low-power transition through a combat kill, and the produced
/// Harvester born harvesting (#134, D-121).
/// Mirror of the .NET lane EconomyIntegrationTests.
/// </summary>
[TestFixture]
Expand Down Expand Up @@ -83,6 +85,43 @@ public static EcoHost Create(ulong seed, int capacity = 256, ushort width = 64,
return new EcoHost(kernel, entities, economy, construction, session, ingress);
}

/// <summary>
/// The same canonical host with construction and production wired
/// in (#134): economy, then construction and production (phases
/// 2/3 before 4/5 before 6), and a command view that resolves
/// QueueUnit — the wiring of
/// <c>ProductionConstructionIntegrationTests</c>.
/// </summary>
public static EcoHost CreateWithProduction(ulong seed, int capacity = 256, ushort width = 64, ushort height = 64)
{
var entities = new EntityManager(capacity);
var pathfinding = new PathfindingSystem(width, height);
var movement = new MovementSystem(entities, pathfinding);
var economy = new EconomySystem(entities);
var construction = new ConstructionSystem(entities, economy, pathfinding.CostField);
var production = new ProductionSystem(entities, economy, construction);
var fogOfWar = new FogOfWarSystem(entities, construction, economy, teamCount: 2, width, height);
var combat = new CombatSystem(entities, fogOfWar, economy, construction);

var kernel = new SimulationKernel(new SimRandom(seed));
kernel.RegisterSystem(economy);
kernel.RegisterSystem(construction);
kernel.RegisterSystem(production);
kernel.RegisterSystem(pathfinding);
kernel.RegisterSystem(movement);
kernel.RegisterSystem(fogOfWar);
kernel.RegisterSystem(combat);

var session = new MatchSession(localSlot: 0, activeSlots: new byte[] { 0, 1 }, inputDelayTicks: 1);
var ingress = new CommandIngress(session);
_ = new LocalLoopbackTransport(ingress);
kernel.BindCommands(
new UnitCommandStateView(entities, pathfinding, economy, construction, production), ingress);

kernel.Start();
return new EcoHost(kernel, entities, economy, construction, session, ingress);
}

/// <summary>One host lockstep iteration: seal the due batch, submit it, step, advance the session.</summary>
public CommandBatch StepTick()
{
Expand Down Expand Up @@ -541,5 +580,111 @@ public void Replay_WithHarvestRejections_PlaybackReproducesResultsAndEndHash()
Assert.That(playback.Kernel.CalculateStateHash(), Is.EqualTo(endHash),
"playback re-executes the rejections to the identical results and end state hash");
}

// ------------------------------------------------------------------
// #134 (D-121): a produced Harvester is born harvesting
// ------------------------------------------------------------------

/// <summary>
/// Completed HQ (power and storage) at (40,40), completed Alliance
/// Refinery at origin (20,20) — footprint (20,20)-(22,22), centre
/// (21,21), default rally (23,21), in deposit reach of the footprint —
/// and one field at (24,22), in harvest reach of that rally cell. Then
/// one Harvester is queued through the sealed command path. Returns
/// the Refinery's raw id.
/// </summary>
private static uint SetUpRefineryAndQueueOneHarvester(EcoHost host)
{
Assert.That(host.Construction.PlaceCompletedBuilding(0, 3, 40, 40).IsValid, Is.True, "HQ");
EntityId refinery = host.Construction.PlaceCompletedBuilding(0, 4, 20, 20);
Assert.That(refinery.IsValid, Is.True, "Refinery");
Assert.That(host.Economy.TryAddField(1, new GridPos2D(24, 22), 9000), Is.True);
uint refineryRaw = UnitCommandStateView.ToRawEntityId(refinery);
Assert.That(
host.Ingress.TrySubmitIntent(CommandIntent.Create(new QueueUnitPayload(refineryRaw, 2, 1)), out _),
Is.EqualTo(CommandIngressResult.Accepted));
return refineryRaw;
}

private static bool TryFindHarvester(EcoHost host, out EntityId harvester)
{
UnitState[] units = host.Entities.RawUnits;
for (int i = 0; i < host.Entities.Capacity; i++)
{
if (units[i].IsActive && units[i].PlayerId == 0 && units[i].Role == UnitRole.Harvester)
{
harvester = units[i].Id;
return true;
}
}
harvester = EntityId.Invalid;
return false;
}

[Test]
public void ProducedHarvester_DeliversCredits_WithoutAnyHarvestCommand()
{
var host = EcoHost.CreateWithProduction(Seed);
SetUpRefineryAndQueueOneHarvester(host);

int recordsSealed = 0;
bool charged = false;
long credits = 0;
for (int tick = 0; tick < 1500; tick++)
{
recordsSealed += host.StepTick().Count;
credits = host.Economy.GetPlayerEconomy(0).AetheriumCredits;
if (credits == 300L) charged = true;
if (charged && credits > 300L) break;
}

Assert.That(charged, Is.True, "the QueueUnit charged the Harvester's 700 AE");
Assert.That(recordsSealed, Is.EqualTo(1),
"the QueueUnit is the only command of the whole run — no Harvest, no Move");
Assert.That(credits, Is.EqualTo(300L + 330L),
"1.000 - 700 for the Harvester, then one full Alliance cargo of 330 AE banked");
Assert.That(TryFindHarvester(host, out EntityId harvester), Is.True);
Assert.That(host.Entities.GetUnitRef(harvester).HarvestFieldId, Is.EqualTo((ushort)1),
"the birth order is retained through the auto-cycle");
Assert.That(host.Economy.TryGetField(1, out AetheriumField field), Is.True);
Assert.That(field.RemainingAE, Is.LessThanOrEqualTo(9000L - 330L));
}

[Test]
public void Stop_OnAProducedHarvester_ClearsTheBirthOrder_AndNothingReassignsIt()
{
var host = EcoHost.CreateWithProduction(Seed);
SetUpRefineryAndQueueOneHarvester(host);

EntityId harvester = EntityId.Invalid;
for (int tick = 0; tick < 400 && !harvester.IsValid; tick++)
{
host.StepTick();
TryFindHarvester(host, out harvester);
}
Assert.That(harvester.IsValid, Is.True, "the Refinery produced its Harvester");
Assert.That(host.Entities.GetUnitRef(harvester).HarvestFieldId, Is.EqualTo((ushort)1),
"born with the order");

uint raw = UnitCommandStateView.ToRawEntityId(harvester);
Assert.That(
host.Ingress.TrySubmitIntent(CommandIntent.Create(new StopPayload(new[] { raw })), out _),
Is.EqualTo(CommandIngressResult.Accepted));
host.StepTick();
host.StepTick();
Assert.That(host.Entities.GetUnitRef(harvester).HarvestFieldId, Is.EqualTo((ushort)0),
"Stop cancels the birth order like any other standing order");

long credits = host.Economy.GetPlayerEconomy(0).AetheriumCredits;
for (int tick = 0; tick < 600; tick++)
{
host.StepTick();
Assert.That(host.Entities.GetUnitRef(harvester).HarvestFieldId, Is.EqualTo((ushort)0),
$"nothing re-assigns a stopped harvester (tick {host.Kernel.CurrentTick.Value}) — the order is a birth order only");
}
Assert.That(host.Entities.GetUnitRef(harvester).CargoAE, Is.EqualTo(0));
Assert.That(host.Economy.GetPlayerEconomy(0).AetheriumCredits, Is.EqualTo(credits),
"a stopped harvester earns nothing until the player orders it again");
}
}
}
Loading
Loading