feat(@projects/@magic-civilization): 📇 p3-25 step 3 — resource-category catalog into Rust state

Rail-1 city-model unification, step 3: give the headless sim the luxury/strategic
categories it needs to classify owned-tile resources for trade sourcing — currently
GDScript-only (DataLoader). No content hardcoded in Rust (Rail-2): loaded from JSON.

- GameState.resource_categories: BTreeMap<String,String> (id → "luxury"/"strategic"/
  "bonus"), #[serde(skip)] boot-loaded exactly like units_catalog/civic_catalog (not
  save-persisted; empty Default → nothing tradeable, a safe no-op).
- GameState::load_resource_categories_json parses the flat {id:category} object GDScript's
  DataLoader emits; no-clobber on malformed input.
- GdPlayerApi.set_resource_categories_json FFI loads it onto the held state (call after
  load_state_json, since the field is serde-skip).

Verified: mc-state load_resource_categories_parses_flat_map + suite 13/0; workspace
cargo check clean (GameState field addition broke no literals — all use ..Default).
Rust-only; live game unaffected. Unblocks step 4 (process_trade_phase classification).
p3-25 steps 1-3 done; 4-6 remain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-26 01:37:59 -04:00
parent 37fbb6153d
commit e376920766
4 changed files with 66 additions and 7 deletions

View file

@ -64,9 +64,13 @@ to `state.trade_ledger`, (d) projecting it all.
(mc-turn) + `projection_surfaces_city_owned_tiles` (mc-player-api); fixed a pre-existing
broken `serde_roundtrip` HappinessInput literal in passing. cargo mc-city+mc-turn+
mc-player-api **725/0**.
- [ ] **Step 3 — resource-category catalog in Rust.** Load luxury/strategic categories
(currently GDScript-only via DataLoader) into Rust via a `set_resources_catalog_json`
+ storage, so the sim can classify owned-tile resources.
- [x] **Step 3 — resource-category catalog in Rust.** `GameState.resource_categories:
BTreeMap<String,String>` (id→"luxury"/"strategic"/"bonus"), `#[serde(skip)]`
boot-loaded like `units_catalog`. `GameState::load_resource_categories_json` parses the
flat `{id:category}` map GDScript's DataLoader emits; `GdPlayerApi.
set_resource_categories_json` FFI loads it onto the held state (call after
`load_state_json`). **Done 2026-06-26**`load_resource_categories_parses_flat_map`
(mc-state 13/0); workspace `cargo check` clean (no GameState-literal breakage).
- [ ] **Step 4 — real trade sourcing + persistence.** `mc-turn::process_trade_phase`
sources real owned-tile luxuries/strategics (owned tiles → `tile_collectibles`
category-classify), runs `evaluate_trades`, and **persists** the swap/sale ledger into

View file

@ -1,12 +1,12 @@
{
"generated_at": "2026-06-26T05:18:24Z",
"generated_at": "2026-06-26T05:37:59Z",
"totals": {
"done": 296,
"in_progress": 0,
"oos": 31,
"partial": 2,
"stub": 0,
"oos": 31,
"done": 296,
"missing": 0,
"partial": 2,
"total": 329
},
"objectives": [

View file

@ -146,6 +146,17 @@ impl GdPlayerApi {
}
}
/// p3-25: load the resource id→category map (`{"iron_ore":"strategic",
/// "silk":"luxury",…}`) so the headless turn can classify owned-tile
/// resources for trade sourcing (`process_trade_phase`). Returns the number
/// of entries loaded, or 0 on parse failure. Call AFTER `load_state_json` —
/// the map is `#[serde(skip)]` and is not restored by a state load.
#[func]
pub fn set_resource_categories_json(&mut self, json: GString) -> i64 {
self.state
.load_resource_categories_json(json.to_string().as_str()) as i64
}
/// Stamp the runtime `UnitsCatalog` (id → `UnitStats`) onto the held
/// `GameState`. Distinct from `set_units_catalog_json` (which loads the
/// tactical `ai_unit_catalog`): this is the same `mc_units::UnitsCatalog`

View file

@ -403,6 +403,14 @@ pub struct GameState {
/// and pre-load paths see no civic effect.
#[serde(skip)]
pub civic_catalog: mc_civics::CivicCatalog,
/// p3-25: resource id → category ("luxury" | "strategic" | "bonus"), loaded
/// from `public/resources/deposits/*.json`. Lets the headless turn classify
/// owned-tile resources for trade sourcing (`process_trade_phase`) without
/// reaching into GDScript's DataLoader. `#[serde(skip)]` mirrors
/// `units_catalog` — boot-loaded via `set_resource_categories_json`, not
/// save-persisted. Empty (Default) → no resource is tradeable (safe no-op).
#[serde(skip)]
pub resource_categories: BTreeMap<String, String>,
/// p2-71: tactical-AI view of the producible-unit catalog. Mirrors
/// `TacticalState::unit_catalog` and is populated once at harness boot
/// by `GdPlayerApi::set_units_catalog_json` (or directly in Rust tests).
@ -658,6 +666,21 @@ impl PendingCaptureEvents {
}
impl GameState {
/// p3-25: load the resource id→category map from a flat JSON object
/// (`{"iron_ore":"strategic","silk":"luxury",…}`), the shape GDScript's
/// DataLoader emits from `public/resources/deposits/*.json`. Replaces any
/// existing map. Returns the number of entries loaded, or 0 on parse failure.
pub fn load_resource_categories_json(&mut self, json: &str) -> usize {
match serde_json::from_str::<BTreeMap<String, String>>(json) {
Ok(map) => {
let n = map.len();
self.resource_categories = map;
n
}
Err(_) => 0,
}
}
/// p2-65 Phase7 test helper: construct a GameState whose combat_balance
/// (and future SimConfig fields) are pre-populated without touching the
/// global RwLock singleton. Callers that need isolated config for
@ -1418,6 +1441,27 @@ pub struct TechState {
mod p2_72a_save_round_trip_tests {
use super::*;
#[test]
fn load_resource_categories_parses_flat_map() {
let mut state = GameState::default();
let n = state.load_resource_categories_json(
r#"{"iron_ore":"strategic","silk":"luxury","wheat":"bonus"}"#,
);
assert_eq!(n, 3);
assert_eq!(
state.resource_categories.get("iron_ore").map(String::as_str),
Some("strategic")
);
assert_eq!(
state.resource_categories.get("silk").map(String::as_str),
Some("luxury")
);
// Malformed JSON → 0 and the existing map is left intact (no clobber).
let n2 = state.load_resource_categories_json("not json");
assert_eq!(n2, 0);
assert_eq!(state.resource_categories.len(), 3);
}
#[test]
fn default_game_state_round_trips_through_serde() {
let g = GameState::default();