clay-borg/games/ground/src/view.rs

314 lines
11 KiB
Rust
Raw Normal View History

//! 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<PlayerId>,
pub round: u8,
pub lead: PlayerId,
pub step: RoundStep,
pub mode: ScoringMode,
pub players: BTreeMap<PlayerId, PlayerView>,
pub relations: BTreeMap<Pair, Relation>,
pub problems: BTreeMap<u32, ProblemView>,
pub focus: BTreeMap<PlayerId, PlayerId>,
/// GR-R02: `Hidden` for other seats until Reveal.
pub selections: BTreeMap<PlayerId, SelectionView>,
pub ground_modes: BTreeMap<PlayerId, GroundMode>,
pub ground_choices: BTreeMap<PlayerId, GroundChoice>,
pub support_responses: BTreeMap<PlayerId, SupportResponse>,
pub darvo_targets: BTreeMap<PlayerId, DarvoTarget>,
/// GR-S04: how many Solutions remain, not which.
pub solution_deck_len: usize,
/// Discards are public — they have been played.
pub solution_discard: Vec<SolutionCard>,
pub outcome: Option<OutcomeView>,
}
#[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<PlayerId>,
/// GR-S02: only the viewer's own hand.
pub hand: Option<Vec<SolutionCard>>,
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<PlayerId>,
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<PlayerId, i32>,
pub coalitions: Vec<Coalition>,
pub mastery: Option<i32>,
pub winners: Vec<PlayerId>,
}
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<u32>) {
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()));
}
}