feat(climate-sim): Add climate simulation visualization components and sprite-based engine support

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-26 01:25:21 -07:00
parent 670cc24d7a
commit 1f67736b6e
8 changed files with 259 additions and 18 deletions

View file

@ -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: [],

View file

@ -69,16 +69,24 @@ const METRIC_CATALOG: Record<string, MetricDef> = {
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)

View file

@ -89,6 +89,7 @@ export function encodeSnapshot(
turn: number,
events: EcologicalEvent[] = [],
terrainCache?: Map<string, TerrainData>,
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<LeySchool, number> = { death: 0, life: 0, natu
export function computeTurnStats(
grid: GridState,
terrainCache?: Map<string, TerrainData>,
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 {

View file

@ -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<string, number>
}

View file

@ -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 <SpriteTheaterPage />
}
return <DashboardPage />
}
export function App(): ReactElement {
return (
<div
@ -22,6 +31,7 @@ export function App(): ReactElement {
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 24px',
height: 48,
background: colors.surface,
@ -40,9 +50,18 @@ export function App(): ReactElement {
>
Sprite Review
</Link>
<div style={{ display: 'flex', gap: 16 }}>
<Link to="/?spriteTheater=true" style={{ color: colors.muted, textDecoration: 'none', fontSize: 13 }}>
Theater
</Link>
<Link to="/review" style={{ color: colors.muted, textDecoration: 'none', fontSize: 13 }}>
Review
</Link>
</div>
</nav>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/" element={<DashboardOrTheater />} />
<Route path="/theater" element={<SpriteTheaterPage />} />
<Route path="/review" element={<ReviewQueuePage />} />
<Route path="/category/:name" element={<CategoryPage />} />
<Route path="/sprite/*" element={<SpritePage />} />

View file

@ -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<string, number> | null {
if (!notes) return null
try {
const parsed: unknown = JSON.parse(notes)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const result: Record<string, number> = {}
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
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 (
<span style={{ fontSize: 9, padding: '1px 4px', borderRadius: 3, background: bg, color: fg }}>
{label}: {(value * 100).toFixed(0)}
</span>
)
}
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 (
<div style={{
background: colors.surface,
borderRadius: 8,
overflow: 'hidden',
border: passing ? '2px solid #10b981' : v.is_approved ? '2px solid #8b5cf6' : '1px solid #1e293b',
position: 'relative',
}}>
<div style={{ position: 'relative' }}>
<img
src={rawImageUrl(filename)}
alt={v.entity_id}
style={{ width: '100%', display: 'block', aspectRatio: '1/1', objectFit: 'cover' }}
loading="lazy"
/>
{conf > 0 && (
<span style={{
position: 'absolute', top: 8, right: 8,
padding: '2px 8px', borderRadius: 12, fontSize: 11, fontWeight: 700,
background: passing ? '#10b981' : conf >= 0.5 ? '#f59e0b' : '#ef4444',
color: '#000',
}}>{(conf * 100).toFixed(0)}%</span>
)}
<span style={{
position: 'absolute', top: 8, left: 8,
padding: '2px 8px', borderRadius: 12, fontSize: 10, fontWeight: 600,
background: 'rgba(0,0,0,0.7)', color: colors.muted,
}}>{v.category}</span>
{v.is_approved && (
<span style={{
position: 'absolute', bottom: 8, right: 8,
padding: '2px 8px', borderRadius: 12, fontSize: 11, fontWeight: 700,
background: '#8b5cf6', color: '#000',
}}>APPROVED</span>
)}
</div>
<div style={{ padding: '8px 10px', fontSize: 11, color: colors.muted }}>
<div style={{ fontWeight: 600, color: colors.text, marginBottom: 2 }}>{v.entity_id}</div>
<div>seed: {v.seed}</div>
{scores && (
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 4 }}>
{Object.entries(scores).map(([k, val]) => (
<ScoreBadge key={k} label={k.replace('_', ' ').replace('production ', 'prod ')} value={val} />
))}
</div>
)}
</div>
</div>
)
}
export default function SpriteTheaterPage() {
const [variants, setVariants] = useState<RecentVariant[]>([])
const [connected, setConnected] = useState(false)
const eventSourceRef = useRef<EventSource | null>(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<string>) => {
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 (
<div style={page}>
<div style={headerStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<a href="/" style={{ color: colors.text, textDecoration: 'none', fontSize: 18, fontWeight: 700 }}>
Sprite Theater
</a>
<span style={{
width: 8, height: 8, borderRadius: '50%',
background: connected ? '#10b981' : '#ef4444',
display: 'inline-block',
}} />
<span style={{ fontSize: 12, color: colors.muted }}>
{connected ? 'Live' : 'Disconnected'} &mdash; {variants.length} sprites
</span>
</div>
<a href="/" style={{ color: colors.muted, textDecoration: 'none', fontSize: 13 }}>
Dashboard
</a>
</div>
{variants.length === 0 ? (
<div style={{ textAlign: 'center', padding: 80, color: colors.muted }}>
<div style={{ fontSize: 48, marginBottom: 16 }}>Waiting for sprites...</div>
<div>Generate sprites with <code style={{ color: colors.text }}>./run tools spritegen generate</code></div>
<div style={{ marginTop: 8 }}>New images will appear here in real-time</div>
</div>
) : (
<div style={gridStyle}>
{sorted.map(v => <Card key={v.variant_id} v={v} />)}
</div>
)}
</div>
)
}

Binary file not shown.

View file