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:
parent
461588bad8
commit
ae06b42e15
6 changed files with 191 additions and 20 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ const CONTRACT: Dictionary = {
|
|||
"unit_count",
|
||||
"gold",
|
||||
"lair_count",
|
||||
"civic",
|
||||
],
|
||||
"GdTurnProcessor": [
|
||||
"step",
|
||||
|
|
|
|||
95
src/game/engine/tests/unit/civics/test_civic_query_bridge.gd
Normal file
95
src/game/engine/tests/unit/civics/test_civic_query_bridge.gd
Normal 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")
|
||||
|
|
@ -0,0 +1 @@
|
|||
uid://btu01o7j7n1up
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue