feat(@projects/@magic-civilization): update playerstate cities to full city type

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-12 11:26:55 -07:00
parent 259695fdbf
commit f2d78e7aa4

View file

@ -0,0 +1,117 @@
---
id: p2-72b-promote-playerstate-cities-to-city
title: "Promote PlayerState.cities from Vec<CityState> → Vec<City>"
priority: p2
status: open
scope: game1
category: architecture
owner: simulator-infra
created: 2026-05-12
updated_at: 2026-05-12
blocked_by: []
follow_ups: [p2-72a, p2-72, p2-67]
---
## Context
`p2-72a Stage 4 Wave 2b` (CityScript view conversion) blocked here. `mc_turn::PlayerState.cities` is `Vec<mc_city::CityState>` — the minimal bench struct with 9 fields (population, food_stored, production_stored, queue, queue_cost, queue_tier, food_yield, prod_yield, worker_expertise). Every public CityScript field the GDScript renderers read lives on `mc_city::City` — the full type at `mc-city/src/city.rs:234` with `city_name`, `position`, `buildings`, `placed_buildings`, `culture_stored`, `focus`, `owned_tiles`, `worked_tiles`, `hp`/`max_hp`, per-building queues, etc.
The two types are deliberately decoupled per the comment at `mc-city/src/lib.rs:105-108`. To make CityScript a thin view, the held type must carry every field renderers consume.
## Locked decision
**Promote, don't widen.** Change `PlayerState.cities: Vec<CityState>``Vec<City>`. The full type already exists and is exercised by the production game via `GdCity` instances; promoting consolidates around one canonical city representation. Widening `CityState` would create a second drift point.
Where `CityState` is genuinely the right minimal struct for some pure-sim subsystem (bench MCTS rollouts?), document the call site and keep a separate `Vec<CityState>` for that path. Don't drop `CityState` entirely.
## Surface
### 1. Field shape
Update `PlayerState::cities` field type. Add `#[serde(default)]` for backward compat.
```rust
#[serde(default)]
pub cities: Vec<mc_city::City>,
```
### 2. Constructors + accessors
`PlayerState::new(...)` and all `PlayerState` construction sites in `api-gdext/src/lib.rs` (lines around 3515, 3721-3737) update to construct `City::starter(name, col, row, owner_slot)` or equivalent.
`set_player_cities_from_array` (`lib.rs:3836-3858`) updates to walk the GDScript array and construct full `City` entries.
### 3. Audit downstream consumers
`grep -rn 'PlayerState.cities\|players\[.*\]\.cities' src/simulator/crates/` — every consumer must accept the new type.
Likely sites:
- `mc-economy::gold_income` — reads `city.production_stored` etc. Fields exist on `City`.
- `mc-score::city_count` / `culture_total` — reads `city.culture_stored`.
- `mc-ai::tactical::evaluator` — city scoring.
- `mc-turn::processor::process_city_growth` — food/production tick.
Each consumer either:
- (a) Compiles trivially because `City` has a superset of `CityState` fields.
- (b) Needs a small adapter call to extract the bench subset (`city.to_bench_state()` helper).
### 4. `GdGameState` accessors
Add per-city Rust accessor:
```rust
#[func]
fn city_dict(&self, player_idx: i64, city_idx: i64) -> Dictionary;
```
Returns the same shape `CityScript.to_dict()` currently emits, sourced from the full `City`.
Plus mutators per Stage 4 Wave 2b's needs: `set_city_population`, `set_city_food_stored`, `set_city_production_stored`, `spawn_city(player_idx, name, col, row)`, `remove_city_by_id`, etc.
### 5. Save round-trip
`SaveEnvelope` v1 already serdes through `GameState`. After the field type change, the envelope shape changes (City has more fields than CityState). Bump `SaveEnvelope::CURRENT_VERSION` to 2 if any pre-bump saves exist; if not, leave at 1 with the wider shape.
### 6. CityScript view conversion (the original Stage 4 Wave 2b sub-task)
Once promotion lands, replay the BuildingScript pattern:
- `CityScript` becomes a thin view holding `_state_ref: Node + _player_idx + _idx`.
- `_get` proxy onto `_state_ref._gd_state.city_dict(_player_idx, _idx)`.
- Mutators route through Rust + emit `state_changed`.
- Spawn paths route through `_gd_state.spawn_city(...)`.
## Acceptance
- ☐ `PlayerState.cities: Vec<City>` (was `Vec<CityState>`).
- ☐ All `mc-turn`, `mc-economy`, `mc-score`, `mc-ai`, `mc-city` consumers compile against the new type.
- ☐ `api-gdext` PlayerState constructors + setters updated.
- ☐ `GdGameState::city_dict(player_idx, city_idx)` accessor exists.
- ☐ Per-city mutators on `GdGameState` (`set_city_population`, `spawn_city`, `remove_city_by_id`, focus/queue mutators as Stage 4 needs them).
- ☐ Save round-trip byte-identity for the wider shape.
- ☐ `cargo check --workspace && cargo test --workspace` green (modulo pre-existing).
- ☐ Real-apricot 5-EndTurn smoke still passes.
- ☐ CityScript view conversion lands inline (Stage 4 Wave 2b sub-task).
- ☐ p2-72a Stage 4 Wave 2 per-class checkbox for CityScript flips ✓.
## Why this size
- PlayerState.cities field type change: ~1 hr.
- Constructor + accessor updates: ~2 hr.
- Consumer audit + fixes: ~1 day (~5 crates).
- New GdGameState accessors: ~2 hr.
- CityScript view conversion (Stage 4 Wave 2b): ~3 hr.
- Tests + smoke: ~2 hr.
**Total: ~2-3 days.**
## Unblocks
- p2-72a Stage 4 Wave 2b (CityScript view conversion).
- Implicitly unblocks `p2-72c-promote-playerstate-units` (same pattern for units).
## References
- `src/simulator/crates/mc-city/src/lib.rs:100-170` — CityState (minimal bench).
- `src/simulator/crates/mc-city/src/city.rs:234` — City (full type).
- `src/simulator/api-gdext/src/lib.rs:3515, 3640-3644, 3721-3737, 3836-3858` — current accessors.
- `src/game/engine/src/entities/city.gd:96-105` — CityScript per-instance `GdCity` (current state).
- `.project/objectives/p2-72a-gdgamestate-canonical-render-source.md` — Stage 4 Wave 2b checkbox.