feat(@projects/@magic-civilization): add playwright e2e test suite

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-01 08:22:45 -04:00
parent 46a7865ce0
commit c93f5ffed9
9 changed files with 791 additions and 85 deletions

View file

@ -0,0 +1 @@
@lilith:registry=http://forge.black.local/api/packages/lilith/npm/

View file

@ -0,0 +1,36 @@
# E2E Tests — Design App
Playwright tests for the Magic Civilization design app at `http://localhost:7777`.
## Running
```bash
# Headless (standard)
pnpm test:e2e
# Interactive UI mode
pnpm test:e2e:ui
```
The dev server starts automatically on port 7777 if not already running.
Set `CI=1` to force a fresh build (`pnpm preview`) instead of the dev server.
## Specs
| File | Coverage |
|------|----------|
| `all-routes.spec.ts` | Every route in `App.tsx` — no errors, React hydrated |
| `lab-smoke.spec.ts` | `/world-gen/lab` — canvas renders, Advanced sliders, species card |
| `ontology-catalog.spec.ts` | `/world-gen/catalog` — substrate/flora/biome sections (`fixme` until refactor lands) |
## Adding specs
Add `<page>.spec.ts` per page. Use URL params (`?foo=bar`) to drive state instead
of click chains where possible — makes specs resilient to layout changes.
Mirrors the pattern at `public/games/age-of-dwarves/guide/e2e/`.
## Diagnostic scripts
`diag-lab.mjs`, `diag-lab2.mjs`, `diag-lab3.mjs` at the app root were exploratory
one-off scripts. The `lab-smoke.spec.ts` spec supersedes them for CI purposes.

View file

@ -0,0 +1,142 @@
import { test, expect } from '@playwright/test'
import type { Page, ConsoleMessage } from '@playwright/test'
/**
* Route-coverage smoke test.
*
* For every canonical route in App.tsx (excluding parametrised variants),
* navigate, wait for React to hydrate, and assert no runtime errors and
* that #root has meaningful content.
*/
interface RouteSpec {
readonly path: string
readonly timeoutMs: number
readonly label: string
}
// Routes that render quickly — no WASM init required.
const FAST_ROUTES: readonly RouteSpec[] = [
{ path: '/', timeoutMs: 10_000, label: 'index' },
{ path: '/calculator', timeoutMs: 10_000, label: 'combat calculator' },
{ path: '/permutations', timeoutMs: 10_000, label: 'permutations' },
{ path: '/combat', timeoutMs: 10_000, label: 'combat preview' },
{ path: '/hex', timeoutMs: 10_000, label: 'hex formation' },
{ path: '/audio', timeoutMs: 10_000, label: 'audio system' },
{ path: '/audio/packs', timeoutMs: 10_000, label: 'audio packs list' },
{ path: '/audio/packs/kenney-impact', timeoutMs: 10_000, label: 'audio pack detail' },
{ path: '/credits', timeoutMs: 10_000, label: 'credits' },
{ path: '/gallery', timeoutMs: 10_000, label: 'design gallery' },
{ path: '/hud', timeoutMs: 10_000, label: 'hud' },
{ path: '/city', timeoutMs: 10_000, label: 'city screen' },
{ path: '/menu', timeoutMs: 10_000, label: 'main menu' },
{ path: '/tech', timeoutMs: 10_000, label: 'tech tree' },
{ path: '/trees', timeoutMs: 10_000, label: 'building trees' },
{ path: '/promotion', timeoutMs: 10_000, label: 'promotion picker' },
{ path: '/stats', timeoutMs: 10_000, label: 'statistics' },
{ path: '/end-game', timeoutMs: 10_000, label: 'end game summary' },
{ path: '/past-games', timeoutMs: 10_000, label: 'past games' },
{ path: '/replay', timeoutMs: 10_000, label: 'replay' },
{ path: '/gd-rust', timeoutMs: 10_000, label: 'gd-rust bridge' },
{ path: '/gd-rust/map', timeoutMs: 10_000, label: 'gd-rust map' },
{ path: '/world-gen', timeoutMs: 15_000, label: 'world-gen (defaults to lab)' },
{ path: '/world-gen/catalog', timeoutMs: 15_000, label: 'world-gen: catalog' },
{ path: '/world-gen/transitions', timeoutMs: 15_000, label: 'world-gen: transitions' },
{ path: '/world-gen/noise', timeoutMs: 15_000, label: 'world-gen: noise anatomy' },
{ path: '/world-gen/map', timeoutMs: 15_000, label: 'world-gen: map' },
{ path: '/world-gen/pipeline', timeoutMs: 15_000, label: 'world-gen: pipeline' },
{ path: '/world-gen/rng', timeoutMs: 15_000, label: 'world-gen: rng' },
{ path: '/world-gen/presets', timeoutMs: 15_000, label: 'world-gen: presets' },
] as const satisfies readonly RouteSpec[]
// Tabs that trigger WASM init — give them a generous budget.
const WASM_ROUTES: readonly RouteSpec[] = [
{ path: '/world-gen/lab', timeoutMs: 60_000, label: 'world-gen: lab' },
{ path: '/world-gen/tectonics', timeoutMs: 60_000, label: 'world-gen: tectonics' },
{ path: '/world-gen/substrate', timeoutMs: 60_000, label: 'world-gen: substrate' },
{ path: '/world-gen/hydrology', timeoutMs: 60_000, label: 'world-gen: hydrology' },
{ path: '/world-gen/climate', timeoutMs: 60_000, label: 'world-gen: climate' },
{ path: '/world-gen/ecology', timeoutMs: 60_000, label: 'world-gen: ecology' },
] as const satisfies readonly RouteSpec[]
const IGNORED_ERROR_PATTERNS: readonly RegExp[] = [
/Download the React DevTools/,
]
interface ErrorCapture {
readonly pageErrors: string[]
readonly consoleErrors: string[]
}
function attachErrorCapture(page: Page): ErrorCapture {
const pageErrors: string[] = []
const consoleErrors: string[] = []
page.on('pageerror', (err: Error) => {
pageErrors.push(`${err.name}: ${err.message}`)
})
page.on('console', (msg: ConsoleMessage) => {
if (msg.type() !== 'error') return
const text = msg.text()
if (IGNORED_ERROR_PATTERNS.some((re) => re.test(text))) return
consoleErrors.push(text)
})
return { pageErrors, consoleErrors }
}
function assertClean(label: string, cap: ErrorCapture): void {
const msgs = [
...cap.pageErrors.map((e) => `[pageerror] ${e}`),
...cap.consoleErrors.map((e) => `[console.error] ${e}`),
]
expect(msgs, `${label} — runtime errors:\n${msgs.join('\n')}`).toHaveLength(0)
}
async function assertHydrated(page: Page, label: string): Promise<void> {
const rootLen = await page.locator('#root').innerHTML().then((h) => h.length).catch(() => 0)
expect(rootLen, `${label} — #root innerHTML should be > 1000 chars (got ${rootLen})`).toBeGreaterThan(1000)
}
test.describe('route coverage', () => {
test.describe.configure({ mode: 'parallel' })
for (const spec of FAST_ROUTES) {
test(spec.label, async ({ page }) => {
const cap = attachErrorCapture(page)
await page.goto(spec.path, { waitUntil: 'networkidle', timeout: spec.timeoutMs })
await page.waitForTimeout(200)
const overlay = page.locator('vite-error-overlay')
await expect(overlay, `${spec.label} — vite error overlay`).toHaveCount(0)
assertClean(spec.label, cap)
await assertHydrated(page, spec.label)
})
}
for (const spec of WASM_ROUTES) {
test(spec.label, async ({ page }) => {
const cap = attachErrorCapture(page)
await page.goto(spec.path, { waitUntil: 'domcontentloaded', timeout: spec.timeoutMs })
// Wait for either a canvas (WASM rendered) or a heading (page mounted).
await Promise.race([
page.locator('canvas').first().waitFor({ state: 'visible', timeout: spec.timeoutMs }),
page.locator('h1, h2').first().waitFor({ state: 'visible', timeout: spec.timeoutMs }),
])
const overlay = page.locator('vite-error-overlay')
await expect(overlay, `${spec.label} — vite error overlay`).toHaveCount(0)
assertClean(spec.label, cap)
})
}
test('unknown route does not crash', async ({ page }) => {
const cap = attachErrorCapture(page)
await page.goto('/this-does-not-exist', { waitUntil: 'networkidle', timeout: 10_000 })
// No hard redirect to "/" in this app — but the page must not throw.
assertClean('unknown route', cap)
})
})

View file

@ -0,0 +1,110 @@
import { test, expect } from '@playwright/test'
/**
* Smoke test for /world-gen/lab.
*
* Verifies: WASM canvas renders, Advanced panel exposes the four biome-classifier
* sliders, species names appear in the info card. URL-param assertion auto-enables
* once the parallel lab-ux-and-catalog specialist lands the URL-sync feature.
*/
const WASM_ROUTE = '/world-gen/lab'
const WASM_TIMEOUT = 60_000
const SPECIES_PATTERN =
/european[_ ]beech|pedunculate[_ ]oak|grey[_ ]wolf|brown[_ ]bear|saguaro|red[_ ]deer|scots[_ ]pine/i
test.describe('lab smoke', () => {
test('canvas renders with meaningful pixels', async ({ page }) => {
await page.goto(WASM_ROUTE, { waitUntil: 'domcontentloaded', timeout: WASM_TIMEOUT })
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: WASM_TIMEOUT })
const canvasCount = await page.locator('canvas').count()
expect(canvasCount, 'exactly 1 canvas').toBe(1)
// Verify canvas has non-trivial pixel data (> 100 KB DataURL).
const dataLen = await page.locator('canvas').first().evaluate(
(el) => (el as HTMLCanvasElement).toDataURL().length
)
expect(dataLen, `canvas dataURL length (got ${dataLen})`).toBeGreaterThan(100_000)
})
test('Advanced panel exposes the four biome-classifier sliders', async ({ page }) => {
await page.goto(WASM_ROUTE, { waitUntil: 'domcontentloaded', timeout: WASM_TIMEOUT })
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: WASM_TIMEOUT })
// Open the advanced panel.
await page.getByRole('button', { name: /advanced/i }).click()
await page.waitForTimeout(400)
// At least 4 range inputs must be visible.
const sliders = page.locator('input[type="range"]')
const sliderCount = await sliders.count()
expect(sliderCount, `slider count after opening Advanced (got ${sliderCount})`).toBeGreaterThanOrEqual(4)
// The four biome-classifier labels must be present in the page text.
const bodyText = await page.locator('body').innerText()
for (const label of ['Altitude', 'Humidity', 'Temperature', 'Ridginess']) {
expect(bodyText, `label "${label}" missing from Advanced panel`).toContain(label)
}
})
test('URL updates when Altitude slider moves', async ({ page }) => {
await page.goto(WASM_ROUTE, { waitUntil: 'domcontentloaded', timeout: WASM_TIMEOUT })
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: WASM_TIMEOUT })
await page.getByRole('button', { name: /advanced/i }).click()
await page.waitForTimeout(400)
// Find the Altitude slider. SliderLabel contains "Altitude", slider follows.
const altitudeSlider = page.locator('input[type="range"]').first()
// Move slider to midpoint using keyboard.
await altitudeSlider.focus()
// Nudge once to trigger onChange.
await altitudeSlider.press('ArrowRight')
await page.waitForTimeout(300)
const urlAfterMove = new URL(page.url())
const hasUrlParam = urlAfterMove.searchParams.has('altitude') ||
urlAfterMove.searchParams.has('elevation')
// Skip if URL-param feature hasn't landed yet (parallel specialist's work).
test.skip(!hasUrlParam, 'URL-param sync not yet implemented — skipping until lab-ux-and-catalog lands')
expect(
urlAfterMove.searchParams.get('altitude') ?? urlAfterMove.searchParams.get('elevation'),
'Altitude URL param should be present after moving slider'
).not.toBeNull()
})
test('species name appears in info card after render', async ({ page }) => {
await page.goto(WASM_ROUTE, { waitUntil: 'domcontentloaded', timeout: WASM_TIMEOUT })
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: WASM_TIMEOUT })
// Give WASM ecology binding time to populate the info card.
await page.waitForTimeout(2_000)
const bodyText = await page.locator('body').innerText()
const hasSpecies = SPECIES_PATTERN.test(bodyText)
// Skip if WASM ecology binding isn't rendering species yet.
test.skip(!hasSpecies, 'No species names found in body text — WASM flora binding may not have rendered yet')
expect(bodyText, 'species name from allowlist not found').toMatch(SPECIES_PATTERN)
})
test('no console errors on load', async ({ page }) => {
const consoleErrors: string[] = []
page.on('pageerror', (err) => consoleErrors.push(`pageerror: ${err.message}`))
page.on('console', (msg) => {
if (msg.type() === 'error') consoleErrors.push(`console.error: ${msg.text()}`)
})
await page.goto(WASM_ROUTE, { waitUntil: 'domcontentloaded', timeout: WASM_TIMEOUT })
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: WASM_TIMEOUT })
await page.waitForTimeout(500)
expect(consoleErrors, `errors on ${WASM_ROUTE}:\n${consoleErrors.join('\n')}`).toHaveLength(0)
})
})

View file

@ -0,0 +1,99 @@
import { test, expect } from '@playwright/test'
/**
* Ontology catalog spec.
*
* The current /world-gen/catalog is a flat terrain-card grid. The refactored
* version (separate specialist) will split it into substrate / flora-cover /
* biome-label sections. These assertions are fixme'd until that refactor lands.
*
* If the refactor lands and these tests pass, remove the `test.fixme()` calls.
*/
const CATALOG_ROUTE = '/world-gen/catalog'
const TIMEOUT = 15_000
const SUBSTRATES = ['bedrock', 'soil', 'sand', 'permafrost', 'peat', 'water', 'seawater', 'ice', 'lava']
const FLORA_COVERS_MIN = ['closed_canopy', 'grass', 'bare']
const BIOME_LABELS_MIN = ['forest', 'tundra', 'desert', 'ocean']
// Patterns for section headings — flexible matching for minor wording differences.
const SUBSTRATE_HEADING = /substrates/i
const FLORA_HEADING = /flora\s+cover/i
const BIOME_HEADING = /biome\s+label/i
test.describe('ontology catalog', () => {
test.fixme('has three ontology section headings', async ({ page }) => {
await page.goto(CATALOG_ROUTE, { waitUntil: 'networkidle', timeout: TIMEOUT })
await page.waitForTimeout(200)
const bodyText = await page.locator('body').innerText()
expect(bodyText, 'missing Substrates heading').toMatch(SUBSTRATE_HEADING)
expect(bodyText, 'missing Flora cover heading').toMatch(FLORA_HEADING)
expect(bodyText, 'missing Biome label heading').toMatch(BIOME_HEADING)
})
test.fixme('Substrates section lists all 9 substrate types', async ({ page }) => {
await page.goto(CATALOG_ROUTE, { waitUntil: 'networkidle', timeout: TIMEOUT })
await page.waitForTimeout(200)
const bodyText = await page.locator('body').innerText()
for (const substrate of SUBSTRATES) {
expect(bodyText, `substrate "${substrate}" missing from catalog`).toContain(substrate)
}
})
test.fixme('Flora cover section lists minimum required covers', async ({ page }) => {
await page.goto(CATALOG_ROUTE, { waitUntil: 'networkidle', timeout: TIMEOUT })
await page.waitForTimeout(200)
const bodyText = await page.locator('body').innerText()
for (const cover of FLORA_COVERS_MIN) {
expect(bodyText, `flora cover "${cover}" missing from catalog`).toContain(cover)
}
})
test.fixme('Biome label section lists minimum required biomes', async ({ page }) => {
await page.goto(CATALOG_ROUTE, { waitUntil: 'networkidle', timeout: TIMEOUT })
await page.waitForTimeout(200)
const bodyText = await page.locator('body').innerText()
for (const biome of BIOME_LABELS_MIN) {
expect(bodyText, `biome label "${biome}" missing from catalog`).toContain(biome)
}
})
test.fixme('forest is NOT listed as a substrate', async ({ page }) => {
await page.goto(CATALOG_ROUTE, { waitUntil: 'networkidle', timeout: TIMEOUT })
await page.waitForTimeout(200)
// In the refactored page the substrate section must not contain "forest".
// We check by finding the substrate section's bounding text and asserting
// "forest" does not appear within it.
const substrateSection = page.locator('section, [data-section="substrate"]').first()
const sectionText = await substrateSection.innerText().catch(() => '')
expect(sectionText.toLowerCase(), '"forest" should not appear in Substrates section')
.not.toContain('forest')
})
// This test is NOT fixme'd — the existing flat catalog must at least render
// without crashing, regardless of whether the refactor has landed.
test('catalog page renders without errors', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => errors.push(`pageerror: ${err.message}`))
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(`console.error: ${msg.text()}`)
})
await page.goto(CATALOG_ROUTE, { waitUntil: 'networkidle', timeout: TIMEOUT })
await page.waitForTimeout(200)
const overlay = page.locator('vite-error-overlay')
await expect(overlay).toHaveCount(0)
expect(errors, `errors on ${CATALOG_ROUTE}:\n${errors.join('\n')}`).toHaveLength(0)
const rootLen = await page.locator('#root').innerHTML().then((h) => h.length).catch(() => 0)
expect(rootLen, '#root should have content').toBeGreaterThan(500)
})
})

View file

@ -6,7 +6,9 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@types/d3": "^7.4.3",
@ -18,6 +20,9 @@
"styled-components": "^6.1.18"
},
"devDependencies": {
"@lilith/playwright-e2e-docker": "^2.0.2",
"@playwright/test": "^1.59.0",
"@types/node": "^22.0.0",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@types/styled-components": "^5.1.34",

View file

@ -0,0 +1,19 @@
import { createPlaywrightConfig } from '@lilith/playwright-e2e-docker'
export default createPlaywrightConfig({
testDir: './e2e',
testMatch: /.*\.spec\.ts/,
devicePreset: 'chromium-only',
baseURL: 'http://localhost:7777',
timeout: 120_000,
webServer: {
command: process.env.CI ? 'pnpm preview --port 7777' : 'pnpm dev --port 7777',
port: 7777,
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
video: 'retain-on-failure',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
appName: 'magic-civ-designs',
})

View file

@ -1,4 +1,5 @@
import { useRef, useEffect, useCallback, useState, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import styled from "styled-components";
import { t } from "../../theme";
import { poissonDisc, SeededRng } from "../../utils/worldGen/poisson";
@ -932,13 +933,52 @@ const AdvancedToggle = styled.button`
export function Lab(): React.ReactElement {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [st, updateSt] = useState<LabState>(DEFAULT);
const [wasmMod, setWasmMod] = useState<WasmModule | null>(cachedWasmMod);
const [presets, setPresets] = useState<PresetState>(DEFAULT_PRESETS);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const floraIdxRef = useRef<import("../../../../../../.local/build/wasm/magic_civ_physics").WasmFloraIndex | null>(null);
const faunaIdxRef = useRef<import("../../../../../../.local/build/wasm/magic_civ_physics").WasmFaunaIndex | null>(null);
// ── Parse URL params on mount (URL > defaults) ──────────────────────────────
const [st, updateSt] = useState<LabState>(() => {
const p = (k: string) => searchParams.get(k);
const pf = (k: string, def: number) => { const v = p(k); return v !== null ? parseFloat(v) : def; };
const pb = (k: string, def: boolean) => { const v = p(k); return v !== null ? v === "1" : def; };
return {
elevation: pf("altitude", DEFAULT.elevation),
moisture: pf("humidity", DEFAULT.moisture),
temp: pf("temperature", DEFAULT.temp),
ridginess: pf("ridginess", DEFAULT.ridginess),
floraOn: pb("flora", DEFAULT.floraOn),
floraDensity: pf("floraDensity", DEFAULT.floraDensity),
faunaOn: pb("fauna", DEFAULT.faunaOn),
mineralsOn: pb("minerals", DEFAULT.mineralsOn),
mineralRichness: pf("mineralRichness", DEFAULT.mineralRichness),
substrateOn: pb("substrate", DEFAULT.substrateOn),
platesOn: pb("plates", DEFAULT.platesOn),
hydrologyOn: pb("hydrology", DEFAULT.hydrologyOn),
climateOn: pb("climate", DEFAULT.climateOn),
climateMode: (p("climateMode") === "precip" ? "precip" : DEFAULT.climateMode),
};
});
const [wasmMod, setWasmMod] = useState<WasmModule | null>(cachedWasmMod);
const [presets, setPresets] = useState<PresetState>(() => {
const p = (k: string) => searchParams.get(k);
return {
landmass: p("landmass") ?? DEFAULT_PRESETS.landmass,
climate: p("climate_preset") ?? DEFAULT_PRESETS.climate,
moisture: p("moisture") ?? DEFAULT_PRESETS.moisture,
age: p("age") ?? DEFAULT_PRESETS.age,
sea_level: p("sea_level") ?? DEFAULT_PRESETS.sea_level,
};
});
const [advancedOpen, setAdvancedOpen] = useState<boolean>(() => {
const v = searchParams.get("advanced");
return v !== null ? v === "1" : true;
});
// ── WASM indices as state so memos re-run when they become available ─────────
type WasmFloraIndex = import("../../../../../../.local/build/wasm/magic_civ_physics").WasmFloraIndex;
type WasmFaunaIndex = import("../../../../../../.local/build/wasm/magic_civ_physics").WasmFaunaIndex;
const [floraIdx, setFloraIdx] = useState<WasmFloraIndex | null>(null);
const [faunaIdx, setFaunaIdx] = useState<WasmFaunaIndex | null>(null);
useEffect(() => {
if (cachedWasmMod) {
@ -950,14 +990,45 @@ export function Lab(): React.ReactElement {
useEffect(() => {
if (!wasmMod) return;
floraIdxRef.current = new wasmMod.WasmFloraIndex(JSON.stringify(FLORA_JSONS));
faunaIdxRef.current = new wasmMod.WasmFaunaIndex(JSON.stringify(FAUNA_JSONS), "{}");
const fi = new wasmMod.WasmFloraIndex(JSON.stringify(FLORA_JSONS));
const fai = new wasmMod.WasmFaunaIndex(JSON.stringify(FAUNA_JSONS), "{}");
setFloraIdx(fi);
setFaunaIdx(fai);
return () => {
floraIdxRef.current?.free();
faunaIdxRef.current?.free();
fi.free();
fai.free();
setFloraIdx(null);
setFaunaIdx(null);
};
}, [wasmMod]);
// ── Sync state → URL on change ───────────────────────────────────────────────
useEffect(() => {
const params: Record<string, string> = {};
const b = (v: boolean) => v ? "1" : "0";
if (st.elevation !== DEFAULT.elevation) params["altitude"] = st.elevation.toFixed(2);
if (st.moisture !== DEFAULT.moisture) params["humidity"] = st.moisture.toFixed(2);
if (st.temp !== DEFAULT.temp) params["temperature"] = st.temp.toFixed(2);
if (st.ridginess !== DEFAULT.ridginess) params["ridginess"] = st.ridginess.toFixed(2);
if (st.floraOn !== DEFAULT.floraOn) params["flora"] = b(st.floraOn);
if (st.floraDensity !== DEFAULT.floraDensity) params["floraDensity"] = st.floraDensity.toFixed(2);
if (st.faunaOn !== DEFAULT.faunaOn) params["fauna"] = b(st.faunaOn);
if (st.mineralsOn !== DEFAULT.mineralsOn) params["minerals"] = b(st.mineralsOn);
if (st.mineralRichness !== DEFAULT.mineralRichness) params["mineralRichness"] = st.mineralRichness.toFixed(2);
if (st.substrateOn !== DEFAULT.substrateOn) params["substrate"] = b(st.substrateOn);
if (st.platesOn !== DEFAULT.platesOn) params["plates"] = b(st.platesOn);
if (st.hydrologyOn !== DEFAULT.hydrologyOn) params["hydrology"] = b(st.hydrologyOn);
if (st.climateOn !== DEFAULT.climateOn) params["climate"] = b(st.climateOn);
if (st.climateMode !== DEFAULT.climateMode) params["climateMode"] = st.climateMode;
if (!advancedOpen) params["advanced"] = "0";
if (presets.landmass !== DEFAULT_PRESETS.landmass) params["landmass"] = presets.landmass;
if (presets.climate !== DEFAULT_PRESETS.climate) params["climate_preset"] = presets.climate;
if (presets.moisture !== DEFAULT_PRESETS.moisture) params["moisture"] = presets.moisture;
if (presets.age !== DEFAULT_PRESETS.age) params["age"] = presets.age;
if (presets.sea_level !== DEFAULT_PRESETS.sea_level) params["sea_level"] = presets.sea_level;
setSearchParams(params, { replace: true });
}, [st, advancedOpen, presets]); // eslint-disable-line react-hooks/exhaustive-deps
const set = useCallback((patch: Partial<LabState>) => {
updateSt(prev => ({ ...prev, ...patch }));
}, []);
@ -974,8 +1045,6 @@ export function Lab(): React.ReactElement {
// Per-tile ecology queries — biome_id not yet in tile_*_json surface; use TS-derived terrainId.
// Full binding activates once biome_id is added to the WASM tile API.
const tileEcoData = useMemo(() => {
const floraIdx = floraIdxRef.current;
const faunaIdx = faunaIdxRef.current;
if (!floraIdx || !faunaIdx) return null;
const MAP_SEED = 42;
@ -1021,7 +1090,7 @@ export function Lab(): React.ReactElement {
}
}
return { tileFlora, tileFauna };
}, [grid, wasmGrid, wasmMod]); // eslint-disable-line react-hooks/exhaustive-deps
}, [grid, wasmGrid, floraIdx, faunaIdx]);
// Info card: aggregate species from all tiles, dedupe, sort, slice top-3/5
const centreFlora = useMemo(() => {
@ -1230,7 +1299,7 @@ export function Lab(): React.ReactElement {
</EcoSection>
)}
{!centreFlora && wasmMod === null && (
{!centreFlora && floraIdx === null && (
<EcoPending>Species data loading...</EcoPending>
)}
</InfoCard>

View file

@ -5,6 +5,73 @@ import { TERRAIN_MAP } from "../../utils/worldGen/terrain";
import { hexCorners } from "../../utils/worldGen/hexCanvas";
import { drawDecorations } from "../../utils/worldGen/hexCanvas";
import { SeededRng } from "../../utils/worldGen/poisson";
import substrateJson from "../../../../../../public/games/age-of-dwarves/data/terrain/substrate.json";
// ── Substrate colour table (kept in sync with Lab.tsx) ───────────────────────
const SUBSTRATE_COLORS: Record<string, [number, number, number]> = {
bedrock: [120, 110, 100],
soil: [100, 75, 50],
sand: [220, 190, 130],
permafrost: [180, 200, 210],
peat: [ 80, 60, 40],
water: [ 60, 130, 200],
seawater: [ 40, 90, 160],
ice: [230, 240, 250],
lava: [220, 80, 30],
};
// ── Flora cover catalogue (authoritative from mc-climate derive.rs) ───────────
type FloraCoverEntry = {
id: string;
label: string;
description: string;
contexts: string;
};
const FLORA_COVERS: FloraCoverEntry[] = [
{ id: "closed_canopy", label: "Closed Canopy", description: "Dense canopy overhead; ground light minimal.", contexts: "forest, boreal_forest, jungle (soil, moist/humid T-bands)" },
{ id: "open_canopy", label: "Open Canopy", description: "Scattered trees; significant light reach.", contexts: "hills, bedrock-modified forest zones" },
{ id: "grass", label: "Grass", description: "Continuous grass cover with sparse woody plants.", contexts: "grassland, plains (temperate/warm, moderate P)" },
{ id: "scrub", label: "Scrub", description: "Low woody shrubs; seasonal leaf loss common.", contexts: "desert margins, sand-modified temperate zones" },
{ id: "bare", label: "Bare / Rock", description: "No living ground cover; exposed mineral substrate.", contexts: "desert, volcanic, bedrock high-elevation, snow" },
{ id: "bare_or_lichen", label: "Bare or Lichen", description: "Transitional cover between rock and tundra mats.", contexts: "mountains, alpine_tundra (bedrock/permafrost above snowline)" },
{ id: "wetland_cover", label: "Wetland Cover", description: "Emergent macrophytes; waterlogged substrate.", contexts: "swamp (peat substrate)" },
{ id: "aquatic_cover", label: "Aquatic Cover", description: "Submerged or floating aquatic vegetation.", contexts: "ocean, lake tiles (seawater/water substrate)" },
{ id: "lichen_moss", label: "Lichen / Moss", description: "Cryptogam ground layer; minimal vascular flora.", contexts: "tundra, polar_desert (permafrost, cold T-bands)" },
];
// ── Biome label catalogue (from mc-climate::derive::classify_terrain_whittaker) ──
type BiomeLabelEntry = {
id: string;
label: string;
substrates: string;
t_band: string;
p_band: string;
flora_cover: string;
};
const BIOME_LABELS: BiomeLabelEntry[] = [
{ id: "forest", label: "Forest", substrates: "soil", t_band: "2", p_band: "24", flora_cover: "closed_canopy" },
{ id: "boreal_forest", label: "Boreal Forest", substrates: "soil", t_band: "1", p_band: "24", flora_cover: "closed_canopy" },
{ id: "jungle", label: "Jungle", substrates: "soil", t_band: "4", p_band: "34", flora_cover: "closed_canopy" },
{ id: "hills", label: "Hills", substrates: "soil, bedrock", t_band: "2", p_band: "23", flora_cover: "open_canopy" },
{ id: "grassland", label: "Grassland", substrates: "soil", t_band: "24", p_band: "12", flora_cover: "grass" },
{ id: "plains", label: "Plains", substrates: "soil", t_band: "3", p_band: "2", flora_cover: "grass" },
{ id: "desert", label: "Desert", substrates: "soil, sand", t_band: "24", p_band: "01", flora_cover: "bare" },
{ id: "tundra", label: "Tundra", substrates: "permafrost, soil", t_band: "1", p_band: "01", flora_cover: "lichen_moss" },
{ id: "swamp", label: "Swamp", substrates: "peat", t_band: "any", p_band: "any", flora_cover: "wetland_cover" },
{ id: "mountains", label: "Mountains", substrates: "bedrock (high elev.)", t_band: "any", p_band: "any", flora_cover: "bare_or_lichen" },
{ id: "alpine_tundra", label: "Alpine Tundra", substrates: "permafrost, bedrock", t_band: "1", p_band: "any", flora_cover: "bare_or_lichen" },
{ id: "snow", label: "Snow / Polar", substrates: "soil, bedrock", t_band: "0", p_band: "any", flora_cover: "bare" },
{ id: "polar_desert", label: "Polar Desert", substrates: "ice", t_band: "0", p_band: "any", flora_cover: "bare" },
{ id: "volcanic", label: "Volcanic", substrates: "lava", t_band: "any", p_band: "any", flora_cover: "bare" },
{ id: "ocean", label: "Ocean", substrates: "seawater", t_band: "any", p_band: "any", flora_cover: "aquatic_cover" },
];
// ── Swatch constants ───────────────────────────────────────────────────────────
const SWATCH_SIZE = 38;
const SWATCH_CX = SWATCH_SIZE + 4;
@ -12,34 +79,81 @@ const SWATCH_CY = SWATCH_SIZE + 4;
const SWATCH_W = (SWATCH_SIZE + 4) * 2;
const SWATCH_H = SWATCH_SIZE * Math.sqrt(3) + 8;
// ── Styled components ─────────────────────────────────────────────────────────
const Wrap = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
gap: 28px;
`;
const Title = styled.h2`
const PageTitle = styled.h2`
font-family: ${t.font.heading};
font-size: 20px;
color: ${t.text.title};
margin: 0 0 6px;
`;
const OntologyNote = styled.p`
font-family: ${t.font.body};
font-size: 13px;
color: ${t.text.secondary};
margin: 0;
max-width: 700px;
line-height: 1.5;
`;
const Grid = styled.div`
display: flex;
flex-wrap: wrap;
gap: 12px;
`;
const Card = styled.div`
const Section = styled.details`
background: ${t.bg.panel};
border: 1px solid ${t.border.panel};
border-radius: ${t.radius.panel};
overflow: hidden;
`;
const SectionSummary = styled.summary`
font-family: ${t.font.heading};
font-size: 15px;
color: ${t.text.title};
padding: 12px 16px;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 10px;
&:hover { color: ${t.accent.gold}; }
&::marker { color: ${t.text.muted}; }
`;
const SectionCount = styled.span`
font-family: ${t.font.mono};
font-size: 11px;
color: ${t.text.muted};
`;
const SectionDesc = styled.p`
font-family: ${t.font.body};
font-size: 12px;
color: ${t.text.muted};
margin: 0 16px 12px;
line-height: 1.4;
`;
const CardGrid = styled.div`
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 0 16px 16px;
`;
const Card = styled.div`
background: ${t.bg.raised};
border: 1px solid ${t.border.divider};
border-radius: 6px;
padding: 10px;
width: 190px;
width: 210px;
display: flex;
flex-direction: column;
gap: 6px;
gap: 5px;
`;
const CardTop = styled.div`
@ -63,7 +177,7 @@ const CardInfo = styled.div`
const CardName = styled.div`
font-family: ${t.font.heading};
font-size: 14px;
font-size: 13px;
color: ${t.text.title};
`;
@ -73,60 +187,56 @@ const CardId = styled.div`
color: ${t.text.muted};
`;
const Badge = styled.span`
display: inline-block;
background: ${t.bg.raised};
border: 1px solid ${t.border.divider};
border-radius: 2px;
const CardMeta = styled.div`
font-family: ${t.font.mono};
font-size: 9px;
font-size: 10px;
color: ${t.text.muted};
padding: 1px 4px;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
`;
const Stats = styled.div`
const StatRow = styled.div`
display: flex;
gap: 6px;
font-size: 11px;
font-family: ${t.font.mono};
color: ${t.text.secondary};
flex-wrap: wrap;
gap: 6px;
font-size: 10px;
font-family: ${t.font.mono};
color: ${t.text.muted};
`;
const Stat = styled.span`
color: ${t.text.muted};
const StatPill = styled.span`
background: ${t.bg.deepest};
border: 1px solid ${t.border.divider};
border-radius: 3px;
padding: 1px 5px;
span { color: ${t.text.secondary}; }
`;
const Desc = styled.p`
font-family: ${t.font.body};
font-size: 11px;
const FooterNote = styled.p`
font-family: ${t.font.mono};
font-size: 10px;
color: ${t.text.muted};
margin: 0;
line-height: 1.4;
opacity: 0.6;
line-height: 1.5;
`;
function SwatchHex({ terrainId }: { terrainId: string }): React.ReactElement {
const ref = useRef<HTMLCanvasElement>(null);
// ── Swatch component helpers ───────────────────────────────────────────────────
function SubstrateSwatchHex({ id }: { id: string }): React.ReactElement {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const terrain = TERRAIN_MAP.get(terrainId);
if (!terrain) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const corners = hexCorners(SWATCH_CX, SWATCH_CY, SWATCH_SIZE);
const [r, g, b] = terrain.color;
// gradient fill
const [r, g, b] = SUBSTRATE_COLORS[id] ?? [180, 180, 180];
const grad = ctx.createLinearGradient(SWATCH_CX, SWATCH_CY - SWATCH_SIZE, SWATCH_CX, SWATCH_CY + SWATCH_SIZE);
grad.addColorStop(0, `rgb(${Math.min(255,r+20)},${Math.min(255,g+20)},${Math.min(255,b+20)})`);
grad.addColorStop(1, `rgb(${Math.max(0,r-30)},${Math.max(0,g-30)},${Math.max(0,b-30)})`);
ctx.beginPath();
ctx.moveTo(corners[0][0], corners[0][1]);
for (let i = 1; i < 6; i++) ctx.lineTo(corners[i][0], corners[i][1]);
@ -136,47 +246,162 @@ function SwatchHex({ terrainId }: { terrainId: string }): React.ReactElement {
ctx.strokeStyle = "rgba(0,0,0,0.3)";
ctx.lineWidth = 1;
ctx.stroke();
}, [id]);
return <SwatchCanvas ref={ref} width={Math.round(SWATCH_W)} height={Math.round(SWATCH_H)} />;
}
function TerrainSwatchHex({ terrainId }: { terrainId: string }): React.ReactElement {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const terrain = TERRAIN_MAP.get(terrainId);
if (!terrain) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const corners = hexCorners(SWATCH_CX, SWATCH_CY, SWATCH_SIZE);
const [r, g, b] = terrain.color;
const grad = ctx.createLinearGradient(SWATCH_CX, SWATCH_CY - SWATCH_SIZE, SWATCH_CX, SWATCH_CY + SWATCH_SIZE);
grad.addColorStop(0, `rgb(${Math.min(255,r+20)},${Math.min(255,g+20)},${Math.min(255,b+20)})`);
grad.addColorStop(1, `rgb(${Math.max(0,r-30)},${Math.max(0,g-30)},${Math.max(0,b-30)})`);
ctx.beginPath();
ctx.moveTo(corners[0][0], corners[0][1]);
for (let i = 1; i < 6; i++) ctx.lineTo(corners[i][0], corners[i][1]);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
ctx.strokeStyle = "rgba(0,0,0,0.3)";
ctx.lineWidth = 1;
ctx.stroke();
drawDecorations(ctx, SWATCH_CX, SWATCH_CY, SWATCH_SIZE, terrainId, new SeededRng(42));
}, [terrainId]);
return <SwatchCanvas ref={ref} width={Math.round(SWATCH_W)} height={Math.round(SWATCH_H)} />;
}
// ── Sections ───────────────────────────────────────────────────────────────────
function SubstratesSection(): React.ReactElement {
const substrates = (substrateJson as { substrates: Array<{ id: string; name: string; albedo: number; drainage: number | null; fertility_base: number | null; evapotranspiration_max: number }> }).substrates;
return (
<SwatchCanvas
ref={ref}
width={Math.round(SWATCH_W)}
height={Math.round(SWATCH_H)}
/>
<Section open>
<SectionSummary>
Substrates <SectionCount>({substrates.length})</SectionCount>
</SectionSummary>
<SectionDesc>
Physical ground material. The lowest ontology layer determines what can grow and which climate cycles apply.
Substrate is assigned by the WASM physics engine (tectonic + hydrology passes) before ecology runs.
</SectionDesc>
<CardGrid>
{substrates.map(s => (
<Card key={s.id}>
<CardTop>
<SubstrateSwatchHex id={s.id} />
<CardInfo>
<CardName>{s.name}</CardName>
<CardId>{s.id}</CardId>
</CardInfo>
</CardTop>
<StatRow>
<StatPill>albedo <span>{s.albedo.toFixed(2)}</span></StatPill>
<StatPill>drain <span>{s.drainage !== null ? s.drainage.toFixed(2) : "—"}</span></StatPill>
<StatPill>fert <span>{s.fertility_base !== null ? s.fertility_base.toFixed(2) : "—"}</span></StatPill>
<StatPill>ET <span>{s.evapotranspiration_max.toFixed(2)}</span></StatPill>
</StatRow>
</Card>
))}
</CardGrid>
</Section>
);
}
export function TerrainCatalog(): React.ReactElement {
const terrains = [...TERRAIN_MAP.values()];
function FloraCoversSection(): React.ReactElement {
return (
<Wrap>
<Title>Terrain Catalog</Title>
<Grid>
{terrains.map((tr) => (
<Card key={tr.id}>
<CardTop>
<SwatchHex terrainId={tr.id} />
<CardInfo>
<CardName>{tr.name}</CardName>
<CardId>{tr.id}</CardId>
{tr.feature_type && <Badge>{tr.feature_type}</Badge>}
</CardInfo>
</CardTop>
<Stats>
<Stat>🌾 <span>{tr.food}</span></Stat>
<Stat> <span>{tr.production}</span></Stat>
<Stat>💰 <span>{tr.trade}</span></Stat>
<Stat>🛡 <span>{tr.defense_bonus}%</span></Stat>
<Stat>👟 <span>{tr.movement_cost}</span></Stat>
</Stats>
<Desc>{tr.description}</Desc>
<Section open>
<SectionSummary>
Flora Covers <SectionCount>({FLORA_COVERS.length})</SectionCount>
</SectionSummary>
<SectionDesc>
Ground-cover classification assigned alongside each biome label. Controls which flora species pools are
eligible and how the ecology engine populates canopy / understory / ground layers.
</SectionDesc>
<CardGrid>
{FLORA_COVERS.map(fc => (
<Card key={fc.id}>
<CardInfo>
<CardName>{fc.label}</CardName>
<CardId>{fc.id}</CardId>
</CardInfo>
<CardMeta>{fc.description}</CardMeta>
<CardMeta style={{ opacity: 0.6 }}>Where: {fc.contexts}</CardMeta>
</Card>
))}
</Grid>
</CardGrid>
</Section>
);
}
function BiomeLabelsSection(): React.ReactElement {
return (
<Section open>
<SectionSummary>
Biome Labels <SectionCount>({BIOME_LABELS.length})</SectionCount>
</SectionSummary>
<SectionDesc>
Display-only composites derived by{" "}
<code>mc-climate::derive::classify_terrain_whittaker</code>. Not stored on
tiles recomputed from substrate × T-band × P-band × elevation each turn.
</SectionDesc>
<CardGrid>
{BIOME_LABELS.map(bl => {
const terrainEntry = TERRAIN_MAP.get(bl.id);
return (
<Card key={bl.id}>
<CardTop>
{terrainEntry
? <TerrainSwatchHex terrainId={bl.id} />
: <div style={{ width: Math.round(SWATCH_W), height: Math.round(SWATCH_H), background: "rgba(255,255,255,0.04)", borderRadius: 4 }} />
}
<CardInfo>
<CardName>{bl.label}</CardName>
<CardId>{bl.id}</CardId>
</CardInfo>
</CardTop>
<CardMeta>substrate: {bl.substrates}</CardMeta>
<CardMeta>T-band: {bl.t_band} P-band: {bl.p_band}</CardMeta>
<CardMeta>cover: {bl.flora_cover}</CardMeta>
</Card>
);
})}
</CardGrid>
</Section>
);
}
// ── Main export ───────────────────────────────────────────────────────────────
export function TerrainCatalog(): React.ReactElement {
return (
<Wrap>
<div>
<PageTitle>Terrain Ontology Catalog</PageTitle>
<OntologyNote>
Three layers, listed from lowest to highest: <strong>Substrate</strong> (physical ground material, 9 types) {" "}
<strong>Flora Cover</strong> (vegetation density/type, {FLORA_COVERS.length} covers) {" "}
<strong>Biome Label</strong> (display composite, {BIOME_LABELS.length} labels).
A tile&apos;s biome label is not stored it is recomputed from substrate × climate (T-band × P-band) × elevation by the Rust classifier.
</OntologyNote>
</div>
<SubstratesSection />
<FloraCoversSection />
<BiomeLabelsSection />
<FooterNote>
Legacy <code>terrain.json</code> files (land_common, land_forest, land_special, water, frozen) are retained for
backward-compat per p2-52 Remaining; this catalog reflects the post-refactor substrate-keyed ontology.
The <code>TERRAIN_MAP</code> import is preserved for other consumers.
</FooterNote>
</Wrap>
);
}