//! The inspector: one seat's whole picture, rendered as text //! (CB-WP-0011 T01). //! //! Moved out of `table.rs`, where it was born in stage 0 as a prompt //! header. That is exactly what was wrong with it: written to show a //! human their legal moves, it showed the six fields a chooser needs and //! silently dropped the rest of the projection — the whole DARVO state //! machine, the whole GROUND practice, the scoring mode, the Focus //! tokens, the discard pile, and every part of the outcome except the //! headline. //! //! **No test could have caught that**, which is the point worth writing //! down. Every assertion a renderer test naturally makes — "the output //! mentions round", "the output is non-empty", "P2's hand does not //! appear" — is satisfied by a render that shows a third of the state. //! The harness-does-nothing class, in its presentation form. //! //! So the renderer ships with [`tests::every_view_field_is_classified`], //! which walks the *serialized shape* of `GroundView` and requires every //! leaf path to be listed as either rendered or deliberately omitted. A //! field added to the projection and forgotten here fails the build. //! //! **Stated non-goal, unchanged from stage 0:** no TUI, no colour, no //! readline. This is the inspectable half of INTENT stage 1; the 2D half //! needs a rendering port, which needs an ADR, which this pass does not //! have (see the workplan's tier declaration). use cb_kernel::PlayerId; use games_ground::view::{GroundView, PlayerView, ProblemView, SelectionView}; pub fn suit_name(s: games_ground::Suit) -> &'static str { match s { games_ground::Suit::Clarify => "Clarify", games_ground::Suit::Repair => "Repair", games_ground::Suit::Boundary => "Boundary", games_ground::Suit::Change => "Change", } } pub fn seat_name(p: PlayerId) -> String { format!("P{}", p.0 + 1) } fn cards(list: &[games_ground::SolutionCard]) -> String { if list.is_empty() { return "-".into(); } list.iter() .map(|c| suit_name(c.suit)) .collect::>() .join(", ") } /// GR-A11/A12: the sub-choice, with its argument. Rendered from the /// variant rather than `{:?}` so the argument seat reads as `P3`, not /// `PlayerId(2)` — the inspector speaks the table's vocabulary. fn ground_choice(c: &games_ground::GroundChoice) -> String { use games_ground::GroundChoice as G; match c { G::RestoreProblem { problem } => format!("restore [{problem}]"), G::CancelAttack { attacker } => format!("cancel attack from {}", seat_name(*attacker)), G::ProtectProblem { problem } => format!("protect [{problem}]"), G::RemoveBlame { owner } => format!("remove blame from {}", seat_name(*owner)), G::BreakRelation { with } => format!("break relation with {}", seat_name(*with)), G::RejectReverse => "reject reverse".into(), } } /// The per-seat social line: everything the seat has *declared* this /// round, as opposed to what it *is*. Empty for a seat that has declared /// nothing, so a quiet table stays readable. fn render_declarations(id: PlayerId, view: &GroundView) -> String { let mut parts: Vec = Vec::new(); if let Some(target) = view.focus.get(&id) { parts.push(format!("focus→{}", seat_name(*target))); } if let Some(mode) = view.ground_modes.get(&id) { let name = match mode { games_ground::GroundMode::Gr => "GR", games_ground::GroundMode::Ou => "OU", games_ground::GroundMode::Nd => "ND", }; parts.push(format!("ground {name}")); } if let Some(choice) = view.ground_choices.get(&id) { parts.push(ground_choice(choice)); } if let Some(r) = view.support_responses.get(&id) { parts.push(format!("support {r:?}")); } if let Some(t) = view.darvo_targets.get(&id) { let mut bits: Vec = Vec::new(); if let Some(p) = t.problem { bits.push(format!("[{p}]")); } if let Some(p) = t.player { bits.push(seat_name(p)); } parts.push(format!( "darvo target {}", if bits.is_empty() { "none".into() } else { bits.join(" ") } )); } if parts.is_empty() { String::new() } else { format!(" {}\n", parts.join(" ")) } } fn render_player(id: PlayerId, p: &PlayerView, is_viewer: bool) -> String { let hand = match &p.hand { Some(list) => format!("hand [{}]", cards(list)), None => format!("hand {} card(s)", p.hand_size), }; format!( " {}{} stress {} freedom {}{} darvo {:?} protect {} blame {} {hand}\n", seat_name(id), if is_viewer { " (you)" } else { " " }, p.stress, if p.freedom_ready { "READY" } else { "SPENT" }, if p.freedom_gate_lifted { " gate-lifted" } else { "" }, p.darvo, p.protection, p.blame_from.len(), ) } /// One seat's whole picture, from the projection and nothing else. pub fn render(view: &GroundView) -> String { let mut out = String::new(); out.push_str(&format!( "\nround {} step {:?} lead {} mode {:?} deck {} discard [{}]\n", view.round, view.step, seat_name(view.lead), view.mode, view.solution_deck_len, cards(&view.solution_discard), )); for (id, p) in &view.players { out.push_str(&render_player(*id, p, Some(*id) == view.viewer)); out.push_str(&render_declarations(*id, view)); } out.push_str(" problems:"); for (priority, problem) in &view.problems { match problem { ProblemView::FaceDown => out.push_str(&format!(" [{priority}] face-down")), ProblemView::FaceUp { suit, value, denied, claimed_by, protected_this_round, } => { out.push_str(&format!( " [{priority}] {} {}{}{}{}", suit_name(*suit), value, if *denied { " DENIED" } else { "" }, if *protected_this_round { " PROTECTED" } else { "" }, match claimed_by { Some(p) => format!(" claimed by {}", seat_name(*p)), None => String::new(), } )); } } } out.push('\n'); if !view.relations.is_empty() { out.push_str(" relations:"); for (pair, relation) in &view.relations { out.push_str(&format!(" {pair} {relation:?}")); } out.push('\n'); } if !view.selections.is_empty() { out.push_str(" selections:"); for (id, sel) in &view.selections { match sel { SelectionView::Hidden => out.push_str(&format!(" {} face-down", seat_name(*id))), SelectionView::Shown(s) => out.push_str(&format!( " {} {:?}{}{}", seat_name(*id), s.action, match s.target { Some(t) => format!("→{}", seat_name(t)), None => String::new(), }, match s.problem { Some(p) => format!("→[{p}]"), None => String::new(), } )), } } out.push('\n'); } if let Some(o) = &view.outcome { out.push_str(&format!( " OUTCOME total {} / threshold {} group {}\n", o.total, o.threshold, if o.group_success { "SUCCESS" } else { "failure" }, )); out.push_str(" personal:"); for (id, score) in &o.personal { out.push_str(&format!(" {} {score}", seat_name(*id))); } out.push('\n'); if let Some(m) = o.mastery { out.push_str(&format!(" mastery {m}\n")); } for c in &o.coalitions { out.push_str(&format!( " coalition [{}] score {}\n", c.members .iter() .map(|m| seat_name(*m)) .collect::>() .join(", "), c.score, )); } out.push_str(&format!( " winners {}\n", o.winners .iter() .map(|w| seat_name(*w)) .collect::>() .join(", "), )); } out } #[cfg(test)] mod tests { use super::*; use games_ground::view::OutcomeView; use games_ground::*; use std::collections::BTreeMap; /// Every leaf path in the serialized `GroundView`, with map keys and /// array indices collapsed to `*`. /// /// Paths, not keys: `problem` appears under a DARVO target, a GROUND /// choice and a Selection, and a key-set walk would let one of the /// three vouch for the other two. fn paths(v: &serde_json::Value, prefix: &str, out: &mut Vec) { match v { serde_json::Value::Object(map) => { for (k, child) in map { let next = if prefix.is_empty() { k.clone() } else { format!("{prefix}.{k}") }; paths(child, &next, out); } } serde_json::Value::Array(items) => { for item in items { paths(item, &format!("{prefix}.*"), out); } } _ => out.push(prefix.to_string()), } } /// A `BTreeMap` serializes as an object, so its *keys* look like /// struct fields. Collapse any path segment that is a map key — /// seat names, Problem priorities, `Pair` keys — to `*`. fn normalize(path: &str) -> String { let maps = [ "players", "relations", "problems", "focus", "selections", "ground_modes", "ground_choices", "support_responses", "darvo_targets", "personal", ]; let mut parts: Vec = Vec::new(); let mut collapse_next = false; for seg in path.split('.') { if collapse_next { parts.push("*".into()); collapse_next = false; continue; } parts.push(seg.to_string()); collapse_next = maps.contains(&seg); } parts.join(".") } /// What the inspector shows, and the token that proves it does. The /// token is asserted against the fixture's output — delete a field /// from `render` and its row goes red naming the field. const RENDERED: &[(&str, &str)] = &[ ("round", "round 3"), ("step", "step Resolve"), ("lead", "lead P2"), ("mode", "mode BondedCoalitions"), ("viewer", "(you)"), ("solution_deck_len", "deck 11"), ("solution_discard.*.suit", "discard [Repair, Change]"), ("players.*.stress", "stress 4"), ("players.*.freedom_ready", "freedom SPENT"), ("players.*.freedom_gate_lifted", "gate-lifted"), ("players.*.darvo", "darvo Attack"), ("players.*.protection", "protect 2"), ("players.*.blame_from.*", "blame 2"), ("players.*.hand.*.suit", "hand [Clarify, Boundary]"), ("players.*.hand_size", "hand 4 card(s)"), ("relations.*", "Rivalry"), ("problems.*.state", "face-down"), ("problems.*.suit", "Boundary"), ("problems.*.value", "Boundary 9"), ("problems.*.denied", "DENIED"), ("problems.*.protected_this_round", "PROTECTED"), ("problems.*.claimed_by", "claimed by P3"), ("focus.*", "focus→P3"), ("ground_modes.*", "ground OU"), ("ground_choices.*.choice", "cancel attack from"), ("ground_choices.*.attacker", "cancel attack from P2"), ("support_responses.*", "support FlipToBond"), ("darvo_targets.*.problem", "darvo target [7]"), ("darvo_targets.*.player", "darvo target [7] P1"), ("selections.*.state", "face-down"), ("selections.*.action", "Investigate"), ("selections.*.target", "Investigate→P2"), ("selections.*.problem", "→[7]"), ("outcome.total", "total 18"), ("outcome.threshold", "threshold 15"), ("outcome.group_success", "group SUCCESS"), ("outcome.personal.*", "P1 6"), ("outcome.mastery", "mastery 3"), ("outcome.coalitions.*.members.*", "coalition [P1, P2]"), ("outcome.coalitions.*.score", "score 11"), ("outcome.winners.*", "winners P1, P2"), ]; /// Deliberately not shown, each with the reason. /// /// **This list is an unchecked claim**, and saying so is cheaper than /// pretending otherwise: the test proves a `RENDERED` row is really /// rendered, and proves nothing about an `OMITTED` one. What it does /// enforce is that the claim was *made* — a new field cannot arrive /// silently in either list. const OMITTED: &[(&str, &str)] = &[ // `viewer: null` is a spectator, rendered by the *absence* of // "(you)" — there is no token for it, and adding one would mean // printing a line that says nothing. // `null` for every seat but the viewer — GR-S02. There is no // content to show, and the absence is exactly what the count // form (`hand 4 card(s)`) renders. A token here would assert // that the inspector prints something about a thing it must not // print anything about. ( "players.*.hand", "null for a non-viewer seat; the absence is rendered as a count", ), ]; /// Round 3, mid-DARVO, mid-GROUND, scored. Built by hand rather than /// played: at deal time two thirds of these fields are empty, and a /// coverage test run against absent fields is the same lie in a /// different costume. fn fixture() -> GroundView { let (p1, p2, p3) = (PlayerId(0), PlayerId(1), PlayerId(2)); let card = |suit| SolutionCard { suit }; let mut players = BTreeMap::new(); players.insert( p1, PlayerView { stress: 4, freedom_ready: false, freedom_gate_lifted: true, darvo: DarvoStage::Attack, protection: 2, blame_from: vec![p2, p3], hand: Some(vec![card(Suit::Clarify), card(Suit::Boundary)]), hand_size: 2, }, ); for (id, stress) in [(p2, 1u8), (p3, 0)] { players.insert( id, PlayerView { stress, freedom_ready: true, freedom_gate_lifted: false, darvo: DarvoStage::Off, protection: 0, blame_from: vec![], hand: None, hand_size: 4, }, ); } let mut problems = BTreeMap::new(); problems.insert(1, ProblemView::FaceDown); problems.insert( 7, ProblemView::FaceUp { suit: Suit::Boundary, value: 9, denied: true, claimed_by: Some(p3), protected_this_round: true, }, ); GroundView { viewer: Some(p1), round: 3, lead: p2, step: RoundStep::Resolve, mode: ScoringMode::BondedCoalitions, players, relations: BTreeMap::from([ (Pair::new(p1, p2), Relation::Bond), (Pair::new(p2, p3), Relation::Rivalry), ]), problems, focus: BTreeMap::from([(p1, p3)]), selections: BTreeMap::from([ ( p1, SelectionView::Shown(Selection { action: Action::Investigate, target: Some(p2), problem: Some(7), }), ), (p2, SelectionView::Hidden), ]), ground_modes: BTreeMap::from([(p1, GroundMode::Ou)]), ground_choices: BTreeMap::from([(p1, GroundChoice::CancelAttack { attacker: p2 })]), support_responses: BTreeMap::from([(p2, SupportResponse::FlipToBond)]), darvo_targets: BTreeMap::from([( p1, DarvoTarget { problem: Some(7), player: Some(p1), }, )]), solution_deck_len: 11, solution_discard: vec![card(Suit::Repair), card(Suit::Change)], outcome: Some(OutcomeView { total: 18, threshold: 15, group_success: true, personal: BTreeMap::from([(p1, 6), (p2, 5), (p3, 7)]), coalitions: vec![Coalition { members: vec![p1, p2], score: 11, }], mastery: Some(3), winners: vec![p1, p2], }), } } fn fixture_paths() -> Vec { let json = serde_json::to_value(fixture()).expect("view serializes"); let mut raw = Vec::new(); paths(&json, "", &mut raw); let mut all: Vec = raw.iter().map(|p| normalize(p)).collect(); all.sort(); all.dedup(); all } /// The gate: every field the projection carries is classified, and /// every field claimed rendered really is. #[test] fn every_view_field_is_classified() { let out = render(&fixture()); let all = fixture_paths(); // EXPECT-VACUOUS control. A coverage test over an empty path set // passes trivially, and that is precisely how this check would // rot — a serde change, a flattened field, a walk that stops at // the first map. `GroundView` has 17 fields and the fixture // populates all of them; 30 leaves is a floor, not a count, so // adding a field never fails this line for the wrong reason. assert!( all.len() >= 30, "the walk found {} leaf path(s) — it is not walking the view", all.len() ); let rendered: Vec<&str> = RENDERED.iter().map(|(p, _)| *p).collect(); let omitted: Vec<&str> = OMITTED.iter().map(|(p, _)| *p).collect(); let unclassified: Vec<&String> = all .iter() .filter(|p| !rendered.contains(&p.as_str()) && !omitted.contains(&p.as_str())) .collect(); assert!( unclassified.is_empty(), "new field(s) in GroundView are neither rendered nor declared omitted: {unclassified:?}\n\ add each to RENDERED (with a token the inspector prints) or to OMITTED (with a reason)" ); let stale: Vec<&str> = rendered .iter() .chain(omitted.iter()) .filter(|p| !all.contains(&p.to_string())) .copied() .collect(); assert!( stale.is_empty(), "classified path(s) no longer exist in GroundView: {stale:?}" ); for (path, token) in RENDERED { assert!( out.contains(token), "{path} is claimed rendered, but the output has no {token:?}\n--- output ---\n{out}" ); } } /// The projection decides what is visible; the inspector must not /// widen it. P1 is the viewer, so P2's and P3's hands are `None` and /// only their sizes may appear. #[test] fn the_inspector_never_widens_the_projection() { let out = render(&fixture()); assert!(out.contains("hand [Clarify, Boundary]"), "{out}"); assert_eq!( out.matches("hand 4 card(s)").count(), 2, "both non-viewer seats show a count and nothing more\n{out}" ); // P2 selected face-down; the tag carries no Selection to leak, // and the render must not invent one. assert!(out.contains("P2 face-down"), "{out}"); } /// A spectator sees no hand at all and is not told they are anyone. #[test] fn a_spectator_view_renders_without_a_seat() { let mut view = fixture(); view.viewer = None; view.players.get_mut(&PlayerId(0)).unwrap().hand = None; let out = render(&view); assert!(!out.contains("(you)"), "{out}"); assert!(!out.contains("hand ["), "{out}"); } }