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
130 changes: 110 additions & 20 deletions Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Nova.Simulation;
using Nova.Simulation.CommandsV1;
using Nova.Simulation.Construction;
using Nova.Simulation.Definitions;
using Nova.Simulation.Economy;
using Nova.Simulation.Movement;
using Nova.Simulation.Pathfinding;
Expand Down Expand Up @@ -106,17 +107,21 @@ private struct FieldLayout

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 },
new FieldLayout { Id = 6, X = 44, Y = 24, ReserveAE = 9000L },
new FieldLayout { Id = 7, X = 80, Y = 100, ReserveAE = 9000L },
new FieldLayout { Id = 8, X = 44, Y = 80, ReserveAE = 12000L },
new FieldLayout { Id = 9, X = 80, Y = 44, ReserveAE = 12000L },
new FieldLayout { Id = 10, X = 24, Y = 96, ReserveAE = 12000L },
new FieldLayout { Id = 11, X = 100, Y = 28, ReserveAE = 12000L },
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 = 8000L },
new FieldLayout { Id = 6, X = 54, Y = 54, ReserveAE = 8000L },
new FieldLayout { Id = 7, X = 70, Y = 70, ReserveAE = 8000L },
new FieldLayout { Id = 8, X = 54, Y = 70, ReserveAE = 8000L },
new FieldLayout { Id = 9, X = 70, Y = 54, ReserveAE = 8000L },
new FieldLayout { Id = 10, X = 44, Y = 24, ReserveAE = 9000L },
new FieldLayout { Id = 11, X = 80, Y = 100, ReserveAE = 9000L },
new FieldLayout { Id = 12, X = 44, Y = 80, ReserveAE = 12000L },
new FieldLayout { Id = 13, X = 80, Y = 44, ReserveAE = 12000L },
new FieldLayout { Id = 14, X = 24, Y = 96, ReserveAE = 12000L },
new FieldLayout { Id = 15, X = 100, Y = 28, ReserveAE = 12000L },
};
// Faction-resolved opening placement ids (SimDefinitions id rule):
// slot 0 Alliance (role value), slot 1 Legion (role value + 17).
Expand Down Expand Up @@ -172,6 +177,15 @@ private static ReferenceHost BuildReferenceHost(ulong seed)
kernel.BindCommands(
new UnitCommandStateView(entities, pathfinding, economy, construction, production), ingress);

// The canonical Glutrinne terrain (21.7, #94), written BEFORE
// Kernel.Start() exactly like the scenario's BuildHost. Unlike the
// headless lane this assembly CAN reference Nova.Gameplay, so the
// reference host uses the canonical source itself — no fourth copy.
// The terrain CONTENT pin (cell-exact plus the shared checksum the
// .NET lane pins against the scenario host) lives below in
// MatchBootstrap_AppliesTheCanonicalTerrain_PinnedToTheSharedChecksum.
GlutrinneTerrainMap.Apply(pathfinding.CostField);

// Mirror of BuildHost's faction assignment (economy block v2):
// slot 0 Alliance, slot 1 Legion, set BEFORE Kernel.Start() —
// the SetSlotFaction guard forbids it once the kernel runs.
Expand Down Expand Up @@ -210,8 +224,8 @@ private sealed class SlotLayout

/// <summary>
/// Byte-exact mirror of Determinism10000Scenario.SetupMatch (D-077):
/// eleven finite Aetherium fields (21.6, #93) in canonical id order,
/// then per slot a
/// fifteen finite Aetherium fields (21.6/21.7, #93/#94) 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.
Expand Down Expand Up @@ -408,13 +422,17 @@ public void MatchBootstrap_PlacesTheCanonicalOpeningGeometry()
new Vector2Int(24, 40),
new Vector2Int(100, 84),
new Vector2Int(62, 62),
new Vector2Int(54, 54),
new Vector2Int(70, 70),
new Vector2Int(54, 70),
new Vector2Int(70, 54),
new Vector2Int(44, 24),
new Vector2Int(80, 100),
new Vector2Int(44, 80),
new Vector2Int(80, 44),
new Vector2Int(24, 96),
new Vector2Int(100, 28),
}), "the eleven canonical fields (21.6, #93) in id order");
}), "the fifteen canonical fields (21.6/21.7, #93/#94) in id order");
Assert.That(bootstrap.LocalHqOrigin, Is.EqualTo(new Vector2Int(4, 4)));
Assert.That(bootstrap.EnemyHqOrigin, Is.EqualTo(new Vector2Int(118, 118)));
Assert.That(bootstrap.MapSize, Is.EqualTo(new Vector2Int(MapWidth, MapHeight)));
Expand All @@ -436,8 +454,75 @@ public void MatchBootstrap_PlacesTheCanonicalOpeningGeometry()
Is.EqualTo(3000L), "the D-077 start balance (EconomySystem.CanonicalMatchStartingCreditsAE)");
}

// ----------------------------------------------------------------
// (c) THE TERRAIN PIN (21.7, #94, D-109)
// ----------------------------------------------------------------

/// <summary>
/// FNV-1a over every cost byte, row-major — the SAME checksum the
/// .NET lane (GlutrinneTerrainTests) computes over the scenario host
/// and pins against the identical literal. The state hash covers only
/// the cost field's epoch (a mutation count), so without this literal
/// a one-cell drift between the Gameplay source and the headless
/// mirror would stay invisible to both hash chains.
/// </summary>
private const ulong PinnedOpeningCostFieldChecksum = 0x68A7C8644C9D06D5UL;

/// <summary>The two canonical HQ footprints (D-107 origins (4,4)/(118,118), 3x3) — the only non-terrain cost the opening writes.</summary>
private static bool IsHqFootprintCell(int x, int y)
{
int f = SimDefinitions.BuildingFootprintCells;
bool local = x >= 4 && x < 4 + f && y >= 4 && y < 4 + f;
bool enemy = x >= 118 && x < 118 + f && y >= 118 && y < 118 + f;
return local || enemy;
}

private static ulong ComputeCostFieldChecksum(CostField costs)
{
const ulong offsetBasis = 14695981039346656037UL;
const ulong prime = 1099511628211UL;
ulong hash = offsetBasis;
for (ushort y = 0; y < costs.Height; y++)
{
for (ushort x = 0; x < costs.Width; x++)
{
hash ^= costs.GetCost(x, y);
hash *= prime;
}
}
return hash;
}

[Test]
public void MatchBootstrap_AppliesTheCanonicalTerrain_PinnedToTheSharedChecksum()
{
MatchBootstrap bootstrap = NewMatchObject(useDefinitionStats: false);
bootstrap.StartGrayboxMatch();
CostField costs = bootstrap.Runner.Pathfinding.CostField;

// Cell-exact against the canonical source plus the two HQ
// footprints: the composition is asserted explicitly so the
// checksum never silently pins content other than intended.
for (int y = 0; y < costs.Height; y++)
{
for (int x = 0; x < costs.Width; x++)
{
bool expected = GlutrinneTerrainMap.IsImpassable(x, y) || IsHqFootprintCell(x, y);
bool actual = costs.GetCost((ushort)x, (ushort)y) == CostField.ImpassableCost;
Assert.That(actual, Is.EqualTo(expected),
$"opening cost field drift at ({x},{y})");
}
}

Assert.That(costs.Epoch, Is.EqualTo((uint)(GlutrinneTerrainMap.ImpassableCellCount + 2 * 9)),
"168 terrain writes + two 3x3 HQ footprints — identical on every host before the first snapshot");
Assert.That(ComputeCostFieldChecksum(costs), Is.EqualTo(PinnedOpeningCostFieldChecksum),
"the .NET lane pins this same literal against the headless scenario host — " +
"a move means one terrain copy drifted from the others");
}

[Test]
public void ReferenceOpeningPosition_RegistersElevenFiniteFields()
public void ReferenceOpeningPosition_RegistersFifteenFiniteFields()
{
ReferenceHost host = BuildReferenceHost(CanonicalSeed);
ApplyOpeningPosition(host);
Expand All @@ -462,13 +547,16 @@ public void CanonicalOpeningGeometry_UsesD107PointAndFootprintMirrors()
Assert.That(Slot0Layout.BuilderX + Slot1Layout.BuilderX, Is.EqualTo(124),
"point coordinates mirror as p -> 124-p");
Assert.That(Slot0Layout.BuilderY + Slot1Layout.BuilderY, Is.EqualTo(124));
// The eleven-field layout (21.6, #93): five mirrored pairs plus the
// self-mirroring centre. Every pair sums to 124 per axis.
// The fifteen-field layout (21.6/21.7, #93/#94): seven mirrored
// pairs plus the self-mirroring middle field of the centre zone.
// Every pair sums to 124 per axis.
AssertMirrorPair(0, 1, "start fields");
AssertMirrorPair(2, 3, "natural expansions");
AssertMirrorPair(5, 6, "second expansions");
AssertMirrorPair(7, 8, "near contested flanks");
AssertMirrorPair(9, 10, "far contested flanks");
AssertMirrorPair(5, 6, "centre-zone diagonal pair");
AssertMirrorPair(7, 8, "centre-zone anti-diagonal pair");
AssertMirrorPair(9, 10, "second expansions");
AssertMirrorPair(11, 12, "near contested flanks");
AssertMirrorPair(13, 14, "far contested flanks");
Assert.That(FieldLayouts[4].X, Is.EqualTo(62));
Assert.That(FieldLayouts[4].Y, Is.EqualTo(62));
// Mirrored fields carry mirrored reserves, or the mirror is only
Expand All @@ -478,6 +566,8 @@ public void CanonicalOpeningGeometry_UsesD107PointAndFootprintMirrors()
AssertMirroredReserves(5, 6);
AssertMirroredReserves(7, 8);
AssertMirroredReserves(9, 10);
AssertMirroredReserves(11, 12);
AssertMirroredReserves(13, 14);
}

private static void AssertMirrorPair(int first, int second, string label)
Expand Down
140 changes: 140 additions & 0 deletions Assets/_Project/Scripts/Gameplay/Match/GlutrinneTerrainMap.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
using System;
using Nova.Simulation.Pathfinding;

namespace Nova.Gameplay.Match
{
/// <summary>
/// GLUTRINNE TERRAIN (21.7, #94, D-109) — the single authoritative source
/// of the canonical map's natural terrain: a square rock ring around the
/// centre zone with four diagonal corner gaps. BOTH sides of the map read
/// this one table: the simulation is fed through
/// <see cref="CostField.SetCost"/> (applied by <see cref="MatchBootstrap"/>
/// when the opening is built), and the presentation layer
/// (Presentation/Maps/GlutrinneBlockoutView — a cref would point against
/// the assembly reference direction) renders exactly these cells as the
/// rock ridge. Two separate sources would produce units walking through
/// rocks and sticking on invisible walls — the failure mode this package
/// exists to prevent.
/// <para>
/// Geometry. The wall is the Chebyshev ring
/// <see cref="RingInnerRadius"/>..<see cref="RingOuterRadius"/> around the
/// centre cell (62,62). TWO cells thick — as a deliberate choice, NOT as a
/// pathfinding necessity. One shell would already be impassable: a king
/// move changes each coordinate by at most 1, so it changes
/// max(|dx|, |dy|) by at most 1 and can never step from inside (r-1) to
/// outside (r+1) across a closed Chebyshev shell. Corner cutting does not
/// help either, because the step still has to land ON a shell cell.
/// (Checked by flooding the closed ring with king moves in both
/// thicknesses: neither leaks.) The second shell buys two other things:
/// it gives each corner gap an actual DEPTH, so the opening reads as a
/// passage instead of a slit, and it gives the rock ridge enough visual
/// mass that a player sees the wall before walking into it. Do not thin
/// it to one shell to save cells — that would move
/// <see cref="ImpassableCellCount"/> (168 -> 84) and every pinned
/// checksum with it. The four corner gaps open
/// exactly onto the diagonal approach lanes — the two start bases (SW/NE)
/// and the two contested far flanks (NW/SE). At the throat (the inner
/// shell) each gap is four cells wide: the MS-1 squad gate is six units
/// at the 0.5-cell default radius (UnitState), and four cells pass them
/// four abreast — a narrower cut would serialize the group into single
/// file, which is a blockade, not a chokepoint. D-107 point symmetry holds by construction: the
/// predicate reads only |x−62| and |y−62|, so every blocked cell's mirror
/// (x, y) -&gt; (124 − x, 124 − y) is blocked too.
/// </para>
/// <para>
/// Snapshot contract (the load-bearing part). The cost field is NOT a
/// snapshot block; the restore proof is structural — footprint content is
/// replayed by the construction block, and the serialized epoch is
/// ADOPTED via <see cref="CostField.RestoreEpoch"/> because a mutation
/// counter cannot be replayed. Static terrain therefore has to be written
/// on EVERY host of the canonical match, identically, BEFORE the first
/// snapshot: local and relay hosts through <see cref="MatchBootstrap"/>,
/// the headless generator AND playback hosts through
/// Determinism10000Scenario.BuildHost — the playback host never runs the
/// scenario's SetupMatch, so terrain could not live in the setup pass.
/// The write count is fixed (<see cref="ImpassableCellCount"/>) and the
/// write order is fixed (y, then x, ascending), so the epoch lands on the
/// same value everywhere and later construction mutations keep counting
/// in lockstep after a restore.
/// </para>
/// <para>
/// The headless lane cannot reference this assembly (tools/Nova.SimRunner
/// compiles Core/Simulation/Networking/AI only — a frozen boundary), so
/// Determinism10000Scenario carries a hand-mirrored copy of this
/// predicate, exactly like the field layout mirror (R-1). The mirror is
/// pinned cell-exact and by a shared FNV-1a content checksum in
/// tools/Nova.SimRunner.Tests (GlutrinneTerrainTests) and in the EditMode
/// CanonicalMatchSetupTests — an unpinned mirror here would be a defect,
/// not a compromise.
/// </para>
/// </summary>
public static class GlutrinneTerrainMap
{
/// <summary>Centre cell of the map and the ring (the D-107 self-mirror point).</summary>
public const int CentreX = 62;
public const int CentreY = 62;

/// <summary>Chebyshev distance of the wall's inner face from the centre.</summary>
public const int RingInnerRadius = 14;

/// <summary>Chebyshev distance of the wall's outer face — the second shell is gap depth and visual mass, not tightness; see the class remarks.</summary>
public const int RingOuterRadius = 15;

/// <summary>
/// Corner gaps: ring cells with min(|dx|, |dy|) at or above this stay
/// open. 11 opens the inner shell on exactly four cells per corner
/// (min in 11..14) — the pinned throat width.
/// </summary>
public const int CornerGapMinRadius = 11;

/// <summary>
/// Blocked cell count of the canonical 128x128 map, as arithmetic:
/// the ring band holds (2*15+1)^2 - (2*13+1)^2 = 232 cells, the four
/// corner gaps keep 16 open each (pairs (a, b) in 11..15 with
/// max(a, b) &gt;= 14), so 232 - 64 = 168. Both terrain tests pin
/// <see cref="Apply"/> against this number.
/// </summary>
public const int ImpassableCellCount = 168;

/// <summary>
/// The terrain predicate. Pure integer math — this feeds the
/// simulation, so no float and no randomness of any kind.
/// </summary>
public static bool IsImpassable(int x, int y)
{
int dx = Math.Abs(x - CentreX);
int dy = Math.Abs(y - CentreY);
int ring = Math.Max(dx, dy);
if (ring < RingInnerRadius || ring > RingOuterRadius)
{
return false;
}
return Math.Min(dx, dy) < CornerGapMinRadius;
}

/// <summary>
/// Writes every blocked cell into the cost field in the canonical
/// order (y, then x, ascending) and returns the write count. The loop
/// is bounded by the field's own dimensions, so no write can fall out
/// of bounds (an out-of-bounds SetCost would silently not count
/// against the epoch — the count check at the call site exists to
/// make that impossible to miss). Callers on the canonical 128x128
/// map must see exactly <see cref="ImpassableCellCount"/>.
/// </summary>
public static int Apply(CostField costField)
{
if (costField == null) throw new ArgumentNullException(nameof(costField));
int written = 0;
for (int y = 0; y < costField.Height; y++)
{
for (int x = 0; x < costField.Width; x++)
{
if (!IsImpassable(x, y)) continue;
costField.SetCost((ushort)x, (ushort)y, CostField.ImpassableCost);
written++;
}
}
return written;
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading