feat(@projects/@magic-civilization): ✨ implement culture research tree ui
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
85fef2b753
commit
7a65fc2fa1
4 changed files with 242 additions and 71 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
91
src/game/engine/scenes/tests/culture_tree_proof.gd
Normal file
91
src/game/engine/scenes/tests/culture_tree_proof.gd
Normal 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()
|
||||
6
src/game/engine/scenes/tests/culture_tree_proof.tscn
Normal file
6
src/game/engine/scenes/tests/culture_tree_proof.tscn
Normal 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")
|
||||
Loading…
Add table
Reference in a new issue