314 lines
11 KiB
Rust
314 lines
11 KiB
Rust
|
|
//! 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()));
|
||
|
|
}
|
||
|
|
}
|