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