feat(@projects/@magic-civilization): update move subsystem to use move requests

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-11 02:38:18 -07:00
parent 1076890c99
commit 59fb8a08cb
2 changed files with 69 additions and 33 deletions

View file

@ -250,6 +250,13 @@ fn apply_end_turn(state: &mut GameState, player: PlayerId) -> Result<Vec<Event>,
});
}
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<Vec<Event>, 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<WireHex> =
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]);

View file

@ -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\""));