feat(@projects/@magic-civilization): add cultural and tech design docs

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-26 02:27:00 -07:00
parent ce115b0dbb
commit c70b433266
18 changed files with 786 additions and 159 deletions

View file

@ -23,3 +23,8 @@ can see the whole shape at once.
**Source of truth.** The actual visual rendering lives in
`src/game/engine/scenes/knowledge_tree/knowledge_tree.gd`. If a design
disagrees with the code, the code wins — open a PR to update the design.
**See also.** `UI_DESIGN_SYSTEM.md` (this directory) — top-level art
direction brief covering mood, palette, and typography for the whole
game. The per-screen designs above inherit those rules; they don't
override them.

View file

@ -0,0 +1,169 @@
# Culture tree — full layout
Six pillars × ten tiers. The tree is sparse early — Game 1 starts with
`oral_tradition` and `ancestor_worship` only available; the wider pillars
unlock as the player fields ancillary buildings and cities. Living Legend
(T10) is the cultural-victory threshold.
## Pillar columns (rendered order — alphabetical)
```
ancestor_worship artisanship legacy oral_tradition philosophy statecraft
───────────────── ─────────────── ─────────────── ─────────────── ──────────────── ───────────────
T1 Ancestor (none) T1 Oral
Veneration c20 Tradition c20
T2 Lineage T2 Decorative T2 Epic
Records c30 Craft c30 Poetry c35
T3 Ancestor T3 High (none) T3 Bardic T3 Natural T3 Diplomatic
Shrines c55 Craft c60 Lore c55 Ethics c55 Tradition c60
T4 Ancestral T4 Sacred (none) T4 Chronicle T4 Moral T4 Cultural
Covenant c80 Art c85 Keeping c80 Philosophy c85 Hegemony c90
T5 Hall of T5 Grand T5 Epic Legacy T5 Political T5 Imperial
Memory c115 Monuments c120 Philosophy Doctrine c120
c110 c115
T6 Cultural T6 Universal T6 Golden Age
Canon c160 Ideals c155 Doctrine c160
T7 World T7 Metaphysics T7 Cultural
Heritage c200 c195 Renaissance c200
T8 Eternal T8 Manifest
Memory c245 Destiny c240
T9 Apotheosis
of Culture c285
T10 Living
Legend c340
```
## Pillar identity (player-facing)
| Pillar | Identity / what the player invests in |
|---|---|
| ancestor_worship | Lineage, ritual, the dead-as-political-presence. Building shrines, recording bloodlines, formal mourning.|
| artisanship | Hand-craft excellence — decorative work, sacred art, grand monuments. Wonder-track. |
| legacy | The civilization-as-myth. Late-game pillar: the prestige and victory track. Unlocks `cultural_victory_enabled` at T10. |
| oral_tradition | Memory, story, song. Founding pillar; everyone starts able to research it. |
| philosophy | Codified ethics, governance theory, metaphysics. Mid-tier track that opens governance options. |
| statecraft | Diplomatic doctrine, imperial scale, golden ages. Mid-late track that ties the empire together. |
## Cumulative cost curve
Total culture required to research the entire tree, by tier:
```
T1 ─ 40 (2 nodes)
T2 ─ 95 (3 nodes)
T3 ─ 285 (5 nodes) ← first wide tier; all six pillars contribute by here
T4 ─ 420 (5 nodes)
T5 ─ 580 (5 nodes)
T6 ─ 475 (3 nodes)
T7 ─ 595 (3 nodes)
T8 ─ 485 (2 nodes)
T9 ─ 285 (1 node)
T10 ─ 340 (1 node) ← Living Legend → cultural_victory_enabled
─────────────
TOTAL ≈ 3600 culture across all 30 traditions
```
At a sustained 4 culture/turn (one mature city) that is ~900 turns to
finish the whole tree — well outside a single game. Players will pick a
pillar focus.
## Render in the screen
The same `KnowledgeTree` scene that draws the tech tree draws this. The
only differences:
* **Title:** "Culture Tree"
* **Cost label:** "Culture: 120" (instead of "Science: 120")
* **Hint:** "Select a tradition to see what it changes."
* **Started signal:** `EventBus.culture_research_started`
* **Completed signal:** `EventBus.culture_researched`
* **Web reference:** `TurnManager.get_culture_web()`
Card colours, indicator badges, detail-panel sections, flavour blocks are
identical to the tech tree.
## SVG mini-map (skeleton)
```svg
<svg xmlns="http://www.w3.org/2000/svg" width="780" height="380" viewBox="0 0 780 380" font-family="-apple-system, sans-serif" font-size="9">
<style>
.h{font-size:11;font-weight:700;fill:#c9a84c}
.n{fill:#3a3022;stroke:#8b6914;stroke-width:0.7}
.nl{fill:#fde58e}
.e{stroke:#666;stroke-width:0.6;fill:none}
</style>
<g><text class="h" x="20" y="15">ancestor</text>
<text class="h" x="150" y="15">artisanship</text>
<text class="h" x="280" y="15">legacy</text>
<text class="h" x="410" y="15">oral</text>
<text class="h" x="540" y="15">philosophy</text>
<text class="h" x="660" y="15">statecraft</text>
</g>
<!-- ancestor -->
<rect class="n" x="20" y="30" width="100" height="20"/><text class="nl" x="70" y="44" text-anchor="middle">T1 Ancestor Veneration</text>
<rect class="n" x="20" y="60" width="100" height="20"/><text class="nl" x="70" y="74" text-anchor="middle">T2 Lineage Records</text>
<rect class="n" x="20" y="90" width="100" height="20"/><text class="nl" x="70" y="104" text-anchor="middle">T3 Ancestor Shrines</text>
<rect class="n" x="20" y="120" width="100" height="20"/><text class="nl" x="70" y="134" text-anchor="middle">T4 Ancestral Covenant</text>
<rect class="n" x="20" y="150" width="100" height="20"/><text class="nl" x="70" y="164" text-anchor="middle">T5 Hall of Memory</text>
<line class="e" x1="70" y1="50" x2="70" y2="60"/>
<line class="e" x1="70" y1="80" x2="70" y2="90"/>
<line class="e" x1="70" y1="110" x2="70" y2="120"/>
<line class="e" x1="70" y1="140" x2="70" y2="150"/>
<!-- artisanship -->
<rect class="n" x="150" y="60" width="100" height="20"/><text class="nl" x="200" y="74" text-anchor="middle">T2 Decorative Craft</text>
<rect class="n" x="150" y="90" width="100" height="20"/><text class="nl" x="200" y="104" text-anchor="middle">T3 High Craft</text>
<rect class="n" x="150" y="120" width="100" height="20"/><text class="nl" x="200" y="134" text-anchor="middle">T4 Sacred Art</text>
<rect class="n" x="150" y="150" width="100" height="20"/><text class="nl" x="200" y="164" text-anchor="middle">T5 Grand Monuments</text>
<line class="e" x1="200" y1="80" x2="200" y2="90"/>
<line class="e" x1="200" y1="110" x2="200" y2="120"/>
<line class="e" x1="200" y1="140" x2="200" y2="150"/>
<!-- legacy -->
<rect class="n" x="280" y="150" width="100" height="20"/><text class="nl" x="330" y="164" text-anchor="middle">T5 Epic Legacy</text>
<rect class="n" x="280" y="180" width="100" height="20"/><text class="nl" x="330" y="194" text-anchor="middle">T6 Cultural Canon</text>
<rect class="n" x="280" y="210" width="100" height="20"/><text class="nl" x="330" y="224" text-anchor="middle">T7 World Heritage</text>
<rect class="n" x="280" y="240" width="100" height="20"/><text class="nl" x="330" y="254" text-anchor="middle">T8 Eternal Memory</text>
<rect class="n" x="280" y="270" width="100" height="20"/><text class="nl" x="330" y="284" text-anchor="middle">T9 Apotheosis</text>
<rect class="n" x="280" y="300" width="100" height="20" stroke="#c9a84c" stroke-width="2"/>
<text class="nl" x="330" y="314" text-anchor="middle" fill="#c9a84c">T10 Living Legend ★</text>
<line class="e" x1="330" y1="170" x2="330" y2="180"/>
<line class="e" x1="330" y1="200" x2="330" y2="210"/>
<line class="e" x1="330" y1="230" x2="330" y2="240"/>
<line class="e" x1="330" y1="260" x2="330" y2="270"/>
<line class="e" x1="330" y1="290" x2="330" y2="300"/>
<!-- oral_tradition -->
<rect class="n" x="410" y="30" width="100" height="20"/><text class="nl" x="460" y="44" text-anchor="middle">T1 Oral Tradition</text>
<rect class="n" x="410" y="60" width="100" height="20"/><text class="nl" x="460" y="74" text-anchor="middle">T2 Epic Poetry</text>
<rect class="n" x="410" y="90" width="100" height="20"/><text class="nl" x="460" y="104" text-anchor="middle">T3 Bardic Lore</text>
<rect class="n" x="410" y="120" width="100" height="20"/><text class="nl" x="460" y="134" text-anchor="middle">T4 Chronicle Keeping</text>
<line class="e" x1="460" y1="50" x2="460" y2="60"/>
<line class="e" x1="460" y1="80" x2="460" y2="90"/>
<line class="e" x1="460" y1="110" x2="460" y2="120"/>
<!-- philosophy -->
<rect class="n" x="540" y="90" width="100" height="20"/><text class="nl" x="590" y="104" text-anchor="middle">T3 Natural Ethics</text>
<rect class="n" x="540" y="120" width="100" height="20"/><text class="nl" x="590" y="134" text-anchor="middle">T4 Moral Philosophy</text>
<rect class="n" x="540" y="150" width="100" height="20"/><text class="nl" x="590" y="164" text-anchor="middle">T5 Political Philosophy</text>
<rect class="n" x="540" y="180" width="100" height="20"/><text class="nl" x="590" y="194" text-anchor="middle">T6 Universal Ideals</text>
<rect class="n" x="540" y="210" width="100" height="20"/><text class="nl" x="590" y="224" text-anchor="middle">T7 Metaphysics</text>
<line class="e" x1="590" y1="110" x2="590" y2="120"/>
<line class="e" x1="590" y1="140" x2="590" y2="150"/>
<line class="e" x1="590" y1="170" x2="590" y2="180"/>
<line class="e" x1="590" y1="200" x2="590" y2="210"/>
<!-- statecraft -->
<rect class="n" x="660" y="90" width="100" height="20"/><text class="nl" x="710" y="104" text-anchor="middle">T3 Diplomatic Tradition</text>
<rect class="n" x="660" y="120" width="100" height="20"/><text class="nl" x="710" y="134" text-anchor="middle">T4 Cultural Hegemony</text>
<rect class="n" x="660" y="150" width="100" height="20"/><text class="nl" x="710" y="164" text-anchor="middle">T5 Imperial Doctrine</text>
<rect class="n" x="660" y="180" width="100" height="20"/><text class="nl" x="710" y="194" text-anchor="middle">T6 Golden Age Doctrine</text>
<rect class="n" x="660" y="210" width="100" height="20"/><text class="nl" x="710" y="224" text-anchor="middle">T7 Cultural Renaissance</text>
<rect class="n" x="660" y="240" width="100" height="20"/><text class="nl" x="710" y="254" text-anchor="middle">T8 Manifest Destiny</text>
<line class="e" x1="710" y1="110" x2="710" y2="120"/>
<line class="e" x1="710" y1="140" x2="710" y2="150"/>
<line class="e" x1="710" y1="170" x2="710" y2="180"/>
<line class="e" x1="710" y1="200" x2="710" y2="210"/>
<line class="e" x1="710" y1="230" x2="710" y2="240"/>
<text x="20" y="370" font-size="10" fill="#888">★ T10 Living Legend unlocks cultural_victory_enabled</text>
</svg>
```
The diagram only shows direct same-pillar prerequisites. Cross-pillar
prereqs (e.g. `cultural_canon` requires `cultural_renaissance` from
statecraft) exist but are not drawn here for legibility.

View file

@ -0,0 +1,143 @@
# Data flow
How a tech / tradition definition moves from JSON on disk into the screen
the player sees, and how a click on `Research` flows back through the
stack.
## Read path (data → screen)
```
┌─────────────────────────────────────────────────────────────────────────────────┐
│ public/games/age-of-dwarves/data/techs/*.json │
│ public/resources/culture/*.json │
│ │
│ ── unlocks: { buildings, units, improvements, wonders, lenses, │
│ resources_revealed, unit_upgrades, mechanics } │
└────────────────────────────────────────┬────────────────────────────────────────┘
│ DataLoader.load_theme()
│ — reads category="techs" + "culture"
│ — manifest.json silently ignored
┌─────────────────────────────────────────────────────────────────────────────────┐
│ DataLoader._data["techs"][id] DataLoader._data["culture"][id] │
│ (Dictionary keyed by tech_id) (Dictionary keyed by tradition_id) │
└────────────────────────────────────────┬────────────────────────────────────────┘
│ TurnManager.get_tech_web() / get_culture_web()
│ ─ first call lazily builds the bridge
┌─────────────────────────────────────────────────────────────────────────────────┐
│ TechWeb wrapper (RefCounted) CultureWeb wrapper (RefCounted) │
│ ▼ ▼ │
│ KnowledgeWeb { _bridge, _config, … } KnowledgeWeb { _bridge, _config }│
│ │ _bridge.load_from_json(JSON.stringify(…)) │
│ ▼ │
└─────────────────────────────────────────────────────────────────────────────────┘
│ JSON over GDExtension boundary
┌─────────────────────────────────────────────────────────────────────────────────┐
│ GdTechWeb (RefCounted) GdCultureWeb (RefCounted) │
│ ▼ ▼ │
│ mc_tech::TechWeb::from_json() mc_culture::CultureWeb │
│ │ — validate prereq DAG (acyclic) (alias of TechWeb) │
│ │ — build dependents map │
│ ▼ │
│ Immutable graph: HashMap<id, TechDefinition> + prereqs + dependents │
└─────────────────────────────────────────────────────────────────────────────────┘
Render-time queries from KnowledgeTree.gd:
─ get_pillars() → GdTechWeb.pillars() (PackedStringArray)
─ get_nodes_by_pillar(p) → GdTechWeb.techs_by_pillar(p) (PackedStringArray, sorted by tier)
─ get_node_data(id) → GdTechWeb.tech_data_json(id) → JSON.parse (Dictionary)
─ is_researched(id, pi) → Player.researched_techs.has(id) (GDScript only)
─ can_research(id, pi) → GdTechWeb.prereqs_met(id, set) (bool)
─ get_current_research(pi) → Player.researching (String)
─ get_progress_fraction(pi) → Player.research_progress / cost (float)
```
## Write path (player click → research started)
```
Click "Research" button on a card
KnowledgeTree._on_research_pressed(id)
TechWeb.start_research(id, player_index)
KnowledgeWeb.start_research(id, player_index)
│ ─ verifies can_research()
│ ─ writes player.researching = id
│ ─ writes player.research_progress = 0
│ ─ EventBus.emit_signal("tech_research_started", id, player_index)
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ tutorial_orchestrator │ │ game_logger │
│ (listens for step 7) │ │ (writes log line) │
└─────────────────────────────┘ └─────────────────────────────┘
```
## Per-turn accumulation (live game)
Each turn, GDScript hands the player's research state + city yields to
`GdTechWeb.process_research()` (and `GdCultureWeb.process_culture_research()`).
The Rust call:
```
process_research(player_json, yield_json, sci_modifier)
│ 1. Parse JSON → GdTechPlayerInput
│ 2. Sum (science + building_science) × (1 + science_percent) per city
│ 3. raw = (science_per_turn + city_sum) × sci_modifier
│ 4. Bootstrap PlayerTechState from researched_techs[]
│ 5. start_research(researching) on a fresh PlayerTechState
│ 6. add_science(research_progress + raw)
│ 7. Translate ResearchResult into return Dictionary:
│ Completed { tech_id, overflow, unlocks }
│ InProgress + new_progress
│ NoResearch
Return: { completed_tech, new_progress, new_researching,
unlock_signals[], error }
```
GDScript then:
* Writes `player.research_progress = result.new_progress`
* Writes `player.researching = result.new_researching`
* For each entry in `result.unlock_signals[]`, calls `EventBus.tech_researched.emit(tech_id, player_index)`
The culture path mirrors this exactly via `process_culture_research`,
fed by `per_turn_culture` (the same yield value driving border expansion).
## Per-turn accumulation (bench / MCTS)
`mc-turn::TurnProcessor::process_culture_research` runs alongside
`process_culture` and `process_science`. It uses the in-process
`PlayerCultureState` (not the flat fields), advancing it the same way
`process_science` advances `PlayerTechState`. On completion the flat
`researching_tradition` / `culture_research_progress` /
`researched_traditions` fields are mirrored, keeping serialisation and
GDScript reads consistent with the bench truth.
## File-by-file ownership
| Layer | File | Responsibility |
|---|---|---|
| Data | `public/games/.../data/techs/*.json`, `public/resources/culture/*.json` | Authoritative content. |
| Schema | `public/games/.../data/schemas/tech.schema.json` | Validates both tech + culture JSON. |
| Loader | `src/game/engine/src/autoloads/data_loader.gd` | Reads category dirs into `_data[category]`. |
| Rust types | `src/simulator/crates/mc-tech/src/web.rs` | `TechDefinition`, `Unlocks`, typed buckets. |
| Rust state | `src/simulator/crates/mc-tech/src/state.rs` | `PlayerTechState`, completion, unlocks signal. |
| Rust alias | `src/simulator/crates/mc-culture/src/research.rs` | `CultureWeb` = `TechWeb` re-export. |
| Bridge | `src/simulator/api-gdext/src/lib.rs` | `GdTechWeb`, `GdCultureWeb`, dictionary marshaling. |
| Bench loop | `src/simulator/crates/mc-turn/src/processor.rs` | `process_culture_research`. |
| GDScript wrapper (shared) | `src/game/engine/src/modules/tech/knowledge_web.gd` | Bridge proxy + Player field reads/writes. |
| GDScript wrapper (tech) | `src/game/engine/src/modules/tech/tech_web.gd` | Tech config. |
| GDScript wrapper (culture) | `src/game/engine/src/modules/empire/culture_web.gd` | Culture config. |
| Scene base | `src/game/engine/scenes/knowledge_tree/knowledge_tree.gd` | Card grid + detail panel rendering. |
| Scene tech | `src/game/engine/scenes/tech_tree/tech_tree.gd`, `.tscn` | Subclass + scene file. |
| Scene culture | `src/game/engine/scenes/culture_tree/culture_tree.gd`, `.tscn` | Subclass + scene file. |
| Vocabulary | `public/games/age-of-dwarves/vocabulary.json` | All player-facing strings. |
| EventBus | `src/game/engine/src/autoloads/event_bus.gd` | `tech_*` and `culture_*` signals. |

View file

@ -0,0 +1,103 @@
# Tech tree — full layout
Eight pillars × ten tiers (the `aviation`, `ecology`, and `naval` pillars
are sparser and start at later tiers; `agriculture`, `civics`, `metallurgy`,
`military`, `scholarship`, `natural_philosophy` start at T1).
## Pillar columns (rendered order — alphabetical)
```
agriculture aviation civics ecology masonry
─────────────── ─────────────── ─────────────── ─────────────── ───────────────
T1 Fishing — T1 Clan Law — T1 Stonecutting
T1 Husbandry T1 Dwarven
T1 Trapping Heritage
T2 Animal H. T2 Ancestor T2 Masonry
T2 Brewing Rites
T2 Irrigation T2 Governance
T3 Ancient T3 Arts and T3 Fortification
Forestry Craft
T3 Trade Routes
T4 Guild T4 Heritage T4 Civil
Charters Engineering
T5 Legacy T5 High
Architecture
T6 T6 Mech. — T6 Deep —
Flight Ecology
T7 T7 Aerial — T7 Stone —
Warfare Lore
T8 T8 Airship — T8 World —
Doctrine Roots
T9 T9 Mithril — T9 Deep —
Flight Husbandry
T10 T10 Sky — T10 Living —
Dominance Mountain
metallurgy military naval natural_philosophy scholarship
─────────────────── ──────────────── ────────────── ────────────────── ──────────────
T1 Mining T1 Military — T1 Surveying T1 Scholarship
Doctrine
T1 War
T2 Smelting T2 Tactics T2 Shipbuilding — T2 Herbalism
T2 Tracking T2 Mathematics
T3 Bronze T3 Siege T3 Naval T3 Forestry T3 Adv.
Working Warfare Warfare T3 Horticulture Scholarship
T3 Steelworking T3 Sailing T3 Ecology
T4 Iron Working T4 Leadership T4 Harbor T4 Alchemy —
Defense T4 Geology
T4 Runelore T4 Meteorology
T5 High Smithing T5 Combined T5 Ocean T5 Astronomy T5 High Lore
Arms Navigation T5 Natural T5 Stellar
Philosophy Mapping
T5 Mithril
Smithing
T6 Differential T6 Imperial T6 Coastal T6 Hydrology —
Hardening Warfare Warfare
T7 Steam T7 Mech. T7 Steam T7 Geophysics —
Forging Warfare Navigation
T7 Steam T7 Siege Doctrine
Metallurgy
T8 Mithril T8 Total War T8 Carrier T8 Oceanography —
Working Doctrine
T8 War Alloys T8 Submarine
Tech
T9 Deep Alloys T9 Ancient T9 Mithril T9 Climatology —
Doctrine Navigation
T10 Adamantine T10 Ascendant T10 Naval T10 World Theory —
Forging Warfare Ascendancy
```
(Some duplicate ids in the data — `combined_arms` at T5 and T6,
`mechanized_warfare` at T7 and T8, `total_war` at T8 and T9 — are
present in the source files. Migration of these duplicates is tracked
elsewhere; the screen renders them all.)
## Pillar identity (player-facing)
| Pillar | What it represents |
|---|---|
| agriculture | Food production, beasts, irrigation, brewing — the early-game economic base. |
| aviation | Flight technology — late-game (T6+) sky-warfare path. |
| civics | Society, governance, heritage. Path to legacy bonuses. |
| ecology | Deep-time biosphere knowledge — late-game (T6+). |
| masonry | Stone craft. Walls, monuments, civil engineering. |
| metallurgy | The dwarf signature pillar. Bronze → steel → mithril → adamantine. |
| military | Doctrine and combat technique. From spear-and-shield to ascendant warfare. |
| natural_philosophy | The natural world — sailing, alchemy, climate. Drives lenses. |
| naval | Sea power — only relevant on water-rich maps. |
| scholarship | Pure knowledge — cumulative speed-up to all later research. |
## Same scene, different data
The KnowledgeTree screen is exactly the same code path that draws the
culture tree (see [culture-tree-full.md](culture-tree-full.md)). Differences:
* **Title:** "Tech Tree"
* **Cost label:** "Science: 70" instead of "Culture: 70"
* **Hint:** "Select a technology to see what it changes."
* **Started signal:** `EventBus.tech_research_started`
* **Completed signal:** `EventBus.tech_researched`
* **Web reference:** `TurnManager.get_tech_web()`
All visual elements, accent colours, and indicator letters are identical.

View file

@ -0,0 +1,123 @@
# Unlock categorization
The `unlocks` JSON object on every tech / tradition uses eight typed
buckets. Each bucket has a fixed render contract — the screen never
infers categories from a free-form string.
## Bucket reference
| JSON key | Section label | Indicator | Renders as |
|---|---|---|---|
| `units[]` | NEW UNITS | `[U]` | tag chip per id, name resolved via `DataLoader.get_data("units", id)` |
| `unit_upgrades[]` | UNIT UPGRADES | `[U]` | tag chip per pair: `"<from>" → "<to>"` |
| `buildings[]` | NEW BUILDINGS | `[B]` | tag chip per id, resolved via `DataLoader.get_data("buildings", id)` |
| `wonders[]` | NEW WONDERS | `[W]` | tag chip per id, resolved via `DataLoader.get_data("buildings", id)` (wonders live in the buildings registry) |
| `improvements[]` | NEW IMPROVEMENTS | `[I]` | tag chip per id, resolved via `DataLoader.get_data("improvements", id)` |
| `lenses[]` | LENSES | `[L]` | tag chip per id, formatted from the id (no resolver) |
| `resources_revealed[]` | REVEALS RESOURCES | `[R]` | tag chip per id, formatted from the id |
| `mechanics[]` | EFFECTS | `[E]` | bullet list with optional value: `◆ <label> <value>` |
## Mechanic shape
```jsonc
{
"key": "stable_machine_id", // not displayed to the player
"label": "Player-facing label", // bold-prefix in render
"value": 1 // optional; number, string, bool, or null
}
```
Examples from the live data:
```jsonc
{ "key": "happiness_per_shrine", "label": "Happiness Per Shrine +1", "value": 1 }
{ "key": "wonder_protection_from_conquest", "label": "Wonder Protection From Conquest" }
{ "key": "score_multiplier", "label": "Score Multiplier +1.5", "value": 1.5 }
{ "key": "cultural_victory_enabled", "label": "Cultural Victory Enabled" }
```
`value` is rendered in bold accent colour after the label when present and
not boolean. Booleans and `null`s are skipped — the label alone carries
the meaning ("Cultural Victory Enabled" needs no `true`).
## Unit-upgrade shape
```jsonc
{ "from": "spearman", "to": "halberdier" }
```
Renders to `"Spearman → Halberdier"`. Both sides are name-resolved
through `DataLoader.get_data("units", id)` independently, so a typo in
either id falls back to the formatted id.
## How the screen walks the bucket
```
for bucket in [units, unit_upgrades, buildings, wonders, improvements,
lenses, resources_revealed, mechanics]:
if bucket is empty:
skip the section entirely (no header, no zero, no "(none)")
else:
render the section with its accent colour
```
This means a node with only `units[]` and `mechanics[]` shows exactly two
sections: NEW UNITS, EFFECTS. Empty buckets never produce an empty header.
## Why typed buckets, not a free-form `other:`
Earlier the tree carried an `unlocks.other: string[]` bucket where the
renderer inferred categories from string prefixes (`lens:`, `wonder_`,
trailing `_N`). That was deleted in p1-26 — the schema now refuses any
field outside the typed buckets via `additionalProperties: false`, the
Rust `Unlocks` struct has no `other` field, and the migration script that
moved every legacy entry into typed buckets was deleted after running.
Result: there is exactly one place that decides what kind of unlock an
entry is — the JSON itself. The screen renders it; it never guesses.
## Per-bucket SVG render examples
```svg
<svg xmlns="http://www.w3.org/2000/svg" width="660" height="240" viewBox="0 0 660 240" font-family="-apple-system, sans-serif" font-size="11">
<text x="0" y="14" font-size="9" fill="#888" letter-spacing="2">UNITS</text>
<rect x="0" y="20" width="100" height="20" rx="3" fill="#1f1f1f" stroke="#c9a84c"/>
<text x="50" y="34" fill="#f3eccd" text-anchor="middle">Spearman</text>
<rect x="110" y="20" width="100" height="20" rx="3" fill="#1f1f1f" stroke="#c9a84c"/>
<text x="160" y="34" fill="#f3eccd" text-anchor="middle">Halberdier</text>
<text x="0" y="64" font-size="9" fill="#888" letter-spacing="2">UNIT UPGRADES</text>
<rect x="0" y="70" width="180" height="20" rx="3" fill="#1f1f1f" stroke="#c9a84c"/>
<text x="90" y="84" fill="#f3eccd" text-anchor="middle">Spearman → Halberdier</text>
<text x="0" y="114" font-size="9" fill="#888" letter-spacing="2">BUILDINGS</text>
<rect x="0" y="120" width="100" height="20" rx="3" fill="#1f1f1f" stroke="#8b6914"/>
<text x="50" y="134" fill="#f3eccd" text-anchor="middle">Hardening Pit</text>
<text x="220" y="14" font-size="9" fill="#888" letter-spacing="2">WONDERS</text>
<rect x="220" y="20" width="120" height="20" rx="3" fill="#1f1f1f" stroke="#a06a3f"/>
<text x="280" y="34" fill="#f3eccd" text-anchor="middle">Mithril Forge</text>
<text x="220" y="64" font-size="9" fill="#888" letter-spacing="2">IMPROVEMENTS</text>
<rect x="220" y="70" width="100" height="20" rx="3" fill="#1f1f1f" stroke="#4a7c3f"/>
<text x="270" y="84" fill="#f3eccd" text-anchor="middle">Mine</text>
<text x="220" y="114" font-size="9" fill="#888" letter-spacing="2">LENSES</text>
<rect x="220" y="120" width="120" height="20" rx="3" fill="#1f1f1f" stroke="#2d5a8b"/>
<text x="280" y="134" fill="#f3eccd" text-anchor="middle">Tile Quality</text>
<text x="450" y="14" font-size="9" fill="#888" letter-spacing="2">REVEALS RESOURCES</text>
<rect x="450" y="20" width="120" height="20" rx="3" fill="#1f1f1f" stroke="#a0522d"/>
<text x="510" y="34" fill="#f3eccd" text-anchor="middle">Mithril Ore</text>
<text x="450" y="74" font-size="9" fill="#888" letter-spacing="2">EFFECTS</text>
<text x="450" y="94" fill="#6b3fa0" opacity="0.55"></text>
<text x="464" y="94" fill="#cfc8b9">Forge speed</text>
<text x="540" y="94" font-weight="700" fill="#6b3fa0">+1.2</text>
<text x="450" y="112" fill="#6b3fa0" opacity="0.55"></text>
<text x="464" y="112" fill="#cfc8b9">Cultural Victory Enabled</text>
</svg>
```
These eight render patterns cover every payload the screen will ever
encounter — there is no free-form path.

View file

@ -2,20 +2,24 @@
id: p2-10h
title: "UnitRenderer: implement _build_sprite_key() helper and fix cache key test"
priority: p2
status: stub
status: done
scope: game1
owner: ""
updated_at: 2026-04-25
evidence: []
owner:
updated_at: 2026-04-26
evidence:
- "src/game/engine/src/rendering/unit_renderer.gd:377-382 — _build_sprite_key() implemented"
- "src/game/engine/tests/unit/test_sprite_renderer.gd:25-42 — 4 sprite key tests authored"
- "src/game/engine/tests/unit/test_sprite_renderer.gd:61-74 — test_cache_populated_after_miss implemented using full path key format from DrawHelpers"
- "src/game/engine/tests/unit/test_sprite_renderer.gd:91-93 — underscore type_id test authored"
- "apricot headless run: 9/9 passed in test_sprite_renderer.gd"
---
## Summary
`test_sprite_renderer.gd` tests `_build_sprite_key(type_id, race_id, sex)` on `UnitRenderer` — a helper function that was never implemented. 5 tests use it directly. Additionally, `test_cache_populated_after_miss` fails because the expected cache key format doesn't match the actual `DrawHelpers`-managed cache key.
## Acceptance
- `UnitRenderer` declares `func _build_sprite_key(type_id: String, race_id: String, sex: String) -> String`
- Returns `"type_race_sex"` when race and sex both non-empty, `"type_race"` when only race, `"type"` when bare
- All 5 `test_sprite_key_*` tests pass
- `test_cache_populated_after_miss` passes (cache key format verified against actual `DrawHelpers` implementation)
- `UnitRenderer` declares `func _build_sprite_key(type_id: String, race_id: String, sex: String) -> String`
- Returns `"type_race_sex"` when race and sex both non-empty, `"type_race"` when only race, `"type"` when bare
- All 5 `test_sprite_key_*` tests pass
- `test_cache_populated_after_miss` passes (cache key is full path `"sprites/units/<type>.png"` per DrawHelpers)

View file

@ -11,9 +11,7 @@
"iron_working"
],
"unlocks": {
"buildings": [
"grand_forge"
],
"buildings": [],
"units": [],
"improvements": []
},
@ -40,9 +38,7 @@
"high_smithing"
],
"unlocks": {
"buildings": [
"steam_foundry"
],
"buildings": [],
"units": [],
"improvements": []
},
@ -72,9 +68,7 @@
"unlocks": {
"buildings": [],
"units": [],
"improvements": [
"mithril_mine"
]
"improvements": []
},
"flavor": "You don't shape mithril. You negotiate with it.",
"encyclopedia": {

View file

@ -1,17 +1,11 @@
import type { ReactElement } from 'react'
import styled from 'styled-components'
import { FadeIn } from '@magic-civ/guide-engine'
import { EncyclopediaCallout } from '@magic-civ/guide-engine'
import {
FadeIn, EncyclopediaCallout, SchoolPip, Sprite,
AccentCard, CardGrid, PageTitle, PageHeading, PageSubtitle,
formatEffect,
} from '@magic-civ/guide-engine'
import { allWonders } from '@/data'
import { SchoolPip } from '@magic-civ/guide-engine'
import { Sprite } from '@magic-civ/guide-engine'
import type { Building } from '@magic-civ/guide-engine'
import { PageTitle, PageHeading, PageSubtitle, CardGrid, Card } from '@magic-civ/guide-engine'
const WonderCard = styled(Card)`
border-top: 3px solid ${({ theme }) => theme.colors.primary.main};
border-radius: 8px;
`
const WonderHeader = styled.div`
display: flex;
@ -67,15 +61,6 @@ const Description = styled.p`
font-style: italic;
`
function formatEffect(effect: Building['effects'][number]): string {
const pct = effect.type.includes('bonus') && typeof effect.value === 'number'
const val = typeof effect.value === 'boolean'
? (effect.value ? 'yes' : 'no')
: `${effect.value > 0 ? '+' : ''}${effect.value}${pct ? '%' : ''}`
const label = effect.type.replace(/_/g, ' ')
return `${label}: ${val}${effect.scope ? ` (${effect.scope})` : ''}`
}
export default function WondersPage(): ReactElement {
return (
<FadeIn duration="fast">
@ -90,7 +75,7 @@ export default function WondersPage(): ReactElement {
{[...allWonders].sort((a, b) => (a.tier ?? 0) - (b.tier ?? 0)).map((wonder) => {
const school = wonder.school ?? null
return (
<WonderCard key={wonder.id} id={wonder.id}>
<AccentCard key={wonder.id} id={wonder.id}>
<WonderHeader>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'flex-start' }}>
<Sprite path={wonder.sprite} size={72} alt={wonder.name} />
@ -116,7 +101,7 @@ export default function WondersPage(): ReactElement {
))}
</EffectList>
<Description>{wonder.description}</Description>
</WonderCard>
</AccentCard>
)
})}
</CardGrid>

View file

@ -1,37 +1,23 @@
import type { ReactElement } from 'react'
import styled from 'styled-components'
import { FadeIn, PageTitle, PageHeading, PageSubtitle, Section, SectionHeading, Prose } from '@magic-civ/guide-engine'
import {
FadeIn, PageTitle, PageHeading, PageSubtitle,
Section, SectionHeading, Prose,
AccentCard, RaceTag, RACE_ACCENT_COLORS,
} from '@magic-civ/guide-engine'
const WCard = styled.div<{ $accent: string }>`
padding: 1rem;
background: ${({ theme }) => theme.colors.surface};
border: 1px solid ${({ theme }) => theme.colors.border.default};
border-top: 3px solid ${({ $accent }) => $accent};
border-radius: 4px;
margin-top: 0.5rem;
`
const WName = styled.div`
const WonderName = styled.h3`
font-size: 0.9375rem;
font-weight: 600;
color: ${({ theme }) => theme.colors.text.primary};
margin-bottom: 0.125rem;
margin: 0 0 0.125rem;
`
const WRace = styled.div`
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: ${({ theme }) => theme.colors.text.muted};
margin-bottom: 0.375rem;
`
const WEffect = styled.div`
const WonderEffect = styled.p`
font-size: 0.8125rem;
color: ${({ theme }) => theme.colors.text.secondary};
line-height: 1.5;
margin: 0;
`
export default function WondersPage(): ReactElement {
@ -51,41 +37,41 @@ export default function WondersPage(): ReactElement {
(one per game, globally).
</Prose>
<WCard $accent="#f0e860">
<WName>High Elf Aetheric Spire</WName>
<WRace>High Elves</WRace>
<WEffect>All High Elf spells in the empire cost 50% less mana to cast. Ley line strength +2 globally for all ley anchors in High Elf territory. Prerequisite for Arcane Ascension victory (along with all Archons at maximum tier). The Aetheric Spire effectively doubles the magical output of the empire.</WEffect>
</WCard>
<AccentCard $accent={RACE_ACCENT_COLORS.high_elf}>
<WonderName>High Elf Aetheric Spire</WonderName>
<RaceTag>High Elves</RaceTag>
<WonderEffect>All High Elf spells in the empire cost 50% less mana to cast. Ley line strength +2 globally for all ley anchors in High Elf territory. Prerequisite for Arcane Ascension victory (along with all Archons at maximum tier). The Aetheric Spire effectively doubles the magical output of the empire.</WonderEffect>
</AccentCard>
<WCard $accent="#3a9e4a">
<WName>Forest Elf World Tree</WName>
<WRace>Forest Elves</WRace>
<WEffect>All forests in Forest Elf territory grow to T10 ecosystem within 50 turns of construction. All Forest Elf units in forest terrain gain +2 defense. The World Tree accelerates the Ecological Transcendence victory from a century-scale goal to a manageable late-game push but requires the Forest Elf empire to have expanded forests sufficiently for the 75% global threshold to be reachable.</WEffect>
</WCard>
<AccentCard $accent={RACE_ACCENT_COLORS.forest_elf}>
<WonderName>Forest Elf World Tree</WonderName>
<RaceTag>Forest Elves</RaceTag>
<WonderEffect>All forests in Forest Elf territory grow to T10 ecosystem within 50 turns of construction. All Forest Elf units in forest terrain gain +2 defense. The World Tree accelerates the Ecological Transcendence victory from a century-scale goal to a manageable late-game push but requires the Forest Elf empire to have expanded forests sufficiently for the 75% global threshold to be reachable.</WonderEffect>
</AccentCard>
<WCard $accent="#2878c8">
<WName>Water Elf Abyssal Gate</WName>
<WRace>Water Elves</WRace>
<WEffect>Water Elf units may teleport between any two ocean tiles on any of the three worlds. Ocean crossing turn costs are eliminated. This directly enables the Oceanic Sovereignty victory by making simultaneous three-world ocean control logistically feasible. Without the Abyssal Gate, maintaining ocean presence on three worlds simultaneously is near-impossible.</WEffect>
</WCard>
<AccentCard $accent={RACE_ACCENT_COLORS.water_elf}>
<WonderName>Water Elf Abyssal Gate</WonderName>
<RaceTag>Water Elves</RaceTag>
<WonderEffect>Water Elf units may teleport between any two ocean tiles on any of the three worlds. Ocean crossing turn costs are eliminated. This directly enables the Oceanic Sovereignty victory by making simultaneous three-world ocean control logistically feasible. Without the Abyssal Gate, maintaining ocean presence on three worlds simultaneously is near-impossible.</WonderEffect>
</AccentCard>
<WCard $accent="#9040c0">
<WName>Dark Elf Grand Rail-Cannon</WName>
<WRace>Dark Elves</WRace>
<WEffect>Launches military units to any world (not just queens). First bombardment each turn is free (no mana or resource cost). Massive siege bonus for units launched from and landing on volcanic terrain. The Grand Rail-Cannon makes the Void Seeding victory achievable by enabling simultaneous military presence on all three worlds without prohibitive logistics.</WEffect>
</WCard>
<AccentCard $accent={RACE_ACCENT_COLORS.dark_elf}>
<WonderName>Dark Elf Grand Rail-Cannon</WonderName>
<RaceTag>Dark Elves</RaceTag>
<WonderEffect>Launches military units to any world (not just queens). First bombardment each turn is free (no mana or resource cost). Massive siege bonus for units launched from and landing on volcanic terrain. The Grand Rail-Cannon makes the Void Seeding victory achievable by enabling simultaneous military presence on all three worlds without prohibitive logistics.</WonderEffect>
</AccentCard>
<WCard $accent="#c07040">
<WName>Dwarf Anti-Magic Bastion</WName>
<WRace>Dwarves Anti-Magic Directorate only</WRace>
<WEffect>Permanently destroys all ley lines within 8 tiles. Specifically: when built on Khazad Prime and targeting Silvandel via the ley network, collapses all elvish ley infrastructure globally. Kzzkyt resonance collapses (resonance amplifies through ley lines). All magical factions lose their mana generation from ley sources simultaneously. Required for Dwarf Anti-Magic Victory. Cannot be rebuilt. Cannot be destroyed after completion. Construction is visible to all factions and triggers global war declaration.</WEffect>
</WCard>
<AccentCard $accent={RACE_ACCENT_COLORS.dwarf}>
<WonderName>Dwarf Anti-Magic Bastion</WonderName>
<RaceTag>Dwarves Anti-Magic Directorate only</RaceTag>
<WonderEffect>Permanently destroys all ley lines within 8 tiles. Specifically: when built on Khazad Prime and targeting Silvandel via the ley network, collapses all elvish ley infrastructure globally. Kzzkyt resonance collapses (resonance amplifies through ley lines). All magical factions lose their mana generation from ley sources simultaneously. Required for Dwarf Anti-Magic Victory. Cannot be rebuilt. Cannot be destroyed after completion. Construction is visible to all factions and triggers global war declaration.</WonderEffect>
</AccentCard>
<WCard $accent="#60c050">
<WName>Kzzkyt Resonance Spire</WName>
<WRace>Kzzkyt</WRace>
<WEffect>Same as Game 2 version amplifies Kzzkyt resonance across all queen network nodes simultaneously. Accelerates the Interplanetary Swarm victory by boosting queen ejection range and landing success probability. Less strategically dominant than in Game 2 due to the presence of full elvish school magic, but still a decisive accelerant for the Kzzkyt victory path.</WEffect>
</WCard>
<AccentCard $accent={RACE_ACCENT_COLORS.kzzkyt}>
<WonderName>Kzzkyt Resonance Spire</WonderName>
<RaceTag>Kzzkyt</RaceTag>
<WonderEffect>Same as Game 2 version amplifies Kzzkyt resonance across all queen network nodes simultaneously. Accelerates the Interplanetary Swarm victory by boosting queen ejection range and landing success probability. Less strategically dominant than in Game 2 due to the presence of full elvish school magic, but still a decisive accelerant for the Kzzkyt victory path.</WonderEffect>
</AccentCard>
</Section>
</FadeIn>
)

View file

@ -1,38 +1,23 @@
import type { ReactElement } from 'react'
import styled from 'styled-components'
import { FadeIn, PageTitle, PageHeading, PageSubtitle, Section, SectionHeading, Prose } from '@magic-civ/guide-engine'
import {
FadeIn, PageTitle, PageHeading, PageSubtitle,
Section, SectionHeading,
AccentCard, RaceTag,
} from '@magic-civ/guide-engine'
const WonderName = styled.h3`
font-size: 0.9375rem;
font-weight: 600;
color: ${({ theme }) => theme.colors.text.primary};
margin: 0 0 0.25rem;
`
const WonderCard = styled.div`
padding: 0.875rem 1rem;
background: ${({ theme }) => theme.colors.surface};
border: 1px solid ${({ theme }) => theme.colors.border.default};
border-radius: 8px;
margin-bottom: 0.5rem;
h3 {
font-size: 0.9375rem;
font-weight: 600;
color: ${({ theme }) => theme.colors.text.primary};
margin: 0 0 0.25rem;
}
.race-tag {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: ${({ theme }) => theme.colors.text.muted};
margin-bottom: 0.375rem;
}
p {
font-size: 0.8125rem;
color: ${({ theme }) => theme.colors.text.secondary};
line-height: 1.6;
margin: 0;
}
const WonderEffect = styled.p`
font-size: 0.8125rem;
color: ${({ theme }) => theme.colors.text.secondary};
line-height: 1.6;
margin: 0;
`
export default function WondersPage(): ReactElement {
@ -45,42 +30,42 @@ export default function WondersPage(): ReactElement {
<Section>
<SectionHeading>Kzzkyt Wonders</SectionHeading>
<WonderCard>
<h3>Queen's Cradle</h3>
<div className="race-tag">Kzzkyt Era 5</div>
<p>The first queen ejected after this wonder is built costs no Ejection Silo charge.
<AccentCard>
<WonderName>Queen's Cradle</WonderName>
<RaceTag>Kzzkyt Era 5</RaceTag>
<WonderEffect>The first queen ejected after this wonder is built costs no Ejection Silo charge.
All future queens from this civilization have +50% survival chance on landing. The
wonder represents the hive's mastery of the biological ejection process queens are
launched with better targeting, better shells, and better survival instincts encoded.</p>
</WonderCard>
<WonderCard>
<h3>The Deep Hive</h3>
<div className="race-tag">Kzzkyt Era 4</div>
<p>The city containing The Deep Hive gains unlimited tunnel depth no era gating on
launched with better targeting, better shells, and better survival instincts encoded.</WonderEffect>
</AccentCard>
<AccentCard>
<WonderName>The Deep Hive</WonderName>
<RaceTag>Kzzkyt Era 4</RaceTag>
<WonderEffect>The city containing The Deep Hive gains unlimited tunnel depth no era gating on
subsurface tier construction. All other Kzzkyt cities on the same world receive +3
production bonus. The wonder represents an architectural breakthrough in vertical
excavation that the hive shares through genetic memory.</p>
</WonderCard>
<WonderCard>
<h3>Resonance Spire</h3>
<div className="race-tag">Kzzkyt Era 7</div>
<p>Doubles ambient resonance density across the entire world where it is built. All Kzzkyt
excavation that the hive shares through genetic memory.</WonderEffect>
</AccentCard>
<AccentCard>
<WonderName>Resonance Spire</WonderName>
<RaceTag>Kzzkyt Era 7</RaceTag>
<WonderEffect>Doubles ambient resonance density across the entire world where it is built. All Kzzkyt
instinct cooldowns on that world are halved. Required for the Resonance Saturation victory
condition. Can only be built on The Hive the spire amplifies The Hive's existing ley
network, and Khazad Prime's ley network architecture is incompatible with Kzzkyt spire geometry.</p>
</WonderCard>
network, and Khazad Prime's ley network architecture is incompatible with Kzzkyt spire geometry.</WonderEffect>
</AccentCard>
</Section>
<Section>
<SectionHeading>Dwarf Wonders</SectionHeading>
<WonderCard>
<h3>Anti-Magic Bastion</h3>
<div className="race-tag">Dwarves Era 6</div>
<p>Severs ley lines in a 5-tile radius permanently. Kzzkyt units in this radius lose all
<AccentCard>
<WonderName>Anti-Magic Bastion</WonderName>
<RaceTag>Dwarves Era 6</RaceTag>
<WonderEffect>Severs ley lines in a 5-tile radius permanently. Kzzkyt units in this radius lose all
resonance bonuses. No instincts can trigger within the radius. Required for the Anti-Magic
Victory condition. The Bastion is the Dwarves' definitive statement: the world does not
need ley lines, and they can prove it by removing them.</p>
</WonderCard>
need ley lines, and they can prove it by removing them.</WonderEffect>
</AccentCard>
</Section>
</FadeIn>
)

View file

@ -374,6 +374,14 @@ func _get_unit_sprite(type_id: String, race_id: String, sex: String) -> Texture2
)
func _build_sprite_key(type_id: String, race_id: String, sex: String) -> String:
if race_id != "" and sex != "":
return "%s_%s_%s" % [type_id, race_id, sex]
elif race_id != "":
return "%s_%s" % [type_id, race_id]
return type_id
func _resolve_race_id(unit: UnitScript) -> String:
## Race lookup order: unit's own `race_id` property (if present),
## then the owning Player's `race_id`. Empty string when neither is set.

View file

@ -156,7 +156,21 @@ func test_era_unlocks_resolve() -> void:
func test_tech_unlocks_resolve() -> void:
pending("16 dangling unlock refs (missing improvements/buildings) — see p2-10e-data-integrity.md")
var failures: Array[String] = []
var bucket: Dictionary = DataLoader._data.get("techs", {})
for id: String in bucket:
var entry: Dictionary = bucket[id]
if not entry.has("unlocks") or not entry["unlocks"] is Dictionary:
continue
var unlock_dict: Dictionary = entry["unlocks"] as Dictionary
_collect_unlock_failures(
"tech '%s'" % id, unlock_dict,
["units", "buildings", "improvements"], failures,
)
assert_eq(
failures.size(), 0,
"techs with dangling unlock references: %s" % str(failures),
)
func _collect_unlock_failures(
@ -204,7 +218,31 @@ func test_tech_requires_resolve() -> void:
func test_no_duplicate_ids_per_category() -> void:
pending("20 duplicate IDs between public/resources/ and public/games/age-of-dwarves/data/ — see p2-10e-data-integrity.md")
## Checks that no id appears in two different files within the same game-pack
## category directory. Cross-source duplicates (public/resources/ vs
## public/games/) are intentional: game data overrides the resource library.
var failures: Array[String] = []
var pack_base: String = "res://public/games/age-of-dwarves/data"
for category: String in [
"units", "buildings", "techs", "terrain", "improvements",
"map_types", "governments", "races", "promotions", "wilds",
]:
var dir_path: String = "%s/%s" % [pack_base, category]
if not DirAccess.dir_exists_absolute(dir_path):
continue
var seen: Dictionary = {}
_scan_directory_for_ids(dir_path, seen)
for id: String in seen:
var sources: Array = seen[id] as Array
if sources.size() > 1:
failures.append(
"id '%s' appears in %d files within %s: %s"
% [id, sources.size(), category, str(sources)]
)
assert_eq(
failures.size(), 0,
"duplicate ids within pack category: %s" % str(failures),
)
func _collect_duplicate_ids(category: String) -> Array[String]:

View file

@ -23,19 +23,23 @@ func after_each() -> void:
## _build_sprite_key — race + sex both set
func test_sprite_key_with_race_and_sex() -> void:
pending("_build_sprite_key() not yet implemented on UnitRenderer — see p2-10h-sprite-renderer-build-key.md")
var key: String = _renderer._build_sprite_key("warrior", "dwarf", "m")
assert_eq(key, "warrior_dwarf_m", "race+sex key must be type_race_sex")
func test_sprite_key_with_race_only() -> void:
pending("_build_sprite_key() not yet implemented on UnitRenderer — see p2-10h-sprite-renderer-build-key.md")
var key: String = _renderer._build_sprite_key("warrior", "dwarf", "")
assert_eq(key, "warrior_dwarf", "race-only key must be type_race")
func test_sprite_key_bare_type_when_no_race_no_sex() -> void:
pending("_build_sprite_key() not yet implemented on UnitRenderer — see p2-10h-sprite-renderer-build-key.md")
var key: String = _renderer._build_sprite_key("warrior", "", "")
assert_eq(key, "warrior", "no race no sex must return bare type_id")
func test_sprite_key_race_empty_sex_nonempty_uses_bare() -> void:
pending("_build_sprite_key() not yet implemented on UnitRenderer — see p2-10h-sprite-renderer-build-key.md")
var key: String = _renderer._build_sprite_key("warrior", "", "f")
assert_eq(key, "warrior", "empty race with non-empty sex must return bare type_id")
## Sprite cache — missing sprite returns null (no crash)
@ -55,7 +59,19 @@ func test_get_unit_sprite_empty_type_id_returns_null() -> void:
## Sprite cache — cache stores null on miss so repeated lookups avoid re-I/O
func test_cache_populated_after_miss() -> void:
pending("Cache key format mismatch between test expectation and DrawHelpers implementation — see p2-10h-sprite-renderer-build-key.md")
# After a miss, DrawHelpers caches the full path string as the key (not the
# short _build_sprite_key form). Generic path: "sprites/units/<type_id>.png".
var type_id: String = "nonexistent_unit_type_xyz"
_renderer._get_unit_sprite(type_id, "", "")
var expected_key: String = "sprites/units/%s.png" % type_id
assert_true(
_renderer._unit_sprite_cache.has(expected_key),
"cache must contain the full-path generic key after a miss"
)
assert_null(
_renderer._unit_sprite_cache[expected_key],
"cached value for a missing sprite must be null"
)
## City renderer HP-bar always rendered regardless of sprite presence
@ -73,4 +89,5 @@ func test_city_renderer_hp_bar_always_present() -> void:
## _build_sprite_key — type_id with special characters
func test_sprite_key_with_underscore_type_id() -> void:
pending("_build_sprite_key() not yet implemented on UnitRenderer — see p2-10h-sprite-renderer-build-key.md")
var key: String = _renderer._build_sprite_key("dwarf_warrior", "clan_iron", "m")
assert_eq(key, "dwarf_warrior_clan_iron_m", "underscored segments must concatenate correctly")

View file

@ -74,15 +74,40 @@ func test_unknown_biome_returns_empty_array() -> void:
func test_panel_starts_hidden() -> void:
pending("tile_info_panel.gd @onready nodes require parent scene context — see p2-10i-tile-tooltip-scene.md")
var packed: PackedScene = load("res://engine/scenes/world_map/tile_info_panel.tscn")
var panel: PanelContainer = packed.instantiate()
add_child_autofree(panel)
assert_false(panel.visible, "panel must start hidden after _ready()")
func test_hide_panel_resets_current_axial() -> void:
pending("tile_info_panel.gd @onready nodes require parent scene context — see p2-10i-tile-tooltip-scene.md")
var packed: PackedScene = load("res://engine/scenes/world_map/tile_info_panel.tscn")
var panel: PanelContainer = packed.instantiate()
add_child_autofree(panel)
var test_tile: Resource = preload("res://engine/src/map/tile.gd").new()
test_tile.biome_id = "temperate_forest"
test_tile.resource_id = ""
test_tile.improvement = ""
panel.show_tile(test_tile, Vector2i(0, 0))
panel.hide_panel()
assert_false(panel.visible, "panel must be hidden after hide_panel()")
panel.show_tile(test_tile, Vector2i(0, 0))
assert_true(panel.visible, "same axial must re-trigger after hide_panel resets it")
func test_show_tile_same_axial_is_noop() -> void:
pending("tile_info_panel.gd @onready nodes require parent scene context — see p2-10i-tile-tooltip-scene.md")
var packed: PackedScene = load("res://engine/scenes/world_map/tile_info_panel.tscn")
var panel: PanelContainer = packed.instantiate()
add_child_autofree(panel)
var test_tile: Resource = preload("res://engine/src/map/tile.gd").new()
test_tile.biome_id = "temperate_forest"
test_tile.resource_id = ""
test_tile.improvement = ""
panel.show_tile(test_tile, Vector2i(3, 5))
assert_true(panel.visible, "panel visible after first show_tile")
panel.visible = false
panel.show_tile(test_tile, Vector2i(3, 5))
assert_false(panel.visible, "second show_tile with same axial must be noop")
func test_hover_interval_constant_is_20hz() -> void:

View file

@ -136,6 +136,23 @@ export const CardCompact = styled(Card)`
gap: 0.5rem;
`
/** Card with a configurable accent border. Defaults to top edge, theme primary color. */
export const AccentCard = styled(Card)<{ $accent?: string; $edge?: 'top' | 'left' }>`
${({ $edge }) => ($edge === 'left' ? 'border-left' : 'border-top')}:
3px solid ${({ $accent, theme }) => $accent ?? theme.colors.primary.main};
border-radius: 4px;
`
/** Small uppercase race/era label beneath a card title. */
export const RaceTag = styled.div`
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: ${({ theme }) => theme.colors.text.muted};
margin-bottom: 0.375rem;
`
/** Unified data-surface primitive. Three visual variants:
* - `base` (default): standard surface, matches `<Card>`.
* - `compact`: tighter padding + smaller radius (SpeciesBrowser cards).

View file

@ -0,0 +1,8 @@
export const RACE_ACCENT_COLORS: Record<string, string> = {
high_elf: '#f0e860',
forest_elf: '#3a9e4a',
water_elf: '#2878c8',
dark_elf: '#9040c0',
dwarf: '#c07040',
kzzkyt: '#60c050',
}

View file

@ -55,6 +55,12 @@ export type {
export type { DerivedStats } from './utils/unit-stats'
export { deriveUnitStats, formatAttackType, formatArmorType } from './utils/unit-stats'
// ─── Building utils ───────────────────────────────────────────────────────────
export { formatEffect } from './utils/building-utils'
// ─── Race colors ─────────────────────────────────────────────────────────────
export { RACE_ACCENT_COLORS } from './data/race-colors'
// ─── Cards ───────────────────────────────────────────────────────────────────
export { TerrainCard } from './components/cards/TerrainCard'
@ -83,6 +89,7 @@ export { Markdown } from './components/ui/Markdown'
export {
PageTitle, PageHeading, PageSubtitle,
Section, CardGrid, SectionHeading, Prose, Description, Card, CardCompact,
AccentCard, RaceTag,
DataCard, StatsGrid, StatCell, QualityIndicator,
TagRow, Tag, FilterRow, FilterChip, Callout, CalloutText,
DataTable, Highlight, FeatureGrid, FeatureChip,

View file

@ -0,0 +1,10 @@
import type { BuildingEffect } from '../types/game-data'
export function formatEffect(effect: BuildingEffect): string {
const pct = effect.type.includes('bonus') && typeof effect.value === 'number'
const val = typeof effect.value === 'boolean'
? (effect.value ? 'yes' : 'no')
: `${effect.value > 0 ? '+' : ''}${effect.value}${pct ? '%' : ''}`
const label = effect.type.replace(/_/g, ' ')
return `${label}: ${val}${effect.scope ? ` (${effect.scope})` : ''}`
}