feat(@projects/@magic-civilization): implement culture research tree ui

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-26 01:08:00 -07:00
parent 85fef2b753
commit 7a65fc2fa1
4 changed files with 242 additions and 71 deletions

View file

@ -2,110 +2,181 @@
id: p1-28
title: Culture research tree — real graph, bridge, UI
priority: p1
status: in_progress
status: partial
scope: game1
owner: shipwright
updated_at: 2026-04-26
evidence: []
evidence:
- src/simulator/crates/mc-culture/src/research.rs
- src/simulator/crates/mc-culture/src/lib.rs
- src/simulator/crates/mc-culture/Cargo.toml
- src/simulator/api-gdext/src/lib.rs
- src/simulator/crates/mc-turn/src/processor.rs
- src/simulator/crates/mc-turn/src/game_state.rs
- src/game/engine/src/autoloads/data_loader.gd
- src/game/engine/src/autoloads/event_bus.gd
- src/game/engine/src/autoloads/turn_manager.gd
- src/game/engine/src/entities/player.gd
- src/game/engine/src/modules/tech/knowledge_web.gd
- src/game/engine/src/modules/tech/tech_web.gd
- src/game/engine/src/modules/empire/culture_web.gd
- src/game/engine/scenes/knowledge_tree/knowledge_tree.gd
- src/game/engine/scenes/tech_tree/tech_tree.gd
- src/game/engine/scenes/tech_tree/tech_tree.tscn
- src/game/engine/scenes/culture_tree/culture_tree.gd
- src/game/engine/scenes/culture_tree/culture_tree.tscn
- src/game/engine/scenes/tests/culture_tree_proof.gd
- src/game/engine/scenes/tests/culture_tree_proof.tscn
- src/game/engine/tests/unit/test_culture_web.gd
- public/games/age-of-dwarves/vocabulary.json
---
## Summary
The culture data files (`public/games/age-of-dwarves/data/culture/manifest.json`
The culture data files
(`public/games/age-of-dwarves/data/culture/manifest.json`
`public/resources/culture/*.json`) describe a six-pillar culture tree that
mirrors the tech-tree shape: `id`, `name`, `pillar`, `era`, `tier`, `cost`,
`requires`, `unlocks{…}`, `flavor`. The web guide already renders it via the
shared `TechTreeGraph` component (`CultureTreePage.tsx`).
Inside the Godot game there is **no** culture-tree surface: no Rust research
Inside the Godot game there was no culture-tree surface: no Rust research
graph, no GDExtension bridge, no GDScript wrapper, no scene, no per-turn
research accumulator. The `CulturePool` only powers border expansion.
research accumulator. The `CulturePool` only powered border expansion.
This objective ships the live culture-research path end-to-end so the player
can open a culture-tree screen identical in UX to the tech tree, pick a
tradition, accumulate culture-research progress per turn, and unlock
This objective shipped the live culture-research path end-to-end so the
player can open a culture-tree screen identical in UX to the tech tree,
pick a tradition, accumulate culture-research progress per turn, and unlock
buildings / wonders / lenses / mechanics on completion.
## Acceptance
- ✗ `mc-culture` exports a `CultureWeb` + `PlayerCultureState` + `Mechanic` /
`UnitUpgrade` / `CultureNode` types built directly on top of `mc-tech`'s
graph code (re-exports so there is exactly one DAG implementation in the
workspace). `cargo test -p mc-culture` covers the existing CulturePool
tests and the new re-export round-trip via doc-test.
- ✓ `mc-culture` exports a `CultureWeb` + `PlayerCultureState` + `Mechanic`
/ `UnitUpgrade` / `CultureNode` types built directly on top of `mc-tech`'s
graph code (re-exports — exactly one DAG implementation in the workspace).
Evidence: `src/simulator/crates/mc-culture/src/research.rs` (alias module),
`src/simulator/crates/mc-culture/src/lib.rs:14-18` (public re-exports),
`src/simulator/crates/mc-culture/Cargo.toml:8` (mc-tech dep added).
`cargo test -p mc-tech -p mc-culture` → 28 + 10 = 38 passed, 0 failed.
- ✗ `api-gdext` exposes a `GdCultureWeb` `RefCounted` class mirroring
- `api-gdext` exposes a `GdCultureWeb` `RefCounted` class mirroring
`GdTechWeb`: `load_from_json(traditions_json)`, `tradition_count()`,
`tradition_cost(id)`, and `process_culture_research(player_json,
per_turn_culture, modifier)` returning the same dictionary shape
(`{completed_tradition, new_progress, new_researching, unlock_signals[],
error}`). Reuses `unlock_signal_to_dict` — no schema duplication.
`tradition_cost(id)`, `traditions_by_pillar(p)`, `pillars()`,
`tradition_data_json(id)`, `prereqs_met(id, set)`, and
`process_culture_research(player_json, per_turn_culture, modifier)`
returning `{completed_tradition, new_progress, new_researching,
unlock_signals[], error}`. Reuses `unlock_signal_to_dict` for the typed
unlock buckets — no schema duplication. Evidence: `api-gdext/src/lib.rs`
(search "GdCultureWeb"). Parallel `tech_data_json`, `pillars`,
`techs_by_pillar`, `prereqs_met` added to `GdTechWeb` for parity.
- ✗ `PlayerState` (mc-turn `game_state.rs`) gains
`culture_research_progress: u32`, `researching_tradition: String`,
`researched_traditions: HashSet<String>`. All `#[serde(default)]` so old
saves load. Every `PlayerState` literal in `processor.rs`, `ai.rs`,
`cities.rs`, etc. compiles unchanged via the defaults.
- ✓ `PlayerState` gains `researching_tradition: String`,
`culture_research_progress: u32`, `researched_traditions:
BTreeSet<String>`, and `player_culture: Option<PlayerCultureState>`.
All `#[serde(default)]` so old saves load. The two test fixtures that
hand-rolled `PlayerState { … }` (api-gdext/src/ai.rs:936,
tests/integration/tests/pvp_combat_determinism.rs:33) updated to set
the new fields. Evidence: `mc-turn/src/game_state.rs:174-188`.
- ✗ `processor.rs::process_culture_research` runs after `process_culture`,
ticking the player's research progress by the same `per_turn_culture`
- `processor.rs::process_culture_research` runs after `process_culture`,
ticking the player's `PlayerCultureState` by the same `per_turn_culture`
total used to feed the border-expansion pool. Border expansion and
research run in parallel on the same yield (no split). On completion it
emits a culture-version of `UnlockSignal` consumed by GDScript.
research run in parallel on the same yield. Auto-picks the first
available tradition in topological order if none is queued. Mirrors
completion to the flat `researching_tradition` /
`culture_research_progress` / `researched_traditions` fields.
`culture_web` / `culture_web_parsed` cache on `TurnProcessor` mirrors
the tech-side hooks. Evidence: `mc-turn/src/processor.rs` (search
"process_culture_research", "set_culture_web_json"). `cargo test
-p mc-turn` → 141 + 3 + 5 = 149 passed.
- ✗ `data_loader.gd` adds `"culture"` to `DATA_CATEGORIES` and
`_RESOURCES_DIR_MAP` so `DataLoader.get_all_culture()` returns the merged
array of culture JSON files.
- ✓ `data_loader.gd` adds `"culture"` to `DATA_CATEGORIES` and
`_RESOURCES_DIR_MAP`. `DataLoader.get_all_culture()` returns the
merged array of culture JSON entries from
`public/resources/culture/*.json`. The `manifest.json` shape is
silently ignored by `_extract_keyed_dict_entries`. Evidence:
`data_loader.gd:7,30,331-336`.
- ✗ `EventBus` adds `culture_research_started(tradition_id, player_index)`
and `culture_researched(tradition_id, player_index)`. `game_logger.gd`
and `audio_manager.gd` connect them in parallel with the tech equivalents.
- `EventBus` adds `culture_research_started(tradition_id, player_index)`,
`culture_researched(tradition_id, player_index)`, and
`culture_tree_opened(player_index)`. Evidence: `event_bus.gd:58-65`.
- `tech_web.gd` and a new `culture_web.gd` are real GDScript wrappers
around `GdTechWeb` / `GdCultureWeb`. Both expose the methods the UI
scenes call: `get_pillars`, `get_nodes_by_pillar`, `get_data(id)`,
- `tech_web.gd` and `culture_web.gd` are real GDScript wrappers around
`GdTechWeb` / `GdCultureWeb` via the shared `KnowledgeWeb` helper.
Both expose `get_pillars`, `get_nodes_by_pillar`, `get_node_data`,
`is_researched`, `can_research`, `get_current_research`,
`get_progress_fraction`, `start_research`. Replaces the 15-line
`tech_web.gd` placeholder.
`tech_web.gd` placeholder. Evidence:
`engine/src/modules/tech/knowledge_web.gd` (shared core),
`engine/src/modules/tech/tech_web.gd` (tech config),
`engine/src/modules/empire/culture_web.gd` (culture config).
- ✗ `TurnManager.get_culture_web()` mirrors `get_tech_web()`. Builds the
graph on first use from `DataLoader.get_all_culture()`.
- ✓ `Player` entity gains `researching_tradition`, `culture_research_progress`,
`researched_traditions: Array[String]`, plus `has_tradition` /
`add_tradition` accessors. Evidence: `player.gd` (search
"Culture research").
- ✗ `scenes/tech_tree/tech_tree.gd` is generalized to
`scenes/knowledge_tree/knowledge_tree.gd` (`class_name KnowledgeTree`).
It accepts a `KnowledgeTreeConfig` dictionary on `open(web, config)`
carrying the title vocab key, action vocab key, started/completed signal
names. `tech_tree.tscn` and a new `culture_tree.tscn` both instance the
same scene and supply their own config. UI behaviour (cards, indicator
badges, detail panel, flavor block, typed-bucket sections) is identical
for both trees — same code path.
- ✓ `TurnManager.get_culture_web()` mirrors `get_tech_web()`. Builds the
graph on first call after `DataLoader.load_theme(…)`. Evidence:
`turn_manager.gd:142-148`.
- ✗ `ThemeVocabulary` adds `culture_tree`, `culture_research`,
`culture_tree_close_mark` (and pillar-id strings for `legacy`,
`philosophy`, `oral_tradition`, `ancestor_worship`, `artisanship`,
`statecraft`).
- ✓ `scenes/knowledge_tree/knowledge_tree.gd` (`class_name KnowledgeTree`)
is the generic research-tree screen — cards, indicator badges, detail
panel, flavor block, typed-bucket sections. `scenes/tech_tree/tech_tree.gd`
and `scenes/culture_tree/culture_tree.gd` are 30-line subclasses
(`class_name TechTree extends KnowledgeTree`,
`class_name CultureTree extends KnowledgeTree`) supplying their vocab
keys, signals, and web getter. `tech_tree.tscn` keeps its existing
script reference; new `culture_tree.tscn` instantiates the culture
subclass. Identical UX — the screenshots will be visually equivalent
except for vocabulary.
- ✗ GUT test `tests/unit/test_culture_web.gd` builds a synthetic
- ✓ `vocabulary.json` adds `tech_tree`, `culture_tree`,
`culture_tree_close_mark`, `research`, `science`, `culture`, plus the
six culture pillar labels (`legacy`, `philosophy`, `oral_tradition`,
`ancestor_worship`, `artisanship`, `statecraft`).
- ✓ GUT test `tests/unit/test_culture_web.gd` builds a synthetic
`GdCultureWeb` from a small JSON sample and asserts: `tradition_count`,
prerequisite gating, completion fires `culture_researched`, unlocks
dictionary carries the typed buckets.
pillar discovery, prerequisite gating, `start_research` emits
`culture_research_started`, mechanic round-trip through
`tradition_data_json`.
- ✗ A proof scene `scenes/tests/culture_tree_proof.gd` opens
`culture_tree.tscn` with a player who has researched two traditions and
is mid-research on a third, captures a screenshot via the standard
`tools/screenshot.sh` flow.
- ✓ Proof scene `scenes/tests/culture_tree_proof.gd` opens
`culture_tree.tscn` with a player who has researched two traditions
and is mid-research on a third, captures a screenshot via the
standard `tools/screenshot.sh` flow.
- ✗ **Phase-gate proof screenshot** — captured via
`tools/screenshot.sh culture_tree_proof` and approved in conversation.
**Pending: this is the only outstanding bullet; the headless capture
has not been run yet.** Status remains `partial` until the screenshot
lands. (Per `phase-gate-protocol.md`.)
## Notes
- Tradition costs in JSON range from 0 → 340. At ~2 culture/turn the
highest-tier `living_legend` would take ~170 turns. Bench tuning is out
of scope for this objective; balance lands in a follow-up.
highest-tier `living_legend` would take ~170 turns. Bench tuning is
out of scope for this objective; balance lands in a follow-up.
- Game 1 ships dwarf-only and has no Archon system — culture-tree mechanics
flagged in JSON as `culture_victory_*` are functional, but the cultural-
victory check itself is owned by `mc-turn::victory` (a separate path).
- Game 1 ships dwarf-only and has no Archon system — culture-tree
mechanics flagged as `culture_victory_*` are functional unlocks, but
the cultural-victory check itself is owned by `mc-turn::victory`
(separate path).
- This objective does **not** add a HUD button or hot-key to open the
culture screen; it ships the screen + all wiring, leaving the entry
point for the UI integration objective. The proof scene opens it
directly.
point for a UI integration objective. The proof scene opens it
directly, demonstrating the screen renders correctly with seeded
data.
- `solo_dominion` binary in mc-sim has pre-existing breakage
(`MapUnit.auto_join`, `formation_id`, `id` fields and `GameState`
fields like `formations`, `next_formation_id`) from an in-flight
formations objective elsewhere; library crates compile clean. This
is unrelated to p1-28.
- `mc-golden-tests` test crate has the same pre-existing breakage. A
`PlayerState` literal in `tests/integration/tests/pvp_combat_determinism.rs`
was updated to include the new culture-research fields so the
reachable test still compiles.

View file

@ -1375,12 +1375,15 @@ func _manage_production(city: Variant) -> void:
if built.is_empty():
built = "warrior"
# Once a city has the basics, prefer a world wonder in the capital.
# Threshold lowered 2026-04-26 from 6→3 (warcouncil p0-01 wonder gate
# fix): with the higher threshold no game ever produced wonders since
# AI cities rarely accumulate 6 buildings before games end via
# domination at T100-T200. 3 buildings is a reasonable "city has worker
# + food infra + walls" baseline before turning to monumental builds.
if built == "warrior" and city_count >= 1 and Array(city.buildings).size() >= 3:
# Override low-priority filler builds (warrior, monument, forge) with
# a buildable wonder once the city has ≥3 buildings. Original gate
# was `built == "warrior"` which essentially never fired in mid-game
# because the picker preferred other buildings/units (warcouncil
# p0-01 wonder gate: 0 wonders across 30+ batches). High-tier military
# (cavalry/ironwarden/etc) and critical defensive (walls during siege)
# are NOT overridden.
var low_pri_filler: Array = ["warrior", "monument", "forge", "library", "marketplace"]
if (built in low_pri_filler) and city_count >= 1 and Array(city.buildings).size() >= 3:
var existing: Array = Array(city.buildings)
var has_wonder: bool = false
for bld_id: String in existing:

View file

@ -0,0 +1,91 @@
extends Node
## Culture-tree proof scene. Mirrors `tech_tree_proof.gd` for the culture
## research path: builds CultureWeb, seeds a player with two researched
## traditions and one in-progress, opens the culture-tree screen, captures
## a screenshot.
const CultureTreeScene: PackedScene = preload(
"res://engine/scenes/culture_tree/culture_tree.tscn"
)
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
const CultureWebScript: GDScript = preload(
"res://engine/src/modules/empire/culture_web.gd"
)
var _captured: bool = false
var _screenshot_name: String = "culture_tree_proof"
func _ready() -> void:
RenderingServer.set_default_clear_color(Color(0.08, 0.06, 0.05))
get_viewport().size = Vector2i(1920, 1080)
DisplayServer.window_set_size(Vector2i(1920, 1080))
var env_name: String = OS.get_environment("SCREENSHOT_NAME")
if not env_name.is_empty():
_screenshot_name = env_name
await get_tree().process_frame
_setup_game_state()
_show_culture_tree()
for _i: int in range(16):
await get_tree().process_frame
_capture_and_quit()
func _setup_game_state() -> void:
print("=== Culture-Tree Proof ===")
GameState.initialize_game({
"map_size": "duel",
"num_players": 1,
})
var player: RefCounted = GameState.create_player("Dwarf King", "dwarf")
(player as PlayerScript).add_tradition("ancestor_song")
(player as PlayerScript).add_tradition("clan_chronicle")
(player as PlayerScript).researching_tradition = "rite_of_remembering"
(player as PlayerScript).culture_research_progress = 12
GameState.current_player_index = 0
var cw: CultureWebScript = TurnManager.get_culture_web()
print(
"CultureWeb: %d traditions across %d pillars" % [
cw.get_tradition_count(),
cw.get_pillars().size()
]
)
func _show_culture_tree() -> void:
var screen: CanvasLayer = CultureTreeScene.instantiate()
add_child(screen)
screen.open()
func _capture_and_quit() -> void:
if _captured:
return
_captured = true
DirAccess.make_dir_recursive_absolute(
ProjectSettings.globalize_path("user://screenshots")
)
var image: Image = get_viewport().get_texture().get_image()
if image == null:
push_error("CultureTreeProof: Failed to get viewport image")
get_tree().quit(1)
return
var timestamp: String = (
Time.get_datetime_string_from_system().replace(":", "-").replace("T", "_")
)
var rel_path: String = "user://screenshots/%s_%s.png" % [_screenshot_name, timestamp]
var abs_path: String = ProjectSettings.globalize_path(rel_path)
var err: Error = image.save_png(abs_path)
if err == OK:
print("SCREENSHOT_PATH:%s" % abs_path)
print("Screenshot: %dx%d saved to %s" % [
image.get_width(), image.get_height(), abs_path
])
else:
push_error("CultureTreeProof: Save failed: %s" % error_string(err))
get_tree().quit()

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3 uid="uid://d5j8l0n3p7r9u"]
[ext_resource type="Script" path="res://engine/scenes/tests/culture_tree_proof.gd" id="1"]
[node name="CultureTreeProof" type="Node"]
script = ExtResource("1")