From 1f67736b6ebcbe1313dca8b50a128581cec862d4 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 26 Mar 2026 01:25:21 -0700 Subject: [PATCH] =?UTF-8?q?feat(climate-sim):=20=E2=9C=A8=20Add=20climate?= =?UTF-8?q?=20simulation=20visualization=20components=20and=20sprite-based?= =?UTF-8?q?=20engine=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../climate-sim/ClimateSimDisplay.tsx | 2 + .../components/climate-sim/StatsDashboard.tsx | 44 ++-- packages/engine-ts/src/runner.ts | 12 +- packages/engine-ts/src/types.ts | 2 + tools/sprite-generation/gui/src/App.tsx | 23 ++- .../gui/src/pages/SpriteTheaterPage.tsx | 194 ++++++++++++++++++ tools/sprite-generation/sprites.db-shm | Bin 0 -> 32768 bytes tools/sprite-generation/sprites.db-wal | 0 8 files changed, 259 insertions(+), 18 deletions(-) create mode 100644 tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx create mode 100644 tools/sprite-generation/sprites.db-shm create mode 100644 tools/sprite-generation/sprites.db-wal diff --git a/guide/engine/src/components/climate-sim/ClimateSimDisplay.tsx b/guide/engine/src/components/climate-sim/ClimateSimDisplay.tsx index 6bc1d591..4511769b 100644 --- a/guide/engine/src/components/climate-sim/ClimateSimDisplay.tsx +++ b/guide/engine/src/components/climate-sim/ClimateSimDisplay.tsx @@ -57,6 +57,8 @@ function frameAsSnapshot(frame: FramePayload, stats?: { avg_moisture: number; to avg_water_quality: 1, avg_aerosol: 0, avg_evapotranspiration: 0, + net_energy: 0, + net_hydro: 0, terrain_counts: {}, }, events: [], diff --git a/guide/engine/src/components/climate-sim/StatsDashboard.tsx b/guide/engine/src/components/climate-sim/StatsDashboard.tsx index 17f54744..7a28cb99 100644 --- a/guide/engine/src/components/climate-sim/StatsDashboard.tsx +++ b/guide/engine/src/components/climate-sim/StatsDashboard.tsx @@ -69,16 +69,24 @@ const METRIC_CATALOG: Record = { et: { key: 'evapotrans', label: 'ET', tooltip: 'Average evapotranspiration across land. Moisture recycled by vegetation per turn.', color: '#80DEEA', getValue: (s) => s.avg_evapotranspiration, formatValue: fmt4, formatDelta: fmtDelta4 }, albedo: { key: 'albedo', label: 'Albedo', tooltip: 'Global average surface reflectivity (0=absorbs all, 1=reflects all). Ice/snow raise it, forests/water lower it.', color: '#BDBDBD', getValue: (s) => s.avg_albedo, formatValue: fmt3, formatDelta: fmtDelta3 }, aerosol: { key: 'aerosol', label: 'Aerosol', tooltip: 'Global average sulfate aerosol opacity. Volcanic eruptions inject aerosols that cool and dry the atmosphere.', color: '#90A4AE', getValue: (s) => s.avg_aerosol, formatValue: fmt4, formatDelta: fmtDelta4 }, - // -- Land ecology -- - land_canopy: { key: 'land_canopy', label: 'Flora', tooltip: 'Average tree canopy cover across land (0=barren, 1=full canopy). Drives succession and shading.', color: '#2E7D32', getValue: (s) => s.avg_land_canopy, formatValue: fmt3, formatDelta: fmtDelta3 }, + net_energy: { key: 'net_energy', label: 'Net Energy', tooltip: 'Per-turn temperature change. Positive = planet absorbing more heat than it radiates (warming). Negative = cooling.', color: '#FF7043', getValue: (s) => s.net_energy, formatValue: (v) => (v >= 0 ? '+' : '') + v.toFixed(4), formatDelta: fmtDelta4 }, + net_hydro: { key: 'net_hydro', label: 'Net Hydro', tooltip: 'Per-turn moisture change. Positive = planet gaining water (wetting). Negative = losing water (drying).', color: '#4FC3F7', getValue: (s) => s.net_hydro, formatValue: (v) => (v >= 0 ? '+' : '') + v.toFixed(4), formatDelta: fmtDelta4 }, + // -- Land ecology: quality + population -- + land_fauna_q: { key: 'land_fauna_q', label: 'Fauna Qlty', tooltip: 'Average land tile quality (1-5). Determines max fauna species tier that can spawn. Higher = rarer species.', color: '#FFD54F', getValue: (s) => s.avg_land_quality, formatValue: fmt2, formatDelta: fmtDelta2 }, + land_fauna_p: { key: 'land_fauna_p', label: 'Fauna Pop', tooltip: 'Average fauna habitat suitability across land (0=inhospitable, 1=thriving). Combines flora density, moisture, temperature.', color: '#A1887F', getValue: (s) => s.avg_land_habitat, formatValue: fmt3, formatDelta: fmtDelta3 }, + land_flora_q: { key: 'land_flora_q', label: 'Flora Qlty', tooltip: 'Average land tile quality (1-5). Determines max flora species tier. Higher quality = denser, more diverse vegetation.', color: '#FFD54F', getValue: (s) => s.avg_land_quality, formatValue: fmt2, formatDelta: fmtDelta2 }, + land_flora_p: { key: 'land_flora_p', label: 'Flora Pop', tooltip: 'Average tree canopy cover across land (0=barren, 1=full canopy). Drives succession, shading, and lumber yield.', color: '#2E7D32', getValue: (s) => s.avg_land_canopy, formatValue: fmt3, formatDelta: fmtDelta3 }, + // -- Land ecology: details -- land_under: { key: 'land_undergrowth', label: 'Undergrowth', tooltip: 'Average ground vegetation across land (0=bare, 1=dense). Drives food yield and habitat quality.', color: '#66BB6A', getValue: (s) => s.avg_land_undergrowth, formatValue: fmt3, formatDelta: fmtDelta3 }, - land_fungi: { key: 'land_fungi', label: 'Fungi', tooltip: 'Average mycorrhizal network across land (0=none, 1=dense). Accelerates forest regrowth, boosts ecosystem resilience.', color: '#8D6E63', getValue: (s) => s.avg_land_fungi, formatValue: fmt3, formatDelta: fmtDelta3 }, - land_habitat: { key: 'land_habitat', label: 'Fauna', tooltip: 'Average fauna habitat suitability across land (0=inhospitable, 1=thriving). Combines flora density, moisture, temperature.', color: '#A1887F', getValue: (s) => s.avg_land_habitat, formatValue: fmt3, formatDelta: fmtDelta3 }, - land_quality: { key: 'land_quality', label: 'Quality', tooltip: 'Average tile quality across land (1=Prolific, 5=Epic). Ecology composite of flora health, fauna diversity, biome stability.', color: '#FFD54F', getValue: (s) => s.avg_land_quality, formatValue: fmt2, formatDelta: fmtDelta2 }, - // -- Water ecology -- - water_reef: { key: 'water_reef', label: 'Flora', tooltip: 'Average reef health across water tiles (0=dead, 1=pristine). Bleaching from high temps (>0.75). Dead reefs halve fish capacity.', color: '#29B6F6', getValue: (s) => s.avg_water_reef, formatValue: fmt3, formatDelta: fmtDelta3 }, - water_fish: { key: 'water_fish', label: 'Fauna', tooltip: 'Average fish stock across water tiles (0=empty, 100+=abundant). Logistic reproduction, temperature-scaled.', color: '#26C6DA', getValue: (s) => s.avg_water_fish, formatValue: fmt2, formatDelta: fmtDelta2 }, - water_quality:{ key: 'water_quality', label: 'Quality', tooltip: 'Average tile quality across water tiles (1=Prolific, 5=Epic). Reflects marine ecosystem health.', color: '#42A5F5', getValue: (s) => s.avg_water_quality, formatValue: fmt2, formatDelta: fmtDelta2 }, + land_fungi: { key: 'land_fungi', label: 'Fungi Net', tooltip: 'Average mycorrhizal network across land (0=none, 1=dense). Accelerates forest regrowth, boosts ecosystem resilience.', color: '#8D6E63', getValue: (s) => s.avg_land_fungi, formatValue: fmt3, formatDelta: fmtDelta3 }, + land_quality: { key: 'land_quality', label: 'Biome Qlty', tooltip: 'Average biome quality across land (1-5). Composite of flora health, fauna diversity, biome stability.', color: '#FFD54F', getValue: (s) => s.avg_land_quality, formatValue: fmt2, formatDelta: fmtDelta2 }, + // -- Water ecology: quality + population -- + water_fauna_q:{ key: 'water_fauna_q', label: 'Fauna Qlty', tooltip: 'Average water tile quality (1-5). Determines max marine fauna species tier.', color: '#42A5F5', getValue: (s) => s.avg_water_quality, formatValue: fmt2, formatDelta: fmtDelta2 }, + water_fauna_p:{ key: 'water_fauna_p', label: 'Fauna Pop', tooltip: 'Average fish stock across water tiles (0=depleted, 1=abundant). Logistic reproduction, temperature-scaled.', color: '#26C6DA', getValue: (s) => s.avg_water_fish, formatValue: fmt3, formatDelta: fmtDelta3 }, + water_flora_q:{ key: 'water_flora_q', label: 'Flora Qlty', tooltip: 'Average water tile quality (1-5). Determines max marine flora species tier.', color: '#42A5F5', getValue: (s) => s.avg_water_quality, formatValue: fmt2, formatDelta: fmtDelta2 }, + water_flora_p:{ key: 'water_flora_p', label: 'Flora Pop', tooltip: 'Average reef health across water tiles (0=dead, 1=pristine). Bleaching from high temps (>0.75).', color: '#29B6F6', getValue: (s) => s.avg_water_reef, formatValue: fmt3, formatDelta: fmtDelta3 }, + water_quality:{ key: 'water_quality', label: 'Biome Qlty', tooltip: 'Average biome quality across water tiles (1-5). Reflects marine ecosystem health.', color: '#42A5F5', getValue: (s) => s.avg_water_quality, formatValue: fmt2, formatDelta: fmtDelta2 }, + water_reef: { key: 'water_reef', label: 'Reef Health', tooltip: 'Average coral reef health across coastal tiles (0=dead, 1=pristine). Bleaching from high temps (>0.75) destroys reefs.', color: '#29B6F6', getValue: (s) => s.avg_water_reef, formatValue: fmt3, formatDelta: fmtDelta3 }, } const m = (key: string): MetricDef => METRIC_CATALOG[key] @@ -100,16 +108,26 @@ const PRIMARY_METRICS: MetricDef[] = [ formatDelta: fmtDelta2, }, m('sea_level'), + m('albedo'), + m('net_energy'), + m('net_hydro'), + m('aerosol'), ] -// Life mode: LAND column — Fauna, Flora, Quality, then details +// Life mode: LAND column const LIFE_LEFT: MetricDef[] = [ - m('land_habitat'), m('land_canopy'), m('land_quality'), m('land_under'), m('land_fungi'), + m('land_quality'), + m('land_fauna_q'), m('land_fauna_p'), + m('land_flora_q'), m('land_flora_p'), + m('land_under'), m('land_fungi'), ] -// Life mode: WATER column — Fauna, Flora, Quality +// Life mode: WATER column const LIFE_RIGHT: MetricDef[] = [ - m('water_fish'), m('water_reef'), m('water_quality'), + m('water_quality'), + m('water_fauna_q'), m('water_fauna_p'), + m('water_flora_q'), m('water_flora_p'), + m('water_reef'), ] // Environment mode: left column (energy budget) diff --git a/packages/engine-ts/src/runner.ts b/packages/engine-ts/src/runner.ts index 0d031146..a78e3929 100644 --- a/packages/engine-ts/src/runner.ts +++ b/packages/engine-ts/src/runner.ts @@ -89,6 +89,7 @@ export function encodeSnapshot( turn: number, events: EcologicalEvent[] = [], terrainCache?: Map, + prevStats?: TurnStats, ): GridSnapshot { const n = grid.tiles.length const texA = new Float32Array(n * 4) @@ -117,7 +118,7 @@ export function encodeSnapshot( texC[base + 3] = tile.habitat_suitability ?? 0.0 } - const stats = computeTurnStats(grid, terrainCache) + const stats = computeTurnStats(grid, terrainCache, prevStats) return { texA, texB, texC, @@ -138,6 +139,7 @@ const EMPTY_SCHOOL_RECORD: Record = { death: 0, life: 0, natu export function computeTurnStats( grid: GridState, terrainCache?: Map, + prevStats?: TurnStats, ): TurnStats { const { tiles, width, height } = grid let tempSum = 0 @@ -216,6 +218,8 @@ export function computeTurnStats( avg_water_quality: waterCount > 0 ? waterQualitySum / waterCount : 1, avg_aerosol: aerosolSum / n, avg_evapotranspiration: landCount > 0 ? etSum / landCount : 0, + net_energy: prevStats ? (landCount > 0 ? tempSum / landCount : 0.5) - prevStats.avg_temp : 0, + net_hydro: prevStats ? (landCount > 0 ? moistSum / landCount : 0.5) - prevStats.avg_moisture : 0, terrain_counts, } } @@ -312,7 +316,8 @@ export function runScenarioSync( const events = physics.processStep(grid, turn, worldSeed) ecology.processStep(grid) - snapshots.push(encodeSnapshot(grid, scenarioTurn, events, terrainCache)) + const prev = snapshots.length > 0 ? snapshots[snapshots.length - 1].stats : undefined + snapshots.push(encodeSnapshot(grid, scenarioTurn, events, terrainCache, prev)) } return { @@ -349,7 +354,8 @@ export function extendSimulation( const events = physics.processStep(grid, turn, worldSeed) ecology.processStep(grid) - snapshots.push(encodeSnapshot(grid, scenarioTurn, events, terrainCache)) + const prev = snapshots.length > 0 ? snapshots[snapshots.length - 1].stats : undefined + snapshots.push(encodeSnapshot(grid, scenarioTurn, events, terrainCache, prev)) } return { diff --git a/packages/engine-ts/src/types.ts b/packages/engine-ts/src/types.ts index 071719f3..8ffb0872 100644 --- a/packages/engine-ts/src/types.ts +++ b/packages/engine-ts/src/types.ts @@ -77,6 +77,8 @@ export interface TurnStats { avg_water_quality: number // average quality across water tiles (1-5) avg_aerosol: number // global average sulfate aerosol opacity avg_evapotranspiration: number // average ET contribution across land tiles + net_energy: number // net energy balance: solar_in - radiative_loss (positive=warming, negative=cooling) + net_hydro: number // net water balance: evap+ET - decay-loss (positive=wetting, negative=drying) terrain_counts: Record } diff --git a/tools/sprite-generation/gui/src/App.tsx b/tools/sprite-generation/gui/src/App.tsx index 95dfc510..02d1a529 100644 --- a/tools/sprite-generation/gui/src/App.tsx +++ b/tools/sprite-generation/gui/src/App.tsx @@ -1,12 +1,21 @@ -import { Routes, Route, Link } from 'react-router-dom' +import { Routes, Route, Link, useSearchParams } from 'react-router-dom' import type { ReactElement } from 'react' import { DashboardPage } from './pages/DashboardPage' import { ReviewQueuePage } from './pages/ReviewQueuePage' import { CategoryPage } from './pages/CategoryPage' import { SpritePage } from './pages/SpritePage' +import SpriteTheaterPage from './pages/SpriteTheaterPage' import { SpriteStream } from './SpriteStream' import { colors } from './pages/theme' +function DashboardOrTheater(): ReactElement { + const [params] = useSearchParams() + if (params.get('spriteTheater') === 'true') { + return + } + return +} + export function App(): ReactElement { return (
Sprite Review +
+ + Theater + + + Review + +
- } /> + } /> + } /> } /> } /> } /> diff --git a/tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx b/tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx new file mode 100644 index 00000000..d4598cdb --- /dev/null +++ b/tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx @@ -0,0 +1,194 @@ +import { useEffect, useState, useRef, type CSSProperties } from 'react' +import { fetchRecentVariants, variantStreamUrl, rawImageUrl } from '../api' +import type { RecentVariant } from '../types' +import { colors } from './theme' + +function extractFilename(rawPath: string): string { + return rawPath.replace(/\\/g, '/').split('/').pop() ?? '' +} + +function parseScores(notes: string | null): Record | null { + if (!notes) return null + try { + const parsed: unknown = JSON.parse(notes) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const result: Record = {} + for (const [k, v] of Object.entries(parsed as Record)) { + if (typeof v === 'number') result[k] = v + } + return Object.keys(result).length > 0 ? result : null + } + } catch { /* parse failure — no scores */ } + return null +} + +function confidence(v: RecentVariant): number { + const scores = parseScores(v.notes) + if (!scores) return 0 + const vals = Object.values(scores) + return vals.reduce((a, b) => a + b, 0) / vals.length +} + +const page: CSSProperties = { + minHeight: '100vh', + background: '#0a0a14', + color: colors.text, + padding: 0, +} + +const headerStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '12px 24px', + borderBottom: `1px solid ${colors.accent}`, + background: colors.bg, +} + +const gridStyle: CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', + gap: '12px', + padding: '16px', +} + +function ScoreBadge({ label, value }: { label: string; value: number }) { + const bg = value >= 0.7 ? 'rgba(16,185,129,0.2)' : value >= 0.5 ? 'rgba(245,158,11,0.2)' : 'rgba(239,68,68,0.2)' + const fg = value >= 0.7 ? '#10b981' : value >= 0.5 ? '#f59e0b' : '#ef4444' + return ( + + {label}: {(value * 100).toFixed(0)} + + ) +} + +function Card({ v }: { v: RecentVariant }) { + const conf = confidence(v) + const scores = parseScores(v.notes) + const passing = conf >= 0.7 + const filename = extractFilename(v.raw_path) + + return ( +
+
+ {v.entity_id} + {conf > 0 && ( + = 0.5 ? '#f59e0b' : '#ef4444', + color: '#000', + }}>{(conf * 100).toFixed(0)}% + )} + {v.category} + {v.is_approved && ( + APPROVED + )} +
+
+
{v.entity_id}
+
seed: {v.seed}
+ {scores && ( +
+ {Object.entries(scores).map(([k, val]) => ( + + ))} +
+ )} +
+
+ ) +} + +export default function SpriteTheaterPage() { + const [variants, setVariants] = useState([]) + const [connected, setConnected] = useState(false) + const eventSourceRef = useRef(null) + + useEffect(() => { + fetchRecentVariants(100).then(setVariants).catch(() => { /* initial load failed — will retry via SSE */ }) + }, []) + + useEffect(() => { + const es = new EventSource(variantStreamUrl()) + eventSourceRef.current = es + + es.onopen = () => setConnected(true) + es.onerror = () => setConnected(false) + + es.onmessage = (event: MessageEvent) => { + try { + const newVariants: RecentVariant[] = JSON.parse(event.data) as RecentVariant[] + setVariants(prev => { + const ids = new Set(prev.map(v => v.variant_id)) + const fresh = newVariants.filter(v => !ids.has(v.variant_id)) + if (fresh.length === 0) return prev + return [...fresh, ...prev] + }) + } catch { /* keepalive or malformed — ignore */ } + } + + return () => es.close() + }, []) + + const sorted = [...variants].sort((a, b) => { + const ca = confidence(a) + const cb = confidence(b) + if (ca !== cb) return cb - ca + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + }) + + return ( +
+
+
+ + Sprite Theater + + + + {connected ? 'Live' : 'Disconnected'} — {variants.length} sprites + +
+ + Dashboard + +
+ + {variants.length === 0 ? ( +
+
Waiting for sprites...
+
Generate sprites with ./run tools spritegen generate
+
New images will appear here in real-time
+
+ ) : ( +
+ {sorted.map(v => )} +
+ )} +
+ ) +} diff --git a/tools/sprite-generation/sprites.db-shm b/tools/sprite-generation/sprites.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..fe9ac2845eca6fe6da8a63cd096d9cf9e24ece10 GIT binary patch literal 32768 zcmeIuAr62r3