islaApocalypse-v2/Tools/Scripts/OffshoreAnalysis.cs
beezm 3b96e06c1a chat2/05: offshore islands — rejoin the stub faithfully, then reshape to a guaranteed hybrid
Stage 1 fills the IslandFalloff.cs stub from the reference, verbatim: the coast shelf
(below-sea only, depth-preserving, held strictly below sea by MathF.BitDecrement — the
clamp that makes "cannot move the waterline" exact rather than statistical) and the islet
layer (OffshoreBlob, CalibrateThreshold against the field's ACTUAL distribution, the
OffshoreZoneWeight moat + pre-Trench-falloff test). Both run as pass 1b — OffshorePass, a
second sweep over the finished pass-1 arrays with the same per-pixel arithmetic in the
same order — and HMaxSeed is retaken AFTER them, as the reference did. That closes
chat2/00 Drift §2. The value did not move on any of 15 runs; the order is now right by
construction and oracle (l) prints it every time.

Stage 2 is the reshape, OffshoreSettings.Hybrid(): a seeded floor of ≥2 N / ≥4 S islands
placed by a PCG32 off the world seed — min-separated, clear of ALL existing land by a gap
so each stamp is its own connected component by construction, fully inside the zone so
the moat and falloff protections gate the floor exactly as they gate the organic layer —
plus the noise layer on top, smaller (freq 16), lower (24 m pre-curve, which the
preserved toe squashes to ~5 m and keeps there across curve tweaks), flatter (core 0.25),
crisper (edge sharpness 2.5, stamp rim jittered so it is rigid without being a compass
disc), south-weighted (0.007 N / 0.012 S, blended across the midline), corners allowed
(trench mask 0.90→0.97). Every lifted cell is tagged with its hemisphere — NORTH is rows
[0, N/2), y runs south, read off the spine's southern fade and the southern sinker, not
invented — and carried through Pass1Result → Pass2Result for a consumer that does not
exist yet.

Two things the probes taught, both now in the code: the first organic densities produced
183 blobs of which 174 were noise debris (a six-config sweep fixed that), and the
reference's lerp-to-crest leaves shallow humps across the seabed wherever a blob fails to
surface (a guard reverts them outside any surviving island's skirt; specks by component
membership, bumps by location). The faithful control keeps both, because it is the
reference — its islets measure min 1 cell, median 28.

Oracle, all hard checks passing: floor met on every hybrid seed (N 2–4, S 9–16); zero
land bridges; every offshore-OFF land cell bit-identical with offshore ON; tag ↔ coastline
consistent in both fields; curve-off still bit-identical to Phase 1's dump and
continuous_restored to task 03's. Both gates default OFF, deliberately: flipping them is
the act that retires the Phase-1 regression dumps, and that belongs in a task that
re-baselines the oracles.

The faithful control on seed 8675309 produced one north island. That is the gap the
floor exists to close.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCWNaDZPfTiAy3meGNGgqt
2026-08-21 05:14:53 -04:00

177 lines
6.3 KiB
C#

using System;
using System.Collections.Generic;
namespace IslaApocalypse.Tools
{
/// <summary>One connected island of tagged offshore land, as the analysis sees it.</summary>
public sealed class IslandComponent
{
public int Id;
public long Cells;
public double CentroidX, CentroidY;
public int MinX, MinY, MaxX, MaxY;
/// <summary>Hemisphere by CENTROID (an island straddling the midline is counted once, where its mass is).</summary>
public byte Hemisphere;
/// <summary>
/// ⚠ 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.
/// </summary>
public bool BridgedToMainland;
}
/// <summary>
/// ⭐ 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 <c>y / MapSize</c> (+ 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): <b>y increases SOUTHWARD. North is the top half of the image.</b>
///
/// 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.
/// </summary>
public static class OffshoreAnalysis
{
public const byte HemiNone = 0;
public const byte HemiNorth = 1;
public const byte HemiSouth = 2;
/// <summary>The convention, in one place. Every consumer of the tag reads hemisphere through this.</summary>
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 };
/// <summary>
/// Label the 8-connected components of tagged offshore land, and for each, whether it
/// touches untagged land (a bridge). <paramref name="height"/> + <paramref name="sea"/>
/// define "land"; <paramref name="tag"/> defines "offshore". Both are needed: the bridge test
/// is "tagged cell next to a land cell that is not tagged".
/// </summary>
public static List<IslandComponent> Components(bool[,] tag, float[,] height, float sea, int mapSize)
=> Components(tag, height, sea, mapSize, out _);
/// <summary>
/// As above, also returning the per-cell component id map (<c>x * mapSize + y</c>; 0 = not
/// tagged) — the debris guard needs membership, not just the list.
/// </summary>
public static List<IslandComponent> Components(bool[,] tag, float[,] height, float sea, int mapSize,
out int[] idMap)
{
var comps = new List<IslandComponent>();
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>();
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;
}
/// <summary>Island counts per hemisphere, by component centroid.</summary>
public static (int north, int south) CountByHemisphere(List<IslandComponent> 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);
}
/// <summary>
/// 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).
/// </summary>
public static (long min, long median, double mean, long max, int belowThreshold)
SizeSummary(List<IslandComponent> comps, long threshold)
{
if (comps.Count == 0) return (0, 0, 0.0, 0, 0);
var sizes = new List<long>(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);
}
/// <summary>How many components touch the mainland. Zero is the only acceptable answer.</summary>
public static int BridgedCount(List<IslandComponent> comps)
{
int b = 0;
foreach (var c in comps) if (c.BridgedToMainland) b++;
return b;
}
}
}