Skip to content

Instantly share code, notes, and snippets.

@ejfox
Last active June 30, 2026 02:00
Show Gist options
  • Select an option

  • Save ejfox/55979852a4a5d2f782e8f4be564140a3 to your computer and use it in GitHub Desktop.

Select an option

Save ejfox/55979852a4a5d2f782e8f4be564140a3 to your computer and use it in GitHub Desktop.
Subway Builder — Ocean Depth Index schema delta (old OceanDepthIndex -> new OptimizedOceanDepthIndex)
#!/usr/bin/env node
//
// ============================================================================
// HOW TO GET THIS FILE AND RUN IT — you do NOT need the game's source code.
// ----------------------------------------------------------------------------
// Requirement: Node.js 16+ (check with: node --version)
//
// 1. Download this one file into your current folder:
//
// curl -O https://gist.githubusercontent.com/ejfox/55979852a4a5d2f782e8f4be564140a3/raw/convert-ocean-depth-index.mjs
//
// ...or grab all the ocean-depth tools at once:
//
// for f in diagnose-ocean-depth validate-ocean-depth-index convert-ocean-depth-index; do curl -O https://gist.githubusercontent.com/ejfox/55979852a4a5d2f782e8f4be564140a3/raw/$f.mjs; done
//
// 2. Run it:
//
// node convert-ocean-depth-index.mjs old.json fixed.json
//
// That's it — no npm, no install, no dependencies. Single self-contained file.
// Output ends in PASS/FAIL and a clear NEXT STEP. Exit codes: 0 ok, 1 problem, 2 couldn't run.
// ============================================================================
// Ocean depth index converter — legacy shape -> optimized shape
// ---------------------------------------------------------------------------
// Usage: node convert-ocean-depth-index.mjs <in.json[.gz]> [out.json]
//
// The in-game collision detector needs the OPTIMIZED index shape
// (cs/bbox/grid/cells-as-array/stats). If you have an older file with
// cellSize/minLon../cols/rows and an object `cells` keyed by "col,row", this
// rewrites it into the shape the game can actually consume. It does NOT touch
// your polygon geometry — only the container/key layout — so the map layer
// keeps working too.
//
// After converting, run validate-ocean-depth-index.mjs on the OUTPUT to confirm.
// Zero dependencies. Node 16+.
// ---------------------------------------------------------------------------
import { readFileSync, writeFileSync } from 'node:fs';
import { gunzipSync, gzipSync } from 'node:zlib';
const inPath = process.argv[2];
const outPath = process.argv[3] || (inPath ? inPath.replace(/\.gz$/, '').replace(/\.json$/, '') + '.optimized.json' : null);
if (!inPath) {
console.error(`Usage: node ${process.argv[1].split('/').pop()} <in.json[.gz]> [out.json]`);
process.exit(1);
}
function die(msg) {
console.error(`\x1b[31mERROR\x1b[0m ${msg}`);
process.exit(2);
}
let raw;
try {
raw = readFileSync(inPath);
} catch (e) {
die(`cannot read ${inPath}: ${e.message}`);
}
let text;
const isGzip = raw.length > 2 && raw[0] === 0x1f && raw[1] === 0x8b;
text = inPath.endsWith('.gz') || isGzip ? gunzipSync(raw).toString('utf-8') : raw.toString('utf-8');
let data;
try {
data = JSON.parse(text);
} catch (e) {
die(`invalid JSON: ${e.message}`);
}
// Already optimized? Bail kindly.
if (Array.isArray(data.cells) && ('cs' in data || 'bbox' in data)) {
console.log('Input already looks OPTIMIZED — nothing to convert. Run the validator to confirm it is correct.');
process.exit(0);
}
// Expect legacy shape.
if (!data.cells || typeof data.cells !== 'object' || Array.isArray(data.cells)) {
die('input `cells` is not the legacy object form ("col,row": [ids]). This converter only handles legacy -> optimized.');
}
if (!Array.isArray(data.depths)) {
die('input `depths` is not an array.');
}
const num = (v, name) => {
if (typeof v !== 'number' || !isFinite(v)) die(`expected numeric ${name}, got ${JSON.stringify(v)}`);
return v;
};
// --- top-level fields -------------------------------------------------------
const cs = num(data.cellSize ?? data.cs, 'cellSize');
const minLon = num(data.minLon, 'minLon');
const minLat = num(data.minLat, 'minLat');
const maxLon = num(data.maxLon, 'maxLon');
const maxLat = num(data.maxLat, 'maxLat');
const cols = num(data.cols, 'cols');
const rows = num(data.rows, 'rows');
// --- depths: {id,bounds,depth_min,polygon} -> {b,d,p}, indexed by array pos --
// Preserve original `id` so we can remap cell references to array indices.
const idToIndex = new Map();
const depths = data.depths.map((d, idx) => {
if (d && typeof d.id === 'number') idToIndex.set(d.id, idx);
const b = Array.isArray(d.b)
? d.b
: d.bounds
? [d.bounds.minX, d.bounds.minY, d.bounds.maxX, d.bounds.maxY]
: null;
if (!b) die(`depths[${idx}] missing bounds`);
const depth = d.d ?? d.depth_min;
if (typeof depth !== 'number') die(`depths[${idx}] missing numeric depth_min`);
const poly = d.p ?? d.polygon;
if (!Array.isArray(poly)) die(`depths[${idx}] missing polygon`);
return { b, d: depth, p: poly };
});
// If every depth had a stored id, remap cell ids through idToIndex; otherwise
// assume cell ids are already array indices.
const haveAllIds = idToIndex.size === depths.length;
// --- cells: {"c,r": [ids]} -> [[c, r, ...ids]] ------------------------------
const cells = [];
for (const [key, ids] of Object.entries(data.cells)) {
const [col, row] = key.split(',').map(Number);
if (!Number.isInteger(col) || !Number.isInteger(row)) die(`cell key "${key}" is not "col,row"`);
if (!Array.isArray(ids)) die(`cell "${key}" value is not an array of ids`);
const remapped = ids.map((id) => (haveAllIds ? idToIndex.get(id) ?? id : id));
cells.push([col, row, ...remapped]);
}
// --- stats ------------------------------------------------------------------
const depthVals = depths.map((d) => d.d).filter((n) => typeof n === 'number' && isFinite(n));
const stats = {
count: depths.length,
minDepth: depthVals.length ? Math.min(...depthVals) : 0, // deepest (most negative)
maxDepth: depthVals.length ? Math.max(...depthVals) : 0, // shallowest
};
const optimized = {
cs,
bbox: [minLon, minLat, maxLon, maxLat],
grid: [cols, rows],
cells,
depths,
stats,
};
const json = JSON.stringify(optimized);
writeFileSync(outPath, json);
writeFileSync(outPath + '.gz', gzipSync(json));
console.log(`\x1b[32mConverted\x1b[0m legacy -> optimized`);
console.log(` in: ${inPath}`);
console.log(` out: ${outPath}`);
console.log(` out: ${outPath}.gz`);
console.log(` depths: ${depths.length}, cells: ${cells.length}, id-remap: ${haveAllIds ? 'yes' : 'ids assumed to be indices'}`);
console.log(`\nNext: node validate-ocean-depth-index.mjs ${outPath}`);
#!/usr/bin/env node
//
// ============================================================================
// HOW TO GET THIS FILE AND RUN IT — you do NOT need the game's source code.
// ----------------------------------------------------------------------------
// Requirement: Node.js 16+ (check with: node --version)
//
// 1. Download this one file into your current folder:
//
// curl -O https://gist.githubusercontent.com/ejfox/55979852a4a5d2f782e8f4be564140a3/raw/diagnose-ocean-depth.mjs
//
// ...or grab all the ocean-depth tools at once:
//
// for f in diagnose-ocean-depth validate-ocean-depth-index convert-ocean-depth-index; do curl -O https://gist.githubusercontent.com/ejfox/55979852a4a5d2f782e8f4be564140a3/raw/$f.mjs; done
//
// 2. Run it:
//
// node diagnose-ocean-depth.mjs data/<yourCity>/ # point at your city data folder
//
// That's it — no npm, no install, no dependencies. Single self-contained file.
// Output ends in PASS/FAIL and a clear NEXT STEP. Exit codes: 0 ok, 1 problem, 2 couldn't run.
// ============================================================================
/* eslint-disable no-console */
// ===========================================================================
// OCEAN DEPTH DOCTOR
// Figures out WHY an ocean depth index renders on the map but does nothing
// in-game — and tells you in plain language which of four things is wrong.
// ===========================================================================
//
// THE ONE THING TO UNDERSTAND
// ---------------------------
// The water you SEE on the map and the water the GAME blocks you from building
// on come from TWO DIFFERENT FILES, built by TWO DIFFERENT data-gen steps:
//
// ocean_foundations.geojson / .pmtiles -> the blue water you SEE
// ocean_depth_index.json -> what the game COLLIDES against
//
// Your polygons looking right on the map only proves the FIRST file is good.
// This script checks the SECOND file — the one that actually matters in-game.
//
// USAGE
// -----
// node diagnose-ocean-depth.mjs <path>
//
// <path> = your city's data folder (BEST — checks everything)
// e.g. node diagnose-ocean-depth.mjs data/BAL/
//
// <path> = a single ocean_depth_index.json[.gz] (structural checks only)
//
// node diagnose-ocean-depth.mjs --help
//
// WHAT IT DIAGNOSES
// -----------------
// #1 INDEX NEVER GENERATED / STALE — you built the map tiles but not the
// spatial index (or it's out of date)
// #2 WRONG SHAPE — the index exists but the game can't
// use it (legacy format, positive depths,
// bad ids, swapped coords, ...)
// #3 OVERRIDE TIMING — (runtime mods) setCityDataFiles() called
// too late, so the game ignored your file
// #4 COVERAGE / PATH MISMATCH — the index covers a different area than
// the map layer (wrong city, swapped lon/lat)
//
// EXIT CODES: 0 = healthy/PASS 1 = a blocking problem was found 2 = couldn't run
// Zero dependencies. Node 16+.
// ===========================================================================
import { readFileSync, statSync, readdirSync } from 'node:fs';
import { join, basename } from 'node:path';
import { gunzipSync } from 'node:zlib';
const tty = process.stdout.isTTY;
const c = (code, s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : s);
const red = (s) => c('31', s);
const grn = (s) => c('32', s);
const yel = (s) => c('33', s);
const cyn = (s) => c('36', s);
const dim = (s) => c('2', s);
const bold = (s) => c('1', s);
const SCENARIO = {
1: 'INDEX NEVER GENERATED / STALE',
2: 'WRONG SHAPE',
3: 'OVERRIDE TIMING',
4: 'COVERAGE / PATH MISMATCH',
};
// ---------------------------------------------------------------------------
// arg handling
// ---------------------------------------------------------------------------
const arg = process.argv[2];
if (!arg || arg === '--help' || arg === '-h') {
printHelp();
process.exit(arg ? 0 : 1);
}
const findings = []; // { scenario, severity: 'fatal'|'warn'|'info', msg, fix }
const checks = []; // human-readable list of what we actually checked
const add = (scenario, severity, msg, fix) => findings.push({ scenario, severity, msg, fix });
const checked = (s) => checks.push(s);
main();
// ===========================================================================
function main() {
let stat;
try {
stat = statSync(arg);
} catch {
console.error(red(bold('Could not find that path.')));
console.error(` ${dim('you gave:')} ${arg}`);
console.error(` Point me at your city's data folder (e.g. ${cyn('data/BAL/')}) or an ${cyn('ocean_depth_index.json')} file.`);
console.error(` Run ${cyn('node ' + basename(process.argv[1]) + ' --help')} for details.`);
process.exit(2);
}
let indexPath = null;
let foundationsPath = null;
let cityLabel = null;
if (stat.isDirectory()) {
cityLabel = basename(arg.replace(/\/+$/, '')) || arg;
const hits = scanDir(arg);
if (hits.indexes.length > 1) {
// Pointed at a parent that holds several cities — ambiguous.
console.error(red(bold('That folder contains more than one ocean_depth_index.json.')));
console.error(' Looks like a parent folder of several cities. Point me at ONE city:');
for (const p of hits.indexes.slice(0, 8)) console.error(` ${cyn('node ' + basename(process.argv[1]) + ' ' + dirOf(p))}`);
process.exit(2);
}
indexPath = hits.indexes[0] || null;
foundationsPath = hits.foundations[0] || null;
} else {
indexPath = arg;
cityLabel = basename(arg);
}
printHeader(cityLabel, indexPath, foundationsPath);
// -------------------------------------------------------------------
// #1 PRESENCE — is there even an index?
// -------------------------------------------------------------------
checked('Index file exists');
if (!indexPath) {
if (foundationsPath) {
add(
1,
'fatal',
'You have the MAP layer (ocean_foundations) but NO ocean_depth_index.json in this folder.\n' +
' These are two SEPARATE data-gen steps. You ran the one that draws the blue water,\n' +
' but not the one the game collides against — so water shows, but building over it is never blocked.\n' +
' This is the single most common cause of exactly the symptom you described.',
'Run the "Generate Ocean Depth Spatial Index" data-gen step for this city, then re-test.'
);
} else {
add(
1,
'fatal',
'No ocean_depth_index.json AND no ocean_foundations here.\n' +
' Either this is the wrong folder, or no ocean data has been generated for this city yet.',
'Point me at the city data folder, or run the ocean data-gen steps. The game loads /data/<cityCode>/ocean_depth_index.json.'
);
}
return finish();
}
// -------------------------------------------------------------------
// load + parse (lots of friendly failure modes)
// -------------------------------------------------------------------
const parsed = loadIndex(indexPath);
if (parsed.error) {
add(2, 'fatal', parsed.error, parsed.fix);
return finish();
}
const data = parsed.data;
checked('Index is readable + valid JSON');
if (data == null || typeof data !== 'object' || Array.isArray(data)) {
add(2, 'fatal', `The index's top level is ${Array.isArray(data) ? 'an array' : typeof data}, but it must be a JSON object {...}.`, 'Re-generate the index with the official data-gen step.');
return finish();
}
// -------------------------------------------------------------------
// #1b STALENESS
// -------------------------------------------------------------------
if (foundationsPath) {
try {
const idxM = statSync(indexPath).mtimeMs;
const fndM = statSync(foundationsPath).mtimeMs;
const skewH = (fndM - idxM) / 3.6e6;
checked('Index is at least as new as the map layer');
if (skewH > 1) {
add(
1,
'warn',
`Your map layer is ${skewH.toFixed(1)}h NEWER than your index — the index is probably STALE.\n` +
' You regenerated the polygons/tiles but not the spatial index, so the game collides against an old version.',
'Re-run "Generate Ocean Depth Spatial Index" whenever you change the depth polygons.'
);
}
} catch {
/* ignore */
}
}
// -------------------------------------------------------------------
// #2 SHAPE
// -------------------------------------------------------------------
const norm = normalize(data);
runShapeChecks(data, norm);
// -------------------------------------------------------------------
// #4 COVERAGE vs the map layer
// -------------------------------------------------------------------
if (foundationsPath && norm.bbox) {
runCoverageCheck(norm.bbox, foundationsPath);
} else if (!foundationsPath && stat.isDirectory()) {
add(4, 'info', 'No ocean_foundations.geojson next to the index, so I could not cross-check coverage. (A .pmtiles-only export can\'t be read here.)');
} else if (!stat.isDirectory()) {
add(4, 'info', 'You gave me just the index file, so I could not compare it against the map layer. Point me at the whole city folder for the coverage check.');
}
// -------------------------------------------------------------------
// #3 RUNTIME OVERRIDE (can't see statically — always explain)
// -------------------------------------------------------------------
add(
3,
'info',
'If you load this file at runtime with api.cities.setCityDataFiles(): the game reads that override\n' +
' WHEN THE CITY LOADS, which is BEFORE the onCityLoad hook fires. Setting it inside onCityLoad\n' +
' (or any later hook) is too late and your file is silently ignored.',
'Call setCityDataFiles() at your mod\'s top level or in onGameInit — not in onCityLoad. To confirm it took effect, check the devtools console for the path the loader logged.'
);
finish();
}
// ===========================================================================
// checks
// ===========================================================================
function runShapeChecks(data, norm) {
const { cs, bbox, grid, depths, cells, isLegacy } = norm;
checked('Container shape (optimized vs legacy)');
if (isLegacy) {
add(
2,
'fatal',
'This is the LEGACY shape (object-keyed `cells`, cellSize/minLon...). The game only reads the OPTIMIZED\n' +
' shape and rejects this one. The official generator emits OPTIMIZED, so a legacy file means it was\n' +
' hand-made or made by an old tool.',
'Convert it: node convert-ocean-depth-index.mjs <this-file> fixed.json (or regenerate with the official step)'
);
}
checked('cellSize / cs is a positive number');
if (typeof cs !== 'number' || !isFinite(cs) || cs <= 0) {
add(2, 'fatal', `cellSize (\`cs\`) is ${human(cs)} — it must be a positive number. The grid math divides by it, so 0/missing means every lookup is NaN and nothing is ever found.`, 'Regenerate the index, or set `cs` to the grid cell size in degrees of latitude.');
}
checked('bbox is [minLon, minLat, maxLon, maxLat], lon-first');
if (!Array.isArray(bbox) || bbox.length !== 4 || bbox.some((n) => typeof n !== 'number' || !isFinite(n))) {
add(2, 'fatal', `bbox is ${human(bbox)} — it must be four numbers [minLon, minLat, maxLon, maxLat] (LONGITUDE FIRST).`, 'Emit bbox lon-first.');
} else {
const [mnLon, mnLat, mxLon, mxLat] = bbox;
if (mnLon >= mxLon || mnLat >= mxLat) {
add(2, 'fatal', `bbox is empty or out of order: [${bbox.join(', ')}]. It is LON-FIRST: [minLon, minLat, maxLon, maxLat], and min must be < max.`, 'Fix the bbox ordering.');
}
if (Math.abs(mnLat) > 90 || Math.abs(mxLat) > 90) {
add(4, 'fatal', `bbox "latitude" values (${mnLat}, ${mxLat}) are outside -90..90. Your coordinates are almost certainly lon/lat SWAPPED.`, 'Use [lon, lat] order everywhere. bbox is [minLon, minLat, maxLon, maxLat].');
}
if (Math.abs(mnLon) > 180 || Math.abs(mxLon) > 180) {
add(2, 'warn', `bbox "longitude" values (${mnLon}, ${mxLon}) are outside -180..180. These don't look like WGS84 lon/lat.`, 'Confirm coordinates are degrees, not meters/projected.');
}
}
checked('grid is [cols, rows] of positive integers');
if (!Array.isArray(grid) || grid.length !== 2 || !grid.every((n) => Number.isInteger(n) && n > 0)) {
add(2, 'fatal', `grid is ${human(grid)} — it must be [cols, rows], two positive integers.`, 'Regenerate the index.');
}
checked('depths array present and non-empty');
if (!Array.isArray(depths)) {
add(2, 'fatal', '`depths` is missing or not an array. The detector maps over it on load, so this is fatal.', 'Regenerate the index.');
} else if (depths.length === 0) {
add(2, 'fatal', '`depths` is empty — there are zero water polygons to collide with, so nothing will ever block a track.', 'Confirm the source bathymetry produced features for this city.');
} else {
const sample = depths.slice(0, 3000);
let pos = 0, badDepth = 0, badBounds = 0, badPoly = 0, strCoord = 0;
for (const d of sample) {
if (!d || typeof d !== 'object') { badBounds++; badDepth++; badPoly++; continue; }
const dv = d.d ?? d.depth_min;
if (typeof dv !== 'number' || !isFinite(dv)) badDepth++;
else if (dv > 0) pos++;
const b = d.b ?? (d.bounds ? [d.bounds.minX, d.bounds.minY, d.bounds.maxX, d.bounds.maxY] : null);
if (!Array.isArray(b) || b.length !== 4 || b.some((n) => typeof n !== 'number')) badBounds++;
const p = d.p ?? d.polygon;
if (!Array.isArray(p) || p.length === 0) badPoly++;
else if (typeof firstNumber(p) === 'string') strCoord++;
}
checked('depth values, bounds, and polygons are well-formed');
if (badDepth) add(2, 'fatal', `${badDepth}/${sample.length} sampled depths have a missing or non-numeric depth value.`, 'Every feature needs a numeric depth (negative meters).');
if (badBounds) add(2, 'fatal', `${badBounds}/${sample.length} sampled depths have a bad/missing bounds array. The detector uses bounds for a fast reject, so a bad one makes that polygon invisible to collision.`, 'Every feature needs b:[minX,minY,maxX,maxY] in lon/lat.');
if (badPoly) add(2, 'fatal', `${badPoly}/${sample.length} sampled depths have empty/missing polygon coordinates.`, 'Every feature needs polygon (Polygon or MultiPolygon) coordinates.');
if (strCoord) add(2, 'fatal', `${strCoord}/${sample.length} sampled polygons store coordinates as STRINGS ("−76.6") instead of numbers (-76.6). Geometry math will silently misbehave.`, 'Emit coordinates as JSON numbers, not strings.');
checked('depth sign convention (negative = underwater)');
if (pos === sample.length) {
add(
2,
'fatal',
'EVERY sampled depth value is POSITIVE. In-game, a track is blocked only when trackElevation > depth, and depths\n' +
' are meant to be NEGATIVE meters (e.g. -10 = 10m deep). With positive depths the check is basically never\n' +
' true, so building over water is never blocked even though the map looks perfect.',
'Store depths as negative meters below sea level (deeper = more negative).'
);
} else if (pos) {
add(2, 'warn', `${pos}/${sample.length} sampled depths are positive. Depths should be negative meters; mixed signs are suspicious.`, 'Check the sign convention.');
}
}
checked('cells is an array of [col, row, ...depthIds]');
if (Array.isArray(cells)) {
if (cells.length === 0) {
add(2, 'fatal', '`cells` is an empty array — the spatial grid has no entries, so no track query ever lands on a polygon. (The map still renders because it reads polygons directly, not the grid.)', 'Regenerate; the grid assignment produced nothing.');
} else {
const n = Array.isArray(depths) ? depths.length : 0;
let badId = 0, refs = 0, outGrid = 0, badRow = 0;
for (const cell of cells.slice(0, 30000)) {
if (!Array.isArray(cell) || cell.length < 2 || !Number.isInteger(cell[0]) || !Number.isInteger(cell[1])) { badRow++; continue; }
if (grid && (cell[0] < 0 || cell[0] >= grid[0] || cell[1] < 0 || cell[1] >= grid[1])) outGrid++;
for (let k = 2; k < cell.length; k++) { refs++; if (!Number.isInteger(cell[k]) || cell[k] < 0 || cell[k] >= n) badId++; }
}
checked('cell rows reference valid depth indices');
if (badRow) add(2, 'fatal', `${badRow} sampled cells are not [col, row, ...ids] integer arrays.`, 'Each cell row must be [col, row, ...depthIds].');
if (badId) add(2, 'fatal', `${badId}/${refs} sampled cell ids point outside depths[0..${n}). In the optimized format a depth is identified by its INDEX in \`depths\`, not a stored "id" field — out-of-range ids resolve to nothing.`, 'Cell ids must be array indices into depths.');
if (outGrid) add(2, 'warn', `${outGrid} sampled cells use col/row outside grid ${grid ? grid.join('x') : '?'}. The detector clamps scans to the grid, so those polygons can't be reached.`, 'Keep cell indices within grid bounds.');
}
} else if (cells && typeof cells === 'object') {
// already covered by the legacy-shape fatal above; avoid double-reporting if so
if (!isLegacy) add(2, 'fatal', '`cells` is an object, but the game expects an array of [col,row,...ids].', 'Convert cells to the array form.');
} else {
add(2, 'fatal', `\`cells\` is ${human(cells)} — it must be an array of [col,row,...ids].`, 'Regenerate the index.');
}
}
function runCoverageCheck(bbox, foundationsPath) {
checked('Index covers the same area as the map layer');
let fjson;
try {
fjson = JSON.parse(stripBom(readFileSync(foundationsPath, 'utf-8')));
} catch {
add(4, 'info', 'Found ocean_foundations.geojson but could not parse it for a coverage cross-check (skipped).');
return;
}
const fb = geojsonBbox(fjson);
if (!fb) {
add(4, 'info', 'ocean_foundations.geojson had no readable coordinates, so I skipped the coverage check.');
return;
}
const overlap = !(bbox[2] < fb[0] || bbox[0] > fb[2] || bbox[3] < fb[1] || bbox[1] > fb[3]);
if (!overlap) {
add(
4,
'fatal',
`Your index covers a DIFFERENT area than your map layer.\n` +
` index bbox: [${bbox.map((n) => n.toFixed(3)).join(', ')}]\n` +
` map layer bbox: [${fb.map((n) => n.toFixed(3)).join(', ')}]\n` +
' They don\'t overlap at all — so even a perfectly-shaped index is colliding somewhere your water isn\'t.\n' +
' Usually this means the index is from the wrong city folder, or its coordinates are lon/lat SWAPPED.',
'Make sure the index was generated for THIS city, and that every coordinate is [lon, lat].'
);
} else {
// also flag a partial/poor overlap
const area = (b) => Math.max(0, b[2] - b[0]) * Math.max(0, b[3] - b[1]);
const inter = [Math.max(bbox[0], fb[0]), Math.max(bbox[1], fb[1]), Math.min(bbox[2], fb[2]), Math.min(bbox[3], fb[3])];
const frac = area(inter) / (area(fb) || 1);
if (frac < 0.5) add(4, 'warn', `The index covers the map area only partially (~${Math.round(frac * 100)}% of the map layer's bbox overlaps). Some water will have no collision.`, 'Confirm the index was generated over the full city extent.');
else add(4, 'info', `Index bbox overlaps the map layer (~${Math.round(frac * 100)}% of the map extent) — coverage looks aligned.`);
}
}
// ===========================================================================
// loading / parsing helpers (friendly error modes)
// ===========================================================================
function loadIndex(path) {
let raw;
try {
raw = readFileSync(path);
} catch (e) {
return { error: `Could not read the index file (${e.code || e.message}).`, fix: 'Check the path and permissions.' };
}
if (raw.length === 0) {
return { error: 'The index file is EMPTY (0 bytes). The generation step likely crashed partway.', fix: 'Re-run the "Generate Ocean Depth Spatial Index" step and watch for errors.' };
}
const gzMagic = raw.length > 2 && raw[0] === 0x1f && raw[1] === 0x8b;
let text;
if (gzMagic) {
try {
text = gunzipSync(raw).toString('utf-8');
} catch (e) {
return { error: `The file is gzip-compressed but failed to decompress (${e.message}).`, fix: 'The .gz is corrupt — re-generate it, or hand me the uncompressed .json.' };
}
} else if (path.endsWith('.gz')) {
// named .gz but not actually gzip — try as plain text rather than dying
text = raw.toString('utf-8');
} else {
text = raw.toString('utf-8');
}
text = stripBom(text);
if (text.trim() === '') {
return { error: 'The index file contains only whitespace.', fix: 'Re-generate the index.' };
}
let data;
try {
data = JSON.parse(text);
} catch (e) {
const near = jsonErrorContext(text, e);
return {
error: `The index is not valid JSON (${e.message}).${near ? '\n near: ' + near : ''}\n A common cause is a truncated file (generation interrupted) or a trailing comma.`,
fix: 'Re-generate the index; if it persists, open the file and check it ends with a proper } and has no trailing commas.',
};
}
return { data };
}
function normalize(data) {
const isLegacy = data.cells && typeof data.cells === 'object' && !Array.isArray(data.cells);
return {
cs: data.cs ?? data.cellSize,
bbox: data.bbox ?? ('minLon' in data ? [data.minLon, data.minLat, data.maxLon, data.maxLat] : undefined),
grid: data.grid ?? ('cols' in data ? [data.cols, data.rows] : undefined),
depths: data.depths,
cells: data.cells,
isLegacy,
};
}
// ===========================================================================
// directory scanning
// ===========================================================================
function scanDir(dir) {
const indexes = [];
const foundations = [];
const walk = (d, depth) => {
let entries;
try {
entries = readdirSync(d, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const p = join(d, e.name);
if (e.isDirectory()) {
if (depth > 0 && !e.name.startsWith('.') && e.name !== 'node_modules') walk(p, depth - 1);
} else if (e.name === 'ocean_depth_index.json' || e.name === 'ocean_depth_index.json.gz') {
indexes.push(p);
} else if (e.name === 'ocean_foundations.geojson') {
foundations.push(p);
}
}
};
walk(dir, 3);
// prefer uncompressed index, and de-dupe a json/json.gz pair to one logical index
indexes.sort((a, b) => (a.endsWith('.gz') ? 1 : 0) - (b.endsWith('.gz') ? 1 : 0));
const logical = [];
const seenBase = new Set();
for (const p of indexes) {
const base = dirOf(p);
if (!seenBase.has(base)) {
seenBase.add(base);
logical.push(p);
}
}
return { indexes: logical, foundations };
}
// ===========================================================================
// small utilities
// ===========================================================================
function dirOf(p) {
return p.replace(/\/[^/]*$/, '/');
}
function stripBom(s) {
return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s;
}
function human(v) {
if (v === undefined) return 'missing';
if (v === null) return 'null';
const s = JSON.stringify(v);
return s && s.length > 60 ? s.slice(0, 57) + '…' : s;
}
function firstNumber(arr) {
let cur = arr;
while (Array.isArray(cur)) cur = cur[0];
return cur;
}
function jsonErrorContext(text, e) {
const m = /position (\d+)/.exec(e.message);
if (!m) return null;
const pos = +m[1];
return JSON.stringify(text.slice(Math.max(0, pos - 25), pos + 25));
}
function geojsonBbox(geojson) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity, seen = false, budget = 200000;
const visit = (co) => {
if (budget <= 0) return;
if (typeof co[0] === 'number') {
const x = co[0], y = co[1];
if (typeof x === 'number' && typeof y === 'number') {
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
seen = true;
budget--;
}
} else for (const c of co) visit(c);
};
const feats = geojson && geojson.type === 'FeatureCollection' ? geojson.features : [geojson];
for (const f of feats || []) {
const g = (f && f.geometry) || f;
if (g && g.coordinates) visit(g.coordinates);
}
return seen ? [minX, minY, maxX, maxY] : null;
}
// ===========================================================================
// output
// ===========================================================================
function printHelp() {
const me = basename(process.argv[1]);
console.log(`${bold('Ocean depth doctor')} — why does my water render but not block tracks in-game?
${bold('Usage')}
node ${me} <path>
<path> your city's data folder ${dim('(best — checks everything)')}
e.g. node ${me} data/BAL/
<path> a single ocean_depth_index.json[.gz] ${dim('(structural checks only)')}
${bold('The key idea')}
The blue water you SEE (ocean_foundations.geojson/.pmtiles) and what the GAME
blocks you from building on (ocean_depth_index.json) are TWO DIFFERENT FILES from
TWO DIFFERENT data-gen steps. Your map looking right only proves the first one is
good. This checks the second one.
${bold('Diagnoses')} #1 not generated/stale #2 wrong shape #3 override timing #4 coverage/path
${bold('Exit codes')} 0 healthy 1 problem found 2 could not run`);
}
function printHeader(cityLabel, indexPath, foundationsPath) {
console.log(bold('🩺 Ocean depth doctor') + dim(` · ${cityLabel}`));
console.log(dim(` index file: ${indexPath || red('NONE FOUND')}`));
console.log(dim(` map layer: ${foundationsPath || 'not found / not provided'}`));
console.log('');
}
function finish() {
const fatals = findings.filter((f) => f.severity === 'fatal');
const warns = findings.filter((f) => f.severity === 'warn');
const infos = findings.filter((f) => f.severity === 'info');
if (infos.length) {
for (const f of infos) {
console.log(` ${dim('·')} ${f.msg}`);
if (f.fix) console.log(` ${dim('→ ' + f.fix)}`);
}
console.log('');
}
if (warns.length) {
console.log(yel(bold('⚠ Warnings')));
for (const f of warns) {
console.log(` ${yel('!')} ${bold(`[#${f.scenario} ${SCENARIO[f.scenario]}]`)} ${f.msg}`);
if (f.fix) console.log(` ${dim('fix:')} ${cyn(f.fix)}`);
}
console.log('');
}
if (fatals.length) {
console.log(red(bold('✗ DIAGNOSIS — this is why it does nothing in-game:')));
for (const f of fatals) {
console.log(` ${red('✗')} ${bold(`[#${f.scenario} ${SCENARIO[f.scenario]}]`)} ${f.msg}`);
if (f.fix) console.log(` ${dim('fix:')} ${cyn(f.fix)}`);
}
// single, clear next step = the highest-priority fatal
const primary = fatals.sort((a, b) => a.scenario - b.scenario)[0];
console.log('');
console.log(red(bold('NEXT STEP → ')) + cyn(primary.fix || 'see above'));
console.log(dim(`(${checks.length} ${checks.length === 1 ? 'check' : 'checks'} run)`));
process.exit(1);
} else {
console.log(grn(bold('✓ PASS')) + ' — the index file is structurally valid' + (warns.length ? ' (see warnings)' : '') + '.');
console.log(dim(` ${checks.length} checks run: ${checks.join(' · ')}`));
console.log('');
console.log(' If it STILL does nothing in-game, the file is not the problem. The two remaining suspects are:');
console.log(` ${dim('•')} ${bold('#3 override timing')} — if you load it via setCityDataFiles(), make sure that runs before the city loads (not in onCityLoad).`);
console.log(` ${dim('•')} a ${bold('stale game build')} — re-launch the game, and check the devtools console for the "Ocean depth index" line, which now states exactly what was loaded or rejected.`);
process.exit(0);
}
}

Ocean Depth Index — Format Guide (and why it works on the map but not in-game)

File: /data/<cityCode>/ocean_depth_index.json (+ .json.gz) Types: shared-data/oceanDepthIndexTypes.ts (OptimizedOceanDepthIndex) Still ocean_depth_index.json(.gz) — NOT .bin. (Don't confuse it with the buildings index, which is a packed binary buildings_index.bin, magic "SBBI". Ocean depth is plain JSON.)

⚠️ Read this first — the trap that wastes your afternoon

The pmtiles ocean_foundations map layer and the in-game ocean_depth_index.json are different files from different code paths. Your polygons rendering correctly in ocean_foundations proves your geometry is valid. It says nothing about whether the index file the game reads is in the right shape.

The game only consumes the OPTIMIZED shape. createOceanDepthCollisionDetector reads the optimized container layout. Feed it the older/legacy shape (object-keyed cells) and historically it would throw, get swallowed, and the city would load with zero ocean collision and no error message. If your index "loads fine but does nothing in-game," it is almost always a format problem, not a geometry problem.

Diagnose it in one command — point this at your city's data folder and it tells you which of the failure modes you're in (index never generated, wrong shape, coverage mismatch, or override timing):

node scripts/diagnose-ocean-depth.mjs data/<yourCity>/

If you only have the index file, validate-ocean-depth-index.mjs <file> runs the structural checks alone. If you have an old-shape file, convert it:

node scripts/convert-ocean-depth-index.mjs old.json fixed.json

The shape the game requires (OPTIMIZED)

type OptimizedOceanDepthData = {
  b: number[];                       // bounds [minX, minY, maxX, maxY]  (lon/lat)
  d: number;                         // depth_min — NEGATIVE meters (e.g. -10 = 10m deep)
  p: number[][][] | number[][][][];  // polygon (Polygon) or MultiPolygon coords
};
type OptimizedOceanDepthIndex = {
  cs: number;                              // cell size (degrees of latitude), > 0
  bbox: [number, number, number, number];  // [minLon, minLat, maxLon, maxLat]  ← LON-FIRST
  grid: [number, number];                  // [cols, rows]
  cells: number[][];                       // [[col, row, ...depthIds], ...]   ← ARRAY of rows
  depths: OptimizedOceanDepthData[];
  stats: { count: number; minDepth: number; maxDepth: number };
};

A depth feature is identified by its index in depths — there is no id field. Cell rows reference depths by that index.


If you're coming from the older shape (legacy → optimized)

The legacy shape used long key names and an object-keyed cells. Map it like this (or just run the converter):

cellSize            -> cs
{minLon,minLat,maxLon,maxLat}  -> bbox  (LON-FIRST: [minLon, minLat, maxLon, maxLat])
{cols, rows}        -> grid  ([cols, rows])
cells["c,r"]=[ids]  -> cells.push([c, r, ...ids])      // object -> array of rows
depths[i].id        -> (removed; a feature IS its index i)
depths[i].bounds    -> depths[i].b   (object {minX,minY,maxX,maxY} -> array [minX,minY,maxX,maxY])
depths[i].depth_min -> depths[i].d
depths[i].polygon   -> depths[i].p
depthCount          -> stats.count
minDepth            -> stats.minDepth
(new)               -> stats.maxDepth   // shallowest
nonEmptyCells       -> (removed)

The things that silently make an index inert in-game

These all pass "my polygons render on the map" yet produce no collision in-game. The validator checks every one:

  1. Wrong container shape. cells as an object {"col,row":[ids]} instead of an array [[col,row,...ids]]. The detector only handles the array form. This is the #1 cause.
  2. bbox not lon-first. It's [minLon, minLat, maxLon, maxLat]. The grid math reads bbox[0] as longitude. Swap it and every cell lookup misses.
  3. Positive depth values. d must be negative meters (deeper = more negative). A track is blocked only when trackElevation > d; positive d means it's basically never blocked.
  4. Cell depth-ids out of range. Ids are indices into depths (0-based), not the old stored id values. Out-of-range ids resolve to nothing.
  5. Feature bounds outside bbox / coords as [lat,lon] instead of [lon,lat]. Those polygons land in cells the detector never scans.
  6. cs <= 0 or missing grid. Division by cell size produces NaN/Infinity cell indices.

Behavior note (recent fix)

The loader used to report every failure — missing file, bad JSON, wrong shape — as the same "this is normal if not generated yet" warning, which is why a broken index was indistinguishable from "no ocean data." Now the detector rejects a legacy-shaped file with a clear, actionable error (telling you to run the converter) instead of an opaque crash, and the loader logs that error loudly (pointing you at the validator) instead of swallowing it. If you're on a build from before that fix, the silent-failure behavior is what you're hitting — use the validator/doctor.

#!/usr/bin/env node
//
// ============================================================================
// HOW TO GET THIS FILE AND RUN IT — you do NOT need the game's source code.
// ----------------------------------------------------------------------------
// Requirement: Node.js 16+ (check with: node --version)
//
// 1. Download this one file into your current folder:
//
// curl -O https://gist.githubusercontent.com/ejfox/55979852a4a5d2f782e8f4be564140a3/raw/test-ocean-depth-doctor.mjs
//
// ...or grab all the ocean-depth tools at once:
//
// for f in diagnose-ocean-depth validate-ocean-depth-index convert-ocean-depth-index; do curl -O https://gist.githubusercontent.com/ejfox/55979852a4a5d2f782e8f4be564140a3/raw/$f.mjs; done
//
// 2. Run it:
//
// node test-ocean-depth-doctor.mjs # needs diagnose-ocean-depth.mjs in the same folder
//
// That's it — no npm, no install, no dependencies. Single self-contained file.
// Output ends in PASS/FAIL and a clear NEXT STEP. Exit codes: 0 ok, 1 problem, 2 couldn't run.
// ============================================================================
/* eslint-disable no-console */
// Test harness for diagnose-ocean-depth.mjs (and the validator).
// Builds a battery of temp fixtures, runs the doctor against each, and asserts
// the exit code + that the right scenario tag (or PASS) shows up in the output.
//
// node test-ocean-depth-doctor.mjs
//
// Exit 0 if every case behaves as expected, 1 otherwise.
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync, chmodSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { gzipSync } from 'node:zlib';
const HERE = dirname(fileURLToPath(import.meta.url));
const DOCTOR = join(HERE, 'diagnose-ocean-depth.mjs');
const root = mkdtempSync(join(tmpdir(), 'odd-test-'));
let pass = 0;
let fail = 0;
const failures = [];
// --- building blocks --------------------------------------------------------
const FOUND_BAL = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [
[
[-76.62, 39.22],
[-76.55, 39.22],
[-76.55, 39.28],
[-76.62, 39.28],
[-76.62, 39.22],
],
],
},
},
],
};
const goodIndex = () => ({
cs: 0.001,
bbox: [-76.65, 39.2, -76.5, 39.32],
grid: [120, 120],
cells: [
[10, 10, 0],
[11, 10, 0, 1],
[12, 11, 1],
],
depths: [
{ b: [-76.62, 39.22, -76.6, 39.24], d: -12, p: [[[-76.62, 39.22], [-76.6, 39.22], [-76.6, 39.24]]] },
{ b: [-76.59, 39.23, -76.57, 39.25], d: -30, p: [[[-76.59, 39.23], [-76.57, 39.23], [-76.57, 39.25]]] },
],
stats: { count: 2, minDepth: -30, maxDepth: -12 },
});
const legacyIndex = () => ({
cellSize: 0.001,
minLon: -76.65,
minLat: 39.2,
maxLon: -76.5,
maxLat: 39.32,
cols: 120,
rows: 120,
cells: { '10,10': [0], '11,10': [0, 1] },
depths: [
{ id: 0, bounds: { minX: -76.62, minY: 39.22, maxX: -76.6, maxY: 39.24 }, depth_min: -12, polygon: [[[-76.62, 39.22], [-76.6, 39.22], [-76.6, 39.24]]] },
{ id: 1, bounds: { minX: -76.59, minY: 39.23, maxX: -76.57, maxY: 39.25 }, depth_min: -30, polygon: [[[-76.59, 39.23], [-76.57, 39.23], [-76.57, 39.25]]] },
],
depthCount: 2,
nonEmptyCells: 2,
minDepth: -30,
});
// --- fixture writer ---------------------------------------------------------
let n = 0;
function caseDir(files) {
const d = join(root, 'c' + n++);
mkdirSync(d, { recursive: true });
for (const [name, content] of Object.entries(files)) {
const p = join(d, name);
if (content === '__raw_empty__') writeFileSync(p, '');
else if (Buffer.isBuffer(content)) writeFileSync(p, content);
else if (typeof content === 'string') writeFileSync(p, content);
else writeFileSync(p, JSON.stringify(content));
}
return d;
}
function run(target) {
try {
const out = execFileSync('node', [DOCTOR, target], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
return { code: 0, out };
} catch (e) {
return { code: e.status ?? -1, out: (e.stdout || '') + (e.stderr || '') };
}
}
// expect: { code, includes?: string[], excludes?: string[] }
function expect(name, target, exp) {
const { code, out } = run(target);
const problems = [];
if (exp.code !== undefined && code !== exp.code) problems.push(`exit ${code}, wanted ${exp.code}`);
for (const s of exp.includes || []) if (!out.includes(s)) problems.push(`missing "${s}"`);
for (const s of exp.excludes || []) if (out.includes(s)) problems.push(`should NOT contain "${s}"`);
if (problems.length) {
fail++;
failures.push({ name, problems, out });
console.log(` ✗ ${name} (${problems.join('; ')})`);
} else {
pass++;
console.log(` ✓ ${name}`);
}
}
// --- cases ------------------------------------------------------------------
console.log('Ocean depth doctor — test battery\n');
// ===== healthy =====
expect('healthy: index + foundations, aligned', caseDir({ 'ocean_depth_index.json': goodIndex(), 'ocean_foundations.geojson': FOUND_BAL }), {
code: 0,
includes: ['PASS'],
excludes: ['DIAGNOSIS'],
});
expect('healthy: index only (no foundations)', caseDir({ 'ocean_depth_index.json': goodIndex() }), {
code: 0,
includes: ['PASS'],
});
expect('healthy: gzipped index', caseDir({ 'ocean_depth_index.json.gz': gzipSync(JSON.stringify(goodIndex())), 'ocean_foundations.geojson': FOUND_BAL }), {
code: 0,
includes: ['PASS'],
});
expect('healthy: single-file mode (point at the .json)', join(caseDir({ 'ocean_depth_index.json': goodIndex() }), 'ocean_depth_index.json'), {
code: 0,
includes: ['PASS'],
});
// ===== #1 not generated / stale =====
expect('#1 foundations present, index missing', caseDir({ 'ocean_foundations.geojson': FOUND_BAL }), {
code: 1,
includes: ['#1', 'NEXT STEP'],
});
expect('#1 nothing at all', caseDir({ 'roads.geojson': '{}' }), {
code: 1,
includes: ['#1'],
});
{
// stale: index older than foundations by 5h
const d = caseDir({ 'ocean_depth_index.json': goodIndex(), 'ocean_foundations.geojson': FOUND_BAL });
const old = Date.now() / 1000 - 5 * 3600;
utimesSync(join(d, 'ocean_depth_index.json'), old, old);
expect('#1 stale index (older than map layer)', d, { code: 0, includes: ['STALE'] });
}
// ===== #2 wrong shape =====
expect('#2 legacy object cells', caseDir({ 'ocean_depth_index.json': legacyIndex(), 'ocean_foundations.geojson': FOUND_BAL }), {
code: 1,
includes: ['#2', 'LEGACY'],
});
{
const idx = goodIndex();
idx.depths.forEach((x) => (x.d = -x.d)); // all positive
expect('#2 positive depths (sign inverted)', caseDir({ 'ocean_depth_index.json': idx, 'ocean_foundations.geojson': FOUND_BAL }), {
code: 1,
includes: ['#2', 'POSITIVE'],
});
}
{
const idx = goodIndex();
idx.cs = 0;
expect('#2 cellSize is zero', caseDir({ 'ocean_depth_index.json': idx }), { code: 1, includes: ['#2', 'cellSize'] });
}
{
const idx = goodIndex();
idx.cells = [];
expect('#2 empty cells grid', caseDir({ 'ocean_depth_index.json': idx }), { code: 1, includes: ['#2', 'empty array'] });
}
{
const idx = goodIndex();
idx.depths = [];
expect('#2 empty depths', caseDir({ 'ocean_depth_index.json': idx }), { code: 1, includes: ['#2', 'empty'] });
}
{
const idx = goodIndex();
idx.cells = [[10, 10, 99]]; // id out of range
expect('#2 cell id out of range', caseDir({ 'ocean_depth_index.json': idx }), { code: 1, includes: ['#2', 'outside depths'] });
}
{
const idx = goodIndex();
idx.bbox = [39.2, -76.65, 39.32, -76.5]; // lat-first → lat values > 90 nope; these are in range. Use a clearer swap:
idx.bbox = [-76.65, 200, -76.5, 260]; // bad latitude
expect('#2/#4 latitude out of range (swapped coords)', caseDir({ 'ocean_depth_index.json': idx }), { code: 1, includes: ['SWAP'] });
}
{
const idx = goodIndex();
idx.depths[0].p = [[['-76.62', '39.22'], ['-76.6', '39.22']]]; // string coords
expect('#2 string coordinates', caseDir({ 'ocean_depth_index.json': idx }), { code: 1, includes: ['STRINGS'] });
}
// ===== #4 coverage / path mismatch =====
{
const idx = goodIndex();
idx.bbox = [139.6, 35.5, 139.9, 35.8]; // Tokyo
idx.depths = [{ b: [139.7, 35.6, 139.8, 35.7], d: -12, p: [[[139.7, 35.6], [139.8, 35.6], [139.8, 35.7]]] }];
idx.cells = [[10, 10, 0]];
expect('#4 index covers different city than map', caseDir({ 'ocean_depth_index.json': idx, 'ocean_foundations.geojson': FOUND_BAL }), {
code: 1,
includes: ['#4', 'DIFFERENT area'],
});
}
// ===== malformed file error modes =====
expect('err: empty index file', caseDir({ 'ocean_depth_index.json': '__raw_empty__', 'ocean_foundations.geojson': FOUND_BAL }), {
code: 1,
includes: ['EMPTY'],
});
expect('err: truncated JSON', caseDir({ 'ocean_depth_index.json': '{"cs":0.001,"bbox":[-76.6,39.2', 'ocean_foundations.geojson': FOUND_BAL }), {
code: 1,
includes: ['not valid JSON'],
});
expect('err: top-level array not object', caseDir({ 'ocean_depth_index.json': '[1,2,3]' }), {
code: 1,
includes: ['must be a JSON object'],
});
expect('err: BOM-prefixed JSON still parses', caseDir({ 'ocean_depth_index.json': '' + JSON.stringify(goodIndex()) }), {
code: 0,
includes: ['PASS'],
});
expect('err: gzip magic but corrupt', caseDir({ 'ocean_depth_index.json': Buffer.from([0x1f, 0x8b, 0x08, 0x00, 0x99, 0x99]) }), {
code: 1,
includes: ['decompress'],
});
expect('err: named .gz but actually plain json', caseDir({ 'ocean_depth_index.json.gz': JSON.stringify(goodIndex()) }), {
code: 0,
includes: ['PASS'],
});
// ===== arg / path handling =====
expect('arg: nonexistent path', join(root, 'does-not-exist'), { code: 2, includes: ['Could not find'] });
expect('arg: --help', '--help', { code: 0, includes: ['Usage', 'TWO DIFFERENT FILES'] });
{
// parent with two cities → ambiguous
const parent = join(root, 'multi');
mkdirSync(join(parent, 'BAL'), { recursive: true });
mkdirSync(join(parent, 'NYC'), { recursive: true });
writeFileSync(join(parent, 'BAL', 'ocean_depth_index.json'), JSON.stringify(goodIndex()));
writeFileSync(join(parent, 'NYC', 'ocean_depth_index.json'), JSON.stringify(goodIndex()));
expect('arg: parent of multiple cities is ambiguous', parent, { code: 2, includes: ['more than one'] });
}
// --- summary ----------------------------------------------------------------
console.log(`\n${pass} passed, ${fail} failed`);
if (fail) {
console.log('\n--- failure detail ---');
for (const f of failures) {
console.log(`\n### ${f.name}\n${f.problems.join('; ')}\n${f.out.split('\n').slice(0, 12).join('\n')}`);
}
}
try {
rmSync(root, { recursive: true, force: true });
} catch {
/* ignore */
}
process.exit(fail ? 1 : 0);
#!/usr/bin/env node
//
// ============================================================================
// HOW TO GET THIS FILE AND RUN IT — you do NOT need the game's source code.
// ----------------------------------------------------------------------------
// Requirement: Node.js 16+ (check with: node --version)
//
// 1. Download this one file into your current folder:
//
// curl -O https://gist.githubusercontent.com/ejfox/55979852a4a5d2f782e8f4be564140a3/raw/validate-ocean-depth-index.mjs
//
// ...or grab all the ocean-depth tools at once:
//
// for f in diagnose-ocean-depth validate-ocean-depth-index convert-ocean-depth-index; do curl -O https://gist.githubusercontent.com/ejfox/55979852a4a5d2f782e8f4be564140a3/raw/$f.mjs; done
//
// 2. Run it:
//
// node validate-ocean-depth-index.mjs ocean_depth_index.json
//
// That's it — no npm, no install, no dependencies. Single self-contained file.
// Output ends in PASS/FAIL and a clear NEXT STEP. Exit codes: 0 ok, 1 problem, 2 couldn't run.
// ============================================================================
// Ocean depth index validator
// ---------------------------------------------------------------------------
// Usage: node validate-ocean-depth-index.mjs <path-to-ocean_depth_index.json[.gz]>
//
// WHY THIS EXISTS
// A polygon set that draws perfectly in the `ocean_foundations` pmtiles map
// layer can still be completely inert in-game. The map layer just renders
// polygons. The GAME instead feeds the index through
// `createOceanDepthCollisionDetector` (app/game/helpers/oceanDepthCollision.ts),
// which:
// 1. ONLY understands the "optimized" index shape. It blindly does
// `index.cells.forEach(...)`. A legacy/object-`cells` file throws there,
// the loader swallows it in a try/catch, and the city silently loads with
// NO ocean collision at all. No error in the UI.
// 2. Buckets every polygon into a uniform grid using
// `col = floor((lon - bbox.minLon) / cellSizeLon)`. If bbox ordering,
// coordinate order ([lon,lat] vs [lat,lon]), depth sign, cell indices, or
// depth ids are off, the grid lookup quietly resolves to nothing and you
// get zero collisions — which looks exactly like "it doesn't work."
//
// This validator replicates that pipeline and reports the precise reason a file
// would be inert or crash in-game. It checks STRUCTURE the game requires, not
// just "are the polygons valid."
//
// Zero dependencies. Node 16+ (uses node:zlib, node:fs).
// ---------------------------------------------------------------------------
import { readFileSync } from 'node:fs';
import { gunzipSync } from 'node:zlib';
const RED = '\x1b[31m';
const GREEN = '\x1b[32m';
const YELLOW = '\x1b[33m';
const CYAN = '\x1b[36m';
const DIM = '\x1b[2m';
const BOLD = '\x1b[1m';
const RESET = '\x1b[0m';
const errors = []; // fatal: file is inert or crashes in-game
const warnings = []; // suspicious but maybe survivable
const notes = []; // informational
function err(msg, fix) {
errors.push({ msg, fix });
}
function warn(msg, fix) {
warnings.push({ msg, fix });
}
function note(msg) {
notes.push(msg);
}
function fail(msg) {
console.error(`${RED}${BOLD}FATAL${RESET} ${msg}`);
process.exit(2);
}
// --- load -------------------------------------------------------------------
const path = process.argv[2];
if (!path) {
console.error(`Usage: node ${process.argv[1].split('/').pop()} <ocean_depth_index.json[.gz]>`);
process.exit(1);
}
let raw;
try {
raw = readFileSync(path);
} catch (e) {
fail(`cannot read file: ${e.message}`);
}
// gunzip if the path says .gz OR the magic bytes say gzip (1f 8b)
let text;
const isGzip = raw.length > 2 && raw[0] === 0x1f && raw[1] === 0x8b;
if (path.endsWith('.gz') || isGzip) {
try {
text = gunzipSync(raw).toString('utf-8');
note(`decompressed gzip (${(raw.length / 1024 / 1024).toFixed(2)} MB compressed)`);
} catch (e) {
fail(`looks like gzip but failed to decompress: ${e.message}`);
}
} else {
text = raw.toString('utf-8');
}
let data;
try {
data = JSON.parse(text);
} catch (e) {
fail(`invalid JSON: ${e.message}`);
}
if (data == null || typeof data !== 'object' || Array.isArray(data)) {
fail('top-level value must be a JSON object');
}
// --- detect shape -----------------------------------------------------------
// Optimized: { cs, bbox, grid, cells: number[][], depths: [{b,d,p}], stats }
// Legacy: { cellSize, minLon.., cols, rows, cells: {"c,r":[..]}, depths:[{id,bounds,depth_min,polygon}], depthCount, minDepth }
const looksOptimized = 'cs' in data || 'bbox' in data || 'grid' in data || 'stats' in data;
const looksLegacy = 'cellSize' in data || 'minLon' in data || 'cols' in data || 'depthCount' in data;
console.log(`${BOLD}Ocean depth index validation${RESET} ${DIM}${path}${RESET}\n`);
if (looksLegacy && !looksOptimized) {
err(
'File is in the LEGACY shape (cellSize/minLon/cols/rows, object-keyed cells). ' +
'The in-game collision detector ONLY reads the OPTIMIZED shape and calls `cells.forEach(...)` ' +
'on it. An object `cells` has no `.forEach`, so it throws on load. The loader catches that and ' +
'returns null — the city loads with NO ocean collision and NO visible error. ' +
'This alone explains "renders on the map but does nothing in-game."',
'Re-emit the file in the optimized shape: cellSize→cs, {minLon,minLat,maxLon,maxLat}→bbox (LON-FIRST), ' +
'{cols,rows}→grid, cells object {"c,r":[ids]}→array of [col,row,...ids], ' +
'each depth {id,bounds,depth_min,polygon}→{b:[minX,minY,maxX,maxY], d, p}, ' +
'{depthCount,minDepth}→stats:{count,minDepth,maxDepth}. ' +
'Use data-gen-2/maps/bathymetry/generateOceanDepthIndex.ts as the reference emitter.'
);
// Keep going so we can still sanity-check the geometry, but treat as optimized-ish where possible.
}
if (looksOptimized) note('shape: OPTIMIZED (this is what the game expects)');
else if (looksLegacy) note('shape: LEGACY (game cannot consume this directly)');
else warn('shape: UNRECOGNIZED — has neither optimized nor legacy marker fields', 'Confirm this is actually an ocean depth index.');
// Helper: read a field allowing either optimized or legacy name, prefer optimized.
function field(optName, legacyTransform) {
if (optName in data) return data[optName];
return legacyTransform ? legacyTransform() : undefined;
}
// --- normalize to the values the detector computes --------------------------
const cs = field('cs', () => data.cellSize);
const bbox = field('bbox', () =>
'minLon' in data ? [data.minLon, data.minLat, data.maxLon, data.maxLat] : undefined
);
const grid = field('grid', () => ('cols' in data ? [data.cols, data.rows] : undefined));
const cells = data.cells;
const depths = data.depths;
const stats = data.stats;
// --- cs ---------------------------------------------------------------------
if (typeof cs !== 'number' || !isFinite(cs)) {
err(`\`cs\` (cellSize) is ${JSON.stringify(cs)} — must be a finite number.`, 'Set cs to the grid cell size in degrees of latitude (e.g. the generator default).');
} else if (cs <= 0) {
err(`\`cs\` is ${cs} — must be > 0. The detector divides by cellSize; 0/neg makes every cell index Infinity/NaN and no polygon is ever found.`, 'Use a positive cell size.');
} else {
note(`cs (cellSize) = ${cs}`);
}
// --- bbox -------------------------------------------------------------------
if (!Array.isArray(bbox) || bbox.length !== 4 || bbox.some((n) => typeof n !== 'number' || !isFinite(n))) {
err(`\`bbox\` is ${JSON.stringify(bbox)} — must be [minLon, minLat, maxLon, maxLat] of 4 finite numbers.`, 'Emit bbox lon-first: [minLon, minLat, maxLon, maxLat].');
} else {
const [minLon, minLat, maxLon, maxLat] = bbox;
note(`bbox = [minLon ${minLon}, minLat ${minLat}, maxLon ${maxLon}, maxLat ${maxLat}]`);
if (minLon >= maxLon) err(`bbox minLon (${minLon}) >= maxLon (${maxLon}). Grid width collapses; cell columns never match.`, 'Check bbox ordering — it is LON-FIRST: [minLon, minLat, maxLon, maxLat].');
if (minLat >= maxLat) err(`bbox minLat (${minLat}) >= maxLat (${maxLat}). Grid height collapses; cell rows never match.`, 'Check bbox ordering — it is LON-FIRST: [minLon, minLat, maxLon, maxLat].');
// ordering / swap heuristics
if (Math.abs(minLon) <= 90 && Math.abs(minLat) > 90) {
err(`bbox looks LAT-FIRST: minLat=${minLat} is out of [-90,90] but would be a valid longitude. The detector reads bbox[0] as longitude.`, 'Swap to lon-first: [minLon, minLat, maxLon, maxLat].');
}
if (Math.abs(minLon) > 180 || Math.abs(maxLon) > 180) warn(`bbox longitude out of [-180,180] (${minLon}..${maxLon}).`, 'Verify coordinates are WGS84 lon/lat.');
if (Math.abs(minLat) > 90 || Math.abs(maxLat) > 90) warn(`bbox latitude out of [-90,90] (${minLat}..${maxLat}).`, 'Verify coordinates are WGS84 lon/lat.');
}
// --- grid -------------------------------------------------------------------
let cols, rows;
if (!Array.isArray(grid) || grid.length !== 2 || grid.some((n) => !Number.isInteger(n))) {
err(`\`grid\` is ${JSON.stringify(grid)} — must be [cols, rows] of two integers.`, 'Emit grid as [cols, rows].');
} else {
[cols, rows] = grid;
note(`grid = ${cols} cols x ${rows} rows`);
if (cols <= 0 || rows <= 0) err(`grid has non-positive dimension (${cols}x${rows}). The detector clamps lookups to [0, cols-1]/[0, rows-1]; nothing is ever scanned.`, 'Recompute grid from bbox/cs.');
// cross-check grid vs bbox/cs
if (Array.isArray(bbox) && typeof cs === 'number' && cs > 0) {
const [minLon, minLat, maxLon, maxLat] = bbox;
const expCols = Math.ceil((maxLon - minLon) / (cs / Math.cos((((minLat + maxLat) / 2) * Math.PI) / 180)));
const expRows = Math.ceil((maxLat - minLat) / cs);
// loose check — generator uses cos-corrected lon cell size; allow slack
if (cols && (cols < expCols * 0.5 || cols > expCols * 2)) warn(`grid cols (${cols}) far from bbox/cs estimate (~${expCols}). Cell math may not line up with your polygons.`, 'Regenerate the index so grid matches bbox and cs.');
if (rows && (rows < expRows * 0.5 || rows > expRows * 2)) warn(`grid rows (${rows}) far from bbox/cs estimate (~${expRows}).`, 'Regenerate the index so grid matches bbox and cs.');
}
}
// --- depths -----------------------------------------------------------------
let depthCount = 0;
if (!Array.isArray(depths)) {
err(`\`depths\` is ${typeof depths} — must be an array of {b,d,p}. The detector maps over it; a non-array crashes on load.`, 'Emit depths as an array.');
} else {
depthCount = depths.length;
note(`depths: ${depthCount} features`);
if (depthCount === 0) err('`depths` is empty. Even with valid cells, there are no polygons to collide with — track building over water will never be blocked.', 'Confirm the source bathymetry actually produced features.');
let badShape = 0;
let positiveDepth = 0;
let zeroDepth = 0;
let badBounds = 0;
let badPoly = 0;
let boundsOutsideBbox = 0;
const sampleN = Math.min(depthCount, 5000);
for (let i = 0; i < sampleN; i++) {
const d = depths[i];
if (d == null || typeof d !== 'object') {
badShape++;
continue;
}
// optimized expects b, d, p
const b = 'b' in d ? d.b : 'bounds' in d ? [d.bounds?.minX, d.bounds?.minY, d.bounds?.maxX, d.bounds?.maxY] : undefined;
const depthVal = 'd' in d ? d.d : d.depth_min;
const poly = 'p' in d ? d.p : d.polygon;
if (!Array.isArray(b) || b.length !== 4 || b.some((n) => typeof n !== 'number' || !isFinite(n))) {
badBounds++;
} else if (Array.isArray(bbox) && bbox.length === 4) {
const [mnLon, mnLat, mxLon, mxLat] = bbox;
if (b[2] < mnLon || b[0] > mxLon || b[3] < mnLat || b[1] > mxLat) boundsOutsideBbox++;
}
if (typeof depthVal !== 'number' || !isFinite(depthVal)) {
badShape++;
} else if (depthVal > 0) {
positiveDepth++;
} else if (depthVal === 0) {
zeroDepth++;
}
if (!Array.isArray(poly) || poly.length === 0) badPoly++;
}
if (badShape) err(`${badShape}/${sampleN} sampled depths have a missing/non-numeric depth value (\`d\`).`, 'Each depth needs a numeric `d` (ocean floor depth in meters).');
if (badBounds) err(`${badBounds}/${sampleN} sampled depths have a bad \`b\` bounds array. The detector does a fast bounds reject using b[0..3]; a missing bounds means the polygon is effectively invisible to collision.`, 'Each depth needs b:[minX,minY,maxX,maxY] in lon/lat.');
if (badPoly) err(`${badPoly}/${sampleN} sampled depths have empty/missing polygon coords (\`p\`).`, 'Each depth needs p: Polygon (number[][][]) or MultiPolygon (number[][][][]) coordinates.');
if (positiveDepth === sampleN && sampleN > 0) {
err(`ALL sampled depth values (\`d\`) are POSITIVE. In-game logic blocks a track only when \`trackElevation > depth_min\` and depths are meant to be NEGATIVE meters (e.g. -10 = 10m deep). With positive d, the collision check is essentially never true, so building over water is never blocked even though the map looks right.`, 'Store depths as negative meters below sea level (deeper = more negative).');
} else if (positiveDepth) {
warn(`${positiveDepth}/${sampleN} sampled depths are positive. Depths should be negative meters (below sea level).`, 'Verify sign convention; deeper = more negative.');
}
if (boundsOutsideBbox) warn(`${boundsOutsideBbox}/${sampleN} sampled depth bounds fall (partly) outside the top-level bbox. Those polygons sit in grid cells the detector never scans → no collision there.`, 'Make sure bbox encloses every feature, and that bounds are [lon,lat] not [lat,lon].');
if (zeroDepth) note(`${zeroDepth}/${sampleN} sampled depths are exactly 0m (sea level / shoreline).`);
}
// --- cells ------------------------------------------------------------------
if (Array.isArray(cells)) {
note(`cells: ${cells.length} non-empty cells (array form — correct for game)`);
let badRow = 0;
let outOfGrid = 0;
let badId = 0;
let totalRefs = 0;
const sampleN = Math.min(cells.length, 20000);
for (let i = 0; i < sampleN; i++) {
const c = cells[i];
if (!Array.isArray(c) || c.length < 2) {
badRow++;
continue;
}
const col = c[0];
const row = c[1];
if (!Number.isInteger(col) || !Number.isInteger(row)) {
badRow++;
continue;
}
if (cols != null && rows != null && (col < 0 || col >= cols || row < 0 || row >= rows)) outOfGrid++;
for (let k = 2; k < c.length; k++) {
totalRefs++;
const id = c[k];
if (!Number.isInteger(id) || id < 0 || id >= depthCount) badId++;
}
}
if (badRow) err(`${badRow}/${sampleN} sampled cells are not [col, row, ...ids] integer arrays.`, 'Each cell row must be [col, row, ...depthIds].');
if (outOfGrid) warn(`${outOfGrid}/${sampleN} sampled cells reference col/row outside grid ${cols}x${rows}. The detector clamps scans to the grid, so those polygons are unreachable.`, 'Ensure cell col/row indices are within grid bounds.');
if (badId) err(`${badId}/${totalRefs} sampled cell depth-ids are out of range [0, ${depthCount}). Remember: in the optimized format a depth is identified by its INDEX in \`depths\`, not a stored \`id\`. Out-of-range ids resolve to nothing → no collision.`, 'Cell ids must be the integer index of the feature within the depths array.');
if (cells.length === 0) err('`cells` is empty — the spatial grid is empty, so no track query ever finds a polygon. Map still renders because it reads depths/polygons directly.', 'Populate the grid: assign each feature index to every cell its bounds overlap.');
} else if (cells && typeof cells === 'object') {
err(
'`cells` is an OBJECT (legacy "col,row" key form). The in-game detector calls `cells.forEach(...)`, which does not exist on a plain object → it throws on load, the loader swallows it, and the city gets NO ocean collision. The pmtiles map layer is unaffected because it never touches `cells`. THIS is the classic "works on the map, dead in-game" cause.',
'Convert cells to the array form: for each "c,r": [ids] entry, push [c, r, ...ids].'
);
} else {
err(`\`cells\` is ${typeof cells} — must be an array of [col,row,...ids].`, 'Emit cells as number[][].');
}
// --- stats ------------------------------------------------------------------
if (stats && typeof stats === 'object') {
if (typeof stats.count !== 'number') warn('`stats.count` missing/non-numeric (cosmetic — drives depthCount).', 'Set stats.count = depths.length.');
if (typeof stats.minDepth !== 'number') warn('`stats.minDepth` missing/non-numeric.', 'Set stats.minDepth to the deepest (most negative) value.');
if (typeof stats.count === 'number' && depthCount && stats.count !== depthCount) warn(`stats.count (${stats.count}) != depths.length (${depthCount}).`, 'Keep stats.count in sync with depths.');
} else if (looksOptimized) {
warn('`stats` missing. Non-fatal (used for counts/labels) but indicates an incompletely-emitted optimized file.', 'Add stats:{count,minDepth,maxDepth}.');
}
// --- report -----------------------------------------------------------------
console.log('');
for (const m of notes) console.log(` ${DIM}·${RESET} ${m}`);
console.log('');
if (warnings.length) {
console.log(`${YELLOW}${BOLD}${warnings.length} warning(s)${RESET}`);
for (const w of warnings) {
console.log(` ${YELLOW}!${RESET} ${w.msg}`);
if (w.fix) console.log(` ${DIM}fix:${RESET} ${CYAN}${w.fix}${RESET}`);
}
console.log('');
}
if (errors.length) {
console.log(`${RED}${BOLD}${errors.length} blocking issue(s) — this is why it breaks in-game:${RESET}`);
for (const e of errors) {
console.log(` ${RED}✗${RESET} ${e.msg}`);
if (e.fix) console.log(` ${DIM}fix:${RESET} ${CYAN}${e.fix}${RESET}`);
}
console.log('');
console.log(`${RED}${BOLD}FAIL${RESET} — the game cannot use this index as-is.`);
process.exit(1);
} else {
console.log(`${GREEN}${BOLD}PASS${RESET} — structurally valid for the in-game collision detector.`);
if (warnings.length) console.log(`${DIM}(review warnings above; they can still cause subtle "nothing happens" behavior)${RESET}`);
process.exit(0);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment