//! K13's first implementor (CB-WP-0008 T02). //! //! `cb_game_runtime::Project` has existed since CB-WP-0001 with **zero //! implementors** — a trait shaped for a consumer that had not been //! written. This is that consumer: `cb-play` shows one seat what it may //! see, and nothing else. //! //! K13 says a projection is *a total function from state to what one seat //! may see*, and that projections can never feed back into validation. //! Nothing here is `pub` to the aggregate; `validate` does not import it. //! //! ## What is hidden, and by which rule //! //! | hidden | rule | //! |---|---| //! | other seats' face-down selections, until Reveal | GR-R02/R04 | //! | other seats' Solution hands (count only) | GR-S02 | //! | the undealt Solution deck (count only) | GR-S04 | //! | a face-down Problem's suit and value | GR-S01 | //! | `seed` | it determines every future shuffle | //! //! The last one is the interesting one: `seed` is not secret *content*, //! but a seat holding it can compute the deck. It is omitted from the //! view for the same reason the deck is. use crate::{ Coalition, DarvoStage, DarvoTarget, GroundChoice, GroundMode, GroundState, Outcome, Pair, ProblemState, Relation, RoundStep, ScoringMode, Selection, SolutionCard, SupportResponse, }; use cb_game_runtime::{Project, Viewer}; use cb_kernel::PlayerId; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; /// What one seat may see of a `GroundState`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct GroundView { /// `None` for a spectator. pub viewer: Option, pub round: u8, pub lead: PlayerId, pub step: RoundStep, pub mode: ScoringMode, pub players: BTreeMap, pub relations: BTreeMap, pub problems: BTreeMap, pub focus: BTreeMap, /// GR-R02: `Hidden` for other seats until Reveal. pub selections: BTreeMap, pub ground_modes: BTreeMap, pub ground_choices: BTreeMap, pub support_responses: BTreeMap, pub darvo_targets: BTreeMap, /// GR-S04: how many Solutions remain, not which. pub solution_deck_len: usize, /// Discards are public — they have been played. pub solution_discard: Vec, pub outcome: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PlayerView { pub stress: u8, pub freedom_ready: bool, pub freedom_gate_lifted: bool, pub darvo: DarvoStage, pub protection: u8, pub blame_from: Vec, /// GR-S02: only the viewer's own hand. pub hand: Option>, pub hand_size: usize, } /// GR-S01: a face-down Problem shows nothing but its priority (the map /// key) and that it exists. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "state")] pub enum ProblemView { FaceDown, FaceUp { suit: crate::Suit, value: u8, denied: bool, claimed_by: Option, protected_this_round: bool, }, } /// GR-R02/R04: face-down means face-down, including to the projection. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "state")] pub enum SelectionView { /// Someone has selected; what, this seat may not see. Hidden, Shown(Selection), } /// The final outcome is public once it exists (GR-E01..E04). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OutcomeView { pub total: u32, pub threshold: u32, pub group_success: bool, pub personal: BTreeMap, pub coalitions: Vec, pub mastery: Option, pub winners: Vec, } impl From<&Outcome> for OutcomeView { fn from(o: &Outcome) -> Self { Self { total: o.total, threshold: o.threshold, group_success: o.group_success, personal: o.personal.clone(), coalitions: o.coalitions.clone(), mastery: o.mastery, winners: o.winners.clone(), } } } impl From<&ProblemState> for ProblemView { fn from(p: &ProblemState) -> Self { if p.face_up { ProblemView::FaceUp { suit: p.suit, value: p.value, denied: p.denied, claimed_by: p.claimed_by, protected_this_round: p.protected_this_round, } } else { ProblemView::FaceDown } } } impl Project for GroundState { type View = GroundView; fn project(&self, viewer: Viewer) -> GroundView { let seat = match viewer { Viewer::Player(p) => Some(p), Viewer::Spectator => None, }; // GR-R04: once Reveal has happened, selections are public for the // rest of the round. let revealed = self.step != RoundStep::Select; GroundView { viewer: seat, round: self.round, lead: self.lead, step: self.step, mode: self.mode, players: self .players .iter() .map(|(id, p)| { ( *id, PlayerView { stress: p.stress, freedom_ready: p.freedom_ready, freedom_gate_lifted: p.freedom_gate_lifted, darvo: p.darvo, protection: p.protection, blame_from: p.blame_from.clone(), hand: (Some(*id) == seat).then(|| p.hand.clone()), hand_size: p.hand.len(), }, ) }) .collect(), relations: self.relations.clone(), problems: self.problems.iter().map(|(k, p)| (*k, p.into())).collect(), focus: self.focus.clone(), selections: self .selections .iter() .map(|(id, sel)| { let visible = revealed || Some(*id) == seat; ( *id, if visible { SelectionView::Shown(*sel) } else { SelectionView::Hidden }, ) }) .collect(), ground_modes: self.ground_modes.clone(), ground_choices: self.ground_choices.clone(), support_responses: self.support_responses.clone(), darvo_targets: self.darvo_targets.clone(), solution_deck_len: self.solution_deck.len(), solution_discard: self.solution_discard.clone(), outcome: self.outcome.as_ref().map(OutcomeView::from), } } } #[cfg(all(test, feature = "scenarios"))] mod tests { use super::*; use crate::{Action, Actor, Aggregate, GroundCommand}; use cb_game_runtime::{ScenarioGame, Setup}; fn setup(players: u8, seed: u64) -> GroundState { GroundState::setup( &Setup { players, preset: format!("standard-{players}p"), patch: Default::default(), }, seed, ) .expect("preset") } fn select(state: &mut GroundState, seat: u8, action: Action, problem: Option) { let cmd = GroundCommand::SelectAction { action, target: None, problem, }; let events = state .validate(Actor::Player(PlayerId(seat)), &cmd) .expect("legal"); for e in &events { state.fold(e); } } /// The acceptance clause: a seat's projection never contains another /// seat's hidden selection. Asserted on the serialized form, because /// that is what a CLI prints and what leaks. #[test] fn a_seat_never_sees_another_seats_face_down_selection() { let mut state = setup(3, 42); // Distinct actions, so finding the other seat's is unambiguous. select(&mut state, 0, Action::Investigate, Some(2)); select(&mut state, 1, Action::Solve, Some(1)); // GR-R04 needs every seat before Reveal. select(&mut state, 2, Action::Ground, None); let view = state.project(Viewer::Player(PlayerId(0))); let json = serde_json::to_string(&view).expect("serialize"); assert!(json.contains("Investigate"), "own selection must be shown"); assert!( !json.contains("Solve"), "seat 1's face-down SOLVE leaked into seat 0's view: {json}" ); assert_eq!( view.selections.get(&PlayerId(1)), Some(&SelectionView::Hidden) ); // And the same view after Reveal shows it — otherwise the // assertion above passes for a projection that shows nothing. let events = state .validate(Actor::System, &GroundCommand::Reveal) .expect("reveal"); for e in &events { state.fold(e); } let after = state.project(Viewer::Player(PlayerId(0))); assert!(matches!( after.selections.get(&PlayerId(1)), Some(SelectionView::Shown(_)) )); } /// GR-S02/S04: hands and deck are hidden; counts are not. #[test] fn hands_and_deck_are_hidden_but_counted() { let state = setup(3, 7); let view = state.project(Viewer::Player(PlayerId(0))); assert!(view.players[&PlayerId(0)].hand.is_some()); assert!(view.players[&PlayerId(1)].hand.is_none()); assert_eq!(view.players[&PlayerId(1)].hand_size, 2); assert_eq!(view.solution_deck_len, state.solution_deck.len()); let json = serde_json::to_string(&view).expect("serialize"); assert!( !json.contains("\"seed\""), "the seed determines every future shuffle and must not project" ); } /// GR-S01: a face-down Problem shows nothing about itself. #[test] fn a_face_down_problem_shows_nothing() { let state = setup(3, 7); let view = state.project(Viewer::Spectator); assert_eq!(view.problems[&1], (&state.problems[&1]).into()); assert!(matches!(view.problems[&1], ProblemView::FaceUp { .. })); assert_eq!(view.problems[&2], ProblemView::FaceDown); assert_eq!(view.viewer, None); } /// A spectator sees no hand at all — the `Some(*id) == seat` test must /// not accidentally match `None`. #[test] fn a_spectator_sees_no_hands() { let view = setup(3, 7).project(Viewer::Spectator); assert!(view.players.values().all(|p| p.hand.is_none())); } }