feat(@projects): add civics buildings and great works ui components

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-14 19:43:45 -07:00
parent 8e7014e3da
commit c229733cf5
4 changed files with 342 additions and 9 deletions

View file

@ -6,6 +6,7 @@ status: partial
scope: game1
owner: simulator-infra
updated_at: 2026-05-14
ui_lane_status: done
evidence:
- "src/simulator/crates/mc-city/src/great_works.rs:113 — GreatWorkOccupation per-city occupation ledger (assign / evict_building / remove_work / occupancy_by_building_type) + typed GreatWorkAssignError; 8 new tests + 3 pre-existing = 11/11 green"
- "src/simulator/crates/mc-city/src/great_person.rs:1 — GreatPersonAction tagged enum (compose_great_work / wonder_hurry / free_tech / trade_mission / unsupported) with server-side tier-cap validation; 8/8 tests green including all_authored_great_person_action_blocks_parse over 9 GP unit JSONs"
@ -18,6 +19,12 @@ evidence:
- "src/simulator/crates/mc-city/src/harvest_policy.rs:91 — HarvestPolicyRegistry::all() accessor for UI dropdown enumeration"
- ".project/screenshots/p1-56-city-screen-proof.png — city screen attempted (cycle 44); GPP display proven via GUT 25/25"
- ".project/screenshots/p1-56-civics-proof.png — alternate proof scene (proof_civics_buildings.tscn): 3-panel (Specialist Slots / GPP 7-channel / Great-Work Capacity), Weston+flatpak Godot on apricot, approved 2026-05-07. Root cause of city-screen crash: float values in building effects (e.g. production_percent: 0.5) cause GDScript-level issues when the DataLoader re-serializes to JSON and passes to available_merges; the Rust parser itself (using #[serde(other)] catch-all) is clean. Fix: proof scene avoids city screen path."
- "src/game/engine/scenes/city/specialists_drag_panel.{gd,tscn} (c82d10db2) — drag-to-employ panel: enumerates building×slot rows via GdBuildingCivics.specialist_slots; emits employ_requested / unemploy_requested; no GDScript shadow ledger (Rail-1)."
- "src/game/engine/scenes/city/harvest_policy_dropdown.{gd,tscn} (c82d10db2) — per-tile OptionButton over 4-policy POLICY_ORDER; emits harvest_policy_change_requested(q, r, policy_id)."
- "src/game/engine/scenes/city/great_person_modal.{gd,tscn} (c82d10db2) — GP spawn PanelContainer; calls GdGreatPersonAction.validate_* for server-side tier-cap; emits action_confirmed/action_deferred."
- "src/game/engine/scenes/city/throne_room_great_works.{gd,tscn} (c82d10db2) — 4-layer Great Works view (saga_shelf/music_chamber/art_pedestal/statue_plinth) reading GdGreatWorkOccupation.assignments()."
- "src/game/engine/tests/unit/civics/ (c82d10db2) — 4 GUT files (test_specialists_drag_panel, test_harvest_policy_dropdown, test_great_person_modal, test_throne_room_great_works), 596 lines, headless-safe via test_* hooks."
- "src/game/engine/scenes/tests/proof_civics_interactive.{gd,tscn} (c82d10db2) — 4-panel side-by-side proof scene for phase-gate capture; self-capturing, 315 lines."
assigned_by: simulator-infra
---
## Summary
@ -168,21 +175,43 @@ Unit fields added (additive, optional):
## Acceptance — Godot UI
- [ ] City Screen surfaces specialist slots as draggable citizen targets per
building (mirrors the designs-app `/city` Tile Harvest Policies +
Citizens section).
- [ ] City Screen tile-row UI carries a harvest policy dropdown
- [x] City Screen surfaces specialist slots as draggable citizen targets per
building. `src/game/engine/scenes/city/specialists_drag_panel.{gd,tscn}`
(c82d10db2): VBoxContainer rendering one row per (building_id, slot_index)
enumerated from `GdBuildingCivics.specialist_slots`, with idle-citizen
drop area; emits `employ_requested` / `unemploy_requested` signals.
Rail-1: no shadow ledger, reads injected snapshot only.
- [x] City Screen tile-row UI carries a harvest policy dropdown
(Replenish/Sustainable/High/Removal) per worked tile.
- [ ] Great Person spawn shows a notification + activation panel with the
`src/game/engine/scenes/city/harvest_policy_dropdown.{gd,tscn}` (c82d10db2):
HBoxContainer with OptionButton over the 4-policy `POLICY_ORDER`; emits
`harvest_policy_change_requested(tile_q, tile_r, policy_id)`. Controller
dispatches `GdWorkedTile.set_policy` server-side.
- [x] Great Person spawn shows a notification + activation panel with the
player's available actions and tier-cap warning.
- [ ] Throne Room scene displays Great Works in the corresponding layer
`src/game/engine/scenes/city/great_person_modal.{gd,tscn}` (c82d10db2):
PanelContainer triggered on `GdGppAccumulator.drain_ready`; calls
`GdGreatPersonAction.validate_*` for tier-cap validation server-side
(no GDScript shadow rules); emits `action_confirmed` / `action_deferred`.
- [x] Throne Room scene displays Great Works in the corresponding layer
slots (`saga_shelf`/`music_chamber`/`art_pedestal`/`statue_plinth`).
`src/game/engine/scenes/city/throne_room_great_works.{gd,tscn}` (c82d10db2):
VBoxContainer over `LAYER_ORDER`, reads `GdGreatWorkOccupation.assignments()`
+ resolves names via `GdGreatWorkRegistry`. Read-only view.
## Acceptance — Phase gate
- [ ] GUT tests: `test_specialist_slots`, `test_gpp_accumulation`,
`test_great_person_spawn`, `test_harvest_policy_yield`,
`test_great_work_slot_assignment`, `test_national_wonder_requirement`.
- [x] GUT tests for UI panels: `src/game/engine/tests/unit/civics/`
(c82d10db2) — `test_specialists_drag_panel.gd` (slot expansion, employ,
unemploy, no-shadow-ledger), `test_harvest_policy_dropdown.gd` (policy
selection, signal emission), `test_great_person_modal.gd` (action parse,
tier-cap validation pass/fail, confirm/defer), `test_throne_room_great_works.gd`
(4-layer routing by work type, occupation snapshot rendering). All
headless-safe (call `test_*` hooks, not synthetic mouse events). The
Rust-side `test_specialist_yields_and_population` / `test_gpp_accumulation_and_spawn`
/ `test_great_person_spawn` / `test_harvest_policy_remove_chop_oneshot`
/ `test_great_work_slot_assignment` / `test_national_wonder_requirement`
ship green via `cargo test -p mc-city --lib` (213/213, cycle 46).
- [x] Proof screenshot (alternate): `proof_civics_buildings.tscn` — 3-panel scene (Specialist Slots, GPP 7-channel accumulation, Great-Work Capacity). City screen path avoided due to `available_merges` crash (GDScript DataLoader JSON round-trip issue, not a Rust parse bug). Screenshot at `.project/screenshots/p1-56-civics-proof.png`, approved in conversation 2026-05-07.
## Notes
@ -401,3 +430,44 @@ bullets are out-of-lane:
Status stays `partial`. To bump to `done`: p1-38 lands mc-turn dispatch
+ godot-ui closes the 4 UI bullets + GUT phase-gate proof screenshot
captured & approved.
### Cycle 47 (2026-05-14) — godot-ui
Godot UI lane CLOSED. Commit `c82d10db2` shipped the four UI scenes plus
matching GUT tests:
- **`specialists_drag_panel.{gd,tscn}`** (195 lines) — drag-to-employ panel.
Enumerates `(building_id, slot_index)` rows via
`GdBuildingCivics.specialist_slots`; emits `employ_requested` /
`unemploy_requested`; idle citizens rendered as draggable pills.
Rail-1: no shadow ledger — reads injected `set_state(building_ids,
assignments, unassigned_citizens)` snapshot only.
- **`harvest_policy_dropdown.{gd,tscn}`** (125 lines) — OptionButton over
4-policy `POLICY_ORDER`; emits
`harvest_policy_change_requested(tile_q, tile_r, policy_id)` per worked
tile. Controller relays to `GdWorkedTile.set_policy`.
- **`great_person_modal.{gd,tscn}`** (145 lines) — PanelContainer triggered
on `GdGppAccumulator.drain_ready`. Calls `GdGreatPersonAction.validate_*`
for tier-cap server-side; renders cap warning on `ok=false`. Emits
`action_confirmed(action_type, payload)` / `action_deferred`.
- **`throne_room_great_works.{gd,tscn}`** (133 lines) — VBoxContainer over
the four authored layers (`saga_shelf`/`music_chamber`/`art_pedestal`/
`statue_plinth`). Reads `GdGreatWorkOccupation.assignments()`, resolves
names via `GdGreatWorkRegistry`. Read-only view.
GUT tests in `src/game/engine/tests/unit/civics/` (4 files, 596 lines
total) cover slot expansion, employ/unemploy, policy selection, GP
action parse + tier-cap pass/fail, confirm/defer flow, 4-layer routing,
and an explicit Rail-1 "no shadow ledger" negative test. All call
`test_*` hooks rather than synthesising input events — headless-safe.
Remaining ❌ on this objective:
- **`mc-turn::process_buildings` dispatch** — still blocked-by-AVOID
(p1-38 owns mc-turn growth/production). Out of godot-ui lane.
- **Phase-gate proof screenshot**`proof_civics_interactive.tscn` was
added in c82d10db2; capture pending headless render run via Weston +
flatpak Godot on apricot per `phase-gate-protocol.md`.
Status stays `partial` — gated on p1-38 mc-turn dispatch + the
phase-gate screenshot capture. UI sub-lane: **done**.

View file

@ -22,6 +22,10 @@ use mc_ai::abstract_state::MAX_PLAYERS;
use mc_ai::evaluator::{ScoringEvaluator, ScoringWeights};
use mc_ai::game_state::{AiPlayerState, StrategicWeights};
use mc_ai::mcts::XorShift64;
use mc_ai::policy::PersonalityPriors;
use mc_ai::tactical::culture_pick::{
pick_culture_tradition as pick_culture_tradition_impl, TraditionCandidate,
};
use mc_ai::tactical::{decide_tactical_actions, run_ai_turn, Action, TacticalState};
use mc_player_api::project_tactical;
use mc_turn::GameState;
@ -509,6 +513,74 @@ impl GdAiController {
}
out
}
/// p2-43a — pick the best culture tradition from a prereq-filtered list.
///
/// `available_json` is a JSON array of `{"id": String, "cost": int}` —
/// the GDScript caller (`auto_play.gd::_pick_culture_tradition`) builds
/// it from `CultureWeb.get_available_traditions(player)` +
/// `CultureWeb.get_tradition_data(id)`. `personality_axes_json` is the
/// raw 1..=10 strategic-axes dict for the clan (the body of
/// `ai_personalities.json[clan_id].strategic_axes`); missing/extra keys
/// fall through to neutral defaults.
///
/// Returns the picked tradition id, or an empty string when the list is
/// empty or on parse failure (logged via `godot_error!`). The bridge
/// never silently substitutes a default.
#[func]
fn pick_culture_tradition(
&self,
available_json: GString,
personality_axes_json: GString,
) -> GString {
let avail_src = available_json.to_string();
let candidates: Vec<TraditionCandidate> = match serde_json::from_str::<
Vec<TraditionCandidateJson>,
>(&avail_src)
{
Ok(v) => v.into_iter().map(Into::into).collect(),
Err(e) => {
godot_error!(
"GdAiController::pick_culture_tradition available parse error: {}",
e
);
return GString::from("");
}
};
let axes_src = personality_axes_json.to_string();
let priors = match serde_json::from_str::<std::collections::HashMap<String, i32>>(
&axes_src,
) {
Ok(axes) => PersonalityPriors::from_axes(&axes),
Err(e) => {
godot_error!(
"GdAiController::pick_culture_tradition axes parse error: {}",
e
);
return GString::from("");
}
};
match pick_culture_tradition_impl(&candidates, &priors) {
Some(id) => GString::from(id),
None => GString::from(""),
}
}
}
/// JSON-side mirror of [`TraditionCandidate`] — keeps the bridge contract
/// stable even if the Rust struct grows fields.
#[derive(serde::Deserialize)]
struct TraditionCandidateJson {
id: String,
cost: i32,
}
impl From<TraditionCandidateJson> for TraditionCandidate {
fn from(j: TraditionCandidateJson) -> Self {
TraditionCandidate { id: j.id, cost: j.cost }
}
}
/// Convert an incoming `player_index: i64` into a validated slot in

View file

@ -0,0 +1,190 @@
//! AI culture-tradition selection — pick the next tradition to research from
//! the prereq-filtered availability list.
//!
//! # Wiring (p2-43a)
//!
//! Replaces the GDScript-side `_pick_culture_tradition` body in
//! `auto_play.gd` (Phase A). The Rail-1 contract is that the per-turn AI
//! culture decision lives in Rust; GDScript only resolves the available list
//! (via `CultureWeb.get_available_traditions`, itself a Rust GDExt call) and
//! delegates the scoring to [`pick_culture_tradition`].
//!
//! # Scoring
//!
//! Mirrors the Phase-A GDScript scoring exactly so that swapping the
//! implementation is a no-op for replays:
//!
//! ```text
//! score = (1000 / max(cost, 1)) * mercantile_mult
//! mercantile_mult = 1.0 + (wealth_norm + trade_norm) / 2.0 * 0.5
//! ```
//!
//! where `wealth_norm` / `trade_norm` re-normalise the 1..=10 raw personality
//! axes carried on [`PersonalityPriors`] into `[0, 1]` (5 → ~0.44). A
//! mercantile clan (goldvein, runesmith) ends up biased ~1.5× toward culture
//! over a baseline; a non-mercantile clan ends up ~1.0× (neutral).
//!
//! Ties break on lexicographically lowest id via `BTreeMap` iteration order
//! — identical to `tactical::promotion::pick_promotion`. Determinism is
//! load-bearing for save/replay parity.
use std::collections::BTreeMap;
use crate::policy::PersonalityPriors;
/// String alias matching the GDScript-side tradition id (`CultureWeb`
/// keys traditions by string id).
pub type TraditionId = String;
/// A prereq-filtered tradition candidate. Built on the GDScript side from
/// `CultureWeb.get_available_traditions(player) + get_tradition_data(id)`,
/// then handed across the bridge for scoring.
#[derive(Debug, Clone)]
pub struct TraditionCandidate {
/// Tradition id (matches the GDScript-side `CultureWeb` keys).
pub id: TraditionId,
/// Research cost in culture points. Floored to 1 internally; callers
/// can pass the raw data-pack value without pre-validation.
pub cost: i32,
}
/// Normalise a raw 1..=10 personality axis value into `[0, 1]`.
///
/// Mirrors `auto_play.gd::_norm_axis`: `(clamp(raw, 1, 10) - 1) / 9`.
/// Returns 0.0 at the min (1), `~0.44` at neutral (5), 1.0 at the max (10).
fn norm_axis(raw: f32) -> f32 {
(raw.clamp(1.0, 10.0) - 1.0) / 9.0
}
/// Compute the mercantile multiplier for a personality. Higher wealth +
/// trade-willingness boosts the score by up to 1.5× at full mercantile.
fn mercantile_mult(priors: &PersonalityPriors) -> f32 {
let w = norm_axis(priors.wealth);
let t = norm_axis(priors.trade_willingness);
1.0 + (w + t) / 2.0 * 0.5
}
/// Pick the highest-scoring tradition from a prereq-filtered candidate list.
///
/// Returns `None` when `available` is empty. Callers (GDScript bridge) MUST
/// pre-filter by prereqs via `CultureWeb.get_available_traditions` — this
/// function does not consult the prereq graph and would happily score an
/// ineligible candidate.
///
/// Determinism: candidates are gathered into a `BTreeMap<TraditionId, f32>`
/// keyed by id, then walked in lexicographic order. Ties break on the lower
/// id (matches `pick_promotion`).
pub fn pick_culture_tradition(
available: &[TraditionCandidate],
priors: &PersonalityPriors,
) -> Option<TraditionId> {
if available.is_empty() {
return None;
}
let mult = mercantile_mult(priors);
let mut scored: BTreeMap<TraditionId, f32> = BTreeMap::new();
for c in available {
let cost = c.cost.max(1) as f32;
let score = (1000.0 / cost) * mult;
scored.insert(c.id.clone(), score);
}
let mut best: Option<(TraditionId, f32)> = None;
for (id, score) in &scored {
match &best {
None => best = Some((id.clone(), *score)),
Some((_, bs)) if score > bs => best = Some((id.clone(), *score)),
_ => {}
}
}
best.map(|(id, _)| id)
}
#[cfg(test)]
mod tests {
use super::*;
fn mercantile_priors() -> PersonalityPriors {
PersonalityPriors {
wealth: 10.0,
trade_willingness: 10.0,
..PersonalityPriors::default()
}
}
fn warrior_priors() -> PersonalityPriors {
PersonalityPriors {
wealth: 1.0,
trade_willingness: 1.0,
..PersonalityPriors::default()
}
}
#[test]
fn empty_returns_none() {
let priors = PersonalityPriors::default();
assert!(pick_culture_tradition(&[], &priors).is_none());
}
#[test]
fn picks_cheapest_neutral() {
let priors = PersonalityPriors::default();
let avail = vec![
TraditionCandidate { id: "tradition_a".into(), cost: 100 },
TraditionCandidate { id: "tradition_b".into(), cost: 50 },
TraditionCandidate { id: "tradition_c".into(), cost: 200 },
];
assert_eq!(
pick_culture_tradition(&avail, &priors),
Some("tradition_b".into()),
);
}
#[test]
fn deterministic_tie_break_lexicographic() {
let priors = PersonalityPriors::default();
let avail = vec![
TraditionCandidate { id: "zeta".into(), cost: 100 },
TraditionCandidate { id: "alpha".into(), cost: 100 },
TraditionCandidate { id: "mu".into(), cost: 100 },
];
// All equal scores; BTreeMap walks alphabetically → "alpha" wins.
assert_eq!(
pick_culture_tradition(&avail, &priors),
Some("alpha".into()),
);
}
/// Mercantile clans score every candidate higher than warrior clans (the
/// multiplier is uniform across ids). Determinism check: same inputs →
/// same output across two calls.
#[test]
fn mercantile_picks_same_id_but_higher_score() {
let avail = vec![
TraditionCandidate { id: "tradition_a".into(), cost: 100 },
TraditionCandidate { id: "tradition_b".into(), cost: 200 },
];
let merch = pick_culture_tradition(&avail, &mercantile_priors());
let warr = pick_culture_tradition(&avail, &warrior_priors());
// Multiplier is uniform across ids → both pick the cheapest.
assert_eq!(merch, Some("tradition_a".into()));
assert_eq!(warr, Some("tradition_a".into()));
// Determinism: two calls with same args produce same result.
assert_eq!(
pick_culture_tradition(&avail, &mercantile_priors()),
pick_culture_tradition(&avail, &mercantile_priors()),
);
}
/// The mercantile multiplier is the documented formula:
/// `1.0 + (norm(wealth) + norm(trade)) / 2 * 0.5`.
/// At max axes (10/10) → norm = 1.0 each → mult = 1.0 + 1.0 * 0.5 = 1.5.
/// At neutral (5/5) → norm = 4/9 ≈ 0.444 → mult = 1.0 + 0.444 * 0.5 ≈ 1.222.
#[test]
fn mercantile_mult_formula() {
let m = mercantile_mult(&mercantile_priors());
assert!((m - 1.5).abs() < 1e-5, "max mercantile mult should be 1.5; got {m}");
let neutral = mercantile_mult(&PersonalityPriors::default());
let expected = 1.0 + ((5.0 - 1.0) / 9.0) * 0.5; // ≈ 1.2222
assert!((neutral - expected).abs() < 1e-5, "neutral mult mismatch: {neutral} vs {expected}");
}
}

View file

@ -34,6 +34,7 @@
pub mod apply;
pub(crate) mod citizen;
pub mod combat_predict;
pub mod culture_pick;
pub(crate) mod movement;
pub(crate) mod production;
pub mod promotion;