feat(@projects/@magic-civilization): ✨ add tactical projection module
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
58ba9ba8d1
commit
1784ecaad3
4 changed files with 326 additions and 1 deletions
1
src/simulator/Cargo.lock
generated
1
src/simulator/Cargo.lock
generated
|
|
@ -1124,6 +1124,7 @@ dependencies = [
|
|||
name = "mc-player-api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"mc-ai",
|
||||
"mc-city",
|
||||
"mc-combat",
|
||||
"mc-core",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ mc-trade = { path = "../mc-trade" }
|
|||
mc-turn = { path = "../mc-turn" }
|
||||
mc-combat = { path = "../mc-combat" }
|
||||
mc-items = { path = "../mc-items" }
|
||||
mc-ai = { path = "../mc-ai" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror = "1"
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ pub mod view;
|
|||
pub mod wire;
|
||||
|
||||
pub use dispatch::apply_action;
|
||||
pub use projection::project_view;
|
||||
pub use projection::{project_tactical, project_view};
|
||||
|
||||
pub use action::{
|
||||
BuildingActionPayload, CivicAxis, DiploResponse, Improvement, PlayerAction,
|
||||
|
|
|
|||
|
|
@ -358,6 +358,248 @@ fn project_empire_legal_actions() -> Vec<LegalActionEntry> {
|
|||
}]
|
||||
}
|
||||
|
||||
// ── Tactical projection (p2-68) ──────────────────────────────────────────────
|
||||
//
|
||||
// `project_tactical(&GameState, player)` builds a [`mc_ai::tactical::TacticalState`]
|
||||
// that the headless Rust AI driver feeds into
|
||||
// `mc_ai::tactical::decide_tactical_actions` and (Wave 3) `mc_ai::run_ai_turn`.
|
||||
//
|
||||
// The spec for p2-68 originally placed this in `mc-ai/src/projector.rs`, but
|
||||
// `mc-turn` already depends on `mc-ai` (mc-turn/Cargo.toml:15) — adding the
|
||||
// reverse edge would create a cycle. The applicator's "Option B" decision
|
||||
// (mc-player-api owns dispatch into existing `apply_*` handlers) already
|
||||
// establishes the symmetric "mc-player-api is downstream of both" pattern, so
|
||||
// the projector lands here alongside the existing `view` projection.
|
||||
//
|
||||
// Field-population policy:
|
||||
// - All fields with `#[serde(default)]` and a sensible default are populated
|
||||
// to their default for v1 (empty vec, 1.0 weight, etc).
|
||||
// - `unit_catalog` / `building_catalog` left empty: the GDExtension bridge
|
||||
// builds these by walking DataLoader; the headless Rust path can either
|
||||
// plumb the same loader through `mc-units` later or remain catalog-less for
|
||||
// the smoke test (tactical AI falls back to tier-1 warrior / no-building).
|
||||
// - `clan_id` left empty: callers wire the personality id post-hoc via the
|
||||
// personalities table they own. The downstream consumers
|
||||
// (`tactical::thresholds`, `tactical::production::pick_best_melee`) treat
|
||||
// empty as "neutral / unset" without panicking.
|
||||
// - City `health` defaults to 100 to match the fixture inventory in
|
||||
// `mc-ai/tests/tactical_port_regression.rs` — bench `CityState` doesn't
|
||||
// carry per-city HP, so we report "full" until the bench grows the field.
|
||||
|
||||
use mc_ai::tactical::{
|
||||
TacticalCity, TacticalMap, TacticalPlayerState, TacticalState, TacticalTile, TacticalUnit,
|
||||
};
|
||||
|
||||
/// Project a [`GameState`] down to a [`TacticalState`] for the headless AI
|
||||
/// driver (p2-68 Wave 1). See module docs for field-population policy.
|
||||
///
|
||||
/// `player` is the slot whose turn is being decided — written into
|
||||
/// `TacticalState::current_player` and used to orient the `relations`
|
||||
/// vector around that slot.
|
||||
pub fn project_tactical(state: &GameState, player: PlayerId) -> TacticalState {
|
||||
let map = project_tactical_map(state);
|
||||
let n_players = state.players.len();
|
||||
let players: Vec<TacticalPlayerState> = state
|
||||
.players
|
||||
.iter()
|
||||
.map(|p| project_tactical_player(state, p, n_players))
|
||||
.collect();
|
||||
|
||||
TacticalState {
|
||||
current_player: player,
|
||||
turn: state.turn,
|
||||
map,
|
||||
players,
|
||||
unit_catalog: Vec::new(),
|
||||
building_catalog: Vec::new(),
|
||||
difficulty_threshold_mult: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn project_tactical_map(state: &GameState) -> TacticalMap {
|
||||
let Some(grid) = state.grid.as_ref() else {
|
||||
return TacticalMap { width: 0, height: 0, tiles: Vec::new() };
|
||||
};
|
||||
|
||||
let tiles: Vec<TacticalTile> = grid
|
||||
.tiles
|
||||
.iter()
|
||||
.map(|t| TacticalTile {
|
||||
hex: (t.col, t.row),
|
||||
biome: t.biome_label_id.clone(),
|
||||
// Bench `TileState` carries climate/ecology fields but no
|
||||
// gameplay (food, production, gold) yield triple — that table
|
||||
// lives in the tactical AI's own per-biome lookup. Report
|
||||
// zeros and let the AI fall through.
|
||||
yields: (0, 0, 0),
|
||||
resource: if t.resource_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(t.resource_id.clone())
|
||||
},
|
||||
is_coast: t.is_coastal,
|
||||
// `TileState` doesn't carry an owner field — ownership is
|
||||
// derived from city positions + culture borders downstream.
|
||||
// Mark all unowned for v1; the tactical AI's settle/expand
|
||||
// path doesn't strictly require it.
|
||||
owner: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
TacticalMap {
|
||||
width: grid.width as u32,
|
||||
height: grid.height as u32,
|
||||
tiles,
|
||||
}
|
||||
}
|
||||
|
||||
fn project_tactical_player(
|
||||
state: &GameState,
|
||||
player: &mc_turn::game_state::PlayerState,
|
||||
n_players: usize,
|
||||
) -> TacticalPlayerState {
|
||||
let units: Vec<TacticalUnit> = player
|
||||
.units
|
||||
.iter()
|
||||
.map(|u| TacticalUnit {
|
||||
id: u.id,
|
||||
kind: u.unit_id.clone(),
|
||||
hex: (u.col, u.row),
|
||||
hp: u.hp.max(0) as u32,
|
||||
hp_max: u.max_hp.max(0) as u32,
|
||||
// Bench `MapUnit` doesn't model moves_left — the turn processor
|
||||
// refreshes it implicitly. v1 reports full (= 2) so the AI
|
||||
// can plan a single move per turn.
|
||||
moves_left: 2,
|
||||
fortified: u.is_fortified,
|
||||
can_found_city: false,
|
||||
patrol_order: None,
|
||||
is_siege: false,
|
||||
is_deployed: u.is_deployed,
|
||||
is_arcing: false,
|
||||
pending_promotion_choices: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let capital = player.capital_position;
|
||||
let cities: Vec<TacticalCity> = player
|
||||
.cities
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| {
|
||||
let hex = player.city_positions.get(i).copied().unwrap_or((0, 0));
|
||||
// Render the in-progress queueable as the head of the queue.
|
||||
// Future-extension: thread the rest of an authored queue once
|
||||
// bench `CityState` grows beyond single-slot `queue`.
|
||||
let production_queue: Vec<String> = c
|
||||
.queue
|
||||
.as_ref()
|
||||
.map(|q| vec![queueable_id(q)])
|
||||
.unwrap_or_default();
|
||||
let buildings = player
|
||||
.city_buildings
|
||||
.get(i)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
TacticalCity {
|
||||
id: i as u32,
|
||||
hex,
|
||||
population: c.population,
|
||||
tiles_worked: Vec::new(),
|
||||
production_queue,
|
||||
buildings,
|
||||
health: 100,
|
||||
is_capital: capital.map(|cap| cap == hex).unwrap_or(false),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let researched_techs: Vec<String> = player
|
||||
.player_tech
|
||||
.as_ref()
|
||||
.map(|t| t.researched_techs().iter().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let relations = project_tactical_relations(state, player.player_index, n_players);
|
||||
|
||||
let strategic_resources: Vec<String> = player
|
||||
.strategic_ledger
|
||||
.iter()
|
||||
.filter(|(_, v)| **v > 0)
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
|
||||
let strategic_axes: std::collections::BTreeMap<String, i32> = player
|
||||
.strategic_axes
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), *v as i32))
|
||||
.collect();
|
||||
|
||||
TacticalPlayerState {
|
||||
index: player.player_index,
|
||||
clan_id: String::new(),
|
||||
gold: player.gold,
|
||||
happiness_pool: 0,
|
||||
units,
|
||||
cities,
|
||||
researched_techs,
|
||||
relations,
|
||||
race_id: None,
|
||||
strategic_resources,
|
||||
strategic_axes,
|
||||
promotion_offense_weight: 1.0,
|
||||
promotion_defense_weight: 1.0,
|
||||
promotion_mobility_weight: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a `Queueable` as its id string (matches the AI's
|
||||
/// `production_queue: Vec<String>` shape).
|
||||
fn queueable_id(q: &mc_city::production::Queueable) -> String {
|
||||
use mc_city::production::Queueable;
|
||||
match q {
|
||||
Queueable::Item { item_id } => item_id.clone(),
|
||||
Queueable::Wonder { wonder_id } => wonder_id.as_str().to_string(),
|
||||
Queueable::Unit { unit_id } => unit_id.as_str().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the `relations` vector for `player_idx` (length `n_players`,
|
||||
/// self-slot = 0). Authoritative copy lives on player slot 0 per the
|
||||
/// `PlayerState::relations` doc comment; we read from there and orient
|
||||
/// the result around `player_idx`.
|
||||
fn project_tactical_relations(state: &GameState, player_idx: u8, n_players: usize) -> Vec<i8> {
|
||||
let mut out = vec![0i8; n_players];
|
||||
let Some(p0) = state.players.first() else {
|
||||
return out;
|
||||
};
|
||||
for ((a, b), rel) in &p0.relations {
|
||||
let (self_slot, other_slot) = if *a == player_idx {
|
||||
(*a, *b)
|
||||
} else if *b == player_idx {
|
||||
(*b, *a)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let _ = self_slot;
|
||||
let i = other_slot as usize;
|
||||
if i >= n_players {
|
||||
continue;
|
||||
}
|
||||
// Map `RelationState::relation` to a coarse signed magnitude:
|
||||
// - "war" → -1
|
||||
// - "peace" / default → 0
|
||||
// - "friend" or any positive variant → 1
|
||||
let kind = rel.relation.as_str();
|
||||
out[i] = match kind {
|
||||
"war" => -1,
|
||||
"friendly" => 1,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -477,4 +719,85 @@ mod tests {
|
|||
let back: PlayerView = serde_json::from_str(&line).expect("round-trip");
|
||||
assert_eq!(back, v);
|
||||
}
|
||||
|
||||
// ── project_tactical (p2-68) ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn tactical_empty_state_projects_to_zero_map_zero_players() {
|
||||
let state = GameState::default();
|
||||
let t = project_tactical(&state, 0);
|
||||
assert_eq!(t.current_player, 0);
|
||||
assert_eq!(t.turn, 0);
|
||||
assert_eq!(t.map.width, 0);
|
||||
assert_eq!(t.map.height, 0);
|
||||
assert!(t.map.tiles.is_empty());
|
||||
assert!(t.players.is_empty());
|
||||
assert!(t.unit_catalog.is_empty());
|
||||
assert!(t.building_catalog.is_empty());
|
||||
assert_eq!(t.difficulty_threshold_mult, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tactical_carries_turn_and_current_player() {
|
||||
let mut state = GameState::default();
|
||||
state.turn = 17;
|
||||
state.players.push(PlayerState::default());
|
||||
state.players.push(PlayerState::default());
|
||||
state.players[1].player_index = 1;
|
||||
let t = project_tactical(&state, 1);
|
||||
assert_eq!(t.current_player, 1);
|
||||
assert_eq!(t.turn, 17);
|
||||
assert_eq!(t.players.len(), 2);
|
||||
assert_eq!(t.players[0].index, 0);
|
||||
assert_eq!(t.players[1].index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tactical_units_round_trip_unit_id_and_position() {
|
||||
let state = make_state(1, 0, vec![(42, 3, 5)]);
|
||||
let t = project_tactical(&state, 0);
|
||||
assert_eq!(t.players[0].units.len(), 1);
|
||||
let u = &t.players[0].units[0];
|
||||
assert_eq!(u.id, 42);
|
||||
assert_eq!(u.hex, (3, 5));
|
||||
assert_eq!(u.kind, "dwarf_warrior");
|
||||
assert_eq!(u.hp, 100);
|
||||
assert_eq!(u.hp_max, 100);
|
||||
assert_eq!(u.moves_left, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tactical_relations_orient_around_current_player() {
|
||||
use mc_trade::relation::{Relation, RelationState};
|
||||
let mut state = GameState::default();
|
||||
state.players.push(PlayerState::default());
|
||||
state.players.push(PlayerState::default());
|
||||
state.players.push(PlayerState::default());
|
||||
state.players[0].player_index = 0;
|
||||
state.players[1].player_index = 1;
|
||||
state.players[2].player_index = 2;
|
||||
// Player 0 holds the authoritative relations table.
|
||||
let mut at_war = RelationState::default();
|
||||
at_war.relation = Relation::War;
|
||||
state.players[0].relations.insert((0, 2), at_war);
|
||||
let mut friendly = RelationState::default();
|
||||
friendly.relation = Relation::Friendly;
|
||||
state.players[0].relations.insert((0, 1), friendly);
|
||||
|
||||
let t = project_tactical(&state, 0);
|
||||
// Player 0's relations: self = 0, friendly to 1, war with 2.
|
||||
assert_eq!(t.players[0].relations, vec![0i8, 1, -1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tactical_round_trips_through_json() {
|
||||
// serde shape parity: projector output must survive a JSON
|
||||
// round-trip identical, so the headless harness can pass it across
|
||||
// the GDExtension boundary (and back) without drift.
|
||||
let state = make_state(2, 75, vec![(9, 2, 2), (10, 4, 4)]);
|
||||
let t = project_tactical(&state, 0);
|
||||
let json = serde_json::to_string(&t).expect("serialise");
|
||||
let back: TacticalState = serde_json::from_str(&json).expect("deserialise");
|
||||
assert_eq!(t, back);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue