From d5c8375b6a9b91c9fe48b178675f049c1732263c Mon Sep 17 00:00:00 2001 From: Natalie Date: Mon, 11 May 2026 00:59:49 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20tech=20research=20dispatch=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- src/simulator/crates/mc-player-api/Cargo.toml | 1 + .../crates/mc-player-api/src/dispatch.rs | 236 ++++++++++++++++-- src/simulator/crates/mc-tech/src/state.rs | 19 ++ 3 files changed, 242 insertions(+), 14 deletions(-) diff --git a/src/simulator/crates/mc-player-api/Cargo.toml b/src/simulator/crates/mc-player-api/Cargo.toml index 429f52df..a3734c8e 100644 --- a/src/simulator/crates/mc-player-api/Cargo.toml +++ b/src/simulator/crates/mc-player-api/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] mc-core = { path = "../mc-core" } mc-city = { path = "../mc-city" } +mc-tech = { path = "../mc-tech" } mc-trade = { path = "../mc-trade" } mc-turn = { path = "../mc-turn" } mc-combat = { path = "../mc-combat" } diff --git a/src/simulator/crates/mc-player-api/src/dispatch.rs b/src/simulator/crates/mc-player-api/src/dispatch.rs index 5fe2cc07..b47f5895 100644 --- a/src/simulator/crates/mc-player-api/src/dispatch.rs +++ b/src/simulator/crates/mc-player-api/src/dispatch.rs @@ -133,14 +133,20 @@ pub fn apply_action( .into(), }), - PlayerAction::QueueProduction { .. } - | PlayerAction::RemoveFromQueue { .. } - | PlayerAction::QueueReorder { .. } + PlayerAction::QueueProduction { city_id, item, tile: _ } => { + apply_queue_production(state, player, city_id, item) + } + PlayerAction::RemoveFromQueue { city_id, index: _ } => { + apply_clear_queue(state, player, city_id) + } + PlayerAction::QueueReorder { .. } | PlayerAction::RushBuy { .. } | PlayerAction::BuyTile { .. } | PlayerAction::SetFocus { .. } | PlayerAction::MergeBuildings { .. } => Err(ActionError::NotYetImplemented { - message: "city ops dispatch into mc-city — TRACKED: p2-67 Phase 1 follow-up".into(), + message: "reorder / rush_buy / buy_tile / set_focus / merge_buildings need \ + bench-vs-full City reconciliation — TRACKED: p2-67 Phase 1 follow-up" + .into(), }), PlayerAction::BuildingAction { .. } => Err(ActionError::NotYetImplemented { @@ -149,16 +155,12 @@ pub fn apply_action( .into(), }), - PlayerAction::ResearchTech { .. } => Err(ActionError::NotYetImplemented { - message: "ResearchTech requires a TechWeb handle on GameState — \ - TRACKED: p2-67 Phase 1 follow-up (web threading)" - .into(), - }), - PlayerAction::ResearchTradition { .. } => Err(ActionError::NotYetImplemented { - message: "ResearchTradition requires a CultureWeb handle on GameState — \ - TRACKED: p2-67 Phase 1 follow-up (web threading)" - .into(), - }), + PlayerAction::ResearchTech { tech_id } => { + apply_research_tech(state, player, tech_id) + } + PlayerAction::ResearchTradition { tradition_id } => { + apply_research_tradition(state, player, tradition_id) + } PlayerAction::OfferPeace { to } => { // mc-trade::offer_peace is an EA stub (always rejects). Emit a @@ -368,6 +370,125 @@ fn apply_accept_peace( Ok(Vec::new()) } +fn find_city_indices( + state: &GameState, + city_id: &str, +) -> Result<(usize, usize), ActionError> { + // City id convention used by the projector: "{player_idx}_{city_idx}" + // (see projection::project_cities). Parse + bounds-check both halves. + let (pi_str, ci_str) = city_id + .split_once('_') + .ok_or_else(|| ActionError::UnknownCity { + city_id: city_id.to_string(), + })?; + let pi: usize = pi_str.parse().map_err(|_| ActionError::UnknownCity { + city_id: city_id.to_string(), + })?; + let ci: usize = ci_str.parse().map_err(|_| ActionError::UnknownCity { + city_id: city_id.to_string(), + })?; + if pi >= state.players.len() { + return Err(ActionError::UnknownCity { + city_id: city_id.to_string(), + }); + } + if ci >= state.players[pi].cities.len() { + return Err(ActionError::UnknownCity { + city_id: city_id.to_string(), + }); + } + Ok((pi, ci)) +} + +fn apply_queue_production( + state: &mut GameState, + _player: PlayerId, + city_id: &str, + item: &str, +) -> Result, ActionError> { + let (pi, ci) = find_city_indices(state, city_id)?; + // Bench `CityState` carries a single-item `queue: Option`. + // Detect whether the requested id is a known unit or treat as building. + // Heuristic: if the id contains "dwarf_" we treat as Unit; otherwise + // Item (the bench struct doesn't carry a Building variant — full + // City does, tracked separately). + use mc_core::ids::UnitId; + let queueable: mc_city::Queueable = if item.starts_with("dwarf_") { + mc_city::Queueable::Unit { + unit_id: UnitId::new(item), + } + } else { + mc_city::Queueable::Item { + item_id: item.to_string(), + } + }; + let city: &mut mc_city::CityState = &mut state.players[pi].cities[ci]; + city.queue = Some(queueable); + // Reset production_stored when the queue head changes. + city.production_stored = 0; + // Default cost when the caller did not supply one; processor will + // recompute on first tick if a real registry is available. + if city.queue_cost.is_none() { + city.queue_cost = Some(40); + } + Ok(Vec::new()) +} + +fn apply_clear_queue( + state: &mut GameState, + _player: PlayerId, + city_id: &str, +) -> Result, ActionError> { + let (pi, ci) = find_city_indices(state, city_id)?; + let city: &mut mc_city::CityState = &mut state.players[pi].cities[ci]; + city.queue = None; + city.queue_cost = None; + city.queue_tier = None; + city.production_stored = 0; + Ok(Vec::new()) +} + +fn apply_research_tech( + state: &mut GameState, + player: PlayerId, + tech_id: &str, +) -> Result, ActionError> { + let pi: usize = player as usize; + if pi >= state.players.len() { + return Err(ActionError::Internal { + message: format!("player slot {player} out of range"), + }); + } + // Lazy-init `player_tech` so adapters can pick research even when + // the harness hasn't seeded a PlayerTechState yet. + if state.players[pi].player_tech.is_none() { + state.players[pi].player_tech = Some(mc_tech::PlayerTechState::new()); + } + let pt: &mut mc_tech::PlayerTechState = + state.players[pi].player_tech.as_mut().unwrap(); + let _ = pt.set_researching_unchecked(tech_id); + Ok(Vec::new()) +} + +fn apply_research_tradition( + state: &mut GameState, + player: PlayerId, + tradition_id: &str, +) -> Result, ActionError> { + let pi: usize = player as usize; + if pi >= state.players.len() { + return Err(ActionError::Internal { + message: format!("player slot {player} out of range"), + }); + } + // Bench tradition state lives in flat fields on `PlayerState` — + // see `game_state.rs::researching_tradition` (a String). Set + // directly; the turn processor reads from that field. + state.players[pi].researching_tradition = tradition_id.to_string(); + state.players[pi].culture_research_progress = 0; + Ok(Vec::new()) +} + fn apply_ransom_response( state: &mut GameState, player: PlayerId, @@ -687,6 +808,93 @@ mod tests { assert_eq!(rs.relation, mc_trade::relation::Relation::Neutral); } + #[test] + fn queue_production_with_unit_id_sets_unit_queueable() { + let mut state = make_state_with_units(vec![(0, 1, 0, 0)]); + // Add a starter city so QueueProduction has a target. + state.players[0].cities.push(mc_city::CityState::starter()); + let _ = apply_action( + &mut state, + 0, + &PlayerAction::QueueProduction { + city_id: "0_0".into(), + item: "dwarf_warrior".into(), + tile: None, + }, + ) + .unwrap(); + let q = state.players[0].cities[0].queue.as_ref().unwrap(); + assert!(matches!(q, mc_city::Queueable::Unit { .. })); + } + + #[test] + fn queue_production_with_item_id_sets_item_queueable() { + let mut state = make_state_with_units(vec![(0, 1, 0, 0)]); + state.players[0].cities.push(mc_city::CityState::starter()); + let _ = apply_action( + &mut state, + 0, + &PlayerAction::QueueProduction { + city_id: "0_0".into(), + item: "iron_sword".into(), + tile: None, + }, + ) + .unwrap(); + let q = state.players[0].cities[0].queue.as_ref().unwrap(); + assert!(matches!(q, mc_city::Queueable::Item { .. })); + } + + #[test] + fn queue_production_unknown_city_returns_unknown_city_error() { + let mut state = GameState::default(); + let err = apply_action( + &mut state, + 0, + &PlayerAction::QueueProduction { + city_id: "99_99".into(), + item: "dwarf_warrior".into(), + tile: None, + }, + ) + .unwrap_err(); + assert!(matches!(err, ActionError::UnknownCity { .. })); + } + + #[test] + fn research_tech_sets_player_tech_state() { + let mut state = make_state_with_units(vec![(0, 1, 0, 0)]); + let _ = apply_action( + &mut state, + 0, + &PlayerAction::ResearchTech { + tech_id: "bronze_working".into(), + }, + ) + .unwrap(); + let pt = state.players[0].player_tech.as_ref().unwrap(); + assert_eq!(pt.current_research(), Some("bronze_working")); + assert_eq!(pt.research_progress(), 0); + } + + #[test] + fn research_tradition_sets_flat_field_on_player_state() { + let mut state = make_state_with_units(vec![(0, 1, 0, 0)]); + let _ = apply_action( + &mut state, + 0, + &PlayerAction::ResearchTradition { + tradition_id: "ancestor_veneration".into(), + }, + ) + .unwrap(); + assert_eq!( + state.players[0].researching_tradition, + "ancestor_veneration" + ); + assert_eq!(state.players[0].culture_research_progress, 0); + } + #[test] fn ransom_response_on_unknown_offer_returns_illegal_action() { let mut state = GameState::default(); diff --git a/src/simulator/crates/mc-tech/src/state.rs b/src/simulator/crates/mc-tech/src/state.rs index a9ec8cc7..72b09c9b 100644 --- a/src/simulator/crates/mc-tech/src/state.rs +++ b/src/simulator/crates/mc-tech/src/state.rs @@ -103,6 +103,25 @@ impl PlayerTechState { // -- Mutation ------------------------------------------------------------- + /// Set the currently-researched tech without checking prerequisites + /// or registering the tech in a `TechWeb`. Used by callers that + /// don't have a `&TechWeb` handle (e.g. the player-API dispatcher + /// drives this from a typed `PlayerAction::ResearchTech` and the + /// turn processor validates legality before the choice settles). + /// + /// Resets `research_progress` to 0 (matches `start_research`). + /// Returns the previously-researching tech id, if any. + pub fn set_researching_unchecked(&mut self, tech_id: &str) -> Option { + let prev: Option = self.researching.take(); + if tech_id.is_empty() { + self.research_progress = 0; + return prev; + } + self.researching = Some(tech_id.to_string()); + self.research_progress = 0; + prev + } + /// Begin researching a tech. Returns `Err` if prerequisites are not met /// or the tech is already researched. pub fn start_research(&mut self, tech_id: &str, web: &TechWeb) -> Result<(), TechError> {