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
72 changes: 56 additions & 16 deletions Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,21 @@ public sealed class CanonicalMatchSetupTests
private const ushort MapWidth = 128;
private const ushort MapHeight = 128;
private const int EntityCapacity = 1024;
private const long FieldReserveAE = 2000000L;
private struct FieldLayout
{
public ushort Id;
public int X, Y;
public long ReserveAE;
}

private static readonly FieldLayout[] FieldLayouts =
{
new FieldLayout { Id = 1, X = 7, Y = 7, ReserveAE = 9000L },
new FieldLayout { Id = 2, X = 117, Y = 117, ReserveAE = 9000L },
new FieldLayout { Id = 3, X = 24, Y = 40, ReserveAE = 9000L },
new FieldLayout { Id = 4, X = 100, Y = 84, ReserveAE = 9000L },
new FieldLayout { Id = 5, X = 62, Y = 62, ReserveAE = 15000L },
};
// Faction-resolved opening placement ids (SimDefinitions id rule):
// slot 0 Alliance (role value), slot 1 Legion (role value + 17).
private const ushort DefHQAlliance = 3;
Expand Down Expand Up @@ -170,46 +184,47 @@ private static ReferenceHost BuildReferenceHost(ulong seed)
/// <summary>Fixed opening layout of one slot, in grid cells.</summary>
private sealed class SlotLayout
{
public ushort FieldId;
public int FieldX, FieldY;
public int HqOriginX, HqOriginY;
public int BuilderX, BuilderY;
}

private static readonly SlotLayout Slot0Layout = new SlotLayout
{
FieldId = 1, FieldX = 7, FieldY = 7,
HqOriginX = 4, HqOriginY = 4,
BuilderX = 13, BuilderY = 7,
};

private static readonly SlotLayout Slot1Layout = new SlotLayout
{
FieldId = 2, FieldX = 119, FieldY = 119,
HqOriginX = 120, HqOriginY = 120,
BuilderX = 113, BuilderY = 119,
BuilderX = 111, BuilderY = 117,
};

/// <summary>
/// Byte-exact mirror of Determinism10000Scenario.SetupMatch (D-077):
/// per slot one Aetherium field, a completed HQ and ONE Builder —
/// nothing else. Spawn ORDER is load-bearing: EntityManager hands
/// out ids from a deterministic free list, so any reordering shifts
/// every id and therefore every hash. Units spawn through SpawnUnit's
/// defaults (maxHealth 100 for all), exactly like the scenario —
/// NOT through SimDefinitions.
/// five finite Aetherium fields in canonical id order, then per slot a
/// completed HQ and ONE Builder — nothing else. Entity spawn order is
/// load-bearing: EntityManager hands out ids from a deterministic free
/// list, so any reordering shifts every id and therefore every hash.
/// Units spawn through SpawnUnit's defaults (maxHealth 100 for all),
/// exactly like the scenario — NOT through SimDefinitions.
/// </summary>
private static void ApplyOpeningPosition(ReferenceHost host)
{
// The slot factions are already bound: BuildReferenceHost mirrors
// BuildHost, which assigns them before Kernel.Start() (the
// SetSlotFaction guard requires it).
for (int i = 0; i < FieldLayouts.Length; i++)
{
FieldLayout field = FieldLayouts[i];
Assert.That(host.Economy.TryAddField(
field.Id, new GridPos2D(field.X, field.Y), field.ReserveAE),
Is.True, "reference field registration");
}

for (byte slot = 0; slot < 2; slot++)
{
SlotLayout c = slot == 0 ? Slot0Layout : Slot1Layout;

Assert.That(host.Economy.TryAddField(c.FieldId, new GridPos2D(c.FieldX, c.FieldY), FieldReserveAE),
Is.True, "reference field registration");
Assert.That(host.Construction.PlaceCompletedBuilding(slot, slot == 0 ? DefHQAlliance : DefHQLegion, c.HqOriginX, c.HqOriginY).IsValid,
Is.True, "reference HQ placement");

Expand Down Expand Up @@ -376,7 +391,15 @@ public void MatchBootstrap_PlacesTheCanonicalOpeningGeometry()
bootstrap.StartGrayboxMatch();

Assert.That(bootstrap.LocalFieldCell, Is.EqualTo(new Vector2Int(7, 7)));
Assert.That(bootstrap.EnemyFieldCell, Is.EqualTo(new Vector2Int(119, 119)));
Assert.That(bootstrap.EnemyFieldCell, Is.EqualTo(new Vector2Int(117, 117)));
Assert.That(bootstrap.AllFieldCells, Is.EqualTo(new[]
{
new Vector2Int(7, 7),
new Vector2Int(117, 117),
new Vector2Int(24, 40),
new Vector2Int(100, 84),
new Vector2Int(62, 62),
}));
Assert.That(bootstrap.LocalHqOrigin, Is.EqualTo(new Vector2Int(4, 4)));
Assert.That(bootstrap.EnemyHqOrigin, Is.EqualTo(new Vector2Int(120, 120)));
Assert.That(bootstrap.MapSize, Is.EqualTo(new Vector2Int(MapWidth, MapHeight)));
Expand All @@ -398,6 +421,23 @@ public void MatchBootstrap_PlacesTheCanonicalOpeningGeometry()
Is.EqualTo(3000L), "the D-077 start balance (EconomySystem.CanonicalMatchStartingCreditsAE)");
}

[Test]
public void ReferenceOpeningPosition_RegistersFiveFiniteFields()
{
ReferenceHost host = BuildReferenceHost(CanonicalSeed);
ApplyOpeningPosition(host);

Assert.That(host.Economy.FieldCount, Is.EqualTo(FieldLayouts.Length));
for (int i = 0; i < FieldLayouts.Length; i++)
{
FieldLayout expected = FieldLayouts[i];
Assert.That(host.Economy.TryGetField(expected.Id, out AetheriumField actual), Is.True);
Assert.That(actual.GridPos.X, Is.EqualTo(expected.X));
Assert.That(actual.GridPos.Y, Is.EqualTo(expected.Y));
Assert.That(actual.RemainingAE, Is.EqualTo(expected.ReserveAE));
}
}

[Test]
public void ReferenceOpeningPosition_IsSeedSensitive()
{
Expand Down
5 changes: 4 additions & 1 deletion Assets/_Project/Data/Maps/MAP_Glutrinne.asset
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,7 @@ MonoBehaviour:
- {x: 121, y: 121}
_resourceNodes:
- {x: 7, y: 7}
- {x: 119, y: 119}
- {x: 117, y: 117}
- {x: 24, y: 40}
- {x: 100, y: 84}
- {x: 62, y: 62}
15 changes: 11 additions & 4 deletions Assets/_Project/Editor/BootstrapSceneGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -269,8 +269,8 @@ private static void CreateMapObject(MatchRunner runner, GameObject ground)
/// <summary>
/// The data-layer map asset. It records exactly the graybox-accurate
/// subset of the Glutrinne manifest layout — the two spawn points and
/// the two aetherium fields the canonical match actually registers.
/// The full five-field layout with both primary routes is G4 scope
/// the five aetherium fields the canonical match registers since
/// Sprint 16.7. Primary-route dressing remains G4 scope
/// (docs/production/ScopeLedger.md) and is deliberately not invented here.
/// </summary>
private static void EnsureGlutrinneMapAsset()
Expand All @@ -292,8 +292,15 @@ private static void EnsureGlutrinneMapAsset()
128,
// HQ footprint centres of the canonical opening (4,4)+(3x3) and its 180° mirror.
new[] { new Vector2(5f, 5f), new Vector2(121f, 121f) },
// The two fields MatchBootstrap registers: local (7,7), enemy (119,119).
new[] { new Vector2(7f, 7f), new Vector2(119f, 119f) });
// The five fields MatchBootstrap registers, in canonical id order.
new[]
{
new Vector2(7f, 7f),
new Vector2(117f, 117f),
new Vector2(24f, 40f),
new Vector2(100f, 84f),
new Vector2(62f, 62f),
});
EditorUtility.SetDirty(map);
}

Expand Down
95 changes: 77 additions & 18 deletions Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,18 +85,20 @@ public NetworkJoinStatus(
/// quality/content/mvp-v1.json startStatePerPlayer): per slot ONLY a
/// completed HQ, ONE Builder and 3.000 AE starting credits
/// (EconomySystem.CanonicalMatchStartingCreditsAE, plumbed in by
/// <see cref="MatchRunner.InitializeMatch"/>'s default) —
/// plus one Aetherium field per slot. The Refinery is NO longer
/// <see cref="MatchRunner.InitializeMatch"/>'s default) — plus the five
/// finite canonical Aetherium fields (two starts, two expansions and the
/// contested centre). The Refinery is NO longer
/// pre-placed: the player builds it (it has no Power-plant prerequisite
/// since D-077), and the completed Refinery — not the HQ — produces the
/// Harvesters.
/// </para>
/// <para>
/// It MIRRORS <c>Determinism10000Scenario.SetupMatch</c>
/// (tools/Nova.SimRunner/Determinism10000Scenario.cs): identical seed,
/// identical map size, identical entity capacity, identical per-slot
/// layout AND identical spawn ORDER — field, HQ, Builder; slot 0 first,
/// then slot 1.
/// identical map size, identical entity capacity, identical field and
/// per-slot layout AND identical entity spawn order — HQ, Builder; slot 0
/// first, then slot 1. Fields register first in canonical id order but do
/// not allocate entity ids.
/// The order is load-bearing: the <see cref="EntityManager"/> hands out
/// entity ids from a deterministic free list, so any reordering shifts
/// every id and therefore every state hash. An EditMode test asserts that
Expand Down Expand Up @@ -139,8 +141,32 @@ public sealed class MatchBootstrap : MonoBehaviour
/// <summary>Canonical scenario seed (DeterminismOptions.Seed). Required for InitialStateHash parity: the PRNG words are hashed into the kernel state block.</summary>
public const ulong CanonicalSeed = 0xDE7E000000010271UL;

/// <summary>Aetherium reserve per field, in AE (Determinism10000Scenario.FieldReserveAE).</summary>
private const long FieldReserveAE = 2000000L;
/// <summary>One Aetherium field of the canonical map (16.7, C1).</summary>
private struct FieldLayout
{
public ushort Id;
public int X, Y;
public long ReserveAE;
}

/// <summary>
/// The five canonical fields (16.7, C1 — MVPContentManifest section 5,
/// mirror of Determinism10000Scenario.FieldLayouts): two start fields
/// and two natural expansions at 9.000 AE each, one contested centre
/// at 15.000. Symmetry is binding: every slot-1 coordinate is the
/// point mirror of slot 0 through the map centre ((x, y) -&gt;
/// (124 - x, 124 - y), the same mirror that maps HQ (4,4) to HQ
/// (120,120)), so both starts are equally far from their expansion
/// (36 cells) and from the centre (58).
/// </summary>
private static readonly FieldLayout[] FieldLayouts =
{
new FieldLayout { Id = 1, X = 7, Y = 7, ReserveAE = 9000L },
new FieldLayout { Id = 2, X = 117, Y = 117, ReserveAE = 9000L },
new FieldLayout { Id = 3, X = 24, Y = 40, ReserveAE = 9000L },
new FieldLayout { Id = 4, X = 100, Y = 84, ReserveAE = 9000L },
new FieldLayout { Id = 5, X = 62, Y = 62, ReserveAE = 15000L },
};

/// <summary>maxHealth stamped by SpawnUnit when no definition stats are applied.</summary>
private const int SpawnDefaultMaxHealth = 100;
Expand Down Expand Up @@ -224,9 +250,27 @@ public sealed class MatchBootstrap : MonoBehaviour
/// <summary>Aetherium field cell of the human player (7, 7).</summary>
public Vector2Int LocalFieldCell => new Vector2Int(LocalPlayerLayout.FieldX, LocalPlayerLayout.FieldY);

/// <summary>Aetherium field cell of the opponent (119, 119).</summary>
/// <summary>Aetherium field cell of the opponent (117, 117 since 16.7 — the exact centre mirror).</summary>
public Vector2Int EnemyFieldCell => new Vector2Int(EnemyPlayerLayout.FieldX, EnemyPlayerLayout.FieldY);

/// <summary>
/// All five registered field cells in canonical id order: start 0/1,
/// expansion 0/1, contested centre. Presentation iterates this list so
/// marker and scatter geometry cannot silently omit a field.
/// </summary>
public Vector2Int[] AllFieldCells
{
get
{
var cells = new Vector2Int[FieldLayouts.Length];
for (int i = 0; i < FieldLayouts.Length; i++)
{
cells[i] = new Vector2Int(FieldLayouts[i].X, FieldLayouts[i].Y);
}
return cells;
}
}

/// <summary>Lower-left footprint origin of the human HQ (4, 4).</summary>
public Vector2Int LocalHqOrigin => new Vector2Int(LocalPlayerLayout.HqOriginX, LocalPlayerLayout.HqOriginY);

Expand Down Expand Up @@ -655,7 +699,10 @@ private void BuildOpening(MatchConfig config)

// Global slot order is load-bearing for entity ids and snapshots:
// BOTH clients build slot 0 first, then slot 1, regardless of
// which one the relay assigned locally.
// which one the relay assigned locally. Field registration
// spawns nothing and therefore does not shift ids, but it is part
// of the hashed initial state and must happen in one fixed order.
SetupFields();
SetupSlot(LocalLayout);
SetupSlot(EnemyLayout);
}
Expand Down Expand Up @@ -865,17 +912,29 @@ private void OnDestroy()
}

/// <summary>
/// One slot's D-077 start state: an Aetherium field, a completed HQ
/// and one Builder near it — nothing else. Spawn order mirrors
/// SetupMatch exactly (field, HQ, Builder).
/// Registers all five canonical fields in ascending id order. This is
/// the exact field pass mirrored by Determinism10000Scenario and both
/// CanonicalMatchSetupTests lanes.
/// </summary>
private void SetupSlot(SlotLayout c)
private void SetupFields()
{
if (!Runner.Economy.TryAddField(c.FieldId, new GridPos2D(c.FieldX, c.FieldY), FieldReserveAE))
for (int i = 0; i < FieldLayouts.Length; i++)
{
throw new InvalidOperationException($"[MatchBootstrap] field {c.FieldId} could not be registered");
FieldLayout field = FieldLayouts[i];
if (!Runner.Economy.TryAddField(field.Id, new GridPos2D(field.X, field.Y), field.ReserveAE))
{
throw new InvalidOperationException($"[MatchBootstrap] field {field.Id} could not be registered");
}
}
}

/// <summary>
/// One slot's D-077 start state: a completed HQ and one Builder near
/// it — nothing else. Fields are registered globally by
/// <see cref="SetupFields"/>; entity spawn order remains HQ, Builder.
/// </summary>
private void SetupSlot(SlotLayout c)
{
FactionId faction = Runner.Economy.GetSlotFaction(c.Slot);
ushort hqDefId = SimDefinitions.ToDefinitionId(faction, UnitRole.HQ);

Expand Down Expand Up @@ -933,13 +992,13 @@ private sealed class SlotLayout
BuilderX = 13, BuilderY = 7,
};

/// <summary>Opponent base, top-right: the 180-degree mirror of <see cref="LocalLayout"/>.</summary>
/// <summary>Opponent base, top-right: the exact centre mirror of <see cref="LocalLayout"/>.</summary>
private static readonly SlotLayout EnemyLayout = new SlotLayout
{
Slot = EnemySlot,
FieldId = 2, FieldX = 119, FieldY = 119,
FieldId = 2, FieldX = 117, FieldY = 117,
HqOriginX = 120, HqOriginY = 120,
BuilderX = 113, BuilderY = 119,
BuilderX = 111, BuilderY = 117,
};
}
}
Loading
Loading