feat(@projects/magic-civilization): add fog-aware view projection module

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-10 15:40:11 -07:00
parent c5d9879327
commit 950323ef97
3 changed files with 493 additions and 0 deletions

View file

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

View file

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

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