Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@
],
"overrides": {
"shell-quote": ">=1.8.4",
"esbuild": ">=0.28.1"
"esbuild": ">=0.28.1",
"js-yaml": "^4.3.0",
"minimatch@10>brace-expansion": "^5.0.7",
"minimatch@3>brace-expansion": "^1.1.16",
"postcss": "^8.5.18"
}
}
}
60 changes: 60 additions & 0 deletions packages/client/src/render/world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
HP_RED,
HP_YELLOW,
WATER_FOAM,
WATER_RAMP,
mix,
scale,
waterAt,
Expand Down Expand Up @@ -132,6 +133,27 @@ export function waterDepth01(
return 0.1 + 0.52 * t;
}

/**
* Seabed-band tint for the ORIGINAL's own per-cell depth (WaterMask.depth:
* 1 = deep, 2 = shallow, 3 = pink passable shallows). Returned as a colour +
* alpha to lay OVER the base sea so the real shoals, channels and bars show
* through — the map's contested middle is a braid of shallows in the original,
* not the flat blue field a pure north-south gradient paints. Band 0 (land)
* returns null: land is drawn by land.ts. Pure — unit-tested.
*/
export function seabedBandTint(band: number): { color: number; alpha: number } | null {
switch (band) {
case 1:
return { color: WATER_RAMP[2] ?? 0x16526f, alpha: 0.5 }; // deep channel
case 2:
return { color: WATER_RAMP[0] ?? 0x2c7e9e, alpha: 0.42 }; // shoal / shallow
case 3:
return { color: 0x6f7fa8, alpha: 0.34 }; // pink passable shallows
default:
return null;
}
}

/** Base water fill color at a given world y (depth-graded). */
export function waterColorAt(worldY: number, bounds: { minY: number; maxY: number }): number {
return waterAt(waterDepth01(worldY, bounds));
Expand Down Expand Up @@ -276,6 +298,44 @@ export function createWorld(renderer?: Renderer): WorldLayer {
seaStatic.rect(left, sy0, width, sy1 - sy0 + 1).fill(waterColorAt(midY, bounds));
}

// TRUE SEABED: lay the ORIGINAL's own per-cell depth bands over the base
// grade so shoals, bars and the braided channels of the contested middle
// read the way they do on the real map (owner: "fix the center" — the
// pure north-south gradient painted it as one flat blue field). Only
// cells in view are drawn, so this is bounded by the viewport, not the
// map; it lives in the CACHED static layer (rebuilt only when the sea
// signature changes). Render-only: sailability is the water mask.
const sea = getCatalog().map.waterMask;
if (sea.depth.length === sea.cells.length && sea.cells.length > 0) {
const c0 = Math.max(0, Math.floor((minX - sea.bounds.minX) / sea.cellSizeX));
const c1 = Math.min(sea.cols - 1, Math.ceil((maxX - sea.bounds.minX) / sea.cellSizeX));
const r0 = Math.max(0, Math.floor((sea.bounds.maxY - maxY) / sea.cellSizeY));
const r1 = Math.min(sea.rows - 1, Math.ceil((sea.bounds.maxY - minY) / sea.cellSizeY));
for (let r = r0; r <= r1; r++) {
const wy0 = sea.bounds.maxY - r * sea.cellSizeY;
const wy1 = wy0 - sea.cellSizeY;
const sy0 = cam.worldToScreen(0, wy0).y;
const sy1 = cam.worldToScreen(0, wy1).y;
let c = c0;
while (c <= c1) {
const band = sea.depth[r * sea.cols + c] ?? 0;
let end = c;
while (end + 1 <= c1 && (sea.depth[r * sea.cols + end + 1] ?? 0) === band) end++;
const tint = seabedBandTint(band);
if (tint !== null) {
const wx0 = sea.bounds.minX + c * sea.cellSizeX;
const wx1 = sea.bounds.minX + (end + 1) * sea.cellSizeX;
const sx0 = cam.worldToScreen(wx0, 0).x;
const sx1 = cam.worldToScreen(wx1, 0).x;
seaStatic
.rect(sx0, sy0, sx1 - sx0 + 1, sy1 - sy0 + 1)
.fill({ color: tint.color, alpha: tint.alpha });
}
c = end + 1;
}
}
}

// Faint world-space grid.
const gx0 = Math.ceil(minX / GRID_STEP) * GRID_STEP;
for (let wx = gx0; wx <= maxX; wx += GRID_STEP) {
Expand Down
29 changes: 28 additions & 1 deletion packages/client/test/world.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
structureSilhouette,
trimColor,
} from '../src/render/structures.js';
import { seaStaticSignature, waterColorAt, waterDepth01 } from '../src/render/world.js';
import { seaStaticSignature, waterColorAt, waterDepth01, seabedBandTint } from '../src/render/world.js';

const ALL_ROLES: StructureRole[] = [
'hq',
Expand Down Expand Up @@ -277,3 +277,30 @@ describe('seaStaticSignature', () => {
expect(a).not.toBe(seaStaticSignature(rect, 1, 1280, 720));
});
});

describe('seabed bands (the ORIGINAL per-cell depth the sea is painted from)', () => {
it('land (band 0) paints nothing — land.ts owns it', () => {
expect(seabedBandTint(0)).toBeNull();
});

it('every water band yields a visible, partially transparent tint', () => {
for (const band of [1, 2, 3]) {
const t = seabedBandTint(band);
expect(t, `band ${band}`).not.toBeNull();
expect(t!.alpha).toBeGreaterThan(0);
expect(t!.alpha).toBeLessThan(1);
expect(Number.isFinite(t!.color)).toBe(true);
}
});

it('deep reads darker than shallow (the channel must contrast the shoals)', () => {
const deep = seabedBandTint(1)!.color;
const shallow = seabedBandTint(2)!.color;
const lum = (c: number) => ((c >> 16) & 0xff) * 0.3 + ((c >> 8) & 0xff) * 0.59 + (c & 0xff) * 0.11;
expect(lum(deep)).toBeLessThan(lum(shallow));
});

it('an unknown band degrades to no tint (forward-compatible)', () => {
expect(seabedBandTint(9)).toBeNull();
});
});
21 changes: 21 additions & 0 deletions packages/core/src/sim/ruleset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2083,6 +2083,7 @@ export function compileWaterMask(bounds: MapSpec['bounds'], terrain?: RawTerrain
cellSizeX: 1,
cellSizeY: 1,
cells: new Uint8Array(0),
depth: new Uint8Array(0),
};
}

Expand Down Expand Up @@ -2110,6 +2111,25 @@ export function compileWaterMask(bounds: MapSpec['bounds'], terrain?: RawTerrain
}
if (col !== cols) fail(`terrain: row ${r} runs sum to ${col}, expected ${cols}`);
}
// RENDER-ONLY seabed bands (value,run pairs; 0=land,1=deep,2=shallow,3=pink).
// Optional and never consulted by the sim: a missing/short field degrades to
// all-zero, which the client falls back to a flat sea for.
const depth = new Uint8Array(cols * rows);
if (Array.isArray(terrain.depth) && terrain.depth.length === rows) {
for (let r = 0; r < rows; r++) {
const rle = terrain.depth[r];
if (!Array.isArray(rle)) continue;
let col = 0;
for (let k = 0; k + 1 < rle.length; k += 2) {
const value = rle[k] ?? 0;
const run = rle[k + 1] ?? 0;
if (value > 0) {
for (let c = 0; c < run && col + c < cols; c++) depth[r * cols + col + c] = value;
}
col += run;
}
}
}
return {
bounds: {
minX: mustNum(terrain.bounds.minX, 'terrain minX'),
Expand All @@ -2122,6 +2142,7 @@ export function compileWaterMask(bounds: MapSpec['bounds'], terrain?: RawTerrain
cellSizeX: mustNum(terrain.cellSizeX, 'terrain cellSizeX'),
cellSizeY: mustNum(terrain.cellSizeY, 'terrain cellSizeY'),
cells,
depth,
};
}

Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/sim/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1640,6 +1640,18 @@ export interface WaterMask {
* is on the immutable Ruleset, never in serialized state.
*/
cells: Uint8Array;
/**
* RENDER-ONLY seabed band per cell (0 = land, 1 = deep, 2 = shallow,
* 3 = pink passable shallows), row-major like `cells`, or an empty array on
* a stub mask. This is the ORIGINAL's own seabed painted per 64u cell — the
* client draws the sea from it so the map reads like the real one (the
* owner's "the center" complaint: the sea was painted by a synthetic
* north-south depth gradient that made the contested middle a featureless
* blue field, hiding the shoals and channels the original clearly shows).
* The SIM IGNORES this — sailability is `cells` alone — so it can never
* affect determinism.
*/
depth: Uint8Array;
}

/**
Expand Down Expand Up @@ -2223,6 +2235,12 @@ export interface RawTerrainFile {
yOrientation: 'top-down';
/** Per-row run-length encoding; one entry per row (length === rows). */
water: number[][];
/**
* OPTIONAL render-only seabed bands, per-row (value, run) pairs —
* 0=land, 1=deep, 2=shallow, 3=pink. The sim ignores it; the client paints
* the sea from it. Absent on older/stub terrain files.
*/
depth?: number[][];
}

export interface RawDataFiles {
Expand Down
8 changes: 7 additions & 1 deletion packages/core/test/ruleset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -890,13 +890,19 @@ describe('ruleset integrity and determinism', () => {
// NavField). Assert serializability of everything ELSE, and pin the
// typed-array representation of the mask + every nav field.
expect(rs.map.waterMask.cells).toBeInstanceOf(Uint8Array);
// `depth` shares the mask's rationale exactly: a packed render-only
// seabed band per cell (0=land,1=deep,2=shallow,3=pink) on the immutable
// Ruleset, never serialized per-match nor hashed. The SIM ignores it.
expect(rs.map.waterMask.depth).toBeInstanceOf(Uint8Array);
expect(rs.map.waterMask.depth.length).toBe(rs.map.waterMask.cells.length);
for (const team of ['south', 'north'] as const) {
expect(rs.map.navByTeam[team].dist).toBeInstanceOf(Int32Array);
expect(rs.map.navHomeByTeam[team].dist).toBeInstanceOf(Int32Array);
}
const { waterMask, navByTeam, navHomeByTeam, navToRegion, ...mapRest } = rs.map;
const { cells, ...maskRest } = waterMask;
const { cells, depth, ...maskRest } = waterMask;
void cells;
void depth;
// Strip the typed `dist` from each nav field; keep the JSON-able metadata.
const stripNav = (nav: (typeof navByTeam)['south']): object => {
const { dist, ...rest } = nav;
Expand Down
46 changes: 25 additions & 21 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading