|
#!/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); |
|
} |
|
} |