feat(@projects/@magic-civilization): ✨ implement rust pathfinding and movement subsystem
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
ab5890b048
commit
c6917abb64
6 changed files with 891 additions and 816 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
unit→building 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::*;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue