islaApocalypse-v2/Tools/Scripts/OffshoreAnalysis.cs
beezm e8571b2778 chat2/06: offshore islands — revert the forced floor, tune the organic layer for coverage
The seeded floor from chat2/05 — FloorNorth/FloorSouth, the PCG32 placement, StampWeight,
StampRadiusFrac/StampCoreFrac/StampEdgeJitter, the centre separation and land gap, the
floor self-check and oracle (h), the centre rings on the tag overlay — is reverted out
whole, as a forward commit. It looked stamped. It is one checkout away at 3b96e06. What
remains is ONE island mechanism: the organic noise-field layer, with chat2/05 stage 2's
shape untouched (freq 16, crest 24 m pre-curve, core 0.25, sharpness 2.5, corners on,
moat + falloff test + outer bound unmoved), now OffshoreSettings.Organic(). The count per
hemisphere is a statistical outcome of the tuning, read off a table, never guaranteed.

The south was measured before anything moved (OffshoreDiagnosis, over task 01's pool at
2048): the premise that the south generates fewer islands is not what the field says.
The NORTH has 1.75x less island-eligible ocean (56 % of its ocean is zone vs 69 %; 233
zone rows per column vs 407) and under half the island candidates (1,183 over-threshold
peaks in zone vs 2,422). Per cell the south is richer too (484 vs 414 candidates per
Mcell). The gate that loses candidates is the moat in the north and the falloff test in
the south — the bulging south coast pushes the 0.72 contour out — but neither binds
enough to suppress it. So the fix is density, and the binding constraint for "a couple
north, consistently" is the north.

The knobs: Density (the main one, the calibration quantile) and SouthWeight (south density
= Density x weight — applied to the quantile, so a south island looks exactly like a north
one; there are just more). Three levels batched on 12 seeds at 4096, SouthWeight 1.25:
0.016 N 2/4.4/10 S 8/13.8/21 (every seed clears N >= 2, S >= 3, but N's minimum IS 2),
0.022 N 3/6.3/12 S 15/19.9/29 (the preset — the first level whose north minimum sits
comfortably above the target), 0.030 N 3/7.8/15 S 19/25.2/33 (the too-many bookend). The
south weight is the developer's stated preference, not a fix; the SouthWeight 1.0 tables
(S 6/10.9/16 at 0.022) are in the batch's scratch/ for the comparison.

The guards, so more density does not buy slop, each a revert by component membership:
specks (< 3e-5 of the map's area, ~500 cells at 4096 — 70-100 per field at these
densities, the noise caps the reference also surfaced), clusters (the smaller of two
islands whose shores are within 0.8 % of the map width — 0-7 per field), blobs (> 6e-4 —
never bit). And the reference's submerged humps, now attributed by HUMP rather than by
bounding box: the first 4096 plate probe showed hollow ghost rings beside islands — a
guard-dropped island's cap went back to seabed but its rim survived inside its
neighbour's bbox keep-region. A hump (one connected raised region) now stays only if it
holds a kept island, and a dropped island's own cap goes even inside a kept hump.

Oracle, all passing: a1 (Phase-1 dump), a3 (task-03 dump), a4 NEW — offshore OFF at 8192
bit-identical to the terrain-curve-v1 tag's own 04 gallery dump over 67,108,864 cells —
j0 shelf inert on land, and per field moat / mainland unmoved / tag-coastline / HMaxSeed
(unchanged, 1.289169) / classify on all 36 table fields and 4 plates. Batch:
BatchRoot(6, "offshore_organic_tune") — exactly 4 plates (three densities on 1063685222,
density_mid on 424242, the table's sparsest south) + count_table.md/.csv + diagnosis.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EY3ZTF6NwzF8ukBHQXSK7
2026-08-21 06:22:54 -04:00

236 lines
8.5 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;
}
// ═══ chat2/06 — the separation guard's geometry ═══
/// <summary>
/// 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. <paramref name="surfaced"/> is the pass's list of
/// every surfaced cell (the bodies), walked once.
/// </summary>
public static Dictionary<int, List<(int x, int y)>> BoundaryCells(bool[,] tag, int[] compId, int mapSize,
IEnumerable<(int x, int y, float h0)> surfaced)
{
var result = new Dictionary<int, List<(int x, int y)>>();
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;
}
/// <summary>Chebyshev gap between two components' bounding boxes (0 if they overlap). A lower bound on their true distance.</summary>
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);
}
/// <summary>
/// The minimum Chebyshev distance between two boundary sets, early-exiting once it is
/// known to be below <paramref name="below"/> (the caller only needs "closer than the
/// minimum or not").
/// </summary>
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;
}
}
}