feat(@projects/@magic-civilization): ✨ update move subsystem to use move requests
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
1076890c99
commit
59fb8a08cb
2 changed files with 69 additions and 33 deletions
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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\""));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue