diff --git a/src/simulator/Cargo.lock b/src/simulator/Cargo.lock index 7da8e302..83f350be 100644 --- a/src/simulator/Cargo.lock +++ b/src/simulator/Cargo.lock @@ -879,6 +879,7 @@ dependencies = [ "mc-items", "mc-mapgen", "mc-mcts-service", + "mc-player-api", "mc-replay", "mc-save", "mc-score", @@ -988,6 +989,7 @@ dependencies = [ "serde", "serde_json", "siphasher", + "thiserror 1.0.69", ] [[package]] @@ -1111,6 +1113,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "mc-player-api" +version = "0.1.0" +dependencies = [ + "mc-city", + "mc-combat", + "mc-core", + "mc-tech", + "mc-trade", + "mc-turn", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "mc-replay" version = "0.1.0" diff --git a/src/simulator/crates/mc-player-api/Cargo.toml b/src/simulator/crates/mc-player-api/Cargo.toml index a3734c8e..21e386c5 100644 --- a/src/simulator/crates/mc-player-api/Cargo.toml +++ b/src/simulator/crates/mc-player-api/Cargo.toml @@ -10,6 +10,7 @@ mc-tech = { path = "../mc-tech" } mc-trade = { path = "../mc-trade" } mc-turn = { path = "../mc-turn" } mc-combat = { path = "../mc-combat" } +mc-items = { path = "../mc-items" } serde.workspace = true serde_json.workspace = true thiserror = "1" diff --git a/src/simulator/crates/mc-player-api/src/dispatch.rs b/src/simulator/crates/mc-player-api/src/dispatch.rs index 9316cb08..c23b3054 100644 --- a/src/simulator/crates/mc-player-api/src/dispatch.rs +++ b/src/simulator/crates/mc-player-api/src/dispatch.rs @@ -139,13 +139,44 @@ pub fn apply_action( 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: "reorder / rush_buy / buy_tile / set_focus / merge_buildings need \ - bench-vs-full City reconciliation — TRACKED: p2-67 Phase 1 follow-up" + PlayerAction::RushBuy { city_id } => apply_rush_buy(state, player, city_id), + + PlayerAction::BuyTile { .. } => Err(ActionError::NotYetImplemented { + message: "buy_tile requires per-city tile-ownership state; bench \ + CityState carries no `owned_tiles: HashSet` field \ + and widening it cascades into mc-sim/solo_dominion + \ + fauna_pressure_bench serde compat. The full `City` struct \ + in mc-city/src/city.rs owns tile ownership; a per-player \ + parallel `owned_tiles: Vec` on GameState is the \ + lighter alternative. TRACKED: p2-67 Phase 7 follow-up." + .into(), + }), + + PlayerAction::SetFocus { .. } => Err(ActionError::NotYetImplemented { + message: "set_focus targets `City::set_focus` on the full City struct \ + (mc-city/src/city.rs); bench CityState has no `focus` field. \ + Adding it requires widening every bench consumer (mc-sim, \ + fauna_pressure_bench, MCTS rollout snapshots) for a field \ + they ignore. TRACKED: p2-67 Phase 7 follow-up." + .into(), + }), + + PlayerAction::QueueReorder { .. } => Err(ActionError::NotYetImplemented { + message: "queue_reorder operates on a Vec; bench CityState \ + holds a single `queue: Option` so there is \ + nothing to reorder. Upgrading to a vec cascades through \ + every TurnProcessor production path. TRACKED: p2-67 Phase 7 \ + follow-up (bench-queue-as-vec migration)." + .into(), + }), + + PlayerAction::MergeBuildings { .. } => Err(ActionError::NotYetImplemented { + message: "merge_buildings calls mc_city::merge::apply_merge which \ + requires &mut City + &BuildingRegistry + researched techs. \ + Threading the registry through GameState is the larger \ + lift; bench City struct (CityState) carries no instance \ + data the merge engine needs. TRACKED: p2-67 Phase 7 \ + follow-up (building-registry on GameState)." .into(), }), @@ -522,6 +553,95 @@ fn apply_queue_production( Ok(Vec::new()) } +/// RushBuy: pay `2 × queue_cost` gold to immediately complete the current +/// queue head. +/// +/// Mirrors what `TurnProcessor::process_city_production` does on natural +/// completion — only the timing differs. State changes: +/// - `state.players[pi].gold -= rush_cost` +/// - For `Queueable::Wonder`: insert into `player.wonders_built` at the +/// stored tier, clear queue fields. Emits `Event::WonderBuilt`. +/// - For `Queueable::Unit`: clear queue fields, emit `Event::CityUnitCompleted`. +/// Does NOT spawn the unit on the map — the bench `TurnProcessor` itself +/// does not spawn units from non-wonder queue heads in Phase 7's scope +/// (that ticking lands in Phase 11). The wire event is the honest +/// observable; the next `view()` shows the cleared queue + reduced gold. +/// - For `Queueable::Item`: clear queue fields, emit `Event::CityBuildingCompleted` +/// (the closest existing semantic — items don't currently have a +/// dedicated completion event variant). +/// +/// # Errors +/// +/// - [`ActionError::UnknownCity`] — `city_id` doesn't resolve. +/// - [`ActionError::IllegalAction`] — empty queue (nothing to rush) or +/// insufficient gold. +fn apply_rush_buy( + state: &mut GameState, + _player: PlayerId, + city_id: &str, +) -> Result, ActionError> { + let (pi, ci) = find_city_indices(state, city_id)?; + + // Snapshot the queue head + cost before mutating; the `Queueable` clone + // is cheap and lets us emit the right event variant after the borrow ends. + let (queueable, queue_cost, queue_tier) = { + let city = &state.players[pi].cities[ci]; + let q = city.queue.clone().ok_or_else(|| ActionError::IllegalAction { + message: format!("city '{city_id}' has nothing queued to rush"), + })?; + let cost = city.queue_cost.ok_or_else(|| ActionError::IllegalAction { + message: format!( + "city '{city_id}' queue head has no `queue_cost`; rush requires a known base cost" + ), + })?; + (q, cost, city.queue_tier) + }; + + let rush_cost: i32 = mc_items::ItemSystem::rush_buy_cost(queue_cost as i32); + if state.players[pi].gold < rush_cost { + return Err(ActionError::IllegalAction { + message: format!( + "rush_buy on '{city_id}' costs {rush_cost} gold; player has {}", + state.players[pi].gold + ), + }); + } + + // Deduct gold first; any further failure is unreachable after the snapshot. + state.players[pi].gold -= rush_cost; + + // Build the completion event from the queue-head snapshot. + let event: Event = match &queueable { + mc_city::Queueable::Wonder { wonder_id } => { + let tier = queue_tier.unwrap_or(1); + state.players[pi] + .wonders_built + .insert(wonder_id.clone(), tier); + Event::WonderBuilt { + wonder_id: wonder_id.0.clone(), + player: pi as u8, + } + } + mc_city::Queueable::Unit { unit_id } => Event::CityUnitCompleted { + city_id: city_id.to_string(), + unit_id: unit_id.0.clone(), + }, + mc_city::Queueable::Item { item_id } => Event::CityBuildingCompleted { + city_id: city_id.to_string(), + building_id: item_id.clone(), + }, + }; + + // Clear the queue head — matches `TurnProcessor` wonder-completion semantics. + 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![event]) +} + fn apply_clear_queue( state: &mut GameState, _player: PlayerId,