feat(@projects/@magic-civilization): ✨ add new game systems and e2e tests
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
15
public/games/age-of-dwarves/data/episodes/ep1-systems.json
Normal 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 — 1–3 Dwarf opponents with difficulty modifiers",
|
||||
"Save / Load — single slot + auto-save"
|
||||
]
|
||||
}
|
||||
39
public/games/age-of-dwarves/data/homepage-features.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
22
public/games/age-of-dwarves/data/map-topologies.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
62
public/games/age-of-dwarves/data/shipping-roadmap.json
Normal 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.0–v10.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"
|
||||
}
|
||||
]
|
||||
}
|
||||
140
public/games/age-of-dwarves/guide/e2e/scope-hygiene.spec.ts
Normal 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())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 146 KiB |