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;
+ }
+}