diff --git a/src/simulator/crates/mc-player-api/Cargo.toml b/src/simulator/crates/mc-player-api/Cargo.toml index 78351b29..e11bc540 100644 --- a/src/simulator/crates/mc-player-api/Cargo.toml +++ b/src/simulator/crates/mc-player-api/Cargo.toml @@ -5,6 +5,8 @@ edition = "2021" [dependencies] mc-core = { path = "../mc-core" } +mc-city = { path = "../mc-city" } +mc-trade = { path = "../mc-trade" } mc-turn = { path = "../mc-turn" } serde.workspace = true serde_json.workspace = true diff --git a/src/simulator/crates/mc-player-api/src/lib.rs b/src/simulator/crates/mc-player-api/src/lib.rs index 17abb084..9f537167 100644 --- a/src/simulator/crates/mc-player-api/src/lib.rs +++ b/src/simulator/crates/mc-player-api/src/lib.rs @@ -19,10 +19,12 @@ pub mod action; pub mod dispatch; pub mod error; +pub mod projection; pub mod view; pub mod wire; pub use dispatch::apply_action; +pub use projection::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 new file mode 100644 index 00000000..54ac7fbe --- /dev/null +++ b/src/simulator/crates/mc-player-api/src/projection.rs @@ -0,0 +1,489 @@ +//! Fog-aware view projection — reads a [`mc_turn::game_state::GameState`] +//! and assembles a [`crate::view::PlayerView`] from the perspective of one +//! player slot. +//! +//! Designed to be called after every successful [`crate::dispatch::apply_action`] +//! and any time the adapter requests a fresh `view`. +//! +//! ## Fog redaction +//! +//! The bench-grade [`mc_turn::game_state::GameState`] does **not** carry per- +//! player per-tile vision data — that data lives on the runtime +//! [`mc_observation`] store, which only the in-game `Player.observations` +//! pipeline populates. Until the bench state grows an `explored_tiles` / +//! `visible_tiles` projection (TRACKED: p2-67 Phase 1 follow-up), this +//! function applies a conservative redaction policy: +//! +//! - **Own player**: full data — own units, own cities, own resources, +//! own research, own diplomacy entries. +//! - **Other players**: skipped entirely from `view.cities` and +//! `view.units`. Only the `view.diplomacy` and `view.score` summaries +//! reference them — never their unit positions, exact populations, +//! or research progress. +//! +//! When `CP_OMNISCIENT=1` is set on the harness, the read-side reverts +//! to no-redaction (full state) for debugging. The flag is read inside +//! [`project_view`] via the `omniscient` parameter (callers pass it +//! from the env at boot). + +use mc_turn::game_state::GameState; + +use crate::view::{ + BuildableEntry, CityView, CivicsView, CultureView, DiplomacyView, LegalActionEntry, + PendingEventsView, PlayerView, ProductionQueueEntry, ResearchView, ResourceView, ScoreView, + TileView, UnitView, +}; +use crate::{PlayerAction, PlayerId, WireHex}; + +/// Project a [`GameState`] down to a fog-aware [`PlayerView`] for the +/// given player slot. `omniscient=true` returns the unredacted view. +pub fn project_view(state: &GameState, player: PlayerId, omniscient: bool) -> PlayerView { + let player_idx = player as usize; + let own = state.players.get(player_idx); + + let resources = own.map(project_resources).unwrap_or_default(); + let research = own.map(project_research).unwrap_or_default(); + let culture = own.map(project_culture).unwrap_or_default(); + let civics = CivicsView::default(); // TRACKED: civic axis state lives off-state for v1 + let cities = project_cities(state, player_idx, omniscient); + let units = project_units(state, player_idx, omniscient); + let diplomacy = project_diplomacy(state, player_idx, omniscient); + let score = own.map(|p| project_score(state, p)).unwrap_or_default(); + let legal_actions = project_empire_legal_actions(); + + PlayerView { + turn: state.turn, + player, + // TRACKED: bench `GameState` has no `current_player_index`. For + // v1, treat the bound player as the current player — the harness + // only releases stdin to the adapter on the bound player's turn, + // so this is consistent with what the adapter observes. + current_player: player, + phase: "player_actions".into(), + is_human_turn: true, + resources, + research, + culture, + civics, + cities, + units, + // TRACKED: tiles projection requires per-tile observation data + // not yet on bench `GameState`. Emit empty for v1. + tiles: Vec::new(), + diplomacy, + pending_events: PendingEventsView::default(), + legal_actions, + score, + } +} + +fn project_resources(player: &mc_turn::game_state::PlayerState) -> ResourceView { + let mut stockpile = std::collections::BTreeMap::new(); + for (k, v) in &player.strategic_ledger { + stockpile.insert(k.clone(), *v as i32); + } + ResourceView { + gold: player.gold, + // Bench state does not separately track per-turn gold delta; the + // turn processor computes it transiently. v1 reports 0 for + // per-turn rates. TRACKED: surface deltas via PlayerState once + // the processor caches them. + gold_per_turn: 0, + science_per_turn: player.science_yield as i32, + culture_per_turn: 0, + happiness_pool: 0, + stockpile, + } +} + +fn project_research(player: &mc_turn::game_state::PlayerState) -> ResearchView { + let (current_tech, tech_progress, tech_cost, researched) = if let Some(pt) = &player.player_tech { + let researched: Vec = pt + .researched + .iter() + .map(std::string::ToString::to_string) + .collect(); + let current = pt + .researching + .as_ref() + .map(std::string::ToString::to_string); + let cost = pt + .researching_cost + .map(|c| c as i32) + .unwrap_or(0); + (current, player.science_pool as i32, cost, researched) + } else { + (None, 0, 0, Vec::new()) + }; + ResearchView { + current_tech, + tech_progress, + tech_cost, + researched, + // TRACKED: enumerate available techs from the TechWeb once + // the projection has a handle to it (passed via context). + available: Vec::new(), + } +} + +fn project_culture(player: &mc_turn::game_state::PlayerState) -> CultureView { + let current = if player.researching_tradition.is_empty() { + None + } else { + Some(player.researching_tradition.clone()) + }; + let researched: Vec = player.researched_traditions.iter().cloned().collect(); + CultureView { + current_tradition: current, + tradition_progress: player.culture_research_progress as i32, + // TRACKED: per-tradition cost lookup needs a CultureWeb handle. + tradition_cost: 0, + researched, + } +} + +fn project_cities(state: &GameState, player_idx: usize, omniscient: bool) -> Vec { + let mut out: Vec = Vec::new(); + for (p_idx, player) in state.players.iter().enumerate() { + let is_own = p_idx == player_idx; + if !is_own && !omniscient { + continue; + } + for (c_idx, city) in player.cities.iter().enumerate() { + let position = player + .city_positions + .get(c_idx) + .map(|(c, r)| [*c, *r]) + .unwrap_or([0, 0]); + let is_capital = player + .capital_position + .map(|cap| cap == player.city_positions.get(c_idx).copied().unwrap_or((0, 0))) + .unwrap_or(false); + let mut yields = std::collections::BTreeMap::new(); + yields.insert("food".into(), city.food_yield as f32); + yields.insert("production".into(), city.prod_yield as f32); + let buildings = player + .city_buildings + .get(c_idx) + .cloned() + .unwrap_or_default(); + let production_queue = project_production_queue(city); + out.push(CityView { + id: format!("{}_{}", p_idx, c_idx), + name: format!("City {}-{}", p_idx, c_idx), + position, + owner: p_idx as PlayerId, + is_capital, + population: city.population, + food_stored: city.food_stored as f32, + // Bench growth threshold formula is roughly 15 * pop * (pop+1) — the + // exact value is private to mc-city's growth processor. v1 emits 0 + // and adapters can call view() across turns to observe progress. + // TRACKED: surface the canonical threshold via mc-city. + food_growth_threshold: 0.0, + production_queue, + buildings, + // TRACKED: per-city owned_tiles list. mc-city's CityState doesn't + // carry it on the bench struct — comes with city.rs full City when + // the bench grows tile ownership. + owned_tiles: Vec::new(), + yields, + hp: 100, + max_hp: 100, + focus: "balanced".into(), + // TRACKED: enumerate buildable items via mc-city + mc-units. + // Empty for v1; adapter must consult Encyclopedia data offline. + buildable: if is_own { Vec::new() } else { Vec::new() }, + }); + } + } + out +} + +fn project_production_queue(city: &mc_city::CityState) -> Vec { + let Some(q) = &city.queue else { + return Vec::new(); + }; + let (item, kind) = match q { + mc_city::Queueable::Unit(id) => (id.to_string(), "unit".to_string()), + mc_city::Queueable::Building(id) => (id.to_string(), "building".to_string()), + mc_city::Queueable::Wonder(id) => (id.to_string(), "wonder".to_string()), + }; + vec![ProductionQueueEntry { + item, + kind, + progress: city.production_stored, + cost: city.queue_cost.unwrap_or(0) as i32, + tile: None, + }] +} + +fn project_units(state: &GameState, player_idx: usize, omniscient: bool) -> Vec { + let mut out: Vec = Vec::new(); + for (p_idx, player) in state.players.iter().enumerate() { + let is_own = p_idx == player_idx; + if !is_own && !omniscient { + // TRACKED: enemy units in vision range should appear here once + // per-tile vision data lands on bench GameState. + continue; + } + for unit in &player.units { + out.push(UnitView { + id: unit.id.to_string(), + type_id: unit.unit_type.clone(), + position: [unit.col, unit.row], + owner: p_idx as PlayerId, + hp: unit.hp, + max_hp: unit.max_hp, + // Bench MapUnit has no per-turn movement counter — the + // turn processor refreshes it transiently. v1 reports + // the unit's `moves_left` if non-zero, else 0. + movement_left: 0, + movement_max: 0, + experience: 0, + promotion_available: false, + fortified: unit.is_fortified, + sentry: false, + legal_actions: if is_own { + project_unit_legal_actions(unit) + } else { + Vec::new() + }, + }); + } + } + out +} + +fn project_unit_legal_actions(unit: &mc_turn::game_state::MapUnit) -> Vec { + let uid = unit.id.to_string(); + let mut entries: Vec = Vec::new(); + // Always-legal verbs that don't need targeting: + entries.push(LegalActionEntry { + action: PlayerAction::Skip { + unit_id: uid.clone(), + }, + enabled: true, + disabled_reason: None, + }); + if !unit.is_fortified { + entries.push(LegalActionEntry { + action: PlayerAction::Fortify { + unit_id: uid.clone(), + }, + enabled: true, + disabled_reason: None, + }); + } else { + entries.push(LegalActionEntry { + action: PlayerAction::Unfortify { + unit_id: uid.clone(), + }, + enabled: true, + disabled_reason: None, + }); + } + // Move/Attack are conditionally legal but their target-validity + // check requires the path/combat subsystems. v1 emits the variant + // disabled with a typed reason so adapters know the surface + // exists. TRACKED: enable once apply_move / apply_ranged are wired. + entries.push(LegalActionEntry { + action: PlayerAction::Move { + unit_id: uid.clone(), + to: [unit.col, unit.row], + }, + enabled: false, + disabled_reason: Some("move_subsystem_pending".into()), + }); + entries +} + +fn project_diplomacy( + state: &GameState, + player_idx: usize, + omniscient: bool, +) -> Vec { + let mut out: Vec = Vec::new(); + for (p_idx, _player) in state.players.iter().enumerate() { + if p_idx == player_idx { + continue; + } + if !omniscient && p_idx >= state.players.len() { + continue; + } + // TRACKED: relations table is keyed (min, max) — derive + // canonical pair lookup once a helper lands in mc-trade. + let pair = if (player_idx as u8) < p_idx as u8 { + (player_idx as u8, p_idx as u8) + } else { + (p_idx as u8, player_idx as u8) + }; + let relation = if let Some(rs) = state + .players + .first() + .and_then(|p0| p0.relations.get(&pair)) + { + classify_relation(rs) + } else { + "peace".into() + }; + out.push(DiplomacyView { + player: p_idx as PlayerId, + race: "dwarf".into(), + name: format!("Player {}", p_idx), + relation, + open_borders: false, + shared_map: false, + agreements_active: Vec::new(), + }); + } + out +} + +fn classify_relation(rel: &mc_trade::relation::RelationState) -> String { + if rel.is_at_war() { + "war".into() + } else { + "peace".into() + } +} + +fn project_score(state: &GameState, player: &mc_turn::game_state::PlayerState) -> ScoreView { + let _ = state; + ScoreView { + gold_total: player.gold, + city_count: player.cities.len() as u32, + unit_count: player.units.len() as u32, + // TRACKED: surface canonical score via mc-score / mc-turn::victory. + score_estimate: 0, + } +} + +fn project_empire_legal_actions() -> Vec { + vec![LegalActionEntry { + action: PlayerAction::EndTurn, + enabled: true, + disabled_reason: None, + }] +} + +#[cfg(test)] +mod tests { + use super::*; + use mc_turn::game_state::{MapUnit, PlayerState}; + + fn make_state(num_players: u8, own_gold: i32, own_units: Vec<(u32, i32, i32)>) -> GameState { + let mut state = GameState::default(); + state.turn = 5; + for p in 0..num_players { + let mut ps = PlayerState::default(); + ps.player_index = p; + if p == 0 { + ps.gold = own_gold; + for (id, col, row) in &own_units { + let mut u = MapUnit::default(); + u.id = *id; + u.col = *col; + u.row = *row; + u.unit_type = "dwarf_warrior".into(); + u.hp = 100; + u.max_hp = 100; + ps.units.push(u); + } + } else { + // Other player gets a unit at (50,50) to test redaction + let mut u = MapUnit::default(); + u.id = 999; + u.col = 50; + u.row = 50; + u.unit_type = "dwarf_warrior".into(); + u.hp = 100; + u.max_hp = 100; + ps.units.push(u); + } + state.players.push(ps); + } + state + } + + #[test] + fn empty_state_projects_to_zeroed_view_for_bound_player() { + let state = GameState::default(); + let v = project_view(&state, 0, false); + assert_eq!(v.turn, 0); + assert_eq!(v.player, 0); + assert!(v.cities.is_empty()); + assert!(v.units.is_empty()); + assert_eq!(v.resources.gold, 0); + } + + #[test] + fn own_player_units_appear_enemy_units_redacted() { + let state = make_state(2, 50, vec![(1, 0, 0), (2, 1, 0)]); + let v = project_view(&state, 0, false); + // Own player has 2 units; enemy unit 999 must be redacted. + assert_eq!(v.units.len(), 2, "expected 2 own units, got {}", v.units.len()); + assert!( + v.units.iter().all(|u| u.owner == 0), + "no enemy unit may leak in non-omniscient mode" + ); + assert!( + v.units.iter().all(|u| u.id != "999"), + "enemy unit id 999 must not appear in view" + ); + } + + #[test] + fn omniscient_mode_exposes_enemy_units() { + let state = make_state(2, 50, vec![(1, 0, 0)]); + let v = project_view(&state, 0, true); + assert_eq!(v.units.len(), 2, "omniscient view exposes both players' units"); + assert!(v.units.iter().any(|u| u.owner == 1 && u.id == "999")); + } + + #[test] + fn legal_actions_include_end_turn_at_empire_level() { + let state = GameState::default(); + let v = project_view(&state, 0, false); + assert!( + v.legal_actions.iter().any(|e| matches!(e.action, PlayerAction::EndTurn)), + "end_turn must always be in the empire legal_actions list" + ); + } + + #[test] + fn own_unit_carries_legal_actions_enemy_does_not() { + let state = make_state(2, 50, vec![(7, 3, 3)]); + let v = project_view(&state, 0, true); // omniscient → see enemy + let own = v.units.iter().find(|u| u.id == "7").unwrap(); + let enemy = v.units.iter().find(|u| u.id == "999").unwrap(); + assert!( + !own.legal_actions.is_empty(), + "own unit must have legal_actions populated" + ); + assert!( + enemy.legal_actions.is_empty(), + "enemy unit must not expose legal_actions even in omniscient mode \ + — adapter cannot command enemy units" + ); + } + + #[test] + fn own_resources_gold_round_trips_through_view() { + let state = make_state(1, 142, vec![]); + let v = project_view(&state, 0, false); + assert_eq!(v.resources.gold, 142); + } + + #[test] + fn view_serialises_as_json_lines_payload() { + let state = make_state(2, 50, vec![(1, 0, 0)]); + let v = project_view(&state, 0, false); + let line = serde_json::to_string(&v).expect("PlayerView must serialise"); + // One-line JSON, no embedded newlines (JSON-Lines transport invariant). + assert!(!line.contains('\n'), "PlayerView serialised to JSON-Lines must be one line"); + // Round-trip back. + let back: PlayerView = serde_json::from_str(&line).expect("round-trip"); + assert_eq!(back, v); + } +}