feat(@projects/@magic-civilization): ✨ add cultural and tech design docs
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
ce115b0dbb
commit
c70b433266
18 changed files with 786 additions and 159 deletions
|
|
@ -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.
|
||||
|
|
|
|||
169
.project/designs/culture-tree-full.md
Normal file
169
.project/designs/culture-tree-full.md
Normal 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.
|
||||
143
.project/designs/data-flow.md
Normal file
143
.project/designs/data-flow.md
Normal 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. |
|
||||
103
.project/designs/tech-tree-full.md
Normal file
103
.project/designs/tech-tree-full.md
Normal 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.
|
||||
123
.project/designs/unlock-categorization.md
Normal file
123
.project/designs/unlock-categorization.md
Normal 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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
8
src/packages/guide/src/data/race-colors.ts
Normal file
8
src/packages/guide/src/data/race-colors.ts
Normal 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',
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
10
src/packages/guide/src/utils/building-utils.ts
Normal file
10
src/packages/guide/src/utils/building-utils.ts
Normal 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})` : ''}`
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue