ui(guide): 💄 Update SurvivalGuidePage component layout, styling, and functionality for better UX and new content in Age of Dwarves

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-07 21:28:48 -07:00
parent 0414b0d53c
commit a8a7fb6f63

View file

@ -1,5 +1,4 @@
import type { ReactElement } from 'react'
import styled from 'styled-components'
import { FadeIn } from '@magic-civ/guide-engine'
import { PageTitle, PageHeading, PageSubtitle, Section, SectionHeading, Prose } from '@magic-civ/guide-engine'
import { allBuildings, climateSpec } from '@/data'
@ -9,345 +8,36 @@ import {
getEffectValue,
formatTech,
} from '@/data/derived'
import {
CascadesGrid, CascadeRow, CascadeStep, CascadeArrow, CascadeLabel,
InfoGrid, InfoTile, TileLabel, TileValue, TileSub,
Callout, CalloutTitle,
PathsRow, PathBlock, PathTitle, ProtTable, ProtTh, ProtTd, ProtTdStrong,
ScenarioGrid, ScenarioCard, ScenarioTitle, ScenarioSubtitle,
ScenarioBlock, ScenarioBlockLabel, ScenarioBlockText,
} from './survival-guide/styled'
import { CASCADE_CHAINS, SCENARIOS } from './survival-guide/data'
// ─── Helpers ─────────────────────────────────────────────────────────────────
interface ManaUpkeep { amount?: number; school?: string }
/** Safely read an optional `mana_upkeep` field that exists on some building JSON but not the typed schema yet. */
function getManaUpkeep(b: object): ManaUpkeep | undefined {
return 'mana_upkeep' in b ? (b as { mana_upkeep: ManaUpkeep }).mana_upkeep : undefined
}
/** Safely read optional `variance` from an effect entry. */
function getEffectVariance(effects: { type: string }[], effectType: string): number {
const match = effects.find((e) => e.type === effectType)
if (match && 'variance' in match) return (match as { variance: number }).variance
return 0
}
// ─── Cascade Flow Diagrams ────────────────────────────────────────────────────
const CascadesGrid = styled.div`
display: flex;
flex-direction: column;
gap: 1rem;
margin-top: 1rem;
`
const CascadeRow = styled.div<{ $color: string }>`
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0;
padding: 0.75rem 1rem;
background: ${({ theme }) => theme.colors.background.secondary};
border-radius: 8px;
border-left: 3px solid ${({ $color }) => $color};
`
const CascadeStep = styled.div<{ $color: string }>`
display: flex;
align-items: center;
padding: 0.25rem 0.625rem;
background: ${({ $color }) => $color}18;
border: 1px solid ${({ $color }) => $color}44;
border-radius: 5px;
font-size: 0.75rem;
font-weight: 600;
color: ${({ $color }) => $color};
white-space: nowrap;
flex-shrink: 0;
`
const CascadeArrow = styled.span`
font-size: 0.875rem;
color: ${({ theme }) => theme.colors.text.muted};
padding: 0 0.375rem;
flex-shrink: 0;
`
const CascadeLabel = styled.span<{ $color: string }>`
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: ${({ $color }) => $color};
margin-right: 0.625rem;
flex-shrink: 0;
`
// ─── Aerosol section ─────────────────────────────────────────────────────────
const InfoGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr));
gap: 0.75rem;
margin-top: 1rem;
`
const InfoTile = styled.div`
background: ${({ theme }) => theme.colors.background.secondary};
border: 1px solid ${({ theme }) => theme.colors.border.default};
border-radius: 7px;
padding: 0.75rem 0.875rem;
`
const TileLabel = styled.div`
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: ${({ theme }) => theme.colors.text.muted};
margin-bottom: 0.3rem;
`
const TileValue = styled.div`
font-size: 0.875rem;
font-weight: 600;
color: ${({ theme }) => theme.colors.text.primary};
`
const TileSub = styled.div`
font-size: 0.75rem;
color: ${({ theme }) => theme.colors.text.muted};
margin-top: 0.125rem;
line-height: 1.4;
`
// ─── Callout ─────────────────────────────────────────────────────────────────
const Callout = styled.div<{ $variant?: 'warning' | 'info' | 'danger' }>`
border-radius: 7px;
border: 1px solid ${({ $variant }) =>
$variant === 'danger' ? 'color-mix(in srgb, #8b1a1a 40%, transparent)' :
$variant === 'warning' ? 'color-mix(in srgb, #c9a84c 35%, transparent)' :
'color-mix(in srgb, #4080c0 30%, transparent)'};
background: ${({ $variant }) =>
$variant === 'danger' ? 'color-mix(in srgb, #8b1a1a 8%, transparent)' :
$variant === 'warning' ? 'color-mix(in srgb, #c9a84c 7%, transparent)' :
'color-mix(in srgb, #4080c0 6%, transparent)'};
padding: 0.875rem 1rem;
font-size: 0.8125rem;
color: ${({ theme }) => theme.colors.text.secondary};
line-height: 1.65;
margin: 1rem 0;
`
const CalloutTitle = styled.div<{ $variant?: 'warning' | 'info' | 'danger' }>`
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.4rem;
color: ${({ $variant }) =>
$variant === 'danger' ? '#e06060' :
$variant === 'warning' ? '#d4a84c' :
'#6090c0'};
`
// ─── Protection tables ───────────────────────────────────────────────────────
const PathsRow = styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
margin-top: 1rem;
@media (max-width: 680px) {
grid-template-columns: 1fr;
}
`
const PathBlock = styled.div`
display: flex;
flex-direction: column;
gap: 0.5rem;
`
const PathTitle = styled.div<{ $color: string }>`
font-size: 0.8125rem;
font-weight: 700;
color: ${({ $color }) => $color};
padding-bottom: 0.375rem;
border-bottom: 2px solid ${({ $color }) => $color}44;
`
const ProtTable = styled.table`
border-collapse: collapse;
font-size: 0.75rem;
width: 100%;
`
const ProtTh = styled.th`
text-align: left;
padding: 0.3rem 0.5rem;
border-bottom: 1px solid ${({ theme }) => theme.colors.border.default};
color: ${({ theme }) => theme.colors.text.muted};
font-weight: 600;
font-size: 0.6875rem;
letter-spacing: 0.4px;
white-space: nowrap;
`
const ProtTd = styled.td`
padding: 0.3rem 0.5rem;
border-bottom: 1px solid ${({ theme }) => theme.colors.border.default};
color: ${({ theme }) => theme.colors.text.secondary};
vertical-align: top;
`
const ProtTdStrong = styled(ProtTd)`
font-weight: 700;
color: ${({ theme }) => theme.colors.text.primary};
`
// ─── Scenario playbook ───────────────────────────────────────────────────────
const ScenarioGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr));
gap: 1rem;
margin-top: 1rem;
`
const ScenarioCard = styled.div<{ $color: string }>`
border-radius: 8px;
border: 1px solid ${({ $color }) => $color}40;
background: ${({ $color }) => $color}0c;
padding: 1rem 1.125rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
`
const ScenarioTitle = styled.div<{ $color: string }>`
font-size: 0.9375rem;
font-weight: 700;
color: ${({ $color }) => $color};
`
const ScenarioSubtitle = styled.div`
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: ${({ theme }) => theme.colors.text.muted};
margin-top: -0.375rem;
`
const ScenarioBlock = styled.div`
display: flex;
flex-direction: column;
gap: 0.2rem;
`
const ScenarioBlockLabel = styled.div`
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: ${({ theme }) => theme.colors.text.muted};
`
const ScenarioBlockText = styled.div`
font-size: 0.8rem;
color: ${({ theme }) => theme.colors.text.secondary};
line-height: 1.55;
`
// ─── Data ────────────────────────────────────────────────────────────────────
const VOLCANIC_COLOR = '#c04020'
const IMPACT_COLOR = '#8060b0'
const SOLAR_COLOR = '#c0a020'
const PANDEMIC_COLOR = '#804040'
interface CascadeChain {
label: string
color: string
steps: string[]
}
const CASCADE_CHAINS: CascadeChain[] = [
{
label: 'Volcanic',
color: VOLCANIC_COLOR,
steps: ['Volcanic T5', 'Aerosol Cloud', 'Global Cooling', 'Terrain Degradation', 'Food Crisis', 'Population Collapse'],
},
{
label: 'Impact',
color: IMPACT_COLOR,
steps: ['Impact T5', 'Global Aerosol', 'Biome Collapse', 'Famine', 'Extinction'],
},
{
label: 'Solar/Glacial',
color: SOLAR_COLOR,
steps: ['Solar Minimum', 'Glacial ×2 freq', 'Ice Sheet T4', 'Warming Trigger', 'Runaway Melt T5', 'Tsunami: All Coasts'],
},
{
label: 'Pandemic',
color: PANDEMIC_COLOR,
steps: ['Pandemic T3', 'Trade Spread', 'Multi-city Pop Loss', 'Production Collapse', 'Military Vulnerability'],
},
]
interface ScenarioData {
title: string
subtitle: string
color: string
trigger: string
what: string
response: string
recovery: string
}
const SCENARIOS: ScenarioData[] = [
{
title: '"The Volcanic Winter"',
subtitle: 'Volcanic T5 — Supervolcano',
color: VOLCANIC_COLOR,
trigger: 'Random volcanic event rolls T5.',
what: '5-hex radius scorched to desert. Aerosol 0.5 injected in 8-hex radius. Approximately 2 turns (20 years) of global cooling and drying. Rivers near the blast zone may freeze.',
response: 'Shelters protect 1 pop per turn. Stockpile food reserves before winter arrives — once aerosol is injected you have 1 turn to prepare. Hardened Granary reserve buys 3 turns of buffer.',
recovery: 'Obsidian spawns at the crater center (Chaos T3 ley anchor). The scorched radius recovers terrain quality slowly over 1020 turns.',
},
{
title: '"Extinction Event"',
subtitle: 'Impact T5 — Extinction Asteroid',
color: IMPACT_COLOR,
trigger: 'Random impact event rolls T5. Astronomically rare.',
what: '6-hex crater vaporized instantly. Global aerosol 1.0 — a 20-turn (200-year) impact winter. All biomes across the map lose 2 quality. A T5 Death+Chaos mithril vein spawns at the crater center.',
response: 'Only Doomsday Vault (2060% survival roll) or Energy Shield (complete nullification) matter. Bomb Shelter, Warding Circle, and Arcane Dome are insufficient. Pre-build before the event or accept catastrophic losses. There is no mid-event recovery path.',
recovery: 'The mithril core is the rarest resource in the game. Whoever controls it dominates late-game unit production. Racing to claim the crater is the primary post-extinction strategic objective.',
},
{
title: '"The Thwaites Cascade"',
subtitle: 'Solar Minimum → Ice Sheet → T5 Melt Chain',
color: SOLAR_COLOR,
trigger: 'Solar T4 (Grand Minimum) doubles glacial frequency → Glacial T4 forms an ice sheet → minimum ends, solar output rebounds above the ice-sheet collapse threshold → Glacial T5 runaway melt.',
what: 'A multi-turn chain disaster. The ice sheet forms slowly over ~10 turns, then suddenly collapses: simultaneous T3 tsunami on every coastal tile in the world, 0.20 moisture surge globally, and a warming feedback loop that can trigger further events.',
response: 'During the ice sheet phase (T4 active), move coastal cities inland or fortify them. The ice sheet will collapse — it is a matter of when, not if, once temperatures begin to rise again. Tsunami-resistant coastal defenses reduce improvement damage.',
recovery: 'Post-collapse warming means the coastlines quickly become fertile again. The moisture surge benefits inland agriculture. This chain is destructive in the short term but leaves the world warmer and wetter.',
},
{
title: '"Trade Plague"',
subtitle: 'Pandemic T3+',
color: PANDEMIC_COLOR,
trigger: 'Pandemic event rolls T3+. Spreads via road-connected and trade-route cities.',
what: 'T3: 1 pop/turn per connected city for 8 turns. T4: spreads to ALL players via active trade routes, 2 pop/turn, kills garrisoned units. T5: global, population halved across all civilizations.',
response: 'Cut trade routes immediately — pillage roads to sever spread vectors. The Life T3 quarantine spell blocks adjacency transmission. Hospital building reduces pop loss by 1 per turn. Isolationist governments have a natural advantage here.',
recovery: 'Cities recover population slowly after the pandemic ends. T5 is generationally catastrophic — prioritize rebuilding food infrastructure and Granaries before military production.',
},
]
// ─── Page ────────────────────────────────────────────────────────────────────
export default function SurvivalGuidePage(): ReactElement {
const { mundane, magic, specialized } = classifyProtectionBuildings(allBuildings)
const aerosol = getAerosolParams(climateSpec)
// Find the two aerosol-mitigating buildings for the dedicated callout tiles
const arcaneDome = magic.find((b) => b.id === 'arcane_dome')
const energyShield = magic.find((b) => b.id === 'energy_shield')
@ -362,7 +52,6 @@ export default function SurvivalGuidePage(): ReactElement {
</PageSubtitle>
</PageTitle>
{/* Section 1: Cascade Model */}
<Section>
<SectionHeading>The Cascade Model</SectionHeading>
@ -584,13 +273,13 @@ export default function SurvivalGuidePage(): ReactElement {
<tbody>
{magic.map((b) => {
const popSave = getEffectValue(b, 'catastrophe_pop_save')
const aerosol = getEffectValue(b, 'aerosol_mitigation')
const aerosolMit = getEffectValue(b, 'aerosol_mitigation')
return (
<tr key={b.id}>
<ProtTdStrong>{b.name}</ProtTdStrong>
<ProtTd>{b.tech_required ? formatTech(b.tech_required) : '—'}</ProtTd>
<ProtTd>{popSave != null ? popSave : '—'}</ProtTd>
<ProtTd>{aerosol != null ? `${aerosol}%` : '—'}</ProtTd>
<ProtTd>{aerosolMit != null ? `${aerosolMit}%` : '—'}</ProtTd>
</tr>
)
})}