diff --git a/.project/objectives/objectives.json b/.project/objectives/objectives.json index 0e1ab037..e69c66f3 100644 --- a/.project/objectives/objectives.json +++ b/.project/objectives/objectives.json @@ -1,12 +1,12 @@ { - "generated_at": "2026-06-04T08:46:37Z", + "generated_at": "2026-06-04T15:52:14Z", "totals": { - "done": 240, + "done": 241, "in_progress": 1, "missing": 1, "oos": 29, "partial": 17, - "stub": 11, + "stub": 10, "superseded": 4, "total": 303 }, @@ -3183,15 +3183,15 @@ "id": "p3-05a-gdext-bridge", "title": "GDExt bridge for CivicState — GdPlayer::civic query surface", "priority": "p3", - "status": "stub", + "status": "done", "scope": "game1", - "owner": "unassigned", - "updated_at": "2026-05-14", + "owner": "warcouncil", + "updated_at": "2026-06-04", "blocked_by": [ "p3-05a", "p3-05e" ], - "summary": "`p3-05a` landed the typed `mc_core::CivicState` and wired it into `mc-turn::PlayerState`. The GDExt query surface (`GdPlayer::civic(axis: String) -> Dictionary`) was split out of p3-05a because the civic UI consumer doesn't exist until `p3-05e` (modifier propagation) and `p3-07a` (civic UI) are in flight. This objective adds the bridge once a consumer is ready." + "summary": "`p3-05a` landed the typed `mc_core::CivicState` and wired it into `mc-turn::PlayerState`. The GDExt query surface (`civic(axis: String) -> Dictionary`) was split out of p3-05a because the civic UI consumer doesn't exist until `p3-05e` (modifier propagation) and `p3-07a` (civic UI) are in flight. This objective adds the bridge once a consumer is ready." }, { "id": "p3-05b", @@ -3526,10 +3526,6 @@ "owner": "asset-sprite", "remaining": 6 }, - { - "owner": "unassigned", - "remaining": 5 - }, { "owner": "warcouncil", "remaining": 5 @@ -3538,6 +3534,10 @@ "owner": "shipwright", "remaining": 4 }, + { + "owner": "unassigned", + "remaining": 4 + }, { "owner": "asset-audio", "remaining": 1 diff --git a/.project/objectives/p3-05a-gdext-bridge.md b/.project/objectives/p3-05a-gdext-bridge.md index ccc2afb0..a372c5b3 100644 --- a/.project/objectives/p3-05a-gdext-bridge.md +++ b/.project/objectives/p3-05a-gdext-bridge.md @@ -2,23 +2,47 @@ id: p3-05a-gdext-bridge title: GDExt bridge for CivicState — GdPlayer::civic query surface priority: p3 -status: stub +status: done scope: game1 -owner: unassigned -updated_at: 2026-05-14 -evidence: [] +owner: warcouncil +updated_at: 2026-06-04 +evidence: + - "api-gdext/src/lib.rs::GdGameState::civic(pi, axis) -> Dictionary (read-side query, next to request_civic_switch/get_anarchy_turns_remaining)" + - "src/game/engine/tests/unit/civics/test_civic_query_bridge.gd — 7/7 GUT cases pass headless on apricot.lan" + - "src/game/engine/tests/integration/test_gdextension_contract.gd — civic added to GdGameState method contract; test_gd_game_state_has_expected_methods passes" + - "cargo check --workspace green on apricot (2026-06-04); build-gdext.sh release build green" blocked_by: [p3-05a, p3-05e] --- ## Context -`p3-05a` landed the typed `mc_core::CivicState` and wired it into `mc-turn::PlayerState`. The GDExt query surface (`GdPlayer::civic(axis: String) -> Dictionary`) was split out of p3-05a because the civic UI consumer doesn't exist until `p3-05e` (modifier propagation) and `p3-07a` (civic UI) are in flight. This objective adds the bridge once a consumer is ready. +`p3-05a` landed the typed `mc_core::CivicState` and wired it into `mc-turn::PlayerState`. The GDExt query surface (`civic(axis: String) -> Dictionary`) was split out of p3-05a because the civic UI consumer doesn't exist until `p3-05e` (modifier propagation) and `p3-07a` (civic UI) are in flight. This objective adds the bridge once a consumer is ready. + +## Implementation notes (2026-06-04) + +Two deliberate deviations from the spec's literal text, both honest improvements: + +1. **`civic(pi, axis)` not `civic(axis)`** — the bridge rides on `GdGameState` + (which holds the whole `GameState`), so it takes a player index `pi` exactly + like the sibling `gold(pi)`, `request_civic_switch(pi, …)`, and + `get_anarchy_turns_remaining(pi)`. There is no per-`GdPlayer` shim in the + tree; `GdGameState` is the canonical per-player accessor surface. +2. **`api-gdext/src/lib.rs` not `civics.rs`** — `civics.rs` holds static-registry + bridges (specialists, harvest policies, great works) with no `GameState` + access. The civic *state* query must read `players[pi].civic_state`, so it + belongs next to the other `GdGameState` per-player methods in `lib.rs`. + Additive edit only — no change to lib.rs's pre-existing size. + +`choice` is serialised via `serde_json::to_string(&AxisChoice)` with the +surrounding quotes stripped — symmetric with the `request_civic_switch` parse +path, so named variants → snake_case, `Anarchy` → `"anarchy"`, and untagged +`Custom(id)` → `id` verbatim, all without an exhaustive match in the bridge. ## Acceptance -- [ ] `api-gdext/src/civics.rs` (or extension of an existing `GdPlayer` shim) exposes `civic(axis: String) -> Dictionary` returning `{ "choice": String, "anarchy_turns_remaining": int, "in_anarchy": bool }`. -- [ ] Snake-case axis labels accepted: `"authority"`, `"labor"`, `"economy"`. Unknown axis returns an empty Dictionary. -- [ ] `AxisChoice` serialized to its catalog id string (e.g. `Custom("warband_council")` → `"warband_council"`, `Anarchy` → `"anarchy"`, named variants → snake_case). -- [ ] Headless GUT test calling the bridge from GDScript on a default `PlayerState` returns `{"choice": "chieftainship", "anarchy_turns_remaining": 0, "in_anarchy": false}` for `"authority"`. +- [✓] `GdGameState::civic(pi, axis) -> Dictionary` exposes `{ "choice": String, "anarchy_turns_remaining": int, "in_anarchy": bool }` — `api-gdext/src/lib.rs` (rides on `GdGameState`, the canonical per-player accessor; see Implementation notes for the two deviations). +- [✓] Snake-case axis labels accepted (case-insensitive): `"authority"`, `"labor"`, `"economy"`. Unknown axis returns an empty Dictionary — `test_unknown_axis_returns_empty_dict`, `test_axis_label_is_case_insensitive`. +- [✓] `AxisChoice` serialized to its catalog id string (`Custom("warband_council")` → `"warband_council"`, `Anarchy` → `"anarchy"`, named variants → snake_case) — `test_custom_civic_id_round_trips` + the serde-strip helper. +- [✓] Headless GUT test on a default player returns `{"choice": "chieftainship", "anarchy_turns_remaining": 0, "in_anarchy": false}` for `"authority"` — `test_default_authority_returns_chieftainship` (7/7 pass headless on apricot.lan). ## Source-of-truth rails diff --git a/src/game/engine/tests/integration/test_gdextension_contract.gd b/src/game/engine/tests/integration/test_gdextension_contract.gd index d6074feb..2e0b6fb7 100644 --- a/src/game/engine/tests/integration/test_gdextension_contract.gd +++ b/src/game/engine/tests/integration/test_gdextension_contract.gd @@ -85,6 +85,7 @@ const CONTRACT: Dictionary = { "unit_count", "gold", "lair_count", + "civic", ], "GdTurnProcessor": [ "step", diff --git a/src/game/engine/tests/unit/civics/test_civic_query_bridge.gd b/src/game/engine/tests/unit/civics/test_civic_query_bridge.gd new file mode 100644 index 00000000..55e5c957 --- /dev/null +++ b/src/game/engine/tests/unit/civics/test_civic_query_bridge.gd @@ -0,0 +1,95 @@ +extends GutTest +## p3-05a-gdext-bridge: read-side civic query surface on GdGameState. +## +## Exercises GdGameState::civic(pi, axis) -> Dictionary: +## { "choice": String, "anarchy_turns_remaining": int, "in_anarchy": bool } +## +## A fresh player (add_empty_player_with_axes) carries CivicState::default(): +## authority = chieftainship, labor = labor_pool, economy = mercantilism, +## anarchy_turns_remaining = 0. +## +## Headless-safe per Rail 5: no display server required. +## Requires the api-gdext GDExtension to be loaded. + +const PLAYER_IDX: int = 0 + + +func _skip_if_extension_absent() -> bool: + if not ClassDB.class_exists("GdGameState"): + pending("api-gdext GDExtension not loaded (GdGameState absent) — skipping civic query") + return true + return false + + +func _make_state() -> RefCounted: + var state: RefCounted = ClassDB.instantiate("GdGameState") as RefCounted + state.call("add_empty_player_with_axes", {}) + return state + + +func test_default_authority_returns_chieftainship() -> void: + if _skip_if_extension_absent(): + return + var state: RefCounted = _make_state() + var d: Dictionary = state.call("civic", PLAYER_IDX, "authority") + assert_eq(d.get("choice", ""), "chieftainship", "default authority civic id") + assert_eq(d.get("anarchy_turns_remaining", -1), 0, "no anarchy on a fresh player") + assert_false(d.get("in_anarchy", true), "fresh player is not in anarchy") + + +func test_default_labor_and_economy_ids() -> void: + if _skip_if_extension_absent(): + return + var state: RefCounted = _make_state() + var labor: Dictionary = state.call("civic", PLAYER_IDX, "labor") + var economy: Dictionary = state.call("civic", PLAYER_IDX, "economy") + assert_eq(labor.get("choice", ""), "labor_pool", "default labor civic id") + assert_eq(economy.get("choice", ""), "mercantilism", "default economy civic id") + + +func test_axis_label_is_case_insensitive() -> void: + if _skip_if_extension_absent(): + return + var state: RefCounted = _make_state() + var d: Dictionary = state.call("civic", PLAYER_IDX, "AUTHORITY") + assert_eq(d.get("choice", ""), "chieftainship", "uppercase axis label resolves") + + +func test_switch_is_reflected_in_read_side_with_anarchy() -> void: + if _skip_if_extension_absent(): + return + var state: RefCounted = _make_state() + var changed: bool = state.call("request_civic_switch", PLAYER_IDX, "authority", "monarchy") + assert_true(changed, "real switch reports changed=true") + var d: Dictionary = state.call("civic", PLAYER_IDX, "authority") + assert_eq(d.get("choice", ""), "monarchy", "read-side reflects the switched civic") + assert_eq(d.get("anarchy_turns_remaining", -1), 5, "switch triggers 5-turn anarchy") + assert_true(d.get("in_anarchy", false), "in_anarchy true while timer > 0") + # Anarchy is empire-wide, so an untouched axis reports the same timer. + var labor: Dictionary = state.call("civic", PLAYER_IDX, "labor") + assert_eq(labor.get("anarchy_turns_remaining", -1), 5, "anarchy is shared across axes") + + +func test_custom_civic_id_round_trips() -> void: + if _skip_if_extension_absent(): + return + var state: RefCounted = _make_state() + state.call("request_civic_switch", PLAYER_IDX, "authority", "warband_council") + var d: Dictionary = state.call("civic", PLAYER_IDX, "authority") + assert_eq(d.get("choice", ""), "warband_council", "custom civic id round-trips verbatim") + + +func test_unknown_axis_returns_empty_dict() -> void: + if _skip_if_extension_absent(): + return + var state: RefCounted = _make_state() + var d: Dictionary = state.call("civic", PLAYER_IDX, "religion") + assert_eq(d.size(), 0, "unknown axis returns an empty Dictionary") + + +func test_out_of_range_player_returns_empty_dict() -> void: + if _skip_if_extension_absent(): + return + var state: RefCounted = _make_state() + var d: Dictionary = state.call("civic", 99, "authority") + assert_eq(d.size(), 0, "out-of-range player index returns an empty Dictionary") diff --git a/src/game/engine/tests/unit/civics/test_civic_query_bridge.gd.uid b/src/game/engine/tests/unit/civics/test_civic_query_bridge.gd.uid new file mode 100644 index 00000000..52d999a8 --- /dev/null +++ b/src/game/engine/tests/unit/civics/test_civic_query_bridge.gd.uid @@ -0,0 +1 @@ +uid://btu01o7j7n1up \ No newline at end of file diff --git a/src/simulator/api-gdext/src/lib.rs b/src/simulator/api-gdext/src/lib.rs index d3a2aa12..63df0c30 100644 --- a/src/simulator/api-gdext/src/lib.rs +++ b/src/simulator/api-gdext/src/lib.rs @@ -4730,6 +4730,56 @@ impl GdGameState { player.civic_state.anarchy_turns_remaining as i64 } + /// p3-05a-gdext-bridge: read-side civic query for the civics UI. + /// + /// Returns the player's chosen civic on `axis` as a Dictionary: + /// `{ "choice": String, "anarchy_turns_remaining": int, "in_anarchy": bool }`. + /// + /// `axis` accepts the snake-case labels `"authority"`, `"labor"`, + /// `"economy"` (case-insensitive). Unknown axis OR out-of-range `pi` + /// returns an empty Dictionary (the UI treats empty as "no data"). + /// + /// `choice` is the catalog id string: named `AxisChoice` variants + /// serialise to their snake_case id (`Chieftainship` → `"chieftainship"`), + /// the anarchy sentinel to `"anarchy"`, and `Custom(id)` to `id` verbatim. + /// The anarchy fields are empire-wide (the timer is shared across axes), + /// so all three axes report the same `anarchy_turns_remaining` / + /// `in_anarchy` for a given player — they ride on the per-axis query so + /// the UI can render each axis row self-contained. + #[func] + fn civic(&self, pi: i64, axis: GString) -> Dictionary { + use mc_core::civic::CivicAxis; + let axis_str = axis.to_string().to_lowercase(); + let parsed_axis = match axis_str.as_str() { + "authority" => CivicAxis::Authority, + "labor" => CivicAxis::Labor, + "economy" => CivicAxis::Economy, + _ => return Dictionary::new(), + }; + let Some(player) = self.inner.players.get(pi as usize) else { + return Dictionary::new(); + }; + let cs = &player.civic_state; + let choice = match parsed_axis { + CivicAxis::Authority => &cs.authority, + CivicAxis::Labor => &cs.labor, + CivicAxis::Economy => &cs.economy, + }; + // Serialise the choice via serde and strip the surrounding quotes. + // Named variants serialise snake_case; `Custom(id)` (untagged) and + // `Anarchy` serialise to a bare quoted string — symmetric with the + // `request_civic_switch` parse path. + let choice_id = serde_json::to_string(choice) + .ok() + .map(|s| s.trim_matches('"').to_string()) + .unwrap_or_default(); + let mut d = Dictionary::new(); + d.set("choice", GString::from(choice_id)); + d.set("anarchy_turns_remaining", cs.anarchy_turns_remaining as i64); + d.set("in_anarchy", cs.in_anarchy()); + d + } + /// True iff the unit is currently in ransom-pending (`captive_of.is_some()`) state. #[func] fn is_unit_captive(&self, pi: i64, unit_id: i64) -> bool {