diff --git a/src/simulator/crates/mc-player-api/src/dispatch.rs b/src/simulator/crates/mc-player-api/src/dispatch.rs index 27e35f43..abb8fe76 100644 --- a/src/simulator/crates/mc-player-api/src/dispatch.rs +++ b/src/simulator/crates/mc-player-api/src/dispatch.rs @@ -250,6 +250,13 @@ fn apply_end_turn(state: &mut GameState, player: PlayerId) -> Result, }); } state.turn = state.turn.saturating_add(1); + // p2-67 Phase 9 — refresh every unit's movement_remaining so the next + // turn's actions (Claude's slot AND every AI slot processed above on + // the next call) start with full movement budgets. Single source of + // truth lives in `mc_turn::refresh_units`. TRACKED (p2-67 Phase 11): + // delete this call once `TurnProcessor::step` is invoked from + // dispatch — the step will own the refresh. + mc_turn::refresh_units(state); let started_turn = state.turn; events.push(Event::TurnStarted { turn: started_turn, @@ -373,42 +380,65 @@ fn apply_move( unit_id: &str, to: WireHex, ) -> Result, ActionError> { - // v1 dispatch (TRACKED: p2-67 Phase 1 follow-up — full Rust move subsystem). - // The production move path is GDScript-native (world_map.gd:_move_unit_to); - // mc-turn carries no movement-points field on MapUnit and no pathfinder, so - // this dispatcher applies a direct mutation with two boundary checks: - // 1. unit must exist (find_unit_indices) - // 2. destination must be unoccupied by another unit - // No bounds check against the grid because the bench GameState may have - // grid=None (mc-sim unit-test path); occupants of the destination would - // surface the conflict path anyway. + // p2-67 Phase 9 — Proper Move subsystem. + // + // Queues a `MoveRequest` and drains it synchronously via + // `mc_turn::processor::process_move_requests`. The synchronous drain + // means each action returns its own events — matching the Claude-API + // contract where one request = one response. + // + // Production rules now apply: + // - movement_remaining must be > 0 (refresh_units is called at + // turn start in apply_end_turn) + // - the path is A*-validated via `mc-pathfinding` + // - per-tile movement cost is summed against the budget + // - destination occupancy still rejects (attack path is separate) + // - captive units cannot move (p2-55 ransom rules) let unit_u32 = parse_unit_id(unit_id)?; let (player_idx, unit_idx) = find_unit_indices(state, unit_u32)?; - let (from_col, from_row) = { - let u = &state.players[player_idx].units[unit_idx]; - (u.col, u.row) - }; - if from_col == to[0] && from_row == to[1] { - return Ok(Vec::new()); - } - if find_unit_at_hex(state, to).is_some() { - return Err(ActionError::TargetInvalid { - message: format!( - "destination hex [{}, {}] already occupied; use attack instead", - to[0], to[1] - ), + + state + .pending_move_requests + .push(mc_turn::MoveRequest { + player_idx, + unit_idx, + target_col: to[0], + target_row: to[1], }); + let outcomes = mc_turn::processor::process_move_requests(state); + + let outcome = outcomes + .into_iter() + .next() + .ok_or_else(|| ActionError::Internal { + message: "move drain produced no outcome".into(), + })?; + + match outcome { + mc_turn::processor::MoveOutcome::Moved { from, to: dest, path, .. } => { + if from == dest { + // No-op (same-tile move) — keep the wire surface empty so + // adapters don't see redundant move events. + return Ok(Vec::new()); + } + let wire_path: Vec = + path.into_iter().map(|(c, r)| [c, r]).collect(); + Ok(vec![Event::UnitMoved { + unit_id: unit_id.to_string(), + from: [from.0, from.1], + to: [dest.0, dest.1], + path: wire_path, + }]) + } + mc_turn::processor::MoveOutcome::Rejected { reason, .. } => { + Err(ActionError::TargetInvalid { + message: format!( + "move to [{}, {}] rejected: {reason}", + to[0], to[1] + ), + }) + } } - { - let u = &mut state.players[player_idx].units[unit_idx]; - u.col = to[0]; - u.row = to[1]; - } - Ok(vec![Event::UnitMoved { - unit_id: unit_id.to_string(), - from: [from_col, from_row], - to, - }]) } fn apply_switch_civic( @@ -776,6 +806,11 @@ mod tests { unit.id = id; unit.col = col; unit.row = row; + // p2-67 Phase 9: tests run without a UnitsCatalog, so + // movement_remaining defaults to 0 — give every test unit a + // generous 32 mp via the builder so existing happy-path tests + // ("move from (0,0) to (3,5)") keep their geometry budget. + unit = unit.with_moves(32); state.players[owner as usize].units.push(unit); state.next_unit_id = id + 1; } @@ -1096,7 +1131,7 @@ mod tests { .unwrap(); assert_eq!(events.len(), 1); match &events[0] { - Event::UnitMoved { unit_id, from, to } => { + Event::UnitMoved { unit_id, from, to, .. } => { assert_eq!(unit_id, "42"); assert_eq!(*from, [0, 0]); assert_eq!(*to, [3, 5]); diff --git a/src/simulator/crates/mc-player-api/src/wire.rs b/src/simulator/crates/mc-player-api/src/wire.rs index aaa3930e..9f75de91 100644 --- a/src/simulator/crates/mc-player-api/src/wire.rs +++ b/src/simulator/crates/mc-player-api/src/wire.rs @@ -387,6 +387,7 @@ mod tests { unit_id: "u_1".into(), from: [3, 4], to: [4, 4], + path: vec![], }; let json = serde_json::to_string(&ev).unwrap(); assert!(json.contains("\"type\":\"unit_moved\""));