feat(@projects/@magic-civilization): add player action dispatch module

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-11 01:55:40 -07:00
parent 7eb0849258
commit 9260496ab2
3 changed files with 145 additions and 7 deletions

View file

@ -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"

View file

@ -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"

View file

@ -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<HexCoord>` 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<HexCoord>` 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<QueueEntry>; bench CityState \
holds a single `queue: Option<Queueable>` 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<Vec<Event>, 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,