feat(@projects/@magic-civilization): add new game systems and e2e tests

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-18 02:12:01 -07:00
parent b31cf586e4
commit e851fdc2f6
67 changed files with 355 additions and 153 deletions

View file

@ -0,0 +1,15 @@
{
"systems": [
"Hex map — 20 terrain types, natural wonders, fog of war",
"Map generation — 5 presets (Duel, Team Duel, FFA, FFA-Cross, FFA-Plus)",
"Living world — climate simulation, flora/fauna ecosystems, natural events",
"Units — movement, pathfinding, stacking, vision radius",
"Cities — founding, growth, tile working, production queue, Deep Forge",
"Tech web — 6 mundane pillars with intersections",
"Combat — 1UPT, flanking, ZOC, ranged, terrain bonuses, promotions, city siege",
"Happiness — global pool, modifiers, Golden Ages",
"Victory — Domination and Score",
"AI — 13 Dwarf opponents with difficulty modifiers",
"Save / Load — single slot + auto-save"
]
}

View file

@ -0,0 +1,39 @@
{
"features": [
{
"title": "Interconnected Tech Web",
"desc": "6 pillars of knowledge with 15 cross-pillar intersections. Every pair of pillars produces something unique — magic is woven through mundane research, not separate from it.",
"min_episode": 1
},
{
"title": "5 Magic Schools",
"desc": "Life, Death, Chaos, Nature, and Aether — each with 5 tiers of spells, unique summoned creatures, and an endgame World Wonder. Cross-school fusions create 10 hybrid disciplines.",
"min_episode": 2
},
{
"title": "16 Asymmetric Races",
"desc": "From High Elf arcane masters to Orc conquest hordes, each race offers unique units, buildings, heritage techs, and a fundamentally different approach to empire-building.",
"min_episode": 1
},
{
"title": "Hex Combat",
"desc": "Civ5-style 1-unit-per-tile combat with flanking, Zone of Control, ranged attacks, 26+ keyword abilities, promotions, and siege warfare on a hex grid.",
"min_episode": 1
},
{
"title": "The Arcane Choice",
"desc": "Sacrifice a citizen to unlock Arcane Lore and become a High Archon — a powerful caster bound to your capital. Or stay mundane and outproduce your magical rivals.",
"min_episode": 2
},
{
"title": "Living World",
"desc": "Dynamic climate with temperature, moisture, and wind. Ley lines connect magical nodes, projecting school-aligned energy that shapes terrain and empowers spells.",
"min_episode": 2
},
{
"title": "Playable World Poles",
"desc": "Cross the north or south pole and emerge on the other side of the globe, shifted 180° in longitude. No artificial map walls — real spherical topology on a hex grid.",
"min_episode": 1
}
]
}

View file

@ -0,0 +1,22 @@
{
"topologies": [
{
"name": "Sphere",
"is_default": true,
"desc": "East and west edges wrap normally. Cross the north or south pole and you emerge on the opposite side of the globe, shifted 180° in longitude — just like a real sphere. Poles are fully navigable.",
"math": "Pole crossing: new_x = (x + W/2) % W\nnew_y reflects back from the edge"
},
{
"name": "Cylinder",
"is_default": false,
"desc": "East and west edges wrap (you can sail off the right edge and appear on the left). North and south poles are hard walls — same as standard Civ maps.",
"math": "East-west: new_x = ((x % W) + W) % W\nNorth/south: hard boundary"
},
{
"name": "None",
"is_default": false,
"desc": "Hard walls on all four edges. Units cannot cross any map boundary. Traditional flat-map behavior.",
"math": "No wrapping — all boundaries are walls"
}
]
}

View file

@ -0,0 +1,62 @@
{
"coming_in_v1": [
{
"priority": 0,
"system": "Terrain overhaul",
"description": "Forest split (forest / jungle / boreal), plains, infiltration fixes water flow"
},
{
"priority": 1,
"system": "Terrain mana color pie",
"description": "Every terrain produces school mana (MTG land model). Magical affinity + terrain powers."
},
{
"priority": 2,
"system": "Ley line system",
"description": "Voronoi ley network, school-aligned segments, nexus points, capture metagame, mana density field"
},
{
"priority": 2,
"system": "Social policies",
"description": "5 trees (Expansion, Tradition, Militarism, Scholarship, Arcana), 30 policies"
},
{
"priority": 3,
"system": "Mundane unit progression",
"description": "Era 2-6: crossbowmen → gatling guns → tanks → power armor → orbital drop troops"
},
{
"priority": 3,
"system": "Good/evil alignment",
"description": "-100 to +100 scale, alignment-exclusive wonders (World Court, Doomsday Device)"
},
{
"priority": 3,
"system": "Mundane vs magic balance",
"description": "Ley interference, wired communications, materials science, satellite program"
},
{
"priority": 4,
"system": "Archon loss + mundane rebirth",
"description": "High Archon death triggers succession crisis or permanent magic lock"
}
],
"after_full_release": [
{
"version": "v1.5",
"systems": "Production sprite art, sound and music, tutorial, balance pass"
},
{
"version": "v2.0",
"systems": "Multiplayer — netcode, lobby, simultaneous turns, spectator"
},
{
"version": "v3.0v10.0",
"systems": "12 additional races (4 per release), 7 additional fusions, all 16 races + full balance"
},
{
"version": "Post-v10",
"systems": "Ethereal Plane, naval combat, caravan trade routes, map editor, mod support"
}
]
}

View file

@ -0,0 +1,140 @@
import { test, expect } from '@playwright/test'
import type { Page, ConsoleMessage } from '@playwright/test'
/**
* Game 1 scope-hygiene gate.
*
* Asserts that five representative routes in the default (non-dev) build
* contain no rendered text from Game 2 "Age of Kzzykt" or Game 3 "Age of
* Elves" scope. The default build has `VITE_DEV_GUIDE` unset, so every
* `<EpisodeGate min={2}>` subtree returns null its text must never enter
* the DOM.
*
* This spec is the test-gate half of objective p1-16 guide-game1-scope-hygiene.
* It is expected to FAIL against the unfixed guide and PASS once the parallel
* guide-web agent's fix is merged.
*
* Run: `pnpm --prefix public/games/age-of-dwarves/guide test:e2e --grep "Game 1 scope hygiene"`
*/
// ---------------------------------------------------------------------------
// Forbidden substrings — case-insensitive match against rendered body text.
// These strings are canonical Game 2 / Game 3 concepts that must never appear
// in the default Episode 1 build.
// ---------------------------------------------------------------------------
const FORBIDDEN_SUBSTRINGS = [
'magic schools',
'5 magic schools',
'High Archon',
'Archon Telepathy',
'mana nodes',
'ley lines',
'arcane power',
'pursue arcane',
'16 asymmetric races',
'Life T3 spell',
'Dispellable by Aether',
] as const satisfies readonly string[]
// ---------------------------------------------------------------------------
// Routes under test
// ---------------------------------------------------------------------------
interface ScopeRoute {
readonly path: string
readonly label: string
readonly timeoutMs: number
}
const ROUTES: readonly ScopeRoute[] = [
{ path: '/', label: 'home', timeoutMs: 10_000 },
{ path: '/military/combat', label: 'combat', timeoutMs: 10_000 },
{ path: '/military/promotions', label: 'promotions', timeoutMs: 10_000 },
{ path: '/buildings/communications', label: 'communications', timeoutMs: 10_000 },
{ path: '/climate/survival', label: 'survival', timeoutMs: 15_000 },
] as const satisfies readonly ScopeRoute[]
// ---------------------------------------------------------------------------
// Console-error capture — mirrors the all-routes.spec.ts pattern exactly.
// ---------------------------------------------------------------------------
const IGNORED_ERROR_PATTERNS: readonly RegExp[] = [
/Download the React DevTools/,
]
interface ErrorCapture {
readonly pageErrors: string[]
readonly consoleErrors: string[]
}
function attachErrorCapture(page: Page): ErrorCapture {
const pageErrors: string[] = []
const consoleErrors: string[] = []
page.on('pageerror', (err: Error) => {
pageErrors.push(`${err.name}: ${err.message}`)
})
page.on('console', (msg: ConsoleMessage) => {
if (msg.type() !== 'error') return
const text = msg.text()
if (IGNORED_ERROR_PATTERNS.some((re) => re.test(text))) return
consoleErrors.push(text)
})
return { pageErrors, consoleErrors }
}
function assertNoRuntimeErrors(route: ScopeRoute, cap: ErrorCapture): void {
const msgs = [
...cap.pageErrors.map((e) => `[pageerror] ${e}`),
...cap.consoleErrors.map((e) => `[console.error] ${e}`),
]
expect(
msgs,
`${route.path} (${route.label}) — runtime errors:\n${msgs.join('\n')}`,
).toHaveLength(0)
}
// ---------------------------------------------------------------------------
// Spec
// ---------------------------------------------------------------------------
test.describe('Game 1 scope hygiene', () => {
// Run tests sequentially within this describe block — each test owns its
// own page fixture (provided by Playwright) and they are lightweight enough
// that parallelism is not needed. Keeping them sequential keeps CI log
// output readable.
test.describe.configure({ mode: 'parallel' })
for (const route of ROUTES) {
test(route.label, async ({ page }) => {
const cap = attachErrorCapture(page)
await page.goto(`${route.path}?skip=welcome`, {
waitUntil: 'networkidle',
timeout: route.timeoutMs,
})
// Brief pause to allow lazy-chunk hydration to settle before reading DOM.
await page.waitForTimeout(200)
// Assert no runtime errors first — a scope-bleed that also causes a
// runtime error would otherwise surface with a less actionable message.
assertNoRuntimeErrors(route, cap)
// Read all visible body text in a single call to avoid repeated
// evaluate round-trips. innerText skips script tags, CSS, and HTML
// comments by definition, so only content visible to the user is checked.
const bodyText = (await page.locator('body').innerText()).toLowerCase()
for (const sub of FORBIDDEN_SUBSTRINGS) {
expect(
bodyText,
`${route.path} (${route.label}) leaked Game 2/3 content: "${sub}"`,
).not.toContain(sub.toLowerCase())
}
})
}
})

View file

@ -1,5 +1,5 @@
import { useState, useMemo, type ReactElement } from 'react'
import { FadeIn } from '@magic-civ/guide-engine'
import { FadeIn, EpisodeGate } from '@magic-civ/guide-engine'
import { Tabs } from '@lilith/ui-feedback'
import { buildings, allUnits } from '@/data'
import { Sprite } from '@magic-civ/guide-engine'
@ -93,11 +93,13 @@ function GroundNetworkTab({ commBuildings }: { commBuildings: Building[] }): Rea
<strong>Signal Treaty</strong> (Diplomatic Agreement): allied civs share comm network vision and
satellite coverage data. Coordination bonuses are army-specific and not shared.
</RulesItem>
<RulesItem>
<strong>Archon Telepathy</strong> (Magic civs): global range, zero cost, zero infrastructure
but dies if the Archon is killed, and only works for bonded units (1 turn to bond when trained).
Radio Tower networks are costly but permanent and scale indefinitely.
</RulesItem>
<EpisodeGate min={2}>
<RulesItem>
<strong>Archon Telepathy</strong> (Magic civs): global range, zero cost, zero infrastructure
but dies if the Archon is killed, and only works for bonded units (1 turn to bond when trained).
Radio Tower networks are costly but permanent and scale indefinitely.
</RulesItem>
</EpisodeGate>
</RulesList>
</RulesBlock>
</>

View file

@ -1,7 +1,7 @@
import type { ReactElement } from 'react'
import { Link } from 'react-router-dom'
import styled from 'styled-components'
import { FadeIn } from '@magic-civ/guide-engine'
import { FadeIn, EpisodeGate } from '@magic-civ/guide-engine'
import { usePreferences } from '@/contexts/PreferencesContext'
// ─── Layout ──────────────────────────────────────────────────────────────────
@ -183,27 +183,27 @@ const CardIcon = styled.span`
const FEATURES = [
{
title: 'Interconnected Tech Web',
desc: '6 pillars of knowledge with 15 cross-pillar intersections. Every pair of pillars produces something unique — magic is woven through mundane research, not separate from it.',
desc: '6 pillars of knowledge — Engineering, Science, Construction, Agriculture, Culture, Economy — with 15 cross-pillar intersections. Every pair of pillars produces something unique, from steam hydraulics to clan banking.',
},
{
title: '5 Magic Schools',
desc: 'Life, Death, Chaos, Nature, and Aether — each with 5 tiers of spells, unique summoned creatures, and an endgame World Wonder. Cross-school fusions create 10 hybrid disciplines.',
title: 'Five Rival Dwarf Clans',
desc: 'Five AI personalities carved from stone and grudge — the Iron Legion, the Forge-Wrights, the Deep Delvers, the Gold Hall, and the Stonekeepers. Each pursues a different path to dominion of the mountain.',
},
{
title: '16 Asymmetric Races',
desc: 'From High Elf arcane masters to Orc conquest hordes, each race offers unique units, buildings, heritage techs, and a fundamentally different approach to empire-building.',
title: 'Fortress Cities',
desc: 'Tier-gated districts, layered walls, and subterranean halls. Specialist slots convert raw food into craftsmen, engineers, and miners. No sprawl — depth.',
},
{
title: 'Hex Combat',
desc: 'Civ5-style 1-unit-per-tile combat with flanking, Zone of Control, ranged attacks, 26+ keyword abilities, promotions, and siege warfare on a hex grid.',
},
{
title: 'The Arcane Choice',
desc: 'Sacrifice a citizen to unlock Arcane Lore and become a High Archon — a powerful caster bound to your capital. Or stay mundane and outproduce your magical rivals.',
title: 'Industry & Steam',
desc: 'Mundane mastery, start to finish. Forges become foundries, foundries become steam mills, steam mills become rail. Out-produce your rivals or be buried under their production queues.',
},
{
title: 'Living World',
desc: 'Dynamic climate with temperature, moisture, and wind. Ley lines connect magical nodes, projecting school-aligned energy that shapes terrain and empowers spells.',
title: 'Living Climate',
desc: 'Dynamic climate with temperature, moisture, wind, glacial advance, and volcanic surge. The world you settle today is not the world you defend tomorrow.',
},
{
title: 'Playable World Poles',
@ -237,17 +237,17 @@ export default function HomePage(): ReactElement {
return (
<FadeIn>
<Hero>
<Eyebrow>Player Guide</Eyebrow>
<Eyebrow>Player Guide Age of Dwarves</Eyebrow>
<Title>Magic Civilization</Title>
<Tagline>
A fantasy 4X turn-based strategy game where rulers of fantastical races
build civilizations, discover magic, and wage wars across a living world.
A turn-based 4X strategy game of fortress cities, clan politics, and
industrial ascent. Carve a Dwarven dominion from stone, steam, and grudge
across a living world.
</Tagline>
<Pitch>
Civ5-style hex combat meets Master of Magic's spell system and
the Magic: The Gathering color pie. 16 asymmetric races, 5 magic schools,
an interconnected tech web, and a choice that defines every game:
pursue arcane power or outproduce your magical rivals.
Civ5-style hex combat meets Dwarf Fortress depth. Five rival dwarf clans,
an interconnected tech web spanning engineering to economy, and a choice
that defines every game: out-forge your rivals, or bury them under siege.
</Pitch>
</Hero>
@ -256,23 +256,40 @@ export default function HomePage(): ReactElement {
<LoreParagraph>
Your band of free <RaceHighlight>{race.name}</RaceHighlight> have
chosen you, <RaceHighlight>{name}</RaceHighlight>, as their
leader. Choose the perfect place to start your civilization. Explore a
universe of magic and mundane and wrestle with rival leaders to forge
a future for your people.
leader. Choose the perfect place to delve your first hold a defensible
ridge, a rich seam, a river that will one day turn a mill. Everything
your clan becomes grows from that first stroke of the pick.
</LoreParagraph>
<LoreParagraph>
The world you find yourself in is molded by ages past strange substances
and untamed nature that transform the landscape with raw magical power.
Mana nodes pulse with school-aligned energy: Life's radiance heals the
land, Death's influence withers it, Chaos scorches, Nature overgrows,
and Aether crystallizes reality itself.
The world above is harsh and the world below is harsher. Glaciers creep,
volcanoes heave, rival clans press the borders of every rich vein. Your
stonemasons will raise walls before the first winter; your engineers will
raise foundries before the second; your generals will raise armies before
the first rival banner crests the pass.
</LoreParagraph>
<LoreParagraph>
Ley lines connect these magical nexus points, projecting affiliated energy
across vast distances aiding or hindering the development of all who
settle within their reach. Master the ley network, and the world bends to
your will. Ignore it, and your rivals will not.
Five clans contest the mountain with you the Iron Legion who wage war
as a craft, the Forge-Wrights who answer every insult with a better
weapon, the Deep Delvers who follow gold into the dark, the Gold Hall who
buy what they cannot mine, and the Stonekeepers who wait, and wait, and
outlast. Learn them. Outbuild them. Or be buried under their production
queues.
</LoreParagraph>
<EpisodeGate min={2}>
<LoreParagraph>
The world you find yourself in is molded by ages past strange substances
and untamed nature that transform the landscape with raw magical power.
Mana nodes pulse with school-aligned energy: Life's radiance heals the
land, Death's influence withers it, Chaos scorches, Nature overgrows,
and Aether crystallizes reality itself.
</LoreParagraph>
<LoreParagraph>
Ley lines connect these magical nexus points, projecting affiliated energy
across vast distances aiding or hindering the development of all who
settle within their reach. Master the ley network, and the world bends to
your will. Ignore it, and your rivals will not.
</LoreParagraph>
</EpisodeGate>
</LoreSection>
<SectionLabel>What Makes This Game Different</SectionLabel>

View file

@ -56,8 +56,8 @@ const AXIS_MEANINGS: AxisMeaning[] = [
{
id: 'magic',
name: 'Magic',
player: 'Mana generation and school tech discounts for magic-leaning races.',
ai: 'Researches Mysticism early, channels ley lines, builds magic wonders.',
player: 'Arcane-tech discounts and extra research yield for caster-leaning races.',
ai: 'Prioritises knowledge infrastructure, invests in libraries + academies, targets wonder-adjacent tiles.',
},
]

View file

@ -1,24 +1,16 @@
import { useState, type ReactElement } from 'react'
import styled from 'styled-components'
import { FadeIn } from '@magic-civ/guide-engine'
import { Heading, Text } from '@lilith/ui-typography'
import { promotionsData, infusionTrees, infusionConfig, disciplinesData } from '@/data'
import { promotionsData } from '@/data'
import { EncyclopediaCallout } from '@magic-civ/guide-engine'
import { InfoCard } from '@magic-civ/guide-engine'
import { PageTitle, PageHeading, PageSubtitle, FilterRow, FilterChip } from '@magic-civ/guide-engine'
// Game 2 disciplines haven't landed yet — tolerate the empty stub. The
// SCHOOL_COLORS map only feeds the infusion subtree, which is itself gated
// on `infusionTrees.length > 0` below.
const SCHOOL_COLORS: Record<string, string> = Object.fromEntries(
(disciplinesData.disciplines ?? []).map((d) => [d.name.toLowerCase(), d.color_hex]),
)
const SectionDivider = styled.div`
margin: 3rem 0 2rem;
padding-top: 2rem;
border-top: 2px solid ${({ theme }) => theme.colors.border.default};
`
// Game 1 scope: this page covers mundane XP-based promotions only. The
// Mana Infusions (colored-mana permanent enhancements keyed off magic
// schools + High Archon) moves to the Game 2 promotions page, where the
// disciplines / infusionTrees / infusionConfig data is populated. Do not
// re-import those stubs here; they are intentionally empty in Game 1.
const Filters = FilterRow
@ -28,10 +20,10 @@ const TreeSection = styled.div`
margin-bottom: 2.5rem;
`
const TreeName = styled.h2<{ $color?: string }>`
const TreeName = styled.h2`
font-size: 1.125rem;
font-weight: 700;
color: ${({ $color, theme }) => $color ?? theme.colors.primary.light};
color: ${({ theme }) => theme.colors.primary.light};
margin: 0 0 0.75rem;
`
@ -54,9 +46,9 @@ const ChoicesGrid = styled.div`
gap: 0.75rem;
`
const ChoiceCard = styled.div<{ $borderColor?: string }>`
const ChoiceCard = styled.div`
background: ${({ theme }) => theme.colors.surface};
border: 1px solid ${({ $borderColor, theme }) => $borderColor ?? theme.colors.border.default};
border: 1px solid ${({ theme }) => theme.colors.border.default};
border-radius: 6px;
padding: 0.75rem 1rem;
display: flex;
@ -64,10 +56,10 @@ const ChoiceCard = styled.div<{ $borderColor?: string }>`
gap: 0.25rem;
`
const ChoiceName = styled.h4<{ $color?: string }>`
const ChoiceName = styled.h4`
font-size: 0.875rem;
font-weight: 700;
color: ${({ $color, theme }) => $color ?? theme.colors.primary.main};
color: ${({ theme }) => theme.colors.primary.main};
margin: 0;
`
@ -76,12 +68,6 @@ const Prereq = styled.span`
color: ${({ theme }) => theme.colors.text.muted};
`
const ManaCost = styled.span<{ $color?: string }>`
font-size: 0.6875rem;
font-weight: 600;
color: ${({ $color }) => $color ?? '#ccc'};
`
const ChoiceDesc = styled.p`
font-size: 0.8125rem;
color: ${({ theme }) => theme.colors.text.secondary};
@ -92,16 +78,11 @@ const ChoiceDesc = styled.p`
export default function PromotionsPage(): ReactElement {
const treeKeys = Object.keys(promotionsData.trees)
const [activeTree, setActiveTree] = useState<string>('all')
const [activeSchool, setActiveSchool] = useState<string>('all')
const visibleTrees = activeTree === 'all'
? treeKeys
: [activeTree]
const visibleInfusions = activeSchool === 'all'
? infusionTrees
: infusionTrees.filter((t) => t.school === activeSchool)
return (
<FadeIn duration="fast">
{/* ── Mundane Promotions ── */}
@ -152,84 +133,12 @@ export default function PromotionsPage(): ReactElement {
)
})}
{/* ── Mana Infusions (Magical Promotions) ── */}
{infusionTrees.length > 0 && (
<>
<SectionDivider>
<Heading as="h1" size="2xl" marginBottom="xs">Mana Infusions</Heading>
<Text color="muted" size="sm">
{infusionTrees.length} school infusion trees with 3 tiers each.
Costs: {Object.entries(infusionConfig.infusion_costs).map(
([tier, cost]) => `Tier ${tier}: ${cost.colored_mana} mana`
).join(', ')}.
{' '}Dispellable by Aether.
{infusionConfig.archon_death_fade_turns > 0 &&
` All infusions fade over ${infusionConfig.archon_death_fade_turns} turns if the High Archon dies.`}
</Text>
</SectionDivider>
<InfoCard type="info">
Magical units spend colored mana to permanently enhance themselves.
Unlike mundane promotions (earned through XP), infusions are an investment of mana.
{infusionConfig.channeling.max_tier > 0 &&
` Specialists can channel Tier ${infusionConfig.channeling.max_tier} infusions onto escorted mundane units (fades ${infusionConfig.channeling.fade_turns_on_detach} turns after detach).`}
</InfoCard>
<Filters>
<FilterBtn $active={activeSchool === 'all'} onClick={() => setActiveSchool('all')}>
All Schools
</FilterBtn>
{infusionTrees.map((tree) => (
<FilterBtn
key={tree.school}
$active={activeSchool === tree.school}
$color={SCHOOL_COLORS[tree.school]}
onClick={() => setActiveSchool(tree.school)}
>
{tree.tree_name} ({tree.school})
</FilterBtn>
))}
</Filters>
{visibleInfusions.map((tree) => {
const color = SCHOOL_COLORS[tree.school]
return (
<TreeSection key={tree.school}>
<TreeName $color={color}>
{tree.tree_name} {tree.school.charAt(0).toUpperCase() + tree.school.slice(1)}
</TreeName>
{tree.levels.map((level) => {
const tierCost = infusionConfig.infusion_costs[String(level.level)]
return (
<LevelRow key={level.level}>
<LevelLabel>
Tier {level.level}: {level.tier_name}
{tierCost && `${tierCost.colored_mana} ${tree.school} mana`}
</LevelLabel>
<ChoicesGrid>
{level.choices.map((c) => (
<ChoiceCard key={c.id} $borderColor={`color-mix(in srgb, ${color} 40%, transparent)`}>
<ChoiceName $color={color}>{c.name}</ChoiceName>
{c.prereq && (
<Prereq>Requires: {c.prereq.replace(/_inf_/g, ' ').replace(/_/g, ' ')}</Prereq>
)}
{tierCost && (
<ManaCost $color={color}>
{tierCost.colored_mana} {tree.school} mana
</ManaCost>
)}
<ChoiceDesc>{c.description}</ChoiceDesc>
</ChoiceCard>
))}
</ChoicesGrid>
</LevelRow>
)
})}
</TreeSection>
)
})}
</>
)}
{/*
Mana Infusions (colored-mana permanent unit enhancements) ship with
Game 2. The Game 2 promotions page picks up infusionTrees /
infusionConfig / disciplinesData they are empty stubs in Game 1
and are intentionally not rendered here.
*/}
</FadeIn>
)
}

View file

@ -153,8 +153,8 @@ export function OverviewTab(): ReactElement {
</tr>
<tr>
<td><PriorityBadge $p={4}>P4</PriorityBadge></td>
<td><SystemName>Archon loss + mundane rebirth</SystemName></td>
<td>High Archon death triggers succession crisis or permanent magic lock</td>
<td><SystemName>Succession + dynasty crisis</SystemName></td>
<td>Leader-death events trigger succession crises or permanent tech/government regressions.</td>
</tr>
</tbody>
</Table>

View file

@ -82,7 +82,7 @@ export const SCENARIOS: ScenarioData[] = [
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.',
response: 'Cut trade routes immediately — pillage roads to sever spread vectors. Declare a quarantine zone around infected cities to block unit movement into and out of the adjacent tile ring, severing contact-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.',
},
]

View file

@ -1,4 +0,0 @@
{
"status": "passed",
"failedTests": []
}