feat(@projects/magic-civilization): ✨ add fog-aware view projection module
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
c5d9879327
commit
950323ef97
3 changed files with 493 additions and 0 deletions
|
|
@ -5,6 +5,8 @@ edition = "2021"
|
|||
|
||||
[dependencies]
|
||||
mc-core = { path = "../mc-core" }
|
||||
mc-city = { path = "../mc-city" }
|
||||
mc-trade = { path = "../mc-trade" }
|
||||
mc-turn = { path = "../mc-turn" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -19,10 +19,12 @@
|
|||
pub mod action;
|
||||
pub mod dispatch;
|
||||
pub mod error;
|
||||
pub mod projection;
|
||||
pub mod view;
|
||||
pub mod wire;
|
||||
|
||||
pub use dispatch::apply_action;
|
||||
pub use projection::project_view;
|
||||
|
||||
pub use action::{
|
||||
BuildingActionPayload, CivicAxis, DiploResponse, Improvement, PlayerAction,
|
||||
|
|
|
|||
489
src/simulator/crates/mc-player-api/src/projection.rs
Normal file
489
src/simulator/crates/mc-player-api/src/projection.rs
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
//! Fog-aware view projection — reads a [`mc_turn::game_state::GameState`]
|
||||
//! and assembles a [`crate::view::PlayerView`] from the perspective of one
|
||||
//! player slot.
|
||||
//!
|
||||
//! Designed to be called after every successful [`crate::dispatch::apply_action`]
|
||||
//! and any time the adapter requests a fresh `view`.
|
||||
//!
|
||||
//! ## Fog redaction
|
||||
//!
|
||||
//! The bench-grade [`mc_turn::game_state::GameState`] does **not** carry per-
|
||||
//! player per-tile vision data — that data lives on the runtime
|
||||
//! [`mc_observation`] store, which only the in-game `Player.observations`
|
||||
//! pipeline populates. Until the bench state grows an `explored_tiles` /
|
||||
//! `visible_tiles` projection (TRACKED: p2-67 Phase 1 follow-up), this
|
||||
//! function applies a conservative redaction policy:
|
||||
//!
|
||||
//! - **Own player**: full data — own units, own cities, own resources,
|
||||
//! own research, own diplomacy entries.
|
||||
//! - **Other players**: skipped entirely from `view.cities` and
|
||||
//! `view.units`. Only the `view.diplomacy` and `view.score` summaries
|
||||
//! reference them — never their unit positions, exact populations,
|
||||
//! or research progress.
|
||||
//!
|
||||
//! When `CP_OMNISCIENT=1` is set on the harness, the read-side reverts
|
||||
//! to no-redaction (full state) for debugging. The flag is read inside
|
||||
//! [`project_view`] via the `omniscient` parameter (callers pass it
|
||||
//! from the env at boot).
|
||||
|
||||
use mc_turn::game_state::GameState;
|
||||
|
||||
use crate::view::{
|
||||
BuildableEntry, CityView, CivicsView, CultureView, DiplomacyView, LegalActionEntry,
|
||||
PendingEventsView, PlayerView, ProductionQueueEntry, ResearchView, ResourceView, ScoreView,
|
||||
TileView, UnitView,
|
||||
};
|
||||
use crate::{PlayerAction, PlayerId, WireHex};
|
||||
|
||||
/// Project a [`GameState`] down to a fog-aware [`PlayerView`] for the
|
||||
/// given player slot. `omniscient=true` returns the unredacted view.
|
||||
pub fn project_view(state: &GameState, player: PlayerId, omniscient: bool) -> PlayerView {
|
||||
let player_idx = player as usize;
|
||||
let own = state.players.get(player_idx);
|
||||
|
||||
let resources = own.map(project_resources).unwrap_or_default();
|
||||
let research = own.map(project_research).unwrap_or_default();
|
||||
let culture = own.map(project_culture).unwrap_or_default();
|
||||
let civics = CivicsView::default(); // TRACKED: civic axis state lives off-state for v1
|
||||
let cities = project_cities(state, player_idx, omniscient);
|
||||
let units = project_units(state, player_idx, omniscient);
|
||||
let diplomacy = project_diplomacy(state, player_idx, omniscient);
|
||||
let score = own.map(|p| project_score(state, p)).unwrap_or_default();
|
||||
let legal_actions = project_empire_legal_actions();
|
||||
|
||||
PlayerView {
|
||||
turn: state.turn,
|
||||
player,
|
||||
// TRACKED: bench `GameState` has no `current_player_index`. For
|
||||
// v1, treat the bound player as the current player — the harness
|
||||
// only releases stdin to the adapter on the bound player's turn,
|
||||
// so this is consistent with what the adapter observes.
|
||||
current_player: player,
|
||||
phase: "player_actions".into(),
|
||||
is_human_turn: true,
|
||||
resources,
|
||||
research,
|
||||
culture,
|
||||
civics,
|
||||
cities,
|
||||
units,
|
||||
// TRACKED: tiles projection requires per-tile observation data
|
||||
// not yet on bench `GameState`. Emit empty for v1.
|
||||
tiles: Vec::new(),
|
||||
diplomacy,
|
||||
pending_events: PendingEventsView::default(),
|
||||
legal_actions,
|
||||
score,
|
||||
}
|
||||
}
|
||||
|
||||
fn project_resources(player: &mc_turn::game_state::PlayerState) -> ResourceView {
|
||||
let mut stockpile = std::collections::BTreeMap::new();
|
||||
for (k, v) in &player.strategic_ledger {
|
||||
stockpile.insert(k.clone(), *v as i32);
|
||||
}
|
||||
ResourceView {
|
||||
gold: player.gold,
|
||||
// Bench state does not separately track per-turn gold delta; the
|
||||
// turn processor computes it transiently. v1 reports 0 for
|
||||
// per-turn rates. TRACKED: surface deltas via PlayerState once
|
||||
// the processor caches them.
|
||||
gold_per_turn: 0,
|
||||
science_per_turn: player.science_yield as i32,
|
||||
culture_per_turn: 0,
|
||||
happiness_pool: 0,
|
||||
stockpile,
|
||||
}
|
||||
}
|
||||
|
||||
fn project_research(player: &mc_turn::game_state::PlayerState) -> ResearchView {
|
||||
let (current_tech, tech_progress, tech_cost, researched) = if let Some(pt) = &player.player_tech {
|
||||
let researched: Vec<String> = pt
|
||||
.researched
|
||||
.iter()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect();
|
||||
let current = pt
|
||||
.researching
|
||||
.as_ref()
|
||||
.map(std::string::ToString::to_string);
|
||||
let cost = pt
|
||||
.researching_cost
|
||||
.map(|c| c as i32)
|
||||
.unwrap_or(0);
|
||||
(current, player.science_pool as i32, cost, researched)
|
||||
} else {
|
||||
(None, 0, 0, Vec::new())
|
||||
};
|
||||
ResearchView {
|
||||
current_tech,
|
||||
tech_progress,
|
||||
tech_cost,
|
||||
researched,
|
||||
// TRACKED: enumerate available techs from the TechWeb once
|
||||
// the projection has a handle to it (passed via context).
|
||||
available: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn project_culture(player: &mc_turn::game_state::PlayerState) -> CultureView {
|
||||
let current = if player.researching_tradition.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(player.researching_tradition.clone())
|
||||
};
|
||||
let researched: Vec<String> = player.researched_traditions.iter().cloned().collect();
|
||||
CultureView {
|
||||
current_tradition: current,
|
||||
tradition_progress: player.culture_research_progress as i32,
|
||||
// TRACKED: per-tradition cost lookup needs a CultureWeb handle.
|
||||
tradition_cost: 0,
|
||||
researched,
|
||||
}
|
||||
}
|
||||
|
||||
fn project_cities(state: &GameState, player_idx: usize, omniscient: bool) -> Vec<CityView> {
|
||||
let mut out: Vec<CityView> = Vec::new();
|
||||
for (p_idx, player) in state.players.iter().enumerate() {
|
||||
let is_own = p_idx == player_idx;
|
||||
if !is_own && !omniscient {
|
||||
continue;
|
||||
}
|
||||
for (c_idx, city) in player.cities.iter().enumerate() {
|
||||
let position = player
|
||||
.city_positions
|
||||
.get(c_idx)
|
||||
.map(|(c, r)| [*c, *r])
|
||||
.unwrap_or([0, 0]);
|
||||
let is_capital = player
|
||||
.capital_position
|
||||
.map(|cap| cap == player.city_positions.get(c_idx).copied().unwrap_or((0, 0)))
|
||||
.unwrap_or(false);
|
||||
let mut yields = std::collections::BTreeMap::new();
|
||||
yields.insert("food".into(), city.food_yield as f32);
|
||||
yields.insert("production".into(), city.prod_yield as f32);
|
||||
let buildings = player
|
||||
.city_buildings
|
||||
.get(c_idx)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let production_queue = project_production_queue(city);
|
||||
out.push(CityView {
|
||||
id: format!("{}_{}", p_idx, c_idx),
|
||||
name: format!("City {}-{}", p_idx, c_idx),
|
||||
position,
|
||||
owner: p_idx as PlayerId,
|
||||
is_capital,
|
||||
population: city.population,
|
||||
food_stored: city.food_stored as f32,
|
||||
// Bench growth threshold formula is roughly 15 * pop * (pop+1) — the
|
||||
// exact value is private to mc-city's growth processor. v1 emits 0
|
||||
// and adapters can call view() across turns to observe progress.
|
||||
// TRACKED: surface the canonical threshold via mc-city.
|
||||
food_growth_threshold: 0.0,
|
||||
production_queue,
|
||||
buildings,
|
||||
// TRACKED: per-city owned_tiles list. mc-city's CityState doesn't
|
||||
// carry it on the bench struct — comes with city.rs full City when
|
||||
// the bench grows tile ownership.
|
||||
owned_tiles: Vec::new(),
|
||||
yields,
|
||||
hp: 100,
|
||||
max_hp: 100,
|
||||
focus: "balanced".into(),
|
||||
// TRACKED: enumerate buildable items via mc-city + mc-units.
|
||||
// Empty for v1; adapter must consult Encyclopedia data offline.
|
||||
buildable: if is_own { Vec::new() } else { Vec::new() },
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn project_production_queue(city: &mc_city::CityState) -> Vec<ProductionQueueEntry> {
|
||||
let Some(q) = &city.queue else {
|
||||
return Vec::new();
|
||||
};
|
||||
let (item, kind) = match q {
|
||||
mc_city::Queueable::Unit(id) => (id.to_string(), "unit".to_string()),
|
||||
mc_city::Queueable::Building(id) => (id.to_string(), "building".to_string()),
|
||||
mc_city::Queueable::Wonder(id) => (id.to_string(), "wonder".to_string()),
|
||||
};
|
||||
vec![ProductionQueueEntry {
|
||||
item,
|
||||
kind,
|
||||
progress: city.production_stored,
|
||||
cost: city.queue_cost.unwrap_or(0) as i32,
|
||||
tile: None,
|
||||
}]
|
||||
}
|
||||
|
||||
fn project_units(state: &GameState, player_idx: usize, omniscient: bool) -> Vec<UnitView> {
|
||||
let mut out: Vec<UnitView> = Vec::new();
|
||||
for (p_idx, player) in state.players.iter().enumerate() {
|
||||
let is_own = p_idx == player_idx;
|
||||
if !is_own && !omniscient {
|
||||
// TRACKED: enemy units in vision range should appear here once
|
||||
// per-tile vision data lands on bench GameState.
|
||||
continue;
|
||||
}
|
||||
for unit in &player.units {
|
||||
out.push(UnitView {
|
||||
id: unit.id.to_string(),
|
||||
type_id: unit.unit_type.clone(),
|
||||
position: [unit.col, unit.row],
|
||||
owner: p_idx as PlayerId,
|
||||
hp: unit.hp,
|
||||
max_hp: unit.max_hp,
|
||||
// Bench MapUnit has no per-turn movement counter — the
|
||||
// turn processor refreshes it transiently. v1 reports
|
||||
// the unit's `moves_left` if non-zero, else 0.
|
||||
movement_left: 0,
|
||||
movement_max: 0,
|
||||
experience: 0,
|
||||
promotion_available: false,
|
||||
fortified: unit.is_fortified,
|
||||
sentry: false,
|
||||
legal_actions: if is_own {
|
||||
project_unit_legal_actions(unit)
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn project_unit_legal_actions(unit: &mc_turn::game_state::MapUnit) -> Vec<LegalActionEntry> {
|
||||
let uid = unit.id.to_string();
|
||||
let mut entries: Vec<LegalActionEntry> = Vec::new();
|
||||
// Always-legal verbs that don't need targeting:
|
||||
entries.push(LegalActionEntry {
|
||||
action: PlayerAction::Skip {
|
||||
unit_id: uid.clone(),
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
});
|
||||
if !unit.is_fortified {
|
||||
entries.push(LegalActionEntry {
|
||||
action: PlayerAction::Fortify {
|
||||
unit_id: uid.clone(),
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
});
|
||||
} else {
|
||||
entries.push(LegalActionEntry {
|
||||
action: PlayerAction::Unfortify {
|
||||
unit_id: uid.clone(),
|
||||
},
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
});
|
||||
}
|
||||
// Move/Attack are conditionally legal but their target-validity
|
||||
// check requires the path/combat subsystems. v1 emits the variant
|
||||
// disabled with a typed reason so adapters know the surface
|
||||
// exists. TRACKED: enable once apply_move / apply_ranged are wired.
|
||||
entries.push(LegalActionEntry {
|
||||
action: PlayerAction::Move {
|
||||
unit_id: uid.clone(),
|
||||
to: [unit.col, unit.row],
|
||||
},
|
||||
enabled: false,
|
||||
disabled_reason: Some("move_subsystem_pending".into()),
|
||||
});
|
||||
entries
|
||||
}
|
||||
|
||||
fn project_diplomacy(
|
||||
state: &GameState,
|
||||
player_idx: usize,
|
||||
omniscient: bool,
|
||||
) -> Vec<DiplomacyView> {
|
||||
let mut out: Vec<DiplomacyView> = Vec::new();
|
||||
for (p_idx, _player) in state.players.iter().enumerate() {
|
||||
if p_idx == player_idx {
|
||||
continue;
|
||||
}
|
||||
if !omniscient && p_idx >= state.players.len() {
|
||||
continue;
|
||||
}
|
||||
// TRACKED: relations table is keyed (min, max) — derive
|
||||
// canonical pair lookup once a helper lands in mc-trade.
|
||||
let pair = if (player_idx as u8) < p_idx as u8 {
|
||||
(player_idx as u8, p_idx as u8)
|
||||
} else {
|
||||
(p_idx as u8, player_idx as u8)
|
||||
};
|
||||
let relation = if let Some(rs) = state
|
||||
.players
|
||||
.first()
|
||||
.and_then(|p0| p0.relations.get(&pair))
|
||||
{
|
||||
classify_relation(rs)
|
||||
} else {
|
||||
"peace".into()
|
||||
};
|
||||
out.push(DiplomacyView {
|
||||
player: p_idx as PlayerId,
|
||||
race: "dwarf".into(),
|
||||
name: format!("Player {}", p_idx),
|
||||
relation,
|
||||
open_borders: false,
|
||||
shared_map: false,
|
||||
agreements_active: Vec::new(),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn classify_relation(rel: &mc_trade::relation::RelationState) -> String {
|
||||
if rel.is_at_war() {
|
||||
"war".into()
|
||||
} else {
|
||||
"peace".into()
|
||||
}
|
||||
}
|
||||
|
||||
fn project_score(state: &GameState, player: &mc_turn::game_state::PlayerState) -> ScoreView {
|
||||
let _ = state;
|
||||
ScoreView {
|
||||
gold_total: player.gold,
|
||||
city_count: player.cities.len() as u32,
|
||||
unit_count: player.units.len() as u32,
|
||||
// TRACKED: surface canonical score via mc-score / mc-turn::victory.
|
||||
score_estimate: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn project_empire_legal_actions() -> Vec<LegalActionEntry> {
|
||||
vec![LegalActionEntry {
|
||||
action: PlayerAction::EndTurn,
|
||||
enabled: true,
|
||||
disabled_reason: None,
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use mc_turn::game_state::{MapUnit, PlayerState};
|
||||
|
||||
fn make_state(num_players: u8, own_gold: i32, own_units: Vec<(u32, i32, i32)>) -> GameState {
|
||||
let mut state = GameState::default();
|
||||
state.turn = 5;
|
||||
for p in 0..num_players {
|
||||
let mut ps = PlayerState::default();
|
||||
ps.player_index = p;
|
||||
if p == 0 {
|
||||
ps.gold = own_gold;
|
||||
for (id, col, row) in &own_units {
|
||||
let mut u = MapUnit::default();
|
||||
u.id = *id;
|
||||
u.col = *col;
|
||||
u.row = *row;
|
||||
u.unit_type = "dwarf_warrior".into();
|
||||
u.hp = 100;
|
||||
u.max_hp = 100;
|
||||
ps.units.push(u);
|
||||
}
|
||||
} else {
|
||||
// Other player gets a unit at (50,50) to test redaction
|
||||
let mut u = MapUnit::default();
|
||||
u.id = 999;
|
||||
u.col = 50;
|
||||
u.row = 50;
|
||||
u.unit_type = "dwarf_warrior".into();
|
||||
u.hp = 100;
|
||||
u.max_hp = 100;
|
||||
ps.units.push(u);
|
||||
}
|
||||
state.players.push(ps);
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_state_projects_to_zeroed_view_for_bound_player() {
|
||||
let state = GameState::default();
|
||||
let v = project_view(&state, 0, false);
|
||||
assert_eq!(v.turn, 0);
|
||||
assert_eq!(v.player, 0);
|
||||
assert!(v.cities.is_empty());
|
||||
assert!(v.units.is_empty());
|
||||
assert_eq!(v.resources.gold, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_player_units_appear_enemy_units_redacted() {
|
||||
let state = make_state(2, 50, vec![(1, 0, 0), (2, 1, 0)]);
|
||||
let v = project_view(&state, 0, false);
|
||||
// Own player has 2 units; enemy unit 999 must be redacted.
|
||||
assert_eq!(v.units.len(), 2, "expected 2 own units, got {}", v.units.len());
|
||||
assert!(
|
||||
v.units.iter().all(|u| u.owner == 0),
|
||||
"no enemy unit may leak in non-omniscient mode"
|
||||
);
|
||||
assert!(
|
||||
v.units.iter().all(|u| u.id != "999"),
|
||||
"enemy unit id 999 must not appear in view"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omniscient_mode_exposes_enemy_units() {
|
||||
let state = make_state(2, 50, vec![(1, 0, 0)]);
|
||||
let v = project_view(&state, 0, true);
|
||||
assert_eq!(v.units.len(), 2, "omniscient view exposes both players' units");
|
||||
assert!(v.units.iter().any(|u| u.owner == 1 && u.id == "999"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legal_actions_include_end_turn_at_empire_level() {
|
||||
let state = GameState::default();
|
||||
let v = project_view(&state, 0, false);
|
||||
assert!(
|
||||
v.legal_actions.iter().any(|e| matches!(e.action, PlayerAction::EndTurn)),
|
||||
"end_turn must always be in the empire legal_actions list"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_unit_carries_legal_actions_enemy_does_not() {
|
||||
let state = make_state(2, 50, vec![(7, 3, 3)]);
|
||||
let v = project_view(&state, 0, true); // omniscient → see enemy
|
||||
let own = v.units.iter().find(|u| u.id == "7").unwrap();
|
||||
let enemy = v.units.iter().find(|u| u.id == "999").unwrap();
|
||||
assert!(
|
||||
!own.legal_actions.is_empty(),
|
||||
"own unit must have legal_actions populated"
|
||||
);
|
||||
assert!(
|
||||
enemy.legal_actions.is_empty(),
|
||||
"enemy unit must not expose legal_actions even in omniscient mode \
|
||||
— adapter cannot command enemy units"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_resources_gold_round_trips_through_view() {
|
||||
let state = make_state(1, 142, vec![]);
|
||||
let v = project_view(&state, 0, false);
|
||||
assert_eq!(v.resources.gold, 142);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_serialises_as_json_lines_payload() {
|
||||
let state = make_state(2, 50, vec![(1, 0, 0)]);
|
||||
let v = project_view(&state, 0, false);
|
||||
let line = serde_json::to_string(&v).expect("PlayerView must serialise");
|
||||
// One-line JSON, no embedded newlines (JSON-Lines transport invariant).
|
||||
assert!(!line.contains('\n'), "PlayerView serialised to JSON-Lines must be one line");
|
||||
// Round-trip back.
|
||||
let back: PlayerView = serde_json::from_str(&line).expect("round-trip");
|
||||
assert_eq!(back, v);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue