feat(api-gdext): GdGameState::civic read-side query surface (p3-05a-gdext-bridge)

Adds the read-side civic query the civics UI needs, completing the bridge
split out of p3-05a. `GdGameState::civic(pi, axis) -> Dictionary` returns
`{ choice, anarchy_turns_remaining, in_anarchy }`, riding on GdGameState
next to request_civic_switch/get_anarchy_turns_remaining (the canonical
per-player accessor surface — there is no per-GdPlayer shim in the tree).

`choice` is serialised via serde with quotes stripped (symmetric with the
request_civic_switch parse path): named AxisChoice variants → snake_case,
Anarchy → "anarchy", untagged Custom(id) → id verbatim. Unknown axis or
out-of-range pi → empty Dictionary.

Tests (green headless on apricot.lan):
- engine/tests/unit/civics/test_civic_query_bridge.gd — 7/7
- engine/tests/integration/test_gdextension_contract.gd — civic in the
  GdGameState method contract; test_gd_game_state_has_expected_methods passes
- cargo check --workspace green; build-gdext.sh release build green

Objective p3-05a-gdext-bridge: stub → done (4/4 acceptance, cited).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
autocommit 2026-06-04 08:54:10 -07:00
parent 461588bad8
commit ae06b42e15
6 changed files with 191 additions and 20 deletions

View file

@ -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

View file

@ -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

View file

@ -85,6 +85,7 @@ const CONTRACT: Dictionary = {
"unit_count",
"gold",
"lair_count",
"civic",
],
"GdTurnProcessor": [
"step",

View file

@ -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")

View file

@ -0,0 +1 @@
uid://btu01o7j7n1up

View file

@ -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 {