From 1784ecaad3293dbda141cfc9cec315bc6eee2b50 Mon Sep 17 00:00:00 2001 From: Natalie Date: Mon, 11 May 2026 03:32:25 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20tactical=20projection=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/Cargo.lock | 1 + src/simulator/crates/mc-player-api/Cargo.toml | 1 + src/simulator/crates/mc-player-api/src/lib.rs | 2 +- .../crates/mc-player-api/src/projection.rs | 323 ++++++++++++++++++ 4 files changed, 326 insertions(+), 1 deletion(-) diff --git a/src/simulator/Cargo.lock b/src/simulator/Cargo.lock index 9d6a3acb..21bf27d6 100644 --- a/src/simulator/Cargo.lock +++ b/src/simulator/Cargo.lock @@ -1124,6 +1124,7 @@ dependencies = [ name = "mc-player-api" version = "0.1.0" dependencies = [ + "mc-ai", "mc-city", "mc-combat", "mc-core", diff --git a/src/simulator/crates/mc-player-api/Cargo.toml b/src/simulator/crates/mc-player-api/Cargo.toml index 21e386c5..0e3cb39a 100644 --- a/src/simulator/crates/mc-player-api/Cargo.toml +++ b/src/simulator/crates/mc-player-api/Cargo.toml @@ -11,6 +11,7 @@ mc-trade = { path = "../mc-trade" } mc-turn = { path = "../mc-turn" } mc-combat = { path = "../mc-combat" } mc-items = { path = "../mc-items" } +mc-ai = { path = "../mc-ai" } serde.workspace = true serde_json.workspace = true thiserror = "1" diff --git a/src/simulator/crates/mc-player-api/src/lib.rs b/src/simulator/crates/mc-player-api/src/lib.rs index 9f537167..0e73c04e 100644 --- a/src/simulator/crates/mc-player-api/src/lib.rs +++ b/src/simulator/crates/mc-player-api/src/lib.rs @@ -24,7 +24,7 @@ pub mod view; pub mod wire; pub use dispatch::apply_action; -pub use projection::project_view; +pub use projection::{project_tactical, project_view}; pub use action::{ BuildingActionPayload, CivicAxis, DiploResponse, Improvement, PlayerAction, diff --git a/src/simulator/crates/mc-player-api/src/projection.rs b/src/simulator/crates/mc-player-api/src/projection.rs index f318c62c..3198b7ae 100644 --- a/src/simulator/crates/mc-player-api/src/projection.rs +++ b/src/simulator/crates/mc-player-api/src/projection.rs @@ -358,6 +358,248 @@ fn project_empire_legal_actions() -> Vec { }] } +// ── Tactical projection (p2-68) ────────────────────────────────────────────── +// +// `project_tactical(&GameState, player)` builds a [`mc_ai::tactical::TacticalState`] +// that the headless Rust AI driver feeds into +// `mc_ai::tactical::decide_tactical_actions` and (Wave 3) `mc_ai::run_ai_turn`. +// +// The spec for p2-68 originally placed this in `mc-ai/src/projector.rs`, but +// `mc-turn` already depends on `mc-ai` (mc-turn/Cargo.toml:15) — adding the +// reverse edge would create a cycle. The applicator's "Option B" decision +// (mc-player-api owns dispatch into existing `apply_*` handlers) already +// establishes the symmetric "mc-player-api is downstream of both" pattern, so +// the projector lands here alongside the existing `view` projection. +// +// Field-population policy: +// - All fields with `#[serde(default)]` and a sensible default are populated +// to their default for v1 (empty vec, 1.0 weight, etc). +// - `unit_catalog` / `building_catalog` left empty: the GDExtension bridge +// builds these by walking DataLoader; the headless Rust path can either +// plumb the same loader through `mc-units` later or remain catalog-less for +// the smoke test (tactical AI falls back to tier-1 warrior / no-building). +// - `clan_id` left empty: callers wire the personality id post-hoc via the +// personalities table they own. The downstream consumers +// (`tactical::thresholds`, `tactical::production::pick_best_melee`) treat +// empty as "neutral / unset" without panicking. +// - City `health` defaults to 100 to match the fixture inventory in +// `mc-ai/tests/tactical_port_regression.rs` — bench `CityState` doesn't +// carry per-city HP, so we report "full" until the bench grows the field. + +use mc_ai::tactical::{ + TacticalCity, TacticalMap, TacticalPlayerState, TacticalState, TacticalTile, TacticalUnit, +}; + +/// Project a [`GameState`] down to a [`TacticalState`] for the headless AI +/// driver (p2-68 Wave 1). See module docs for field-population policy. +/// +/// `player` is the slot whose turn is being decided — written into +/// `TacticalState::current_player` and used to orient the `relations` +/// vector around that slot. +pub fn project_tactical(state: &GameState, player: PlayerId) -> TacticalState { + let map = project_tactical_map(state); + let n_players = state.players.len(); + let players: Vec = state + .players + .iter() + .map(|p| project_tactical_player(state, p, n_players)) + .collect(); + + TacticalState { + current_player: player, + turn: state.turn, + map, + players, + unit_catalog: Vec::new(), + building_catalog: Vec::new(), + difficulty_threshold_mult: 1.0, + } +} + +fn project_tactical_map(state: &GameState) -> TacticalMap { + let Some(grid) = state.grid.as_ref() else { + return TacticalMap { width: 0, height: 0, tiles: Vec::new() }; + }; + + let tiles: Vec = grid + .tiles + .iter() + .map(|t| TacticalTile { + hex: (t.col, t.row), + biome: t.biome_label_id.clone(), + // Bench `TileState` carries climate/ecology fields but no + // gameplay (food, production, gold) yield triple — that table + // lives in the tactical AI's own per-biome lookup. Report + // zeros and let the AI fall through. + yields: (0, 0, 0), + resource: if t.resource_id.is_empty() { + None + } else { + Some(t.resource_id.clone()) + }, + is_coast: t.is_coastal, + // `TileState` doesn't carry an owner field — ownership is + // derived from city positions + culture borders downstream. + // Mark all unowned for v1; the tactical AI's settle/expand + // path doesn't strictly require it. + owner: None, + }) + .collect(); + + TacticalMap { + width: grid.width as u32, + height: grid.height as u32, + tiles, + } +} + +fn project_tactical_player( + state: &GameState, + player: &mc_turn::game_state::PlayerState, + n_players: usize, +) -> TacticalPlayerState { + let units: Vec = player + .units + .iter() + .map(|u| TacticalUnit { + id: u.id, + kind: u.unit_id.clone(), + hex: (u.col, u.row), + hp: u.hp.max(0) as u32, + hp_max: u.max_hp.max(0) as u32, + // Bench `MapUnit` doesn't model moves_left — the turn processor + // refreshes it implicitly. v1 reports full (= 2) so the AI + // can plan a single move per turn. + moves_left: 2, + fortified: u.is_fortified, + can_found_city: false, + patrol_order: None, + is_siege: false, + is_deployed: u.is_deployed, + is_arcing: false, + pending_promotion_choices: Vec::new(), + }) + .collect(); + + let capital = player.capital_position; + let cities: Vec = player + .cities + .iter() + .enumerate() + .map(|(i, c)| { + let hex = player.city_positions.get(i).copied().unwrap_or((0, 0)); + // Render the in-progress queueable as the head of the queue. + // Future-extension: thread the rest of an authored queue once + // bench `CityState` grows beyond single-slot `queue`. + let production_queue: Vec = c + .queue + .as_ref() + .map(|q| vec![queueable_id(q)]) + .unwrap_or_default(); + let buildings = player + .city_buildings + .get(i) + .cloned() + .unwrap_or_default(); + TacticalCity { + id: i as u32, + hex, + population: c.population, + tiles_worked: Vec::new(), + production_queue, + buildings, + health: 100, + is_capital: capital.map(|cap| cap == hex).unwrap_or(false), + } + }) + .collect(); + + let researched_techs: Vec = player + .player_tech + .as_ref() + .map(|t| t.researched_techs().iter().cloned().collect()) + .unwrap_or_default(); + + let relations = project_tactical_relations(state, player.player_index, n_players); + + let strategic_resources: Vec = player + .strategic_ledger + .iter() + .filter(|(_, v)| **v > 0) + .map(|(k, _)| k.clone()) + .collect(); + + let strategic_axes: std::collections::BTreeMap = player + .strategic_axes + .iter() + .map(|(k, v)| (k.clone(), *v as i32)) + .collect(); + + TacticalPlayerState { + index: player.player_index, + clan_id: String::new(), + gold: player.gold, + happiness_pool: 0, + units, + cities, + researched_techs, + relations, + race_id: None, + strategic_resources, + strategic_axes, + promotion_offense_weight: 1.0, + promotion_defense_weight: 1.0, + promotion_mobility_weight: 1.0, + } +} + +/// Render a `Queueable` as its id string (matches the AI's +/// `production_queue: Vec` shape). +fn queueable_id(q: &mc_city::production::Queueable) -> String { + use mc_city::production::Queueable; + match q { + Queueable::Item { item_id } => item_id.clone(), + Queueable::Wonder { wonder_id } => wonder_id.as_str().to_string(), + Queueable::Unit { unit_id } => unit_id.as_str().to_string(), + } +} + +/// Build the `relations` vector for `player_idx` (length `n_players`, +/// self-slot = 0). Authoritative copy lives on player slot 0 per the +/// `PlayerState::relations` doc comment; we read from there and orient +/// the result around `player_idx`. +fn project_tactical_relations(state: &GameState, player_idx: u8, n_players: usize) -> Vec { + let mut out = vec![0i8; n_players]; + let Some(p0) = state.players.first() else { + return out; + }; + for ((a, b), rel) in &p0.relations { + let (self_slot, other_slot) = if *a == player_idx { + (*a, *b) + } else if *b == player_idx { + (*b, *a) + } else { + continue; + }; + let _ = self_slot; + let i = other_slot as usize; + if i >= n_players { + continue; + } + // Map `RelationState::relation` to a coarse signed magnitude: + // - "war" → -1 + // - "peace" / default → 0 + // - "friend" or any positive variant → 1 + let kind = rel.relation.as_str(); + out[i] = match kind { + "war" => -1, + "friendly" => 1, + _ => 0, + }; + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -477,4 +719,85 @@ mod tests { let back: PlayerView = serde_json::from_str(&line).expect("round-trip"); assert_eq!(back, v); } + + // ── project_tactical (p2-68) ───────────────────────────────────────────── + + #[test] + fn tactical_empty_state_projects_to_zero_map_zero_players() { + let state = GameState::default(); + let t = project_tactical(&state, 0); + assert_eq!(t.current_player, 0); + assert_eq!(t.turn, 0); + assert_eq!(t.map.width, 0); + assert_eq!(t.map.height, 0); + assert!(t.map.tiles.is_empty()); + assert!(t.players.is_empty()); + assert!(t.unit_catalog.is_empty()); + assert!(t.building_catalog.is_empty()); + assert_eq!(t.difficulty_threshold_mult, 1.0); + } + + #[test] + fn tactical_carries_turn_and_current_player() { + let mut state = GameState::default(); + state.turn = 17; + state.players.push(PlayerState::default()); + state.players.push(PlayerState::default()); + state.players[1].player_index = 1; + let t = project_tactical(&state, 1); + assert_eq!(t.current_player, 1); + assert_eq!(t.turn, 17); + assert_eq!(t.players.len(), 2); + assert_eq!(t.players[0].index, 0); + assert_eq!(t.players[1].index, 1); + } + + #[test] + fn tactical_units_round_trip_unit_id_and_position() { + let state = make_state(1, 0, vec![(42, 3, 5)]); + let t = project_tactical(&state, 0); + assert_eq!(t.players[0].units.len(), 1); + let u = &t.players[0].units[0]; + assert_eq!(u.id, 42); + assert_eq!(u.hex, (3, 5)); + assert_eq!(u.kind, "dwarf_warrior"); + assert_eq!(u.hp, 100); + assert_eq!(u.hp_max, 100); + assert_eq!(u.moves_left, 2); + } + + #[test] + fn tactical_relations_orient_around_current_player() { + use mc_trade::relation::{Relation, RelationState}; + let mut state = GameState::default(); + state.players.push(PlayerState::default()); + state.players.push(PlayerState::default()); + state.players.push(PlayerState::default()); + state.players[0].player_index = 0; + state.players[1].player_index = 1; + state.players[2].player_index = 2; + // Player 0 holds the authoritative relations table. + let mut at_war = RelationState::default(); + at_war.relation = Relation::War; + state.players[0].relations.insert((0, 2), at_war); + let mut friendly = RelationState::default(); + friendly.relation = Relation::Friendly; + state.players[0].relations.insert((0, 1), friendly); + + let t = project_tactical(&state, 0); + // Player 0's relations: self = 0, friendly to 1, war with 2. + assert_eq!(t.players[0].relations, vec![0i8, 1, -1]); + } + + #[test] + fn tactical_round_trips_through_json() { + // serde shape parity: projector output must survive a JSON + // round-trip identical, so the headless harness can pass it across + // the GDExtension boundary (and back) without drift. + let state = make_state(2, 75, vec![(9, 2, 2), (10, 4, 4)]); + let t = project_tactical(&state, 0); + let json = serde_json::to_string(&t).expect("serialise"); + let back: TacticalState = serde_json::from_str(&json).expect("deserialise"); + assert_eq!(t, back); + } }