From c305fd656550db061b5a1ae1f3a0c4d2a74fd1e1 Mon Sep 17 00:00:00 2001 From: beezm Date: Thu, 6 Aug 2026 07:24:08 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20blueprint=20v2=20envelope=20=E2=80=94?= =?UTF-8?q?=20writer,=20dual-write,=20round-trip=20harness=20(terrain-wate?= =?UTF-8?q?r=20task=2002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2 tagged-section container (D-030): raw ISLA magic + u32 version gate, [u32 tag][u64 length][payload] sections — params (resolved generation inputs incl. impact centre), heights f32, biomes u8, towns with the highway-node flag, four road tiers identified by tag not position. - BlueprintFormat.cs: the single tag/constant registry. - BlueprintWriter.WriteV2: blueprint-typed, callable outside a generation run; generator and harness are both just callers. - ExportMapData dual-writes: v2 under the primary seed name, legacy v1 beside it as _v1.dat (safety net; removal is a future task). - MapDataParser: LoadMapDataFromPath entry point; v1 parse body extracted intact as LoadV1 (v2 reader lands in the next commit). - RoundTripHarness scene: load v1 -> write v2 -> load v2 -> semantic equality, headless, seconds per cycle. Co-Authored-By: Claude Fable 5 --- Core/Scripts/BlueprintFormat.cs | 54 ++++++++++ Core/Scripts/BlueprintWriter.cs | 165 +++++++++++++++++++++++++++++ Core/Scripts/MapDataParser.cs | 61 ++++++++++- Tools/Scenes/RoundTripHarness.tscn | 6 ++ Tools/Scripts/MapGenerator.cs | 61 ++++++++++- Tools/Scripts/RoundTripHarness.cs | 156 +++++++++++++++++++++++++++ 6 files changed, 494 insertions(+), 9 deletions(-) create mode 100644 Core/Scripts/BlueprintFormat.cs create mode 100644 Core/Scripts/BlueprintWriter.cs create mode 100644 Tools/Scenes/RoundTripHarness.tscn create mode 100644 Tools/Scripts/RoundTripHarness.cs diff --git a/Core/Scripts/BlueprintFormat.cs b/Core/Scripts/BlueprintFormat.cs new file mode 100644 index 0000000..153e166 --- /dev/null +++ b/Core/Scripts/BlueprintFormat.cs @@ -0,0 +1,54 @@ +namespace IslaApocalypse.Core +{ + /// + /// The single registry of constants for the v2 blueprint container — magic, version, + /// section tags, validation bounds, and the sentinel values used when a v2 file is + /// re-encoded from a v1 source that cannot supply real generation parameters. + /// + /// The byte-accurate layout lives in Core/Scripts/BLUEPRINT_FORMAT.md. Writer: + /// BlueprintWriter.WriteV2. Reader: MapDataParser (v2 branch). Tags are FourCC codes + /// stored as little-endian u32, so the raw bytes on disk read as ASCII ("PRMS", "HGTS", + /// ...) in a hex dump. + /// + public static class BlueprintFormat + { + // "ISLA" as raw bytes 'I','S','L','A' == little-endian u32 0x414C5349. + // Deliberately NOT a length-prefixed .NET string: the v1 header starts with the + // 7-bit length prefix 0x07, so the first byte alone (0x49 vs 0x07) identifies the + // format and the reader can dispatch without ambiguity. + public const uint MAGIC = 0x414C5349; + public const uint VERSION = 2; + + // Section tags (FourCC, little-endian). Reader rule: known tag -> parse, + // unknown tag -> skip by length and continue. Never reuse a retired tag value. + public const uint TAG_PARAMS = 0x534D5250; // "PRMS" + public const uint TAG_HEIGHTS = 0x53544748; // "HGTS" + public const uint TAG_BIOMES = 0x4D4F4942; // "BIOM" + public const uint TAG_TOWNS = 0x4E574F54; // "TOWN" + public const uint TAG_ROADS_HIGHWAY = 0x57484452; // "RDHW" + public const uint TAG_ROADS_BRANCH = 0x52424452; // "RDBR" + public const uint TAG_ROADS_RUGGED = 0x47524452; // "RDRG" + public const uint TAG_ROADS_TRAIL = 0x4C544452; // "RDTL" + + // MapSize sanity bounds, checked before any allocation on read. + public const int MIN_MAP_SIZE = 256; + public const int MAX_MAP_SIZE = 32768; + + // Sentinels written into the params section when the source blueprint came from a + // v1 file (a re-encode cannot know the original generation inputs). WorldSeed 0 is + // also the config "randomize" sentinel, which no real resolved seed ever is. + public const int SENTINEL_WORLD_SEED = 0; + public const float SENTINEL_CRATER_RADIUS = -1f; + public const float SENTINEL_DENSITY_MULTIPLIER = -1f; + public const float SENTINEL_IMPACT_CENTER = -1f; // both components + + /// Renders a tag as its 4 ASCII characters, for log messages. + public static string TagToString(uint tag) + { + return new string(new[] { + (char)(tag & 0xFF), (char)((tag >> 8) & 0xFF), + (char)((tag >> 16) & 0xFF), (char)((tag >> 24) & 0xFF) + }); + } + } +} diff --git a/Core/Scripts/BlueprintWriter.cs b/Core/Scripts/BlueprintWriter.cs new file mode 100644 index 0000000..d904590 --- /dev/null +++ b/Core/Scripts/BlueprintWriter.cs @@ -0,0 +1,165 @@ +using Godot; +using System; +using System.IO; + +namespace IslaApocalypse.Core +{ + /// + /// Writes a WorldBlueprint to disk in the v2 tagged-section container + /// (layout: Core/Scripts/BLUEPRINT_FORMAT.md; constants: BlueprintFormat). + /// + /// Deliberately written against the blueprint data types, not the generator's + /// internal arrays, so it is callable outside a generation run — the map generator + /// and the round-trip harness are both just callers. + /// + /// Provenance (timestamp, git hash) is stamped at write time — it describes the + /// file being written, not whatever was in blueprint.Params. If blueprint.Params is + /// null (a re-encode of a legacy v1 file), the sentinel values from BlueprintFormat + /// are written instead of real generation inputs. + /// + public static class BlueprintWriter + { + public static void WriteV2(string absolutePath, WorldBlueprint bp) + { + ulong started = Time.GetTicksMsec(); + + using (FileStream stream = File.Open(absolutePath, FileMode.Create)) + using (BinaryWriter writer = new BinaryWriter(stream)) + { + // Header: raw "ISLA" magic + u32 version. Little-endian throughout. + writer.Write(BlueprintFormat.MAGIC); + writer.Write(BlueprintFormat.VERSION); + + WriteSection(writer, BlueprintFormat.TAG_PARAMS, w => WriteParams(w, bp)); + WriteSection(writer, BlueprintFormat.TAG_HEIGHTS, w => WriteHeights(w, bp)); + WriteSection(writer, BlueprintFormat.TAG_BIOMES, w => WriteBiomes(w, bp)); + WriteSection(writer, BlueprintFormat.TAG_TOWNS, w => WriteTowns(w, bp)); + WriteSection(writer, BlueprintFormat.TAG_ROADS_HIGHWAY, w => WriteRoadTier(w, bp.Highways)); + WriteSection(writer, BlueprintFormat.TAG_ROADS_BRANCH, w => WriteRoadTier(w, bp.BranchRoads)); + WriteSection(writer, BlueprintFormat.TAG_ROADS_RUGGED, w => WriteRoadTier(w, bp.RuggedRoads)); + WriteSection(writer, BlueprintFormat.TAG_ROADS_TRAIL, w => WriteRoadTier(w, bp.TrailRoads)); + } + + double seconds = (Time.GetTicksMsec() - started) / 1000.0; + Vector2 impact = bp.Params?.ImpactCenter + ?? new Vector2(BlueprintFormat.SENTINEL_IMPACT_CENTER, BlueprintFormat.SENTINEL_IMPACT_CENTER); + GD.Print($"[BlueprintWriter] v2 blueprint written in {seconds:F1}s to: {absolutePath} " + + $"(seed {bp.Params?.WorldSeed ?? BlueprintFormat.SENTINEL_WORLD_SEED}, " + + $"MapSize {bp.MapSize}, impactCenter ({impact.X:F1},{impact.Y:F1}))"); + } + + /// + /// Frames one section: [u32 tag][u64 payload-length][payload]. The length is + /// back-patched after the payload is written, so payload writers never have to + /// pre-compute their own size. + /// + private static void WriteSection(BinaryWriter writer, uint tag, Action payload) + { + writer.Write(tag); + long lengthPos = writer.BaseStream.Position; + writer.Write((ulong)0); + long payloadStart = writer.BaseStream.Position; + + payload(writer); + + long payloadEnd = writer.BaseStream.Position; + writer.BaseStream.Position = lengthPos; + writer.Write((ulong)(payloadEnd - payloadStart)); + writer.BaseStream.Position = payloadEnd; + } + + private static void WriteParams(BinaryWriter writer, WorldBlueprint bp) + { + BlueprintParams p = bp.Params; + writer.Write(p?.WorldSeed ?? BlueprintFormat.SENTINEL_WORLD_SEED); + writer.Write(bp.MapSize); // always real — the blueprint knows its own size + writer.Write(p?.CraterRadius ?? BlueprintFormat.SENTINEL_CRATER_RADIUS); + writer.Write(p?.DensityMultiplier ?? BlueprintFormat.SENTINEL_DENSITY_MULTIPLIER); + Vector2 impact = p?.ImpactCenter + ?? new Vector2(BlueprintFormat.SENTINEL_IMPACT_CENTER, BlueprintFormat.SENTINEL_IMPACT_CENTER); + writer.Write(impact.X); + writer.Write(impact.Y); + + // Provenance, stamped now (length-prefixed .NET strings — fine inside a + // length-framed section; only the file HEADER must avoid them). + writer.Write(DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")); + writer.Write(TryGetGitShortHash()); + } + + private static void WriteHeights(BinaryWriter writer, WorldBlueprint bp) + { + // X outer / Y inner — the v1 convention, kept: the second index is the map's + // north/south axis and the server consumes it as world Z. + int n = bp.MapSize; + for (int x = 0; x < n; x++) + for (int y = 0; y < n; y++) + writer.Write(bp.HeightMap[x, y]); + } + + private static void WriteBiomes(BinaryWriter writer, WorldBlueprint bp) + { + int n = bp.MapSize; + for (int x = 0; x < n; x++) + { + for (int y = 0; y < n; y++) + { + int ordinal = (int)bp.BiomeMap[x, y]; + if (ordinal < 0 || ordinal > byte.MaxValue) + throw new InvalidDataException( + $"[BlueprintWriter] Biome ordinal {ordinal} at ({x},{y}) does not fit in a byte."); + writer.Write((byte)ordinal); + } + } + } + + private static void WriteTowns(BinaryWriter writer, WorldBlueprint bp) + { + writer.Write(bp.Towns.Count); + foreach (TownLocation town in bp.Towns) + { + int tier = (int)town.Tier; + if (tier < 0 || tier > byte.MaxValue) + throw new InvalidDataException( + $"[BlueprintWriter] TownTier ordinal {tier} does not fit in a byte."); + writer.Write(town.Position.X); + writer.Write(town.Position.Y); + writer.Write((byte)tier); + writer.Write(town.IsHighwayNode ? (byte)1 : (byte)0); + } + } + + private static void WriteRoadTier(BinaryWriter writer, System.Collections.Generic.List paths) + { + writer.Write(paths.Count); + foreach (Vector2[] path in paths) + { + writer.Write(path.Length); + foreach (Vector2 p in path) { writer.Write(p.X); writer.Write(p.Y); } + } + } + + /// + /// Best-effort short hash of the generator repo (reads res://.git directly, no + /// git invocation). Returns "" when unavailable — e.g. an exported build. + /// + private static string TryGetGitShortHash() + { + try + { + string gitDir = Path.Combine(ProjectSettings.GlobalizePath("res://"), ".git"); + string head = File.ReadAllText(Path.Combine(gitDir, "HEAD")).Trim(); + if (head.StartsWith("ref: ")) + { + string refPath = Path.Combine(gitDir, head.Substring(5)); + if (!File.Exists(refPath)) return ""; + head = File.ReadAllText(refPath).Trim(); + } + return head.Length >= 8 ? head.Substring(0, 8) : head; + } + catch + { + return ""; + } + } + } +} diff --git a/Core/Scripts/MapDataParser.cs b/Core/Scripts/MapDataParser.cs index 8703cac..6b354c2 100644 --- a/Core/Scripts/MapDataParser.cs +++ b/Core/Scripts/MapDataParser.cs @@ -11,6 +11,29 @@ namespace IslaApocalypse.Core { public Vector2 Position; public TownTier Tier; + + // Whether the generator marked this settlement as a highway node (it drives the + // road topology at generation time). Carried by the v2 blueprint; always false + // when loading a legacy v1 file, which never stored it. Nothing server-side + // consumes it yet — it is carried and exposed, not acted on. + public bool IsHighwayNode; + } + + /// + /// The resolved generation inputs embedded in a v2 blueprint, so a file is + /// self-describing and config desync is detectable. Null on a legacy v1 load. + /// A v2 file re-encoded from a v1 source carries the BlueprintFormat sentinel + /// values instead of real inputs. + /// + public class BlueprintParams + { + public int WorldSeed; // the generator's RESOLVED noise seed (names the file) + public int MapSize; + public float CraterRadius; + public float DensityMultiplier; + public Vector2 ImpactCenter; // the crater's _impactCenter, in map pixels + public string GeneratedUtc = ""; // stamped at write time, ISO-8601 + public string GeneratorGitHash = ""; // short hash of the generator repo, "" if unknown } public class WorldBlueprint @@ -19,12 +42,17 @@ namespace IslaApocalypse.Core public float[,] HeightMap; public Biome[,] BiomeMap; public List Towns = new List(); - + // All 4 road tiers! public List Highways = new List(); public List BranchRoads = new List(); public List RuggedRoads = new List(); public List TrailRoads = new List(); + + // Which on-disk format this blueprint was parsed from (1 or 2), and the embedded + // generation parameters. Params is null when the source was a legacy v1 file. + public int FormatVersion = 1; + public BlueprintParams Params; } // 2. The Parser Utility @@ -33,18 +61,41 @@ namespace IslaApocalypse.Core public static WorldBlueprint LoadMapData(string seedStr) { string filePath = ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}.dat"); - + return LoadMapDataFromPath(filePath); + } + + /// + /// Loads a blueprint from an explicit absolute path. Same result as LoadMapData; + /// exists so tooling (the round-trip harness) can load files that do not follow + /// the seed-derived naming. + /// + public static WorldBlueprint LoadMapDataFromPath(string filePath) + { if (!File.Exists(filePath)) { GD.PrintErr($"[MapDataParser] CRITICAL ERROR: Map file not found at {filePath}"); return null; } - WorldBlueprint blueprint = new WorldBlueprint(); - using (FileStream stream = File.OpenRead(filePath)) { using (BinaryReader reader = new BinaryReader(stream)) + { + return LoadV1(reader); + } + } + } + + // =============================================================== + // LEGACY v1 PARSE PATH — the original "ISLA_V1" positional format. + // Kept intact and untouched; do not extend it. New content belongs + // in the v2 tagged-section format. + // =============================================================== + private static WorldBlueprint LoadV1(BinaryReader reader) + { + WorldBlueprint blueprint = new WorldBlueprint(); + + { { // 1. Header Validation (CRITICAL: Must read string first to align bytes!) string header = reader.ReadString(); @@ -122,7 +173,7 @@ namespace IslaApocalypse.Core } } - GD.Print($"[MapDataParser] Successfully loaded Blueprint for Seed {seedStr}. Dimension: {blueprint.MapSize}x{blueprint.MapSize}."); + GD.Print($"[MapDataParser] Successfully loaded v1 Blueprint. Dimension: {blueprint.MapSize}x{blueprint.MapSize}."); return blueprint; } } diff --git a/Tools/Scenes/RoundTripHarness.tscn b/Tools/Scenes/RoundTripHarness.tscn new file mode 100644 index 0000000..4091d96 --- /dev/null +++ b/Tools/Scenes/RoundTripHarness.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://Tools/Scripts/RoundTripHarness.cs" id="1_rth"] + +[node name="RoundTripHarness" type="Node"] +script = ExtResource("1_rth") diff --git a/Tools/Scripts/MapGenerator.cs b/Tools/Scripts/MapGenerator.cs index 5a0f655..cd75452 100644 --- a/Tools/Scripts/MapGenerator.cs +++ b/Tools/Scripts/MapGenerator.cs @@ -180,9 +180,62 @@ public partial class MapGenerator : TextureRect private void ExportMapData() { string seedStr = _noise.Seed.ToString(); - string filePath = $"user://MapData_Seed_{seedStr}.dat"; - - using (FileStream stream = File.Open(ProjectSettings.GlobalizePath(filePath), FileMode.Create)) + + // PRIMARY: the v2 tagged-section container (Core/Scripts/BLUEPRINT_FORMAT.md), + // under the name the server looks for. + string v2Path = ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}.dat"); + BlueprintWriter.WriteV2(v2Path, BuildBlueprint()); + + // SAFETY NET: the legacy v1 format beside it, until the developer has lived + // with v2 across several regenerations. Removal is a future task. + ExportMapDataV1(ProjectSettings.GlobalizePath($"user://MapData_Seed_{seedStr}_v1.dat")); + } + + /// + /// Packages the generator's internal arrays as a WorldBlueprint (plus the resolved + /// generation params) for BlueprintWriter. Arrays are shared, not copied. + /// + private WorldBlueprint BuildBlueprint() + { + var blueprint = new WorldBlueprint + { + MapSize = MapSize, + HeightMap = _heightMap, + BiomeMap = _biomeMap, + Highways = _highwayPaths, + BranchRoads = _branchPaths, + RuggedRoads = _ruggedPaths, + TrailRoads = _trailPaths, + FormatVersion = 2, + Params = new BlueprintParams + { + WorldSeed = _noise.Seed, // the RESOLVED seed — also names the file + MapSize = MapSize, + CraterRadius = _impactRadius, + DensityMultiplier = ConfigManager.DensityMultiplier, + ImpactCenter = _impactCenter + } + }; + foreach (var town in _towns) + { + blueprint.Towns.Add(new TownLocation + { + Position = town.Position, + Tier = town.Tier, + IsHighwayNode = town.IsHighwayNode + }); + } + return blueprint; + } + + // =============================================================== + // LEGACY v1 WRITER — the original "ISLA_V1" positional format. + // Kept intact as the dual-write safety net; removal is a future + // task. Do not extend it — new content belongs in the v2 writer. + // =============================================================== + private void ExportMapDataV1(string absolutePath) + { + using (FileStream stream = File.Open(absolutePath, FileMode.Create)) { using (BinaryWriter writer = new BinaryWriter(stream)) { @@ -237,7 +290,7 @@ public partial class MapGenerator : TextureRect } } } - GD.Print("Binary Data Exported to: " + ProjectSettings.GlobalizePath(filePath)); + GD.Print("Legacy v1 Binary Data Exported to: " + absolutePath); } // --- THE ORIGINAL MAP GENERATION CODE (Restored!) --- diff --git a/Tools/Scripts/RoundTripHarness.cs b/Tools/Scripts/RoundTripHarness.cs new file mode 100644 index 0000000..bc0c6db --- /dev/null +++ b/Tools/Scripts/RoundTripHarness.cs @@ -0,0 +1,156 @@ +using Godot; +using System.Collections.Generic; +using IslaApocalypse.Core; + +/// +/// The blueprint round-trip oracle (terrain-water task 02). Runs headless in seconds, +/// no generation, no road pass: +/// +/// 1. load a known-good v1 blueprint (default: the preserved reference copy of seed +/// 1409879727) through the real parser, +/// 2. write it back out as v2 through the real writer (to a harness-only name — the +/// reference file is never overwritten), +/// 3. load the v2 file through the real parser, +/// 4. assert semantic equality: heights bitwise, biomes equal, towns equal in +/// position/tier/count, each road tier point-for-point. +/// +/// Params and the per-town highway flag are EXCLUDED from equality — a v1 source +/// cannot supply them; they are verified in the full-generation acceptance run. +/// +/// Run: Godot --headless --path res://Tools/Scenes/RoundTripHarness.tscn +/// Env overrides: HARNESS_V1_PATH (source file), HARNESS_V2_PATH (output file). +/// Exit code 0 = PASS, 1 = FAIL. Worth keeping permanently as a format regression test. +/// +public partial class RoundTripHarness : Node +{ + public override void _Ready() + { + string v1Path = OS.GetEnvironment("HARNESS_V1_PATH"); + if (string.IsNullOrEmpty(v1Path)) + v1Path = ProjectSettings.GlobalizePath("user://reference_1409879727/MapData_Seed_1409879727.dat"); + string v2Path = OS.GetEnvironment("HARNESS_V2_PATH"); + if (string.IsNullOrEmpty(v2Path)) + v2Path = ProjectSettings.GlobalizePath("user://Harness_RoundTrip_v2.dat"); + + GD.Print($"[Harness] v1 source: {v1Path}"); + GD.Print($"[Harness] v2 output: {v2Path}"); + + bool pass = false; + try + { + pass = RunRoundTrip(v1Path, v2Path); + } + catch (System.Exception e) + { + GD.PrintErr($"[Harness] EXCEPTION: {e}"); + } + + GD.Print(pass ? "[Harness] RESULT: PASS" : "[Harness] RESULT: FAIL"); + GetTree().Quit(pass ? 0 : 1); + } + + private bool RunRoundTrip(string v1Path, string v2Path) + { + ulong t0 = Time.GetTicksMsec(); + WorldBlueprint original = MapDataParser.LoadMapDataFromPath(v1Path); + ulong t1 = Time.GetTicksMsec(); + if (original == null) { GD.PrintErr("[Harness] v1 load failed."); return false; } + GD.Print($"[Harness] v1 parse: {(t1 - t0) / 1000.0:F1}s (format v{original.FormatVersion})"); + + BlueprintWriter.WriteV2(v2Path, original); + ulong t2 = Time.GetTicksMsec(); + GD.Print($"[Harness] v2 write: {(t2 - t1) / 1000.0:F1}s"); + + WorldBlueprint reread = MapDataParser.LoadMapDataFromPath(v2Path); + ulong t3 = Time.GetTicksMsec(); + if (reread == null) { GD.PrintErr("[Harness] v2 load failed."); return false; } + GD.Print($"[Harness] v2 parse: {(t3 - t2) / 1000.0:F1}s (format v{reread.FormatVersion})"); + + return Compare(original, reread); + } + + private bool Compare(WorldBlueprint a, WorldBlueprint b) + { + bool ok = true; + + if (a.MapSize != b.MapSize) + { + GD.PrintErr($"[Harness] MapSize mismatch: {a.MapSize} vs {b.MapSize}"); + return false; // nothing below is comparable + } + + // Heights: bitwise-identical floats. + long heightDiffs = 0; + for (int x = 0; x < a.MapSize; x++) + for (int y = 0; y < a.MapSize; y++) + if (System.BitConverter.SingleToInt32Bits(a.HeightMap[x, y]) != + System.BitConverter.SingleToInt32Bits(b.HeightMap[x, y])) + heightDiffs++; + if (heightDiffs > 0) { GD.PrintErr($"[Harness] {heightDiffs} height pixels differ bitwise."); ok = false; } + + // Biomes: equal values (i32 -> u8 narrowing is documented and lossless for 0..255). + long biomeDiffs = 0; + for (int x = 0; x < a.MapSize; x++) + for (int y = 0; y < a.MapSize; y++) + if (a.BiomeMap[x, y] != b.BiomeMap[x, y]) + biomeDiffs++; + if (biomeDiffs > 0) { GD.PrintErr($"[Harness] {biomeDiffs} biome pixels differ."); ok = false; } + + // Towns: identical position/tier/count (highway flag deliberately not compared). + if (a.Towns.Count != b.Towns.Count) + { + GD.PrintErr($"[Harness] Town count mismatch: {a.Towns.Count} vs {b.Towns.Count}"); + ok = false; + } + else + { + for (int i = 0; i < a.Towns.Count; i++) + { + if (a.Towns[i].Position != b.Towns[i].Position || a.Towns[i].Tier != b.Towns[i].Tier) + { + GD.PrintErr($"[Harness] Town {i} mismatch: " + + $"{a.Towns[i].Position}/{a.Towns[i].Tier} vs {b.Towns[i].Position}/{b.Towns[i].Tier}"); + ok = false; + } + } + } + + ok &= CompareRoads("Highway", a.Highways, b.Highways); + ok &= CompareRoads("Branch", a.BranchRoads, b.BranchRoads); + ok &= CompareRoads("Rugged", a.RuggedRoads, b.RuggedRoads); + ok &= CompareRoads("Trail", a.TrailRoads, b.TrailRoads); + + if (ok) + GD.Print($"[Harness] Semantic equality holds: {a.MapSize}x{a.MapSize} grid, " + + $"{a.Towns.Count} towns, roads {a.Highways.Count}/{a.BranchRoads.Count}/" + + $"{a.RuggedRoads.Count}/{a.TrailRoads.Count} paths."); + return ok; + } + + private bool CompareRoads(string tier, List a, List b) + { + if (a.Count != b.Count) + { + GD.PrintErr($"[Harness] {tier} path count mismatch: {a.Count} vs {b.Count}"); + return false; + } + for (int i = 0; i < a.Count; i++) + { + if (a[i].Length != b[i].Length) + { + GD.PrintErr($"[Harness] {tier} path {i} length mismatch: {a[i].Length} vs {b[i].Length}"); + return false; + } + for (int p = 0; p < a[i].Length; p++) + { + if (System.BitConverter.SingleToInt32Bits(a[i][p].X) != System.BitConverter.SingleToInt32Bits(b[i][p].X) || + System.BitConverter.SingleToInt32Bits(a[i][p].Y) != System.BitConverter.SingleToInt32Bits(b[i][p].Y)) + { + GD.PrintErr($"[Harness] {tier} path {i} point {p} differs: {a[i][p]} vs {b[i][p]}"); + return false; + } + } + } + return true; + } +}