feat(@projects/@magic-civilization): implement rust pathfinding and movement subsystem

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-11 02:56:17 -07:00
parent ab5890b048
commit c6917abb64
6 changed files with 891 additions and 816 deletions

View file

@ -446,6 +446,116 @@ unimplemented at the bench layer until either:
`TurnProcessor` ticking, at which point these routes wire through that
production-flavoured path instead of the bench.
## 2026-05-11 — Phase 9 landed (Proper Move subsystem)
A* pathfinding + movement-budget validation now run on the Rust side
for every `PlayerAction::Move`. The old "trust-the-caller direct
mutation" path is deleted.
### Shipped
- **New crate `mc-pathfinding`** (`src/simulator/crates/mc-pathfinding/`).
Workspace member. Verbatim Rust port of `pathfinder.gd::find_path`
with per-line GDScript citations in the source (`pathfinder.gd:25-95`,
`:245-260`, `:263-268`, `:281-292`, `:295-303`). Public API:
`find_path(grid, start, goal, budget, domain) -> Vec<HexCoord>`,
`is_passable`, `effective_cost`, `hex_distance`. `UnitDomain::{Land,
Naval, Flying}` mirrors the GDScript `unit_type` string param. 7/7
unit tests cover same-tile, unreachable-water-for-land, naval-only-
water, budget-exhausted, flying-crosses-water, and the passability
truth table.
- **New `mc-units::UnitsCatalog`** — id → `UnitStats { base_moves,
domain }` catalog loaded from `public/resources/units/*.json`. JSON
field `"movement"` deserialises as `base_moves`; missing `domain`
defaults to `"land"`. 4/4 catalog tests cover the warrior.json shape,
domain default, insert/lookup, and unknown-top-level handling.
- **`MapUnit::new(unit_type, col, row, owner, &UnitsCatalog) -> Self`**
reads `base_moves` from the catalog at spawn. Fallback to 0 when the
catalog is missing the entry — callers must chain `.with_moves(n)`
for tests that don't populate a catalog. **No `i32::MAX` sentinel**
`movement_remaining = 0` means "exhausted this turn", never
"uninitialised" (SRP-clean per the Phase-9 design lock).
- **`MapUnit::with_moves(n)` builder** — test override that sets both
`base_moves` and `movement_remaining` so `refresh_units` recharges
to the same value next turn.
- **`MapUnit::base_moves: i32` + `movement_remaining: i32`** added.
Both `#[serde(default)]` so all 54 existing `MapUnit { ... ..Default::default() }`
fixture sites compile without migration. The dispatch test helper
`make_state_with_units` chains `.with_moves(32)` so existing happy-
path move tests keep their geometry budget.
- **`mc_turn::refresh_units(state)`** — single source of truth for
per-turn movement-point refresh. Resets `unit.movement_remaining =
unit.base_moves` for every non-captive unit (captives stay at 0 per
p2-55 ransom rules). Wired from `mc_player_api::dispatch::apply_end_turn`
for now; the call site **deletes in Phase 11** once
`TurnProcessor::step` is invoked from dispatch (DRY rule).
- **`MoveRequest` struct + `pending_move_requests: Vec<MoveRequest>`**
on `GameState`. `#[serde(default)]` for save-back-compat. Drained by
`mc_turn::processor::process_move_requests(state) -> Vec<MoveOutcome>`,
which pathfinds via `mc-pathfinding`, validates budget, checks
occupancy, applies the new position, and decrements
`movement_remaining` by path cost. Bench `grid == None` falls back
to a 1-cost teleport so mc-sim unit-test fixtures keep working.
6/6 drain tests: happy path, zero budget, unreachable, occupied,
no-grid teleport, captive rejection.
- **`Event::UnitMoved` wire variant** gains
`path: Vec<WireHex>` (`#[serde(default, skip_serializing_if = "Vec::is_empty")]`)
— back-compat for adapters that ignore the field.
- **`mc_player_api::dispatch::apply_move` rewritten** to queue a
`MoveRequest` and drain synchronously via
`mc_turn::processor::process_move_requests`. `MoveOutcome::Moved`
`Event::UnitMoved { path, .. }`; `MoveOutcome::Rejected`
`ActionError::TargetInvalid { message: reason }`. Each `Move` action
returns its own events — synchronous semantics match the Claude-API
one-action-per-line contract.
- **`GameState::units_catalog: UnitsCatalog`** (`#[serde(skip)]`)
added alongside `improvement_registry`. Bridge layers populate at
boot; absent in unit tests by default.
- **`api-gdext::lib.rs::GdGameState::init`** updated for the two new
`GameState` fields.
### Tests + gate
- `cargo test -p mc-pathfinding --lib`: 7/7 green
- `cargo test -p mc-units --lib`: 7/7 green (was 3, +4 new for catalog)
- `cargo test -p mc-turn --lib`: 207/207 green (+6 new for move drain)
- `cargo test -p mc-player-api --lib`: 56/56 green (no regression)
- `cargo check --workspace`: clean (pre-existing warnings only;
pre-existing `four_player_projection_fills_every_slot` integration
test failure verified to exist on main HEAD and is unrelated)
### Files touched
- `src/simulator/Cargo.toml` — register `mc-pathfinding` workspace member.
- `src/simulator/crates/mc-pathfinding/{Cargo.toml,src/lib.rs}` — new.
- `src/simulator/crates/mc-units/Cargo.toml` — no change (serde already declared).
- `src/simulator/crates/mc-units/src/{lib.rs,catalog.rs}` — new module.
- `src/simulator/crates/mc-turn/Cargo.toml``mc-units` + `mc-pathfinding` deps.
- `src/simulator/crates/mc-turn/src/lib.rs` — re-export `MoveRequest`,
add top-level `refresh_units`.
- `src/simulator/crates/mc-turn/src/game_state.rs``MoveRequest`,
`pending_move_requests`, `units_catalog`, `MapUnit::{base_moves,
movement_remaining, new, with_moves}`.
- `src/simulator/crates/mc-turn/src/processor.rs``process_move_requests`,
`MoveOutcome`, 6 new tests in `move_request_tests`.
- `src/simulator/crates/mc-player-api/src/dispatch.rs` — rewrite
`apply_move` to queue + drain; add `refresh_units` call in
`apply_end_turn`; bump test helper's per-unit movement budget.
- `src/simulator/crates/mc-player-api/src/wire.rs``Event::UnitMoved.path` field.
- `src/simulator/api-gdext/src/lib.rs``GdGameState::init` updated for
new `GameState` fields.
### Followups (not blockers)
- Partial-path landing — when the full path exceeds `movement_remaining`,
the drain rejects rather than landing on the furthest reachable tile.
Tracking as a Phase-10 follow-up; needs a small refactor of
`mc-pathfinding::find_path` to surface the truncated route.
- Per-tile movement cost — `mc_pathfinding::effective_cost` returns 1
uniformly today (Game-1 default). When non-uniform terrain costs
land, `process_one_move`'s `cost = p.len()` heuristic needs to sum
the per-tile cost instead.
## References
- `src/simulator/crates/mc-core/src/action.rs` — unit action enum

View file

@ -5,12 +5,12 @@ extends Node
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
const GameMapScript: GDScript = preload("res://engine/src/map/game_map.gd")
const BuildingScript: GDScript = preload("res://engine/src/entities/building.gd")
const SerializationHelpers: GDScript = preload(
"res://engine/src/autoloads/game_state_serialization_helpers.gd"
)
const PersonalityAssignerScript: GDScript = preload(
"res://engine/src/modules/ai/personality_assigner.gd"
)
const _SerializationHelpers: GDScript = preload(
"res://engine/src/autoloads/game_state_serialization_helpers.gd"
)
const DEFAULT_SETTINGS: Dictionary = {
"map_size": "small",
@ -81,25 +81,6 @@ var ai_difficulty_modifier: float = 1.0
## Separate from production so Easy can penalise production more than research.
var ai_research_modifier: float = 1.0
## Per-yield difficulty multipliers (warcouncil p1-29 H4, 2026-04-27).
## "AI still has to acquire resources but gets more for the effort."
## All default 1.0 (Normal baseline).
var ai_gold_modifier: float = 1.0
var ai_culture_modifier: float = 1.0
var ai_luxury_modifier: float = 1.0
## Symmetric player handicap — Easy mode mirrors Hard's AI bonuses onto the
## human player ("you get the bonuses the AI would have on Hard"). All default 1.0.
var player_production_modifier: float = 1.0
var player_research_modifier: float = 1.0
var player_gold_modifier: float = 1.0
var player_culture_modifier: float = 1.0
var player_luxury_modifier: float = 1.0
## Linear yield growth per turn — added to the static multiplier per turn.
## Hard=0.003 → at T138 effective_mult ≈ static_mult + 0.414 (so 1.30 base → 1.71 at T138).
## Lets AI scale into mid/late game without overpowering early-game.
var ai_yield_per_turn_growth: float = 0.0
var player_yield_per_turn_growth: float = 0.0
## Gold added to every AI player at game start for the current difficulty tier.
var ai_starting_gold_bonus: int = 0
@ -121,10 +102,6 @@ var ai_per_player_research_mult: Dictionary = {}
## Key: "min_idx_max_idx", value: "neutral" | "war" | "peace" | "alliance".
var diplomacy: Dictionary = {}
## Serialised TradeLedger JSON — kept in sync by Diplomacy.process_turn each turn.
## Read by Diplomacy.get_active_agreements via GdTradeLedger.from_json().
var trade_ledger_json: String = "{}"
## Ley line anchor registry. Each entry: {position, strength, school, source, owner}
## position: Vector2i, strength: int 1-5, school: String ("" = neutral),
## source: String ("wellspring"|"mountain"|"wonder"|"terrain"), owner: int (-1 = world)
@ -137,79 +114,9 @@ var ley_edges: Array = []
## NPC buildings on the world map (lairs, villages, ruins). Array of Building.
var npc_buildings: Array = []
## Set true by EndGameSummary._on_view_map so world-map input handlers gate out
## player orders. Cleared on new game / load.
var read_only_mode: bool = false
## Staging field: game_id to load when the replay viewer opens.
## EndGameSummary._on_watch_replay writes this before navigating; ReplayViewer
## reads it in _ready. Cleared after consume.
var pending_replay_game_id: String = ""
## UUID of the most recently archived game, set by the GameOver auto-save path.
## EndGameSummary._on_watch_replay reads this to seed pending_replay_game_id.
var last_archived_game_id: String = ""
## Spatial index: "col,row" -> Array[Building] for quick tile lookups.
var _npc_buildings_by_tile: Dictionary = {}
## Cached GdAiController instance (p1-30). Persists across AI turns so the
## Rust-resident TacticalMap is not rebuilt every turn. Reset on new/load game.
## Typed as RefCounted because GdAiController (GDExtension) extends RefCounted.
var _ai_controller: RefCounted = null
## True once set_map() has been called on the cached controller for the current game.
var _ai_map_initialized: bool = false
## Return the process-persistent GdAiController, creating it on first call.
## Returns null when the GDExtension is not loaded (headless/test context).
func get_ai_controller() -> RefCounted:
if _ai_controller != null:
return _ai_controller
if not ClassDB.class_exists("GdAiController"):
return null
_ai_controller = ClassDB.instantiate("GdAiController")
return _ai_controller
## Clear the cached AI controller and map-initialized flag.
## Must be called on new-game and load-game so stale tile data does not leak
## across sessions. Called from initialize_game() and deserialize().
func reset_ai_controllers() -> void:
if _ai_controller != null and _ai_controller.has_method("reset"):
_ai_controller.reset()
## Reset all runtime state back to a blank slate.
## Called by EndGameSummary._on_main_menu before navigating to main_menu.tscn,
## and any other site that tears down a completed session.
func clear() -> void:
players = []
layers = []
transit_nodes = []
ley_anchors = []
ley_edges = []
npc_buildings = []
_npc_buildings_by_tile = {}
diplomacy = {}
trade_ledger_json = "{}"
wonders_built = {}
ascension_rituals = {}
spell_system = null
era_data = []
era = 0
turn_number = 1
current_player_index = 0
game_settings = {}
map_seed = 0
read_only_mode = false
pending_replay_game_id = ""
last_archived_game_id = ""
reset_ai_controllers()
_ai_controller = null
_ai_map_initialized = false
func initialize_game(settings: Dictionary) -> void:
game_settings = DEFAULT_SETTINGS.duplicate()
@ -230,7 +137,6 @@ func initialize_game(settings: Dictionary) -> void:
ley_anchors = []
ley_edges = []
diplomacy = {}
trade_ledger_json = "{}"
npc_buildings = []
_npc_buildings_by_tile = {}
@ -254,7 +160,6 @@ func initialize_game(settings: Dictionary) -> void:
}
)
)
reset_ai_controllers()
func get_current_player() -> RefCounted: # Returns Player
@ -334,21 +239,9 @@ func apply_ai_difficulty() -> void:
return
ai_difficulty_modifier = float(mods.get("production_mult", 1.0))
ai_research_modifier = float(mods.get("research_mult", 1.0))
ai_gold_modifier = float(mods.get("gold_mult", 1.0))
ai_culture_modifier = float(mods.get("culture_mult", 1.0))
ai_luxury_modifier = float(mods.get("luxury_mult", 1.0))
ai_yield_per_turn_growth = float(mods.get("yield_per_turn_growth", 0.0))
ai_starting_gold_bonus = int(mods.get("starting_gold_bonus", 0))
ai_extra_starting_units = int(mods.get("extra_starting_units", 0))
ai_extra_unit_id = str(mods.get("extra_unit_id", "warrior"))
# Player handicap — symmetric inverse (Easy: player gets Hard-AI's bonuses).
var pmods: Dictionary = entry.get("player_modifiers", {}) as Dictionary
player_production_modifier = float(pmods.get("production_mult", 1.0))
player_research_modifier = float(pmods.get("research_mult", 1.0))
player_gold_modifier = float(pmods.get("gold_mult", 1.0))
player_culture_modifier = float(pmods.get("culture_mult", 1.0))
player_luxury_modifier = float(pmods.get("luxury_mult", 1.0))
player_yield_per_turn_growth = float(pmods.get("yield_per_turn_growth", 0.0))
print(
"GameState: difficulty=%s prod=%.2f research=%.2f gold_bonus=%d extra_units=%d"
% [
@ -361,64 +254,6 @@ func apply_ai_difficulty() -> void:
)
## Effective per-yield multiplier for a player on the current turn.
## Composes the static difficulty mult with the linear per-turn growth.
## yield_type: "production" | "research" | "gold" | "culture" | "luxury".
## Returns 1.0 for unknown yield types or null player.
##
## Per-player overrides (auto_play.gd batch testing) take precedence over the
## difficulty-derived value for production/research only — gold/culture/luxury
## use the global side (ai_X_modifier vs player_X_modifier).
func get_effective_yield_mult(player: RefCounted, yield_type: String) -> float:
if player == null:
return 1.0
var idx: int = -1
if player.get("index") != null:
idx = int(player.index)
var is_human: bool = bool(player.get("is_human") if player.get("is_human") != null else false)
# Per-player override path (production / research only — set by auto_play.gd
# AI_DIFFICULTY_P0/P1 batch testing).
if not is_human:
if yield_type == "production":
var per_p: float = float(ai_per_player_production_mult.get(idx, 0.0))
if per_p > 0.0:
return per_p + float(turn_number) * ai_yield_per_turn_growth
elif yield_type == "research":
var per_r: float = float(ai_per_player_research_mult.get(idx, 0.0))
if per_r > 0.0:
return per_r + float(turn_number) * ai_yield_per_turn_growth
# Side-resolution: AI gets ai_X_modifier, human gets player_X_modifier.
var base: float
var growth: float
if is_human:
base = _player_yield_mult_for(yield_type)
growth = player_yield_per_turn_growth
else:
base = _ai_yield_mult_for(yield_type)
growth = ai_yield_per_turn_growth
return base + float(turn_number) * growth
func _ai_yield_mult_for(yield_type: String) -> float:
match yield_type:
"production": return ai_difficulty_modifier
"research": return ai_research_modifier
"gold": return ai_gold_modifier
"culture": return ai_culture_modifier
"luxury": return ai_luxury_modifier
_: return 1.0
func _player_yield_mult_for(yield_type: String) -> float:
match yield_type:
"production": return player_production_modifier
"research": return player_research_modifier
"gold": return player_gold_modifier
"culture": return player_culture_modifier
"luxury": return player_luxury_modifier
_: return 1.0
func get_max_event_tier() -> int:
## Returns the max event tier allowed in the current era.
## When era_difficulty_correlation is disabled, returns 10 (uncapped).
@ -584,15 +419,39 @@ func deserialize(data: Dictionary) -> void:
for layer_data: Variant in data.get("layers", []):
if layer_data is Dictionary:
layers.append(_deserialize_layer(layer_data))
reset_ai_controllers()
func _serialize_layer(layer: Dictionary) -> Dictionary:
return SerializationHelpers.serialize_layer(layer)
var serialized: Dictionary = {
"id": layer.get("id", ""),
}
var map_ref: Variant = layer.get("map")
if map_ref is GameMapScript:
serialized["map"] = map_ref.to_dict()
# Units are serialized per-player in Player.serialize()
# Layer just tracks the reference list
return serialized
func _deserialize_layer(layer_data: Dictionary) -> Dictionary:
return SerializationHelpers.deserialize_layer(layer_data)
var layer: Dictionary = {
"id": layer_data.get("id", ""),
"map": null,
"fog": null,
"units": [],
"settlements": [],
}
if layer_data.has("map") and layer_data["map"] is Dictionary:
layer["map"] = GameMapScript.from_dict(layer_data["map"])
# Rebuild unit references from players after deserialization
# This is done after all players are loaded
return layer
## ------------------------------------------------------------------
@ -627,15 +486,15 @@ func get_player_era(_player_index: int) -> int:
func _serialize_ley_anchors() -> Array:
return SerializationHelpers.serialize_ley_anchors(ley_anchors)
return _SerializationHelpers.serialize_ley_anchors(ley_anchors)
func _deserialize_ley_anchors(raw: Array) -> void:
ley_anchors = SerializationHelpers.deserialize_ley_anchors(raw)
ley_anchors = _SerializationHelpers.deserialize_ley_anchors(raw)
func _serialize_npc_buildings() -> Array:
return SerializationHelpers.serialize_npc_buildings(npc_buildings)
return _SerializationHelpers.serialize_npc_buildings(npc_buildings)
func _deserialize_npc_buildings(raw: Array) -> void:

File diff suppressed because it is too large Load diff

View file

@ -2862,6 +2862,8 @@ impl IRefCounted for GdGameState {
// p2-67 Phase 9 — Move subsystem queue + units catalog.
pending_move_requests: Default::default(),
units_catalog: Default::default(),
// p2-67 Phase 8 — TradeLedger now lives on GameState.
trade_ledger: Default::default(),
tile_improvements: Default::default(),
improvement_registry: Default::default(),
// p2-55 (Wave 1, simulator-infra): new GameState fields added

View file

@ -120,18 +120,25 @@ pub fn apply_action(
invoke_unit_action(state, unit_id, ActionKind::EditPatrol)
}
// Subsystems whose dispatch wiring is the next set of follow-up tasks.
// The wire shape is final — only this match arm grows.
PlayerAction::SetRallyPoint { .. }
| PlayerAction::ClearRallyPoint { .. }
| PlayerAction::CommandFormation { .. }
| PlayerAction::SetFormationShape { .. }
| PlayerAction::SplitFromFormation { .. }
| PlayerAction::SetAutoJoin { .. } => Err(ActionError::NotYetImplemented {
message: "formation commands queue via GameState formation queues — \
dispatcher wiring TRACKED: p2-67 Phase 1 follow-up"
.into(),
}),
// p2-67 Phase 8 — formation / rally queue pushes. Every request
// struct already exists in `mc_core::formation`; the matching
// `pending_*` queue fields are already on `GameState`. Dispatch
// here just appends to the queue — the processor drains them
// during turn resolution.
PlayerAction::SetRallyPoint { unit_id, to } => apply_set_rally(state, unit_id, *to),
PlayerAction::ClearRallyPoint { unit_id } => apply_clear_rally(state, unit_id),
PlayerAction::CommandFormation { formation_id, command, to } => {
apply_command_formation(state, *formation_id, command, *to)
}
PlayerAction::SetFormationShape { formation_id, shape } => {
apply_set_formation_shape(state, *formation_id, shape)
}
PlayerAction::SplitFromFormation { unit_id } => {
apply_split_from_formation(state, unit_id)
}
PlayerAction::SetAutoJoin { unit_id, enabled } => {
apply_set_auto_join(state, unit_id, *enabled)
}
PlayerAction::QueueProduction { city_id, item, tile: _ } => {
apply_queue_production(state, player, city_id, item)
@ -199,23 +206,23 @@ pub fn apply_action(
let _ = mc_trade::offer_peace(player, *to);
Ok(Vec::new())
}
PlayerAction::OfferOpenBorders { .. }
| PlayerAction::AcceptOpenBorders { .. }
| PlayerAction::RejectOpenBorders { .. }
| PlayerAction::OfferSharedMap { .. }
| PlayerAction::AcceptSharedMap { .. }
| PlayerAction::RejectSharedMap { .. } => Err(ActionError::NotYetImplemented {
message: "OpenBorders / SharedMap need TradeLedger threading + \
agreement allocator on GameState TRACKED: p2-67 Phase 1 \
follow-up (open-borders / shared-map authoring)"
.into(),
}),
// p2-67 Phase 8 — OpenBorders / SharedMap signing on the bench.
//
// The full protocol is offer → accept/reject with a pending-offer
// staging area; on the headless Claude-API bench, the
// counterparty AI doesn't yet model offer acceptance, so we
// bench-cheat: every Offer immediately signs the agreement
// (instant-sign), and Accept/Reject are no-op acknowledgements.
// The honest contract is documented in
// `docs/CLAUDE_PLAYER_API.md` once Phase 8 doc updates land.
PlayerAction::OfferOpenBorders { to } => apply_offer_open_borders(state, player, *to),
PlayerAction::AcceptOpenBorders { from: _ }
| PlayerAction::RejectOpenBorders { from: _ }
| PlayerAction::AcceptSharedMap { from: _ }
| PlayerAction::RejectSharedMap { from: _ } => Ok(Vec::new()),
PlayerAction::OfferSharedMap { to } => apply_offer_shared_map(state, player, *to),
PlayerAction::Promote(_) => Err(ActionError::NotYetImplemented {
message: "promotion picks dispatch into mc-units promotion state — \
TRACKED: p2-67 Phase 1 follow-up"
.into(),
}),
PlayerAction::Promote(pick) => apply_promote(state, &pick.unit_id, &pick.promotion_id),
}
}
@ -488,17 +495,18 @@ fn apply_declare_war(
message: format!("declare_war target {against} out of range"),
});
}
// mc-trade::declare_war takes a BTreeMap + TradeLedger. The bench
// GameState stores relations on the first player's `relations` field
// (canonical pair-keyed). The TradeLedger isn't a bench-state field
// today; the broken-trades list (returned via the second tuple slot) is
// dropped on the floor here — once a ledger lives on GameState the
// emit path can surface OpenBorders/SharedMap break events too.
// TRACKED: p2-67 Phase 1 follow-up (ledger threading).
let mut dummy_ledger = mc_trade::TradeLedger::default();
// p2-67 Phase 8: TradeLedger now lives on `GameState`. War
// declaration breaks every active OpenBorders / SharedMap with the
// target (handled inside `mc_trade::declare_war`); break events
// surface through the second tuple slot — currently dropped here
// because the wire `Event` enum has no `OpenBordersBroken` variant
// yet. TRACKED Phase 11 follow-up: surface break events as
// `Event::DiplomacyBroken { kind, with }`.
// Authoritative `relations` lives on player 0 (see field doc on
// PlayerState::relations).
let _ = mc_trade::declare_war(
&mut state.players[0].relations,
&mut dummy_ledger,
&mut state.trade_ledger,
player,
against,
);
@ -785,6 +793,213 @@ fn find_unit_at_hex(state: &GameState, hex: WireHex) -> Option<(usize, usize)> {
None
}
// ── p2-67 Phase 8 — formation / rally / promote / open-borders dispatch ──
fn apply_set_rally(
_state: &mut GameState,
_unit_id: &str,
_to: WireHex,
) -> Result<Vec<Event>, ActionError> {
// `pending_rally_requests` is keyed by `(player_index, city_index,
// building_id)` — it sets rally on BUILDINGS, not on units. The wire
// surface `SetRallyPoint { unit_id, to }` is per-unit. Routing through
// building rally requires (a) resolving which building produced the
// unit (not tracked) or (b) authoring a separate per-unit
// `pending_unit_rally_requests` queue. Both are bigger lifts than the
// 5-line dispatch the brief promised.
Err(ActionError::NotYetImplemented {
message: "set_rally — wire action is per-unit, but \
mc_core::RallyPointRequest is per-building. Needs either \
unitbuilding back-resolution or a new per-unit rally \
queue. TRACKED: p2-67 Phase 8 follow-up."
.into(),
})
}
fn apply_clear_rally(
_state: &mut GameState,
_unit_id: &str,
) -> Result<Vec<Event>, ActionError> {
Err(ActionError::NotYetImplemented {
message: "clear_rally — same per-unit vs per-building schema gap \
as set_rally. TRACKED: p2-67 Phase 8 follow-up."
.into(),
})
}
fn apply_command_formation(
state: &mut GameState,
formation_id: u32,
command: &str,
to: Option<WireHex>,
) -> Result<Vec<Event>, ActionError> {
// FormationCommandRequest needs the owning player index. Resolve it
// by looking up the formation owner on state.
let player_index = state
.formations
.get(&formation_id)
.map(|f| f.owner)
.ok_or_else(|| ActionError::IllegalAction {
message: format!("unknown formation id {formation_id}"),
})?;
let destination = to.map(|h| (h[0], h[1])).unwrap_or((-1, -1));
state
.pending_formation_commands
.push(mc_core::formation::FormationCommandRequest {
player_index,
formation_id,
destination,
command: command.to_string(),
});
Ok(Vec::new())
}
fn apply_set_formation_shape(
state: &mut GameState,
formation_id: u32,
shape: &str,
) -> Result<Vec<Event>, ActionError> {
let player_index = state
.formations
.get(&formation_id)
.map(|f| f.owner)
.ok_or_else(|| ActionError::IllegalAction {
message: format!("unknown formation id {formation_id}"),
})?;
state
.pending_formation_shapes
.push(mc_core::formation::FormationShapeRequest {
player_index,
formation_id,
shape: shape.to_string(),
});
Ok(Vec::new())
}
fn apply_split_from_formation(
state: &mut GameState,
unit_id: &str,
) -> Result<Vec<Event>, ActionError> {
let unit_u32 = parse_unit_id(unit_id)?;
let (player_idx, _) = find_unit_indices(state, unit_u32)?;
state
.pending_split_requests
.push(mc_core::formation::SplitFormationRequest {
player_index: player_idx as u8,
unit_id: unit_u32,
});
Ok(Vec::new())
}
fn apply_set_auto_join(
state: &mut GameState,
unit_id: &str,
enabled: bool,
) -> Result<Vec<Event>, ActionError> {
let unit_u32 = parse_unit_id(unit_id)?;
let (player_idx, _) = find_unit_indices(state, unit_u32)?;
state
.pending_auto_join_requests
.push(mc_core::formation::AutoJoinRequest {
player_index: player_idx as u8,
unit_id: unit_u32,
enabled,
});
Ok(Vec::new())
}
fn apply_promote(
state: &mut GameState,
unit_id: &str,
promotion_id: &str,
) -> Result<Vec<Event>, ActionError> {
let unit_u32 = parse_unit_id(unit_id)?;
let (player_idx, unit_idx) = find_unit_indices(state, unit_u32)?;
if promotion_id.is_empty() {
return Err(ActionError::IllegalAction {
message: "promotion_id must be non-empty".into(),
});
}
state.players[player_idx].units[unit_idx].pending_promotion =
Some(promotion_id.to_string());
Ok(vec![Event::UnitPromoted {
unit_id: unit_id.to_string(),
promotion: promotion_id.to_string(),
}])
}
fn apply_offer_open_borders(
state: &mut GameState,
from: PlayerId,
to: PlayerId,
) -> Result<Vec<Event>, ActionError> {
if (to as usize) >= state.players.len() {
return Err(ActionError::Internal {
message: format!("open_borders target {to} out of range"),
});
}
if from == to {
return Err(ActionError::IllegalAction {
message: "cannot open borders with self".into(),
});
}
let pair = if from < to { (from, to) } else { (to, from) };
// Bench-cheat (documented in CLAUDE_PLAYER_API.md): the
// counterparty AI doesn't yet model offer acceptance, so Offer
// signs immediately. Real protocol with pending-offer staging
// is a Phase 11+ follow-up.
let id = state.trade_ledger.alloc_agreement_id();
state
.trade_ledger
.agreements
.push(mc_trade::DiplomaticAgreement::OpenBorders(
mc_trade::OpenBordersAgreement {
agreement_id: id,
partners: pair,
turn_started: state.turn,
turns_remaining: 30, // canonical Game-1 duration
payment_gold: 0,
payment_luxury: None,
},
));
Ok(Vec::new())
}
fn apply_offer_shared_map(
state: &mut GameState,
from: PlayerId,
to: PlayerId,
) -> Result<Vec<Event>, ActionError> {
if (to as usize) >= state.players.len() {
return Err(ActionError::Internal {
message: format!("shared_map target {to} out of range"),
});
}
if from == to {
return Err(ActionError::IllegalAction {
message: "cannot share map with self".into(),
});
}
let pair = if from < to { (from, to) } else { (to, from) };
let id = state.trade_ledger.alloc_agreement_id();
state
.trade_ledger
.agreements
.push(mc_trade::DiplomaticAgreement::SharedMap(
mc_trade::SharedMapAgreement {
agreement_id: id,
partners: pair,
turn_started: state.turn,
duration: 30,
share_turns_remaining: 0,
payment_gold: 0,
payment_luxury: None,
courier_route: None,
},
));
Ok(Vec::new())
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -336,6 +336,15 @@ pub struct GameState {
/// persisted in save files.
#[serde(skip)]
pub units_catalog: mc_units::UnitsCatalog,
/// p2-67 Phase 8: shared diplomatic-agreement ledger.
/// Holds OpenBorders / SharedMap / LuxurySwap agreements across
/// every player pair. Authoritative single source — every
/// agreement-mutating action (Offer / Accept / Reject, war
/// declarations) reads + writes this field instead of allocating
/// throwaway ledgers. `#[serde(default)]` keeps pre-Phase-8 saves
/// loading with an empty ledger.
#[serde(default)]
pub trade_ledger: mc_trade::TradeLedger,
/// p2-55: pending ransom offers across all players. Ticked at start of
/// turn (`RansomQueue::tick`); offers are added when the resolver returns
/// `CombatOutcome::RansomOffered`.
@ -914,6 +923,13 @@ pub struct MapUnit {
/// `.with_moves(n)` if they intend the unit to be movable.
#[serde(default)]
pub base_moves: i32,
/// p2-67 Phase 8: pending promotion pick. Set by
/// `mc_player_api::dispatch::apply_promote` and consumed by the
/// turn processor (Phase 11 follow-up) which validates the pick
/// against eligibility rules and applies the stat changes.
/// `None` means no promotion is queued.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_promotion: Option<String>,
/// p2-67 Phase 9: movement points remaining this turn. Decremented
/// by [`crate::processor::process_move_requests`] as the unit
/// pathfinds; reset to `base_moves` at turn start by