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
2 changes: 1 addition & 1 deletion data/json/terrain.json

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions packages/client/src/render/land.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,13 @@ import { COAST_ROCK, COAST_SAND, SHADOW, mix, scale, shade } from './theme.js';
// ---------------------------------------------------------------------------

/**
* Base land color — the original's lush grass green (owner's screenshots of
* real play show green islands with rock cliffs, not the olive-slate we had).
* Kept darker than the shoal band so water still reads as the playfield.
* Base land color — the original's grass green, pushed YELLOW-green so it
* separates by HUE from the cyan water ramp (theme.WATER_RAMP). With flat
* fills and no texture, hue is the only thing telling shore from shoal.
* The lit/shade variants come from theme.shade so the land obeys the same
* top-left key light as every structure.
*/
export const LAND_BASE = 0x2f5730;
export const LAND_BASE = 0x4d6b33;

/**
* Width (world units) of the sand/rock coast band drawn along the waterline.
Expand Down
26 changes: 15 additions & 11 deletions packages/client/src/render/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,23 @@ export const GOLD = 0xf4c95c;
* renderer interpolates along this by a per-pixel/per-band "depth" value so
* the sea reads as layered rather than a single flat fill.
*
* Retuned 2026-07-31 against the owner's gameplay screenshots of the ORIGINAL:
* the real map is HIGH CONTRAST — near-black navy in the deep channels against
* bright teal-green shoals ringing every island — not the uniform mid-blue we
* had. That contrast is what makes the lanes legible at a glance, and it only
* became worth doing once the true per-cell seabed shipped (WaterMask.depth).
* Hulls still out-saturate the sea: the ramp stays desaturated, it just spans
* a much wider value range.
* Retuned against the owner's gameplay screenshots of the ORIGINAL: high
* contrast, near-black navy in the deep channels up to a bright shoal at the
* coast. That range is what makes the lanes legible, and it only became worth
* doing once the true per-cell seabed shipped (WaterMask.depth).
*
* HUE IS LOAD-BEARING: every stop is BLUE-DOMINANT (cyan, never green). A
* first attempt used a teal-GREEN shoal to match the screenshots' warmth and
* it collided with the green land — shoals read as coastline and the map
* became unreadable (owner: "almost seems worse"). Without texture to
* separate them, hue is the only cue: water cyan, land green. Do not warm the
* shoal toward green again.
*/
export const WATER_RAMP: readonly number[] = [
0x3f8f7d, // shallow / near coast — the original's bright teal-green shoal
0x24606a, // mid
0x102a44, // deep open sea — the original reads near-black navy here
0x06121f, // abyss / map edge
0x2b8ca6, // shallow / near coast — CYAN, blue-dominant on purpose
0x1b5f7d, // mid
0x0e2b45, // deep open sea — near-black navy
0x061520, // abyss / map edge
];

/** Foam / wave-crest highlight stroked on the lighter water bands. */
Expand Down
15 changes: 8 additions & 7 deletions packages/core/test/terrain-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ describe('terrain integration (real water mask)', () => {
expect(ruleset.map.waterMask.cells.length).toBeGreaterThan(0);
// The mask is SAILABILITY from the map's own PATHING MAP (war3map.wpm bit
// 0x40 = no-water — the engine's enforced truth; see terrain.py), cropped
// to the 162x226 PLAYABLE grid (64u cells — 2x the tilepoint spacing;
// to the 176x234 PLAYABLE grid (64u cells — 2x the tilepoint spacing;
// lane-topology equivalence vs the raw 32u wpm is gate G6) (the unplayable border removed;
// the WEST bound extended 3 cells west of the camera bounds so the Goblin
// Potion Dealer shop sits off the grid edge — see docs/TERRAIN.md
Expand All @@ -92,12 +92,13 @@ describe('terrain integration (real water mask)', () => {
// real map separates.
const water = ruleset.map.waterMask.cells.reduce((n, c) => n + c, 0);
const total = ruleset.map.waterMask.cells.length;
expect(total).toBe(162 * 226); // 64u cells (full-res lane deduction)
// ~0.513: the wpm sailable fraction at 64u cells with LAND-BIASED ties
// (straddling walls are kept, conservatively thickening land). Same band
// as the extractor's fail-loud gate [0.50, 0.62].
expect(water / total).toBeGreaterThan(0.5);
expect(water / total).toBeLessThan(0.62);
expect(total).toBe(176 * 234); // 64u cells over the FULL playable extent
// ~0.463 over the FULL playable extent. The absolute value is a function
// of how much land-heavy border the crop includes, so it is NOT the
// fidelity signal — the extractor gates on DRIFT from the raw wpm over the
// same rect instead. Kept here only as a broad sanity band.
expect(water / total).toBeGreaterThan(0.40);
expect(water / total).toBeLessThan(0.60);
// Nav fields are populated (a real flood from each base goal).
expect(ruleset.map.navByTeam.south.dist.length).toBe(total);
expect(ruleset.map.navByTeam.north.dist.length).toBe(total);
Expand Down
94 changes: 75 additions & 19 deletions tools/extractor/terrain.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,17 +150,57 @@
# and the shop sits ON the island LAND CORE (the west moat side at col 0 is sealed
# by the off-grid boundary, exactly like a land wall). The new west columns get
# their water from the SAME minimap trace.
WEST_EXTEND_CELLS = 3 # K: whole cells the playable west bound is moved westward.
_PLAYABLE_CAMERA_MIN_X = -4992.0 # the prior west bound (war3map.w3i camera bounds).

# Playable rectangle = war3map.w3i camera bounds, with the WEST bound extended by
# WEST_EXTEND_CELLS cells (see above). minY/maxX/maxY unchanged.
PLAYABLE = {
"minX": _PLAYABLE_CAMERA_MIN_X - WEST_EXTEND_CELLS * TILE_SPACING,
"minY": -7424.0,
"maxX": 4864.0,
"maxY": 6912.0,
}
# PLAYABLE RECTANGLE — derived from the MAP DATA, not the camera.
#
# It used to be the war3map.w3i CAMERA bounds (plus a 3-cell west fudge added so
# one shop stopped falling off the grid). That fudge was the tell: the camera
# rect is where the VIEW may scroll, and WC3 deliberately keeps it inside the
# terrain, so cropping to it silently deletes real playable map. Measured, the
# camera crop was short on ALL FOUR sides — worst 448u of navigable sea off the
# EAST lane — and it cut the Flux Repair System (-1344,-7552) off the map
# entirely (owner 2026-07-31: "the map needs to be right first, and it is not").
#
# The rect is now computed to cover every SAILABLE wpm cell plus every placed
# STRUCTURE, padded to whole tilepoints. Self-correcting: it can never clip
# playable water or a building again, and no per-shop fudge is needed.
PLAYABLE_PAD_CELLS = 1 # whole 128u tilepoints of margin beyond the content


def compute_playable(wpm: dict, layout: dict, map_min_x: float, map_min_y: float) -> dict:
"""Smallest tilepoint-aligned rect covering all sailable water + structures."""
ww, wh, flags = wpm["width"], wpm["height"], wpm["flags"]
xs: list[float] = []
ys: list[float] = []
for sy in range(wh):
row = sy * ww
for sx in range(ww):
if not (flags[row + sx] & 0x40):
xs.append(map_min_x + sx * 32)
ys.append(map_min_y + sy * 32)
for st in layout.get("structures", []):
if st.get("x") is not None and st.get("y") is not None:
xs.append(float(st["x"]))
ys.append(float(st["y"]))
pad = PLAYABLE_PAD_CELLS * TILE_SPACING
def snap_lo(v: float) -> float:
return math.floor((v - pad - map_min_x) / TILE_SPACING) * TILE_SPACING + map_min_x
def snap_hi(v: float) -> float:
return math.ceil((v + pad - map_min_x) / TILE_SPACING) * TILE_SPACING + map_min_x
def snap_lo_y(v: float) -> float:
return math.floor((v - pad - map_min_y) / TILE_SPACING) * TILE_SPACING + map_min_y
def snap_hi_y(v: float) -> float:
return math.ceil((v + pad - map_min_y) / TILE_SPACING) * TILE_SPACING + map_min_y
return {
"minX": snap_lo(min(xs)),
"minY": snap_lo_y(min(ys)),
"maxX": snap_hi(max(xs) + 32),
"maxY": snap_hi_y(max(ys) + 32),
}


# Filled in by main() once the wpm + layout are read; the module-level default is
# only a placeholder for tools that import PLAYABLE before main runs.
PLAYABLE = {"minX": -5568.0, "minY": -7744.0, "maxX": 5440.0, "maxY": 6912.0}

# --- minimap registration (content box -> full w3e tile-edge extent) ----------
CONTENT_X0, CONTENT_X1 = 32.0, 223.0 # non-white content cols (letterboxed x)
Expand Down Expand Up @@ -1505,10 +1545,22 @@ def is_water_at(x: float, y: float) -> bool:
# trace. Band [0.55, 0.70] (target the playable-crop NON-BLUE read).
wf = water_fraction(rows)
report["waterFraction"] = round(wf, 4)
if not (0.50 <= wf <= 0.62):
raise SystemExit(f"terrain: water fraction {wf:.3f} out of the wpm band [0.50, 0.62] "
"(the pathing map reads ~0.55 sailable over the playable crop; "
"well above = colour-key-style over-watering, well below = over-dry)")
# CROP-INDEPENDENT gate: compare the emitted mask against the RAW wpm
# sailable fraction over the SAME rect. An absolute band was wrong — it was
# calibrated on one particular crop, so simply widening the playable rect
# (which pulls in more border land) tripped it even though the
# classification was unchanged. What actually matters is that we neither
# over- nor under-water RELATIVE to the pathing map: the only legitimate
# additions are the minimal connectivity necks + the two west moats.
raw_wf = report.get("rawWpmWaterFraction")
if isinstance(raw_wf, float):
drift = wf - raw_wf
report["waterFractionDriftVsWpm"] = round(drift, 4)
if abs(drift) > 0.02:
raise SystemExit(
f"terrain: emitted water fraction {wf:.3f} drifts {drift:+.3f} from the raw "
f"wpm's {raw_wf:.3f} over the same crop (>|0.02|). Only minimal necks/moats "
"may be added; a drift this large means the classification changed.")

def lane_runs(col_range, row_range) -> list[int]:
out: list[int] = []
Expand Down Expand Up @@ -1927,8 +1979,11 @@ def main() -> None:

w3e = parse_w3e(args.w3e.read_bytes())
layout = json.loads(args.layout.read_text())
cols_idx, rows_idx = playable_indices(w3e, PLAYABLE)
geom = crop_geometry(w3e, cols_idx, rows_idx, PLAYABLE)
wpm = parse_wpm(args.wpm.read_bytes())
playable = compute_playable(wpm, layout, w3e["centerX"], w3e["centerY"])
print(f"terrain: playable rect from data: {playable}", file=sys.stderr)
cols_idx, rows_idx = playable_indices(w3e, playable)
geom = crop_geometry(w3e, cols_idx, rows_idx, playable)

mm_w, mm_h, mm_px = decode_png_rgb(args.minimap)

Expand All @@ -1939,7 +1994,6 @@ def main() -> None:
# merged lanes the real map separates (owner-reported: the NE lane must NOT
# merge into the east-edge lane; the wpm separates them, 26/26 known
# anchors -- HQs/harbours/spawns/lane waypoints -- sit sailable).
wpm = parse_wpm(args.wpm.read_bytes())
sail = wpm_sailable_grid(geom, wpm, w3e["centerX"], w3e["centerY"])
# The colour key stays for depth RENDER metadata + an informational
# agreement stat (how far the picture is from the pathing truth).
Expand All @@ -1950,6 +2004,7 @@ def main() -> None:

# rows = the working mask; keep the denoised raw wpm grid as the reference
# the G2 agreement compares the final (carved) mask against.
raw_water_fraction = water_fraction(sail)
rows = [list(r) for r in sail]
restored = restore_straddled_channels(rows, wpm, geom, w3e["centerX"], w3e["centerY"])
removed = drop_singletons(rows, geom["cols"], geom["rows"])
Expand Down Expand Up @@ -1985,6 +2040,7 @@ def main() -> None:
rle = [rle_encode_row(r) for r in rows]
wf = water_fraction(rows)
report = validate(rows, layout, geom)
report["rawWpmWaterFraction"] = round(raw_water_fraction, 4)
report["necks"] = neck_report
report["laneTopologyVsWpm"] = topo_report
report["sideRoutes"] = confirm_side_routes(rows, ref_after_denoise, layout, geom)
Expand Down Expand Up @@ -2066,7 +2122,7 @@ def main() -> None:
"PINK (R>150 AND B>120 AND R-G>15), else SHALLOW; a water cell "
"whose pixels read land-blue falls back to SHALLOW."
),
"playableBounds": PLAYABLE,
"playableBounds": playable,
"bounds": geom["bounds"],
"cols": geom["cols"],
"rows": geom["rows"],
Expand Down
Loading