using System; using System.Collections.Generic; namespace IslaApocalypse.Tools { /// One connected island of tagged offshore land, as the analysis sees it. public sealed class IslandComponent { public int Id; public long Cells; public double CentroidX, CentroidY; public int MinX, MinY, MaxX, MaxY; /// Hemisphere by CENTROID (an island straddling the midline is counted once, where its mass is). public byte Hemisphere; /// /// ⚠ True if any cell of this island is 8-adjacent to land that is NOT tagged offshore — /// i.e. the island touches the mainland. The moat exists to make this impossible; this is /// the check that it did. /// public bool BridgedToMainland; } /// /// ⭐ THE OFFSHORE ANALYSIS — counts islands, reads their hemisphere, and catches a land bridge. /// Engine-free; used by the pass (to prove its own floor) and by the oracle (to prove it again, /// independently, on the finished field). /// /// ═══ THE HEMISPHERE CONVENTION — read from the code, not invented ═══ /// /// Pass 1's latitude scalar is y / MapSize (+ a ±0.1 wobble). The spine fades out where /// that scalar exceeds 0.65 — "the southern fade" — and the "southern sinker" bites in the /// BOTTOM 25 % of rows. So in this codebase, and in the lore it encodes (snow-town north, /// shipwreck south): y increases SOUTHWARD. North is the top half of the image. /// /// NORTH y ∈ [0, MapSize/2) /// SOUTH y ∈ [MapSize/2, MapSize) /// /// ⚠ The tag uses the clean row midline, NOT the wobbled latitude field. A hemisphere tag keyed /// to a field that wanders ±10 % of the map would put the same island in different hemispheres /// on different seeds for no geographic reason. The field's ORIENTATION is what is borrowed; its /// wobble is not. /// public static class OffshoreAnalysis { public const byte HemiNone = 0; public const byte HemiNorth = 1; public const byte HemiSouth = 2; /// The convention, in one place. Every consumer of the tag reads hemisphere through this. public static byte HemisphereOfRow(int y, int mapSize) => y < mapSize / 2 ? HemiNorth : HemiSouth; public static string HemisphereName(byte h) => h switch { HemiNorth => "north", HemiSouth => "south", _ => "none", }; // 8-connectivity, fixed order. private static readonly int[] DX = { -1, -1, -1, 0, 0, 1, 1, 1 }; private static readonly int[] DY = { -1, 0, 1, -1, 1, -1, 0, 1 }; /// /// Label the 8-connected components of tagged offshore land, and for each, whether it /// touches untagged land (a bridge). + /// define "land"; defines "offshore". Both are needed: the bridge test /// is "tagged cell next to a land cell that is not tagged". /// public static List Components(bool[,] tag, float[,] height, float sea, int mapSize) => Components(tag, height, sea, mapSize, out _); /// /// As above, also returning the per-cell component id map (x * mapSize + y; 0 = not /// tagged) — the debris guard needs membership, not just the list. /// public static List Components(bool[,] tag, float[,] height, float sea, int mapSize, out int[] idMap) { var comps = new List(); int n = mapSize; var id = new int[n * n]; // 0 = unvisited / not tagged idMap = id; if (tag == null) return comps; var stack = new Stack(); int next = 0; for (int sx = 0; sx < n; sx++) { for (int sy = 0; sy < n; sy++) { if (!tag[sx, sy] || id[sx * n + sy] != 0) continue; var c = new IslandComponent { Id = ++next, MinX = sx, MaxX = sx, MinY = sy, MaxY = sy, }; double sumX = 0, sumY = 0; id[sx * n + sy] = c.Id; stack.Push(sx * n + sy); while (stack.Count > 0) { int cur = stack.Pop(); int cx = cur / n, cy = cur % n; c.Cells++; sumX += cx; sumY += cy; if (cx < c.MinX) c.MinX = cx; if (cx > c.MaxX) c.MaxX = cx; if (cy < c.MinY) c.MinY = cy; if (cy > c.MaxY) c.MaxY = cy; for (int k = 0; k < 8; k++) { int nx = cx + DX[k], ny = cy + DY[k]; if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue; if (tag[nx, ny]) { int ni = nx * n + ny; if (id[ni] != 0) continue; id[ni] = c.Id; stack.Push(ni); } else if (height[nx, ny] >= sea) { // Land, not tagged offshore ⇒ mainland (or a lake-shore) touching // this island. The moat should have made this impossible. c.BridgedToMainland = true; } } } c.CentroidX = sumX / c.Cells; c.CentroidY = sumY / c.Cells; c.Hemisphere = HemisphereOfRow((int)Math.Round(c.CentroidY), mapSize); comps.Add(c); } } return comps; } /// Island counts per hemisphere, by component centroid. public static (int north, int south) CountByHemisphere(List comps) { int nN = 0, nS = 0; foreach (var c in comps) { if (c.Hemisphere == HemiNorth) nN++; else if (c.Hemisphere == HemiSouth) nS++; } return (nN, nS); } /// /// Island SIZE statistics — the thing a count alone hides. 189 islands averaging 66 cells is /// noise debris, not an archipelago; 12 islands averaging 900 cells is what the developer /// asked for. Cells are map cells (1 column = 1 m at the target scale). /// public static (long min, long median, double mean, long max, int belowThreshold) SizeSummary(List comps, long threshold) { if (comps.Count == 0) return (0, 0, 0.0, 0, 0); var sizes = new List(comps.Count); double sum = 0; int below = 0; foreach (var c in comps) { sizes.Add(c.Cells); sum += c.Cells; if (c.Cells < threshold) below++; } sizes.Sort(); return (sizes[0], sizes[sizes.Count / 2], sum / sizes.Count, sizes[sizes.Count - 1], below); } /// How many components touch the mainland. Zero is the only acceptable answer. public static int BridgedCount(List comps) { int b = 0; foreach (var c in comps) if (c.BridgedToMainland) b++; return b; } // ═══ chat2/06 — the separation guard's geometry ═══ /// /// Per component id, its BOUNDARY cells — tagged cells with at least one 8-neighbour that is /// not tagged (or the map edge). The nearest approach between two islands is between /// boundary cells, so the separation guard compares boundaries, not bodies: a few hundred /// cells per island instead of thousands. is the pass's list of /// every surfaced cell (the bodies), walked once. /// public static Dictionary> BoundaryCells(bool[,] tag, int[] compId, int mapSize, IEnumerable<(int x, int y, float h0)> surfaced) { var result = new Dictionary>(); foreach (var (x, y, _) in surfaced) { if (!tag[x, y]) continue; int id = compId[x * mapSize + y]; if (id == 0) continue; bool edge = false; for (int k = 0; k < 8 && !edge; k++) { int nx = x + DX[k], ny = y + DY[k]; if (nx < 0 || nx >= mapSize || ny < 0 || ny >= mapSize || !tag[nx, ny]) edge = true; } if (!edge) continue; if (!result.TryGetValue(id, out var list)) result[id] = list = new List<(int, int)>(); list.Add((x, y)); } return result; } /// Chebyshev gap between two components' bounding boxes (0 if they overlap). A lower bound on their true distance. public static int BoxGap(IslandComponent a, IslandComponent b) { int gx = Math.Max(0, Math.Max(a.MinX - b.MaxX, b.MinX - a.MaxX)); int gy = Math.Max(0, Math.Max(a.MinY - b.MaxY, b.MinY - a.MaxY)); return Math.Max(gx, gy); } /// /// The minimum Chebyshev distance between two boundary sets, early-exiting once it is /// known to be below (the caller only needs "closer than the /// minimum or not"). /// public static int MinChebyshev(List<(int x, int y)> a, List<(int x, int y)> b, int below) { int best = int.MaxValue; if (a == null || b == null) return best; foreach (var (ax, ay) in a) { foreach (var (bx, by) in b) { int d = Math.Max(Math.Abs(ax - bx), Math.Abs(ay - by)); if (d < best) { best = d; if (best < below) return best; } } } return best; } } }