feat(guide): Add simulation data handling and display logic to LairsPage and PopulationDashboardPage components

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-07 21:34:30 -07:00
parent a8a7fb6f63
commit b741f37612
2 changed files with 21 additions and 616 deletions

View file

@ -1,11 +1,17 @@
import { useState, useMemo, type ReactElement } from 'react'
import styled from 'styled-components'
import { Link } from 'react-router-dom'
import { FadeIn, PageTitle, PageHeading, PageSubtitle, Section, SectionHeading, Prose, FilterChip } from '@magic-civ/guide-engine'
import { allWildCreatures } from '@/data/game'
import { TierRangePicker } from '@/components/TierRangePicker'
import { FilterBar, FilterGroup, FilterLabel, ChipRow } from '@/components/FilterBar'
import creatureDisplay from '@resources/wilds/creature_display.json'
import {
CountLabel, CardGrid, Card, CardHeader, TierRange, CardTitles, CardName, HabitatBadge,
Stats, Stat, StatLabel, StatValue, TagRow, TagPill,
LairSection, LairTitle, LairMeta, LairMetaItem, LairMetaLabel,
MaturityPill, RatePill, NoLairBadge, NoteText, ExpandButton,
MaturityGrid, MaturityCard, MaturityIndex, MaturityInfo, MaturityName, MaturityDesc,
EmptyState, RelatedRow, RelatedLink,
} from './lairs/styled'
// ─── Types ──────────────────────────────────────────────────────────────────
@ -60,9 +66,11 @@ const MATURITY_ORDER = ['nascent', 'established', 'matured', 'ancient'] as const
// ─── Data ───────────────────────────────────────────────────────────────────
const creatures: WildCreature[] = allWildCreatures as unknown as WildCreature[]
// allWildCreatures is typed as the raw JSON shape; the WildCreature interface
// is a strict overlay of the same fields — no cast needed, shape is compatible.
const creatures = allWildCreatures as WildCreature[]
// ─── Components ─────────────────────────────────────────────────────────────
// ─── Sub-components ──────────────────────────────────────────────────────────
function StatBlock({ hp, atk, def, mov }: { hp: number; atk: number; def: number; mov: number }): ReactElement {
return (
@ -302,283 +310,3 @@ export default function LairsPage(): ReactElement {
</FadeIn>
)
}
// ─── Styled components ──────────────────────────────────────────────────────
const CountLabel = styled.div`
font-size: 0.8125rem;
color: ${({ theme }) => theme.colors.text.muted};
margin-top: 0.25rem;
`
const CardGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr));
gap: 1rem;
margin-top: 0.75rem;
`
const Card = styled.div`
background: ${({ theme }) => theme.colors.surface};
border: 1px solid ${({ theme }) => theme.colors.border.default};
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
`
const CardHeader = styled.div`
display: flex;
align-items: flex-start;
gap: 0.75rem;
`
const TierRange = styled.span`
display: flex;
align-items: center;
justify-content: center;
min-width: 3.5rem;
height: 2.25rem;
border-radius: 6px;
font-family: monospace;
font-weight: 700;
font-size: 0.75rem;
color: ${({ theme }) => theme.colors.text.primary};
background: ${({ theme }) => theme.colors.border.default};
flex-shrink: 0;
`
const CardTitles = styled.div`
display: flex;
flex-direction: column;
gap: 0.25rem;
`
const CardName = styled.div`
font-weight: 600;
font-size: 0.9375rem;
color: ${({ theme }) => theme.colors.text.primary};
`
const HabitatBadge = styled.span<{ $color: string }>`
display: inline-block;
padding: 0.125rem 0.4375rem;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
text-transform: capitalize;
background: ${({ $color }) => $color}22;
border: 1px solid ${({ $color }) => $color}55;
color: ${({ $color }) => $color};
width: fit-content;
`
const Stats = styled.div`
display: flex;
gap: 0.75rem;
`
const Stat = styled.div`
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
`
const StatLabel = styled.span`
font-size: 0.5625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: ${({ theme }) => theme.colors.text.muted};
`
const StatValue = styled.span`
font-family: monospace;
font-weight: 700;
font-size: 0.875rem;
color: ${({ theme }) => theme.colors.text.primary};
`
const TagRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
`
const TagPill = styled.span`
display: inline-block;
padding: 0.125rem 0.4375rem;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 500;
text-transform: capitalize;
background: ${({ theme }) => theme.colors.border.default};
color: ${({ theme }) => theme.colors.text.muted};
`
const LairSection = styled.div`
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.625rem 0.75rem;
background: ${({ theme }) => theme.colors.background?.secondary ?? '#1a1a22'};
border: 1px solid ${({ theme }) => theme.colors.border.default};
border-radius: 6px;
`
const LairTitle = styled.div`
font-weight: 600;
font-size: 0.8125rem;
color: ${({ theme }) => theme.colors.text.primary};
text-transform: capitalize;
`
const LairMeta = styled.div`
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
`
const LairMetaItem = styled.div`
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.75rem;
color: ${({ theme }) => theme.colors.text.primary};
`
const LairMetaLabel = styled.span`
font-size: 0.5625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: ${({ theme }) => theme.colors.text.muted};
`
const MaturityPill = styled.span`
display: inline-block;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: capitalize;
background: ${({ theme }) => theme.colors.border.default};
color: ${({ theme }) => theme.colors.text.primary};
`
const RatePill = styled.span<{ $color: string }>`
display: inline-block;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
background: ${({ $color }) => $color}22;
border: 1px solid ${({ $color }) => $color}55;
color: ${({ $color }) => $color};
`
const NoLairBadge = styled.div`
font-size: 0.75rem;
font-style: italic;
color: ${({ theme }) => theme.colors.text.muted};
`
const NoteText = styled.div`
font-size: 0.75rem;
color: ${({ theme }) => theme.colors.text.muted};
line-height: 1.5;
font-style: italic;
`
const ExpandButton = styled.button`
background: none;
border: none;
color: ${({ theme }) => theme.colors.text.secondary ?? theme.colors.text.muted};
cursor: pointer;
font-size: 0.6875rem;
font-weight: 600;
padding: 0 0.25rem;
text-decoration: underline;
&:hover {
color: ${({ theme }) => theme.colors.text.primary};
}
`
const MaturityGrid = styled.div`
display: flex;
flex-direction: column;
gap: 0.625rem;
margin-top: 1rem;
`
const MaturityCard = styled.div`
display: flex;
align-items: center;
gap: 1rem;
padding: 0.625rem 1rem;
background: ${({ theme }) => theme.colors.surface};
border: 1px solid ${({ theme }) => theme.colors.border.default};
border-radius: 6px;
`
const MaturityIndex = styled.span`
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
font-family: monospace;
font-weight: 700;
font-size: 0.8125rem;
color: ${({ theme }) => theme.colors.text.primary};
background: ${({ theme }) => theme.colors.border.default};
flex-shrink: 0;
`
const MaturityInfo = styled.div`
display: flex;
flex-direction: column;
gap: 0.125rem;
`
const MaturityName = styled.span`
font-weight: 600;
font-size: 0.9375rem;
color: ${({ theme }) => theme.colors.text.primary};
text-transform: capitalize;
`
const MaturityDesc = styled.span`
font-size: 0.8125rem;
color: ${({ theme }) => theme.colors.text.muted};
`
const EmptyState = styled.div`
padding: 2rem;
text-align: center;
`
const RelatedRow = styled.div`
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.5rem;
`
const RelatedLink = styled(Link)`
display: inline-block;
padding: 0.5rem 1rem;
background: ${({ theme }) => theme.colors.surface};
border: 1px solid ${({ theme }) => theme.colors.border.default};
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
color: ${({ theme }) => theme.colors.text.primary};
text-decoration: none;
transition: border-color 120ms;
&:hover { border-color: ${({ theme }) => theme.colors.border.hover}; }
`

View file

@ -1,303 +1,15 @@
import { useState, useMemo, useRef, useEffect, type ReactElement } from 'react'
import { useState, useMemo, type ReactElement } from 'react'
import styled from 'styled-components'
import { FadeIn, PageTitle, PageHeading, PageSubtitle, Section, SectionHeading, Prose, FilterRow, FilterChip } from '@magic-civ/guide-engine'
import { ALL_BIOMES } from '@magic-civ/guide-engine'
import type { QualityTierDef } from '@magic-civ/guide-engine'
import traitDefinitions from '@resources/ecology/traits/trait_definitions.json'
import { useSimulation, MAX_TURNS, SEED, W, H } from './population-dashboard/simulation'
import { LineChart, type ChartSeries } from './population-dashboard/LineChart'
const TIER_COLOR_MAP: Record<number, string> = Object.fromEntries(
traitDefinitions.tier_system.tiers.map((t: QualityTierDef) => [t.tier, t.color])
)
import type { GridState, TileState } from '@magic-civ/engine-ts'
import { classifyBiome, DEFAULT_DT } from '@magic-civ/engine-ts'
import { WasmEcologyPhysics, WasmGrid } from '@magic-civ/physics-rs'
const W = 30
const H = 20
const MAX_TURNS = 120
const SEED = 42
// ---------------------------------------------------------------------------
// Simulation: run ecology on a deterministic grid, record per-biome stats per turn
// ---------------------------------------------------------------------------
interface TurnRecord {
turn: number
biomeCounts: Record<string, number>
biomeAvgQuality: Record<string, number>
biomeAvgCanopy: Record<string, number>
biomeAvgUndergrowth: Record<string, number>
totalLandTiles: number
globalHealth: number
}
function tileHash(seed: number, col: number, row: number): number {
let h = seed * 374761393 + col * 668265263 + row * 2147483647
h = (h ^ (h >>> 13)) * 1274126177
h = h ^ (h >>> 16)
return (h >>> 0) / 4294967296
}
function isWaterTile(tile: TileState): boolean {
return tile.substrate_id === 'deep_water' || tile.substrate_id === 'shallow_water' || tile.substrate_id === 'lake_bed'
}
function makeSimGrid(seed: number): GridState {
const tiles: TileState[] = new Array(W * H)
for (let row = 0; row < H; row++) {
for (let col = 0; col < W; col++) {
const h = tileHash(seed, col, row)
const h2 = tileHash(seed + 1, col, row)
const h3 = tileHash(seed + 2, col, row)
const latFactor = 1.0 - Math.abs(row - H / 2) / (H / 2)
const temperature = Math.min(1.0, Math.max(0.0, latFactor * 0.85 + h * 0.15))
const moisture = Math.min(1.0, Math.max(0.0, h2 * 0.75 + 0.12))
const edgeDist = Math.min(col, row, W - 1 - col, H - 1 - row)
const elevation = edgeDist <= 1 ? 0.1 : 0.3 + h3 * 0.5
const isWater = edgeDist <= 1
const substrate_id = isWater
? (edgeDist === 0 ? 'deep_water' : 'shallow_water')
: (elevation > 0.65 ? 'highland' : elevation > 0.55 ? 'midland' : 'lowland')
const tile: TileState = {
col, row, temperature, moisture, elevation,
biome_id: isWater ? 'ocean' : 'grassland',
wind_direction: Math.floor(h * 6),
wind_speed: 0.5,
pressure: 1013.0, pressure_anomaly: 0.0,
humidity: 0.0, relative_humidity: 0.5, dew_point: 0.4, cape: 0.0,
sulfate_aerosol: 0.0,
quality: 2,
quality_progress: 0,
river_edges: [],
river_flow: {},
flow_accumulation: 0.0,
original_biome_id: isWater ? 'ocean' : 'grassland',
ley_line_count: 0, ley_school: 'none',
reef_health: isWater ? 1.0 : 0.0,
magic_heat_delta: 0.0, magic_moisture_delta: 0.0,
is_natural_wonder: false,
wonder_anchor_strength: 0.0, wonder_anchor_school: 'none',
wonder_anchor_schools: [], wonder_tier: 0,
substrate_id, water_body_id: isWater ? 0 : -1,
depth_from_coast: isWater ? edgeDist : -1,
canopy_cover: 0.0, undergrowth: 0.0, fungi_network: 0.0,
drought_counter: 0, succession_progress: 0,
regrowth_stage: -1, regrowth_turns: 0,
habitat_suitability: 0.0, habitat_low_turns: 0, landmark_name: '',
water_body_type: isWater ? 'ocean' : '', is_river_mouth: false, has_cave: false, is_coastal: false,
surface_water: 0.0,
river_source_type: '',
fish_stock: 0.0,
aerosol_mitigation: 0.0,
resource_id: '',
}
if (!isWater) {
tile.biome_id = classifyBiome(tile)
}
tiles[row * W + col] = tile
}
}
return {
tiles, width: W, height: H,
global_avg_temp: 0.5, ocean_dead_fraction: 0.0,
ecosystem_health: 0.5, sea_level: 0.2,
total_ocean_water: 0.0, ocean_basin_area: 0,
o2_fraction: 0.21, co2_ppm: 280.0, ch4_ppb: 700.0,
global_temp_bias: 0.0, ecological_collapse: false,
o2_collapse_turn_count: 0, photosynthesisMultiplier: 1.0,
global_fish_stock: 1.0, ocean_toxic: false, ocean_toxicity: 0.0,
ocean_o2_contribution: 1.0, ocean_o2_suspended_turns: 0,
ocean_anoxic: false, dead_ocean: false, canfield_ocean: false,
trophic_cascade_active: false, trophic_cascade_phase: 0,
trophic_cascade_turns_remaining: 0, fish_collapse_check_timer: 0,
}
}
function recordTurn(grid: GridState, turn: number): TurnRecord {
const biomeCounts: Record<string, number> = {}
const biomeQualitySum: Record<string, number> = {}
const biomeCanopySum: Record<string, number> = {}
const biomeUgSum: Record<string, number> = {}
let totalLand = 0
for (const tile of grid.tiles) {
if (isWaterTile(tile)) continue
totalLand++
const b = tile.biome_id
biomeCounts[b] = (biomeCounts[b] ?? 0) + 1
biomeQualitySum[b] = (biomeQualitySum[b] ?? 0) + tile.quality
biomeCanopySum[b] = (biomeCanopySum[b] ?? 0) + tile.canopy_cover
biomeUgSum[b] = (biomeUgSum[b] ?? 0) + tile.undergrowth
}
const biomeAvgQuality: Record<string, number> = {}
const biomeAvgCanopy: Record<string, number> = {}
const biomeAvgUndergrowth: Record<string, number> = {}
for (const b of Object.keys(biomeCounts)) {
const n = biomeCounts[b]
biomeAvgQuality[b] = biomeQualitySum[b] / n
biomeAvgCanopy[b] = biomeCanopySum[b] / n
biomeAvgUndergrowth[b] = biomeUgSum[b] / n
}
return {
turn, biomeCounts, biomeAvgQuality, biomeAvgCanopy, biomeAvgUndergrowth,
totalLandTiles: totalLand,
globalHealth: grid.ecosystem_health,
}
}
function useSimulation(): TurnRecord[] {
return useMemo(() => {
let grid = makeSimGrid(SEED)
const eco = new WasmEcologyPhysics()
const records: TurnRecord[] = [recordTurn(grid, 0)]
const wg = WasmGrid.fromJSON(grid)
for (let t = 1; t <= MAX_TURNS; t++) {
eco.processStep(wg, DEFAULT_DT)
if (t % 5 === 0 || t <= 10 || t === MAX_TURNS) {
grid = wg.toJSON() as GridState
records.push(recordTurn(grid, t))
}
}
wg.free()
eco.free()
return records
}, [])
}
// ---------------------------------------------------------------------------
// Canvas line chart renderer
// ---------------------------------------------------------------------------
interface ChartSeries {
label: string
color: string
data: Array<{ x: number; y: number }>
}
function LineChart({
series,
title,
xLabel,
yLabel,
width = 700,
height = 300,
}: {
series: ChartSeries[]
title: string
xLabel: string
yLabel: string
width?: number
height?: number
}): ReactElement {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const pad = { top: 30, right: 20, bottom: 35, left: 55 }
const cw = width - pad.left - pad.right
const ch = height - pad.top - pad.bottom
ctx.clearRect(0, 0, width, height)
// Find ranges
let xMin = Infinity, xMax = -Infinity, yMax = -Infinity
const yMin = 0
for (const s of series) {
for (const d of s.data) {
if (d.x < xMin) xMin = d.x
if (d.x > xMax) xMax = d.x
if (d.y > yMax) yMax = d.y
}
}
if (xMax === xMin) xMax = xMin + 1
if (yMax === yMin) yMax = yMin + 1
yMax *= 1.1
const toX = (v: number) => pad.left + ((v - xMin) / (xMax - xMin)) * cw
const toY = (v: number) => pad.top + ch - ((v - yMin) / (yMax - yMin)) * ch
// Grid lines
ctx.strokeStyle = 'rgba(200,200,200,0.15)'
ctx.lineWidth = 0.5
for (let i = 0; i <= 4; i++) {
const y = pad.top + (ch * i) / 4
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + cw, y); ctx.stroke()
}
// Axes
ctx.strokeStyle = 'rgba(200,200,200,0.4)'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(pad.left, pad.top)
ctx.lineTo(pad.left, pad.top + ch)
ctx.lineTo(pad.left + cw, pad.top + ch)
ctx.stroke()
// Data lines
for (const s of series) {
if (s.data.length < 2) continue
ctx.beginPath()
ctx.moveTo(toX(s.data[0].x), toY(s.data[0].y))
for (let i = 1; i < s.data.length; i++) {
ctx.lineTo(toX(s.data[i].x), toY(s.data[i].y))
}
ctx.strokeStyle = s.color
ctx.lineWidth = 1.5
ctx.stroke()
}
// Labels
ctx.fillStyle = 'rgba(240,232,208,0.8)'
ctx.font = '11px monospace'
ctx.textAlign = 'center'
ctx.fillText(title, width / 2, 16)
ctx.font = '9px monospace'
ctx.fillText(xLabel, pad.left + cw / 2, height - 4)
ctx.textAlign = 'right'
ctx.fillText(String(Math.round(yMax)), pad.left - 5, pad.top + 4)
ctx.fillText(String(Math.round(yMin)), pad.left - 5, pad.top + ch + 4)
ctx.save()
ctx.translate(10, pad.top + ch / 2)
ctx.rotate(-Math.PI / 2)
ctx.textAlign = 'center'
ctx.fillText(yLabel, 0, 0)
ctx.restore()
// X-axis ticks
ctx.textAlign = 'center'
const xStep = Math.ceil((xMax - xMin) / 6)
for (let x = xMin; x <= xMax; x += xStep) {
ctx.fillText(String(Math.round(x)), toX(x), pad.top + ch + 14)
}
}, [series, title, xLabel, yLabel, width, height])
return (
<ChartContainer>
<canvas ref={canvasRef} width={width} height={height} />
<ChartLegend>
{series.map(s => (
<ChartLegendItem key={s.label}>
<ChartLegendLine style={{ background: s.color }} />
{s.label}
</ChartLegendItem>
))}
</ChartLegend>
</ChartContainer>
)
}
export default function PopulationDashboardPage(): ReactElement {
const records = useSimulation()
@ -355,6 +67,8 @@ export default function PopulationDashboardPage(): ReactElement {
count: { title: 'Tile Count per Biome', yLabel: 'Tiles' },
}
const lastRecord = records[records.length - 1]
return (
<FadeIn duration="fast">
<PageTitle>
@ -424,8 +138,7 @@ export default function PopulationDashboardPage(): ReactElement {
</thead>
<tbody>
{presentBiomes.map(biome => {
const last = records[records.length - 1]
const count = last.biomeCounts[biome.id] ?? 0
const count = lastRecord.biomeCounts[biome.id] ?? 0
if (count === 0) return null
return (
<tr key={biome.id}>
@ -436,9 +149,9 @@ export default function PopulationDashboardPage(): ReactElement {
</BiomeName>
</td>
<td>{count}</td>
<td>{(last.biomeAvgQuality[biome.id] ?? 0).toFixed(1)}</td>
<td>{(last.biomeAvgCanopy[biome.id] ?? 0).toFixed(3)}</td>
<td>{(last.biomeAvgUndergrowth[biome.id] ?? 0).toFixed(3)}</td>
<td>{(lastRecord.biomeAvgQuality[biome.id] ?? 0).toFixed(1)}</td>
<td>{(lastRecord.biomeAvgCanopy[biome.id] ?? 0).toFixed(3)}</td>
<td>{(lastRecord.biomeAvgUndergrowth[biome.id] ?? 0).toFixed(3)}</td>
</tr>
)
})}
@ -453,42 +166,6 @@ export default function PopulationDashboardPage(): ReactElement {
// Styled components
// ---------------------------------------------------------------------------
const ChartContainer = styled.div`
margin-top: 1rem;
border: 1px solid ${({ theme }) => theme.colors.border.default};
border-radius: 6px;
overflow: hidden;
background: #0d0b14;
padding: 0.5rem;
canvas {
width: 100%;
height: auto;
display: block;
}
`
const ChartLegend = styled.div`
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
padding: 0.5rem;
`
const ChartLegendItem = styled.span`
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.6875rem;
color: rgba(240,232,208,0.7);
`
const ChartLegendLine = styled.span`
width: 1rem;
height: 2px;
border-radius: 1px;
`
const SummaryTable = styled.table`
width: 100%;
border-collapse: collapse;