feat(@projects/@magic-civilization): add tactical projection module

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-11 03:32:25 -07:00
parent 58ba9ba8d1
commit 1784ecaad3
4 changed files with 326 additions and 1 deletions

View file

@ -1124,6 +1124,7 @@ dependencies = [
name = "mc-player-api"
version = "0.1.0"
dependencies = [
"mc-ai",
"mc-city",
"mc-combat",
"mc-core",

View file

@ -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"

View file

@ -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,

View file

@ -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);
}
}