From 594f4fac9e37c736df4bbe8c43981f029d6b3b9c Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sat, 29 Aug 2026 12:34:04 +0200 Subject: [PATCH] feat(map): die Mitte wird ein Gebiet mit schmalen Zufahrten (21.7, #94, D-109) --- .../Gameplay/CanonicalMatchSetupTests.cs | 130 +++++- .../Gameplay/Match/GlutrinneTerrainMap.cs | 140 ++++++ .../Match/GlutrinneTerrainMap.cs.meta | 2 + .../Scripts/Gameplay/Match/MatchBootstrap.cs | 106 +++-- .../Maps/GlutrinneBlockoutView.cs | 112 ++++- CHANGELOG.md | 29 ++ .../BuildZoneCapacityTests.cs | 35 +- .../CanonicalMatchSetupTests.cs | 54 ++- .../GlutrinneTerrainTests.cs | 430 ++++++++++++++++++ .../Determinism10000Scenario.cs | 130 ++++-- 10 files changed, 1038 insertions(+), 130 deletions(-) create mode 100644 Assets/_Project/Scripts/Gameplay/Match/GlutrinneTerrainMap.cs create mode 100644 Assets/_Project/Scripts/Gameplay/Match/GlutrinneTerrainMap.cs.meta create mode 100644 tools/Nova.SimRunner.Tests/GlutrinneTerrainTests.cs diff --git a/Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs b/Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs index 6a09a0c..2c93d9e 100644 --- a/Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs +++ b/Assets/Tests/EditMode/Gameplay/CanonicalMatchSetupTests.cs @@ -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; @@ -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). @@ -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. @@ -210,8 +224,8 @@ private sealed class SlotLayout /// /// 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. @@ -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))); @@ -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) + // ---------------------------------------------------------------- + + /// + /// 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. + /// + private const ulong PinnedOpeningCostFieldChecksum = 0x68A7C8644C9D06D5UL; + + /// The two canonical HQ footprints (D-107 origins (4,4)/(118,118), 3x3) — the only non-terrain cost the opening writes. + 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); @@ -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 @@ -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) diff --git a/Assets/_Project/Scripts/Gameplay/Match/GlutrinneTerrainMap.cs b/Assets/_Project/Scripts/Gameplay/Match/GlutrinneTerrainMap.cs new file mode 100644 index 0000000..e7a63b5 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Match/GlutrinneTerrainMap.cs @@ -0,0 +1,140 @@ +using System; +using Nova.Simulation.Pathfinding; + +namespace Nova.Gameplay.Match +{ + /// + /// 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 + /// (applied by + /// 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. + /// + /// Geometry. The wall is the Chebyshev ring + /// .. 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 + /// (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) -> (124 − x, 124 − y) is blocked too. + /// + /// + /// 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 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 , + /// 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 () 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. + /// + /// + /// 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. + /// + /// + public static class GlutrinneTerrainMap + { + /// Centre cell of the map and the ring (the D-107 self-mirror point). + public const int CentreX = 62; + public const int CentreY = 62; + + /// Chebyshev distance of the wall's inner face from the centre. + public const int RingInnerRadius = 14; + + /// Chebyshev distance of the wall's outer face — the second shell is gap depth and visual mass, not tightness; see the class remarks. + public const int RingOuterRadius = 15; + + /// + /// 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. + /// + public const int CornerGapMinRadius = 11; + + /// + /// 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) >= 14), so 232 - 64 = 168. Both terrain tests pin + /// against this number. + /// + public const int ImpassableCellCount = 168; + + /// + /// The terrain predicate. Pure integer math — this feeds the + /// simulation, so no float and no randomness of any kind. + /// + 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; + } + + /// + /// 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 . + /// + 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; + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/Match/GlutrinneTerrainMap.cs.meta b/Assets/_Project/Scripts/Gameplay/Match/GlutrinneTerrainMap.cs.meta new file mode 100644 index 0000000..f5446de --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/Match/GlutrinneTerrainMap.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7c4a9e21d3b84f58a1e06c2d9f5b38aa diff --git a/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs b/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs index a32b6f6..acd984b 100644 --- a/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs +++ b/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs @@ -86,9 +86,9 @@ public NetworkJoinStatus( /// completed HQ, ONE Builder and 3.000 AE starting credits /// (EconomySystem.CanonicalMatchStartingCreditsAE, plumbed in by /// 's default) — plus the - /// eleven finite canonical Aetherium fields (two starts, four natural - /// expansions, four contested flanks and the contested centre; 21.6, - /// #93). The Refinery is NO longer + /// fifteen finite canonical Aetherium fields (two starts, four natural + /// expansions, four contested flanks and the five-field contested centre + /// zone; 21.6/21.7, #93/#94). 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. @@ -151,42 +151,48 @@ private struct FieldLayout } /// - /// The eleven canonical fields (21.6, #93 — the T-01 report's "at - /// least roughly twice as many", up from the five of 16.7/C1): two - /// start fields and TWO natural expansions per side at 9.000 AE - /// each, four contested flank fields at 12.000 and the contested - /// centre at 15.000. The centre deliberately stays a SINGLE field - /// here — 21.7 (D-109) turns it into the grouped zone; this package - /// only grows the count. Symmetry is binding (D-107): point + /// The fifteen canonical fields (21.6/21.7, #93/#94 — the T-01 + /// report's "at least roughly twice as many", up from the five of + /// 16.7/C1): two start fields and TWO natural expansions per side at + /// 9.000 AE each, four contested flank fields at 12.000 and the + /// centre as a FIVE-FIELD ZONE (D-109) at 8.000 each — 40.000 AE in + /// the middle, 28 % of the map's 142.000, the same share the single + /// 15.000-AE centre field held before (29 % of 51.000), but divisible + /// and therefore partially holdable: the zone is valuable without + /// deciding the match alone. Symmetry is binding (D-107): point /// coordinates mirror as (x, y) -> (124 - x, 124 - y); a 3x3 /// footprint's lower-left origin therefore mirrors as - /// (x, y) -> (122 - x, 122 - y). The pairs 1/2, 3/4, 6/7, 8/9 - /// and 10/11 are exact mirrors; the centre (62,62) mirrors onto - /// itself. Distances are Chebyshev from the HQ footprint's - /// centre-facing corner cell ((6,6) against (118,118) — the - /// convention behind the canonical 34/56): each start field sits 1 - /// from its HQ, the naturals lie 34 and 38 out (two per side, so - /// the opening is a CHOICE, not a race), the near flank pair is - /// exactly equidistant (74/74), the far flank pair leans four - /// cells toward its side (90/94), the centre stays at 56. - /// Reserves: naturals match the start field (the 21.3 longevity - /// measurement), the flanks pay +33 % for the contested walk, the - /// centre keeps its premium. Total on the map: 117.000 AE (was - /// 51.000). + /// (x, y) -> (122 - x, 122 - y). The pairs 1/2, 3/4, 6/7, 8/9, + /// 10/11, 12/13 and 14/15 are exact mirrors; the middle field (62,62) + /// mirrors onto itself. Distances are Chebyshev from the HQ + /// footprint's centre-facing corner cell ((6,6) against (118,118) — + /// the convention behind the canonical 34/56): start fields 1, + /// naturals 34 and 38, the zone's near corner fields 48, its + /// anti-diagonal pair exactly equidistant (64/64) and its centre 56, + /// the near flank pair exactly equidistant (74/74), the far flank + /// pair leaning four cells toward its side (90/94). Reserves: + /// naturals match the start field (the 21.3 longevity measurement), + /// flanks pay +33 % for the contested walk, zone fields sit just + /// below a natural — the zone's prize is its sum, not its single + /// field. /// 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 }, }; /// maxHealth stamped by SpawnUnit when no definition stats are applied. @@ -296,11 +302,11 @@ public bool TryGetFieldInitialReserve(ushort fieldId, out long reserveAE) public Vector2Int EnemyFieldCell => new Vector2Int(EnemyPlayerLayout.FieldX, EnemyPlayerLayout.FieldY); /// - /// All eleven registered field cells in canonical id order: start - /// 0/1, expansion 0/1, contested centre, second expansion 0/1, - /// contested near flank 0/1, contested far flank 0/1. Presentation - /// iterates this list so marker and scatter geometry cannot - /// silently omit a field. + /// All fifteen registered field cells in canonical id order: start + /// 0/1, expansion 0/1, the five centre-zone fields (middle, diagonal + /// pair, anti-diagonal pair), second expansion 0/1, contested near + /// flank 0/1, contested far flank 0/1. Presentation iterates this + /// list so marker and scatter geometry cannot silently omit a field. /// public Vector2Int[] AllFieldCells { @@ -724,6 +730,26 @@ private void BuildOpening(MatchConfig config) Runner.InitializeMatch(_activeConfig); + // 21.7 (#94, D-109): the canonical Glutrinne terrain. The cost + // field is no snapshot block, so the restore proof only holds if + // EVERY host of the canonical match — local, relay peer, and the + // headless scenario's generator and playback hosts — writes the + // identical cells before the first snapshot; the headless mirror + // lives in Determinism10000Scenario.BuildHost and is pinned + // cell-exact by GlutrinneTerrainTests. Written here, after + // InitializeMatch and before StartMatch, so no tick, flow-field + // request or snapshot can ever observe a host without its + // terrain. The count check guards the canonical map size: on a + // non-128 map the ring would half-apply and the epoch would + // silently diverge from every other host. + int terrainCells = GlutrinneTerrainMap.Apply(Runner.Pathfinding.CostField); + if (terrainCells != GlutrinneTerrainMap.ImpassableCellCount) + { + throw new InvalidOperationException( + $"[MatchBootstrap] canonical terrain applied {terrainCells} cells, expected " + + $"{GlutrinneTerrainMap.ImpassableCellCount} — the match is not running the canonical 128x128 map"); + } + // Faction assignment (economy block v2) comes from MatchConfig. // Set BEFORE StartMatch — the SetSlotFaction // guard forbids any change once the kernel runs, because the @@ -956,7 +982,7 @@ private void OnDestroy() } /// - /// Registers all eleven canonical fields in ascending id order. This + /// Registers all fifteen canonical fields in ascending id order. This /// is the exact field pass mirrored by Determinism10000Scenario and /// both CanonicalMatchSetupTests lanes. /// diff --git a/Assets/_Project/Scripts/Presentation/Maps/GlutrinneBlockoutView.cs b/Assets/_Project/Scripts/Presentation/Maps/GlutrinneBlockoutView.cs index af6fc6b..6e812b2 100644 --- a/Assets/_Project/Scripts/Presentation/Maps/GlutrinneBlockoutView.cs +++ b/Assets/_Project/Scripts/Presentation/Maps/GlutrinneBlockoutView.cs @@ -11,14 +11,22 @@ namespace Nova.Presentation.Maps /// itself at Play from 's canonical layout, so /// what is rendered is exactly what the simulation registered: the /// procedural desert ground of the Glutrinne biome, scattered rock - /// debris, a weathered edge band instead of a hard frame, and an - /// aetherium crystal cluster on each of the five fields the canonical - /// match registers. + /// debris, a weathered edge band instead of a hard frame, an aetherium + /// crystal cluster on each registered field, and — since 21.7 — the rock + /// ring around the centre zone. /// - /// Pure presentation: this component reads the bootstrap's layout - /// properties and spawns primitive-only markers; it never writes into - /// simulation state. The five-field manifest layout is visible since - /// Sprint 16.7; primary-route dressing remains later map-art scope. + /// Pure presentation in the write direction: this component reads the + /// bootstrap's layout properties and the canonical terrain table and + /// spawns primitive-only markers; it never writes into simulation state. + /// But the ring rocks are different from the debris IN KIND, not just in + /// look: fed the same + /// table into the simulation's cost + /// field at match build, so the ridge you see IS the wall the pathfinding + /// respects. That single-source rule (21.7, D-109) is what keeps units + /// from walking through rocks or sticking on invisible walls. The debris + /// keeps its old contract — pure decoration, no collider, unseen by the + /// sim — and additionally keeps two cells of distance to every wall cell, + /// so a decorative rock never reads as part of the ring. /// /// /// KARTENBILD (D-085): everything here is generated at runtime with a @@ -55,6 +63,9 @@ public sealed class GlutrinneBlockoutView : MonoBehaviour /// Exclusion radius around each aetherium field (cells) — harvester approach cells stay clear. private const float FieldExclusionRadius = 3.5f; + /// Debris distance to every wall cell of the terrain ring (cells) — a decorative rock never touches the ring's read (21.7). + private const int WallProximityCells = 2; + /// Width of the weathered edge band in cells, as a fraction of the 128-cell map for the veil texture. private const float EdgeFadeCells = 3f; @@ -108,6 +119,10 @@ public sealed class GlutrinneBlockoutView : MonoBehaviour private float _reserveNextReadTime; private MaterialPropertyBlock _propertyBlock; + // Dilation of the ring's wall cells by WallProximityCells, computed + // once in Start: the debris scatter rejects any candidate inside it. + private bool[,] _wallProximity; + private void Start() { if (_bootstrap == null) _bootstrap = FindAnyObjectByType(); @@ -120,6 +135,8 @@ private void Start() Vector2Int[] fieldCells = _bootstrap.AllFieldCells; TintGround(); + BuildWallProximityGrid(_bootstrap.MapSize); + BuildTerrainWalls(_bootstrap.MapSize); BuildScatterRocks(fieldCells); BuildWeatheredEdge(_bootstrap.MapSize); for (int i = 0; i < fieldCells.Length; i++) @@ -198,12 +215,80 @@ private void TintGround() _groundRenderer.sharedMaterial = material; } + /// + /// The rock ring of the centre zone (21.7, D-109), built cell-exact + /// from — the SAME table + /// wrote into the simulation's cost + /// field at match build. One flush, axis-aligned cube per blocked + /// cell: rotating or shrinking the cubes would make the gap + /// corridors look wider or narrower than the simulation's four-cell + /// throats, so the only variation is height (deterministic integer + /// hash of the cell, no Random — editor and player show the + /// identical ridge). No colliders, exactly like the debris: nothing + /// may ever pick or physically block a wall rock. + /// + private void BuildTerrainWalls(Vector2Int mapSize) + { + var walls = new GameObject("TerrainWalls"); + walls.transform.SetParent(transform, false); + Material wallMaterial = CreateRuntimeMaterial(_rockColor); + + for (int y = 0; y < mapSize.y; y++) + { + for (int x = 0; x < mapSize.x; x++) + { + if (!GlutrinneTerrainMap.IsImpassable(x, y)) continue; + + int hash = (x * 7349 + y * 15187) & 0x7FFF; + float height = 0.9f + (hash % 100) * 0.006f; // 0.90 .. 1.49 + + GameObject rock = GameObject.CreatePrimitive(PrimitiveType.Cube); + rock.name = $"Wall_{x}_{y}"; + rock.transform.SetParent(walls.transform, false); + rock.transform.position = new Vector3(x + 0.5f, height * 0.5f, y + 0.5f); + // Slightly over a full cell so neighbours merge into one + // ridge line instead of reading as a fence of posts. + rock.transform.localScale = new Vector3(1.04f, height, 1.04f); + Destroy(rock.GetComponent()); + rock.GetComponent().sharedMaterial = wallMaterial; + } + } + } + + /// The debris keep-out around every wall cell, dilated by . + private void BuildWallProximityGrid(Vector2Int mapSize) + { + _wallProximity = new bool[mapSize.x, mapSize.y]; + for (int y = 0; y < mapSize.y; y++) + { + for (int x = 0; x < mapSize.x; x++) + { + if (!GlutrinneTerrainMap.IsImpassable(x, y)) continue; + for (int dy = -WallProximityCells; dy <= WallProximityCells; dy++) + { + for (int dx = -WallProximityCells; dx <= WallProximityCells; dx++) + { + int nx = x + dx; + int ny = y + dy; + if (nx >= 0 && nx < mapSize.x && ny >= 0 && ny < mapSize.y) + { + _wallProximity[nx, ny] = true; + } + } + } + } + } + } + /// /// Rock debris: squashed-sphere boulders and pebbles, placed by a /// fixed-seed xorshift (no UnityEngine.Random), rejected inside the - /// exclusion zones around both start bases and all five aetherium - /// fields, and NEVER carrying a collider — the debris is pure - /// visual, the sim's grid pathing does not see it (and must not). + /// exclusion zones around both start bases, around every registered + /// aetherium field and next to every wall cell of the ring, and NEVER + /// carrying a collider. The debris is pure visual: the sim's grid + /// pathing does not see it — unlike the ring, which is the same table + /// the sim reads (see the class remarks for why that difference is + /// the whole point of 21.7). /// private void BuildScatterRocks(Vector2Int[] fieldCells) { @@ -240,7 +325,7 @@ private void BuildScatterRocks(Vector2Int[] fieldCells) } } - /// Inside a start-base or aetherium-field exclusion zone the scatter stays out (the D-085 brief). + /// Inside a start-base, aetherium-field or ring-wall exclusion zone the scatter stays out (the D-085 brief, extended by the 21.7 wall keep-out). private bool IsExcluded(float x, float z, Vector2Int[] fieldCells) { Vector2Int localHq = _bootstrap.LocalHqCenterCell; @@ -251,6 +336,11 @@ private bool IsExcluded(float x, float z, Vector2Int[] fieldCells) return true; } + if (_wallProximity != null && _wallProximity[(int)x, (int)z]) + { + return true; + } + for (int i = 0; i < fieldCells.Length; i++) { Vector2Int field = fieldCells[i]; diff --git a/CHANGELOG.md b/CHANGELOG.md index 5291b5d..dd6c995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -189,6 +189,35 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de sie belegt keine Verbesserung. ### Geändert +- **Die Kartenmitte ist ein Gebiet mit vier schmalen Zufahrten (Paket 21.7, #94, D-109).** + Aus dem einen Feld bei (62,62) mit 15.000 AE werden **fünf** Felder à 8.000 — + zusammen 40.000 AE und damit 28 % des Kartenwerts, exakt das Gewicht, das das + Einzelfeld vorher hatte, nur teilbar. Die Karte trägt jetzt 15 Vorkommen mit + 142.000 AE. Um die Zone liegt ein Felsring (Chebyshev-Radius 14–15 um die + Mitte, 168 gesperrte Zellen) mit vier Ecköffnungen auf den Anmarschdiagonalen; + die Kehle jeder Öffnung ist **vier Zellen** breit, gemessen gegen den + MS-1-Trupp aus sechs Einheiten — vier passen nebeneinander durch, eine + schmalere Öffnung wäre eine Blockade und kein Engpass. +- **Begehbarkeit und Optik stammen jetzt aus einer einzigen Quelle (D-109-Auflage).** + `Gameplay/Match/GlutrinneTerrainMap` hält die kanonische Geländetabelle; + die Simulation bekommt sie über `CostField.SetCost` beim Aufbau der Eröffnung, + die `GlutrinneBlockoutView` baut exakt dieselben Zellen als Felsrücken, und + die alte Deko-Felsstreuung hält jetzt Abstand zum Ring. Zwei getrennte Quellen + hätten Einheiten ergeben, die durch Felsen laufen und an unsichtbaren Wänden + hängenbleiben. Weil der headless-Pfad `Gameplay/` nicht kompiliert, trägt + `Determinism10000Scenario` einen handgespiegelten Zweitausdruck — zellgenau + und über eine gemeinsame FNV-1a-Prüfsumme gepinnt, damit die beiden Spuren + nicht auseinanderlaufen können. +- **Das Gelände wird auf jedem Host identisch und vor dem ersten Snapshot geschrieben.** + Das `CostField` ist kein Snapshot-Block; seine Wiederherstellbarkeit war bisher + damit begründet, dass sein Inhalt vollständig aus dem Bau-Snapshot folgt. + Statisches Gelände hätte diese Begründung gebrochen, wenn es nicht auf jedem + Host aus derselben Tabelle käme — auch auf dem Wiedergabe-Host, der + `SetupMatch` nie durchläuft. Schreibzahl und Schreibreihenfolge liegen fest, + damit der `Epoch` überall auf demselben Wert landet; ein neuer Test beweist + den zellgenauen und byte-identischen Snapshot-Umlauf. Ein + Erreichbarkeitstest sichert, dass jedes Feld und jedes HQ von beiden + Startpunkten aus erreichbar bleibt - **Die Karte trägt 11 Vorkommen statt 5 (Paket 21.6, #93).** Bisher lagen fünf Aetheriumfelder auf 128×128, zwei davon als Startbasis gebunden — für zwei Spieler blieben **drei** umkämpfte Felder, und daraus entsteht keine diff --git a/tools/Nova.SimRunner.Tests/BuildZoneCapacityTests.cs b/tools/Nova.SimRunner.Tests/BuildZoneCapacityTests.cs index 31400ef..93e7bb0 100644 --- a/tools/Nova.SimRunner.Tests/BuildZoneCapacityTests.cs +++ b/tools/Nova.SimRunner.Tests/BuildZoneCapacityTests.cs @@ -19,8 +19,8 @@ namespace Nova.SimRunner.Tests /// The start zone is the build-influence area of the canonical player-0 /// HQ anchor alone (footprint origin (4,4), D-107): every 3x3 footprint /// whose footprint-aware Chebyshev distance to the HQ rectangle is at most - /// . All eleven - /// canonical fields (21.6/#93 layout) are registered, though only the + /// . All fifteen + /// canonical fields (21.6/21.7 layout) are registered, though only the /// start field at (7,7) intersects the zone — the next-nearest field sits /// at (44,24), thirty cells past the zone boundary, so the pinned numbers /// below are deliberately insensitive to the outfield layout. @@ -33,8 +33,12 @@ namespace Nova.SimRunner.Tests /// (map bounds, footprint occupancy, spacing, non-refinery field distance) /// parameterized by the minimum distance; it must reproduce the real /// lane's placement set cell-for-cell at the current constant before its - /// 1-and-0 numbers mean anything. Terrain walkability is not modelled: - /// the canonical map writes no terrain costs today (all cells open). + /// 1-and-0 numbers mean anything. Terrain walkability is not modelled and + /// not present IN THIS FIXTURE: the canonical terrain ring (21.7, D-109) + /// sits at Chebyshev distance 14..15 from the map centre, dozens of cells + /// outside the start zone, so the fixture's bare cost field is the + /// truthful local picture of the zone — and the pinned numbers are + /// deliberately insensitive to it. /// /// /// The greedy scan is reading order (y, then x): a deterministic, @@ -50,8 +54,9 @@ public sealed class BuildZoneCapacityTests { // Canonical player-0 start (D-107): HQ footprint origin (4,4), start // field point (7,7) with 9.000 AE. The remaining canonical fields - // (21.6/#93 layout, mirrored from MatchBootstrap.FieldLayouts like the - // four R-1 copies) complete the field set the spacing rule iterates. + // (21.6/21.7 layout, mirrored from MatchBootstrap.FieldLayouts like + // the four R-1 copies) complete the field set the spacing rule + // iterates. None of the fourteen outfield fields comes near the zone. private const int HqOriginX = 4; private const int HqOriginY = 4; private const ushort DefHQAlliance = 3; @@ -64,13 +69,17 @@ private static readonly (ushort Id, int X, int Y, long ReserveAE)[] CanonicalFie (2, 117, 117, 9000L), (3, 24, 40, 9000L), (4, 100, 84, 9000L), - (5, 62, 62, 15000L), - (6, 44, 24, 9000L), - (7, 80, 100, 9000L), - (8, 44, 80, 12000L), - (9, 80, 44, 12000L), - (10, 24, 96, 12000L), - (11, 100, 28, 12000L), + (5, 62, 62, 8000L), + (6, 54, 54, 8000L), + (7, 70, 70, 8000L), + (8, 54, 70, 8000L), + (9, 70, 54, 8000L), + (10, 44, 24, 9000L), + (11, 80, 100, 9000L), + (12, 44, 80, 12000L), + (13, 80, 44, 12000L), + (14, 24, 96, 12000L), + (15, 100, 28, 12000L), }; private static int F => SimDefinitions.BuildingFootprintCells; diff --git a/tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs b/tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs index 9c112b5..ca65817 100644 --- a/tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs +++ b/tools/Nova.SimRunner.Tests/CanonicalMatchSetupTests.cs @@ -78,17 +78,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). @@ -144,6 +148,13 @@ private static ReferenceHost BuildReferenceHost(ulong seed) kernel.BindCommands( new UnitCommandStateView(entities, pathfinding, economy, construction, production), ingress); + // Mirror of BuildHost's terrain application (21.7, #94): the + // canonical Glutrinne ring is written into the cost field BEFORE + // Kernel.Start(), so the reference, the scenario and the Unity + // bootstrap carry the identical terrain into the tick-0 snapshot. + // The terrain content pin lives in GlutrinneTerrainTests. + CanonicalTerrainMirror.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. @@ -182,8 +193,8 @@ private sealed class SlotLayout /// /// 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. @@ -301,7 +312,7 @@ public void ReferenceOpeningPosition_MatchesDeterminism10000ScenarioSetupMatch() } [Test] - public void ReferenceOpeningPosition_RegistersElevenFiniteFields() + public void ReferenceOpeningPosition_RegistersFifteenFiniteFields() { ReferenceHost host = BuildReferenceHost(CanonicalSeed); ApplyOpeningPosition(host); @@ -326,13 +337,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 @@ -342,6 +356,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) diff --git a/tools/Nova.SimRunner.Tests/GlutrinneTerrainTests.cs b/tools/Nova.SimRunner.Tests/GlutrinneTerrainTests.cs new file mode 100644 index 0000000..6ea2892 --- /dev/null +++ b/tools/Nova.SimRunner.Tests/GlutrinneTerrainTests.cs @@ -0,0 +1,430 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using NUnit.Framework; +using Nova.Core; +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; +using Nova.Simulation.Production; +using Nova.Simulation.State; +using Nova.Simulation.Vision; + +namespace Nova.SimRunner.Tests +{ + /// + /// Hand-mirrored copy of the canonical Glutrinne terrain predicate + /// (Gameplay/Match/GlutrinneTerrainMap.cs, 21.7/#94/D-109). The .NET lane + /// cannot reference the Gameplay assembly, so — exactly like the field + /// layout (R-1) — the terrain rides as a mirror: canonical in + /// Gameplay/Match, applied headlessly by Determinism10000Scenario's own + /// copy, and pinned here cell-exact and by the shared FNV-1a checksum the + /// EditMode lane pins against the Unity host. ANY edit must be applied to + /// every copy; the tests below exist to make a one-sided edit red. + /// + internal static class CanonicalTerrainMirror + { + public const int CentreX = 62; + public const int CentreY = 62; + public const int RingInnerRadius = 14; + public const int RingOuterRadius = 15; + public const int CornerGapMinRadius = 11; + + /// 232 ring-band cells minus the 64 corner-gap cells. + public const int ImpassableCellCount = 168; + + 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; + } + + public static int Apply(CostField 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; + } + } + + /// + /// Sprint 21 package 21.7 (issue #94, D-109): the centre of the canonical + /// map is a zone ringed by rock with four chokepoint gaps, and the terrain + /// has exactly ONE authoritative source per lane with the mirrors pinned. + /// This suite pins what a hash cannot: the cost field CONTENT (the state + /// hash covers only the epoch, a mutation count), the D-107 symmetry of + /// the walls, the measured throat width of the gaps, the + /// flow-field reachability of every field and HQ from both starts, and + /// the snapshot/restore identity of a terrain-carrying host. + /// + [TestFixture] + public sealed class GlutrinneTerrainTests + { + private const ulong CanonicalSeed = 0xDE7E000000010271UL; + + /// + /// FNV-1a over every cost byte, row-major. The canonical opening's + /// cost field (terrain + the two HQ footprints) on the Unity host + /// must hash to this exact value — the EditMode lane + /// (CanonicalMatchSetupTests) pins the SAME literal against + /// MatchBootstrap, which chains the Gameplay source, the scenario + /// mirror and both test mirrors into one equality. + /// + private const ulong PinnedOpeningCostFieldChecksum = 0x68A7C8644C9D06D5UL; + + // ---------------------------------------------------------------- + // Reflection into the scenario (BuildHost/SetupMatch are private) + // ---------------------------------------------------------------- + + private static object InvokeScenarioBuildHost(ulong seed) + { + MethodInfo buildHost = typeof(Determinism10000Scenario).GetMethod( + "BuildHost", BindingFlags.NonPublic | BindingFlags.Static); + Assert.That(buildHost, Is.Not.Null, + "Determinism10000Scenario.BuildHost was renamed — this guard test must follow it."); + return buildHost.Invoke(null, new object[] { seed, null }); + } + + private static void InvokeScenarioSetupMatch(object host) + { + MethodInfo setupMatch = typeof(Determinism10000Scenario).GetMethod( + "SetupMatch", BindingFlags.NonPublic | BindingFlags.Static); + Assert.That(setupMatch, Is.Not.Null, + "Determinism10000Scenario.SetupMatch was renamed — this guard test must follow it."); + setupMatch.Invoke(null, new[] { host }); + } + + private static T HostField(object host, string name) + { + FieldInfo field = host.GetType().GetField(name); + Assert.That(field, Is.Not.Null, $"Determinism10000Scenario.Host.{name} was renamed."); + return (T)field.GetValue(host); + } + + private static SimulationKernel KernelOf(object host) => HostField(host, "Kernel"); + private static EconomySystem EconomyOf(object host) => HostField(host, "Economy"); + private static EntityManager EntitiesOf(object host) => HostField(host, "Entities"); + private static PathfindingSystem PathfindingOf(object host) => HostField(host, "Pathfinding"); + + internal 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; + } + + /// The two canonical HQ footprints (D-107 origins (4,4)/(118,118), 3x3) — the only non-terrain cost the opening writes. + internal 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; + } + + // ---------------------------------------------------------------- + // (a) THE MIRROR PIN: content, not just the epoch count + // ---------------------------------------------------------------- + + [Test] + public void ScenarioHost_AppliesTheMirroredTerrain_CellForCell() + { + // BuildHost only — no HQ footprints yet, so the cost field is + // PURE terrain and any single-cell drift between the scenario's + // mirror and this reference lands here. + object host = InvokeScenarioBuildHost(CanonicalSeed); + CostField costs = PathfindingOf(host).CostField; + + int blocked = 0; + for (int y = 0; y < costs.Height; y++) + { + for (int x = 0; x < costs.Width; x++) + { + bool expected = CanonicalTerrainMirror.IsImpassable(x, y); + bool actual = costs.GetCost((ushort)x, (ushort)y) == CostField.ImpassableCost; + Assert.That(actual, Is.EqualTo(expected), + $"terrain drift at ({x},{y}): the scenario mirror and the reference disagree"); + if (expected) blocked++; + } + } + Assert.That(blocked, Is.EqualTo(CanonicalTerrainMirror.ImpassableCellCount), + "232 ring-band cells minus 4x16 corner-gap cells"); + Assert.That(costs.Epoch, Is.EqualTo((uint)CanonicalTerrainMirror.ImpassableCellCount), + "the epoch after host construction counts exactly the terrain writes"); + } + + [Test] + public void Terrain_IsD107PointMirrorSymmetric() + { + object host = InvokeScenarioBuildHost(CanonicalSeed); + CostField costs = PathfindingOf(host).CostField; + // The D-107 mirror (x, y) -> (124 - x, 124 - y) is defined on the + // 0..124 square: the outer three map rows/columns mirror out of + // bounds and are map fringe (the weathered edge band), never + // terrain-bearing. + for (int y = 0; y <= 124; y++) + { + for (int x = 0; x <= 124; x++) + { + Assert.That( + costs.GetCost((ushort)(124 - x), (ushort)(124 - y)), + Is.EqualTo(costs.GetCost((ushort)x, (ushort)y)), + $"D-107: cell ({x},{y}) and its mirror ({124 - x},{124 - y}) must carry the same cost"); + } + } + } + + // ---------------------------------------------------------------- + // (b) THE CHOKEPOINT WIDTH — measured, not drawn + // ---------------------------------------------------------------- + + [Test] + public void Terrain_Chokepoints_MeasureFourCellsAtTheThroat() + { + object host = InvokeScenarioBuildHost(CanonicalSeed); + CostField costs = PathfindingOf(host).CostField; + + // The wall's side middles are solid: the only ways through are + // the four corner gaps. + foreach ((int mx, int my) in new[] { (62, 47), (62, 77), (47, 62), (77, 62) }) + { + Assert.That(costs.GetCost((ushort)mx, (ushort)my), Is.EqualTo(CostField.ImpassableCost), + $"the ring's side middle ({mx},{my}) must be wall"); + } + + // Per corner: walking inward along each shell from the corner, + // the open run must be exactly 5 cells on the outer shell (15) + // and exactly 4 on the inner shell (14) — the throat. Four cells + // pass the MS-1 squad gate (six units at the 0.5-cell default + // radius) four abreast; three would already mean single-file, a + // blockade. + foreach (int sx in new[] { -1, 1 }) + { + foreach (int sy in new[] { -1, 1 }) + { + int outer = OpenRunAlongShell(costs, CanonicalTerrainMirror.RingOuterRadius, sx, sy); + int inner = OpenRunAlongShell(costs, CanonicalTerrainMirror.RingInnerRadius, sx, sy); + Assert.That(outer, Is.EqualTo(5), $"outer-shell gap run at corner ({sx},{sy})"); + Assert.That(inner, Is.EqualTo(4), $"inner-shell throat at corner ({sx},{sy})"); + } + } + } + + /// + /// Counts the open run on one ring shell, starting at the corner cell + /// and walking away from it along the shell, in both directions; the + /// run length is the same along x and along y by symmetry of the + /// predicate, so one direction is measured and the other is asserted + /// equal. The run ends at the first wall cell. + /// + private static int OpenRunAlongShell(CostField costs, int shellRadius, int sx, int sy) + { + int alongY = 0; + for (int k = shellRadius; k >= 0; k--) + { + int x = CanonicalTerrainMirror.CentreX + sx * shellRadius; + int y = CanonicalTerrainMirror.CentreY + sy * k; + if (costs.GetCost((ushort)x, (ushort)y) == CostField.ImpassableCost) break; + alongY++; + } + int alongX = 0; + for (int k = shellRadius; k >= 0; k--) + { + int x = CanonicalTerrainMirror.CentreX + sx * k; + int y = CanonicalTerrainMirror.CentreY + sy * shellRadius; + if (costs.GetCost((ushort)x, (ushort)y) == CostField.ImpassableCost) break; + alongX++; + } + Assert.That(alongX, Is.EqualTo(alongY), "the gap is symmetric across the corner diagonal"); + return alongY; + } + + // ---------------------------------------------------------------- + // (c) THE CROSS-LANE CHECKSUM PIN + // ---------------------------------------------------------------- + + [Test] + public void CanonicalOpening_CostFieldContent_PinnedBySharedChecksum() + { + object host = InvokeScenarioBuildHost(CanonicalSeed); + InvokeScenarioSetupMatch(host); + CostField costs = PathfindingOf(host).CostField; + + // The full opening's cost field is terrain PLUS the two HQ + // footprints — assert the composition explicitly, so the checksum + // below never silently pins a different content than intended. + for (int y = 0; y < costs.Height; y++) + { + for (int x = 0; x < costs.Width; x++) + { + bool expected = CanonicalTerrainMirror.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})"); + } + } + + ulong checksum = ComputeCostFieldChecksum(costs); + TestContext.Out.WriteLine($"opening cost field checksum: 0x{checksum:X16}"); + Assert.That(checksum, Is.EqualTo(PinnedOpeningCostFieldChecksum), + "the EditMode lane pins this same literal against the Unity host — " + + "a move means one terrain copy drifted from the others"); + } + + // ---------------------------------------------------------------- + // (d) REACHABILITY — the test that keeps a later map edit honest + // ---------------------------------------------------------------- + + [Test] + public void Terrain_KeepsEveryFieldAndHeadquarterReachable_FromBothStarts() + { + object host = InvokeScenarioBuildHost(CanonicalSeed); + InvokeScenarioSetupMatch(host); + PathfindingSystem pathfinding = PathfindingOf(host); + EconomySystem economy = EconomyOf(host); + EntityManager entities = EntitiesOf(host); + + // Start points are read from the state, not re-literalised: the + // two Builders of the D-077 opening. + var starts = new List<(int X, int Y)>(); + var headquarters = new List(); + UnitState[] units = entities.RawUnits; + for (int i = 0; i < entities.Capacity; i++) + { + if (!units[i].IsActive) continue; + int cx = SimFixed.WorldToGrid(units[i].Transform.PositionX); + int cy = SimFixed.WorldToGrid(units[i].Transform.PositionY); + if (units[i].Role == UnitRole.Builder) starts.Add((cx, cy)); + else if (units[i].Role == UnitRole.HQ) headquarters.Add(new GridPos2D(cx, cy)); + } + Assert.That(starts, Has.Count.EqualTo(2), "the D-077 opening spawns exactly one Builder per slot"); + Assert.That(headquarters, Has.Count.EqualTo(2)); + + var destinations = new List(); + for (ushort id = 1; id <= EconomySystem.MaxFields; id++) + { + if (economy.TryGetField(id, out AetheriumField field)) + { + destinations.Add(field.GridPos); + } + } + Assert.That(destinations, Has.Count.EqualTo(15), "the 21.7 map registers fifteen fields"); + foreach (GridPos2D hq in headquarters) + { + destinations.Add(HqDoorCell(pathfinding.CostField, hq)); + } + + foreach (GridPos2D destination in destinations) + { + pathfinding.RequestFlowField(destination); + foreach ((int sx, int sy) in starts) + { + // The integration wave of the LAST request is the shared + // scratch buffer; a destination's field is freshly + // generated here because every destination is requested + // exactly once. + ushort distance = pathfinding.IntegrationField.GetDistance((ushort)sx, (ushort)sy); + Assert.That(distance, Is.Not.EqualTo(IntegrationField.Unreachable), + $"destination ({destination.X},{destination.Y}) is unreachable from start ({sx},{sy}) — " + + "a terrain edit has sealed a field or a base"); + } + } + } + + /// + /// The HQ footprint is impassable by construction, and its centre is + /// fully enclosed by its own wall cells, so a wave seeded ON the + /// centre dies in place. "HQ reachable" therefore means: a unit can + /// stand beside the footprint. The door cell is the first walkable + /// cell of the Chebyshev ring two around the centre, in reading + /// order — deterministic and, for the canonical HQs, open ground. + /// + private static GridPos2D HqDoorCell(CostField costs, GridPos2D hqCentre) + { + for (int dy = -2; dy <= 2; dy++) + { + for (int dx = -2; dx <= 2; dx++) + { + if (Math.Max(Math.Abs(dx), Math.Abs(dy)) != 2) continue; + int x = hqCentre.X + dx; + int y = hqCentre.Y + dy; + if (costs.IsInBounds(x, y) && costs.IsWalkable((ushort)x, (ushort)y)) + { + return new GridPos2D(x, y); + } + } + } + Assert.Fail($"no walkable door cell around the HQ at ({hqCentre.X},{hqCentre.Y})"); + return GridPos2D.Invalid; + } + + // ---------------------------------------------------------------- + // (e) THE SNAPSHOT CONTRACT, made executable (Randbedingung 3) + // ---------------------------------------------------------------- + + [Test] + public void SnapshotRestore_ReproducesTheTerrainCarryingCostFieldExactly() + { + object source = InvokeScenarioBuildHost(CanonicalSeed); + InvokeScenarioSetupMatch(source); + uint epochBefore = PathfindingOf(source).CostField.Epoch; + Assert.That(epochBefore, + Is.EqualTo((uint)(CanonicalTerrainMirror.ImpassableCellCount + 2 * 9)), + "168 terrain writes + two 3x3 HQ footprints — identical on every host before the first snapshot"); + + ulong hashBefore = KernelOf(source).CalculateStateHash(); + byte[] snapshot = KernelOf(source).SaveSnapshot(); + + // The restore consumer par excellence: a host built by BuildHost + // and NEVER run through SetupMatch — exactly the scenario's + // playback path. Because the terrain is part of host + // construction, this host already carries the walls. + object restored = InvokeScenarioBuildHost(CanonicalSeed); + Assert.That(KernelOf(restored).TryRestoreSnapshot(snapshot), Is.True); + + CostField sourceCosts = PathfindingOf(source).CostField; + CostField restoredCosts = PathfindingOf(restored).CostField; + for (int y = 0; y < sourceCosts.Height; y++) + { + for (int x = 0; x < sourceCosts.Width; x++) + { + Assert.That(restoredCosts.GetCost((ushort)x, (ushort)y), + Is.EqualTo(sourceCosts.GetCost((ushort)x, (ushort)y)), + $"restored cost field differs at ({x},{y}) — the structural restore proof is broken"); + } + } + Assert.That(restoredCosts.Epoch, Is.EqualTo(epochBefore), + "the serialized epoch is adopted via RestoreEpoch, so later snapshots stay byte-comparable"); + Assert.That(KernelOf(restored).CalculateStateHash(), Is.EqualTo(hashBefore)); + Assert.That(KernelOf(restored).SaveSnapshot(), Is.EqualTo(snapshot), + "the restored host must round-trip the snapshot byte-identically"); + } + } +} diff --git a/tools/Nova.SimRunner/Determinism10000Scenario.cs b/tools/Nova.SimRunner/Determinism10000Scenario.cs index 54041bc..599d04d 100644 --- a/tools/Nova.SimRunner/Determinism10000Scenario.cs +++ b/tools/Nova.SimRunner/Determinism10000Scenario.cs @@ -136,8 +136,8 @@ internal sealed class DeterminismComparison /// MS-1 manifest start state of quality/content/mvp-v1.json /// (startStatePerPlayer) per slot — a COMPLETED HQ, ONE Builder and /// 3.000 AE (EconomySystem.CanonicalMatchStartingCreditsAE, wired by - /// ) — plus eleven finite canonical Aetherium - /// fields (21.6, #93). Nothing + /// ) — plus fifteen finite canonical Aetherium + /// fields (21.6/21.7, #93/#94). Nothing /// else is spawned: the script then drives the opening exactly like a /// player, for BOTH slots — walk the Builder to the future site, place /// the Refinery once it is affordable and the committed grid covers its @@ -194,34 +194,89 @@ private struct FieldLayout } /// - /// The eleven canonical fields (16.7/C1 layout as extended by sprint - /// 21.6, #93 — the T-01 report's "at least roughly twice as many"): - /// two start fields and TWO natural expansions per side at 9.000 AE - /// each, four contested flank fields at 12.000 and the contested - /// centre at 15.000 (a single field until 21.7 groups it). Every - /// slot-1 coordinate is the point mirror of slot 0 across the - /// D-102/D-107 Glutrinne layout axis ((x, y) -> (124 - x, 124 - y)): - /// the pairs 1/2, 3/4, 6/7, 8/9 and 10/11 mirror exactly, the centre - /// (62,62) mirrors onto itself. Registration in ascending id order is - /// part of the canonical initial state. Mirror of - /// MatchBootstrap.FieldLayouts — a drift between the two is caught by - /// the CanonicalMatchSetupTests reference hash chain in BOTH lanes. + /// The fifteen canonical fields (16.7/C1 layout as extended by sprint + /// 21.6/#93 and 21.7/#94): two start fields and TWO natural + /// expansions per side at 9.000 AE each, four contested flank fields + /// at 12.000 and the centre as a five-field zone (D-109) at 8.000 + /// each. Every slot-1 coordinate is the point mirror of slot 0 across + /// the D-102/D-107 Glutrinne layout axis ((x, y) -> (124 - x, 124 - y)): + /// the pairs 1/2, 3/4, 6/7, 8/9, 10/11, 12/13 and 14/15 mirror + /// exactly, the middle field (62,62) mirrors onto itself. + /// Registration in ascending id order is part of the canonical + /// initial state. Mirror of MatchBootstrap.FieldLayouts — a drift + /// between the two is caught by the CanonicalMatchSetupTests + /// reference hash chain in BOTH lanes. /// 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 }, }; + /// + /// Hand-mirrored copy of the canonical Glutrinne terrain + /// (MatchBootstrap's GlutrinneTerrainMap, 21.7/#94/D-109): the square + /// rock ring around the centre zone with four diagonal corner gaps. + /// The headless lane cannot reference the Gameplay assembly (the + /// csproj compiles Core/Simulation/Networking/AI only — a frozen + /// boundary), so the terrain rides as a mirror, exactly like the + /// field layout (R-1). Any edit must be applied to EVERY copy; + /// GlutrinneTerrainTests pins this mirror cell-exact against its own + /// reference and by the FNV-1a content checksum the EditMode lane + /// pins against the Unity host. + /// + private static class GlutrinneTerrain + { + public const int CentreX = 62; + public const int CentreY = 62; + public const int RingInnerRadius = 14; + public const int RingOuterRadius = 15; + public const int CornerGapMinRadius = 11; + + /// 232 ring-band cells minus the 64 corner-gap cells. + public const int ImpassableCellCount = 168; + + 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; + } + + public static int Apply(CostField 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; + } + } + /// /// Faction-resolved definition id of a role for the given slot /// (SimDefinitions id rule: Alliance = role wire value, Legion = @@ -264,6 +319,7 @@ private sealed class Host public EntityManager Entities; public EconomySystem Economy; public ConstructionSystem Construction; + public PathfindingSystem Pathfinding; public MatchSession Session; public CommandIngress Ingress; } @@ -648,7 +704,7 @@ private static void IssueSlotCommands( }; /// - /// Applies the deterministic match setup to a fresh host: the eleven + /// Applies the deterministic match setup to a fresh host: the fifteen /// canonical fields in ascending id order, then per slot the D-077 /// start state of quality/content/mvp-v1.json /// (startStatePerPlayer) — a COMPLETED HQ, ONE Builder and the 3.000 @@ -694,7 +750,10 @@ private static SlotState[] SetupMatch(Host host) /// construction and production phases 4/5 BEFORE pathfinding/ /// movement phase 6, then the 5 Hz FoW recompute, then combat, then /// the D-056 victory evaluation LAST), the sealed session/ingress - /// command pipeline, slots 0+1 active, input delay 1. + /// command pipeline, slots 0+1 active, input delay 1 — and the + /// canonical Glutrinne terrain (21.7), written before + /// Kernel.Start() so the generator AND the playback host carry + /// the identical cost field into the first snapshot. /// private static Host BuildHost(ulong seed, INovaLogger logger) { @@ -713,6 +772,22 @@ private static Host BuildHost(ulong seed, INovaLogger logger) var combat = new Nova.Simulation.Combat.CombatSystem(entities, fogOfWar, economy, construction); var victory = new Nova.Simulation.Victory.VictorySystem(entities, construction); + // 21.7 (#94, D-109): the canonical terrain, as part of HOST + // CONSTRUCTION — not of SetupMatch. The playback host is built by + // this same method and then restores the initial snapshot without + // ever running SetupMatch; terrain written only in the setup pass + // would leave the restored host rebuilding its derived flow + // fields against different walls than the generator (the cost + // field is no snapshot block, and the serialized epoch is + // adopted, not replayed). Identical table and identical (y, x) + // write order as the Unity host's GlutrinneTerrainMap.Apply, so + // the epoch lands on the same value on every host before the + // first snapshot. The count check pins the canonical map size. + if (GlutrinneTerrain.Apply(pathfinding.CostField) != GlutrinneTerrain.ImpassableCellCount) + { + throw new InvalidOperationException("the canonical terrain did not apply its expected cell count"); + } + kernel.RegisterSystem(economy); kernel.RegisterSystem(construction); kernel.RegisterSystem(production); @@ -743,6 +818,7 @@ private static Host BuildHost(ulong seed, INovaLogger logger) Entities = entities, Economy = economy, Construction = construction, + Pathfinding = pathfinding, Session = session, Ingress = ingress, };