diff --git a/public/games/age-of-dwarves/guide/src/pages/LairsPage.tsx b/public/games/age-of-dwarves/guide/src/pages/LairsPage.tsx index dd8c30a5..c20072b7 100644 --- a/public/games/age-of-dwarves/guide/src/pages/LairsPage.tsx +++ b/public/games/age-of-dwarves/guide/src/pages/LairsPage.tsx @@ -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 { ) } - -// ─── 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}; } -` diff --git a/public/games/age-of-dwarves/guide/src/pages/PopulationDashboardPage.tsx b/public/games/age-of-dwarves/guide/src/pages/PopulationDashboardPage.tsx index 07f3e16f..9b308ce7 100644 --- a/public/games/age-of-dwarves/guide/src/pages/PopulationDashboardPage.tsx +++ b/public/games/age-of-dwarves/guide/src/pages/PopulationDashboardPage.tsx @@ -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 = 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 - biomeAvgQuality: Record - biomeAvgCanopy: Record - biomeAvgUndergrowth: Record - 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 = {} - const biomeQualitySum: Record = {} - const biomeCanopySum: Record = {} - const biomeUgSum: Record = {} - 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 = {} - const biomeAvgCanopy: Record = {} - const biomeAvgUndergrowth: Record = {} - 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(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 ( - - - - {series.map(s => ( - - - {s.label} - - ))} - - - ) -} 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 ( @@ -424,8 +138,7 @@ export default function PopulationDashboardPage(): ReactElement { {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 ( @@ -436,9 +149,9 @@ export default function PopulationDashboardPage(): ReactElement { {count} - {(last.biomeAvgQuality[biome.id] ?? 0).toFixed(1)} - {(last.biomeAvgCanopy[biome.id] ?? 0).toFixed(3)} - {(last.biomeAvgUndergrowth[biome.id] ?? 0).toFixed(3)} + {(lastRecord.biomeAvgQuality[biome.id] ?? 0).toFixed(1)} + {(lastRecord.biomeAvgCanopy[biome.id] ?? 0).toFixed(3)} + {(lastRecord.biomeAvgUndergrowth[biome.id] ?? 0).toFixed(3)} ) })} @@ -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;