CB-WP-0011-T01: the inspector shows everything, and a gate says so
The renderer moves out of the play loop into inspect.rs and grows from 24 to 42 of the 43 leaf paths a populated GroundView carries. What it had been dropping was the whole DARVO state machine, the whole GROUND practice, the scoring mode, Focus tokens, the discard pile, per-seat protection, and every part of the outcome except the headline. The load-bearing half is every_view_field_is_classified, which walks the serialized view for leaf paths and requires each to be listed as rendered (with a token the output must contain) or omitted (with a reason). Paths rather than keys: 'problem' occurs 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. Four M-D1-MUT controls, each red for its stated reason. The unclassified-field control fired for real on the first run -- players.*.hand, a field the gate's own author had missed.
This commit is contained in:
parent
c1d140626e
commit
d2ca1046c0
5 changed files with 624 additions and 125 deletions
|
|
@ -14,10 +14,11 @@
|
||||||
| workplan | CB-WP-0004 | done | — | workplans/CB-WP-0004-mechanical-work.md |
|
| workplan | CB-WP-0004 | done | — | workplans/CB-WP-0004-mechanical-work.md |
|
||||||
| workplan | CB-WP-0005 | done | — | workplans/CB-WP-0005-assertion-coverage.md |
|
| workplan | CB-WP-0005 | done | — | workplans/CB-WP-0005-assertion-coverage.md |
|
||||||
| workplan | CB-WP-0006 | done | — | workplans/CB-WP-0006-instrument-the-table.md |
|
| workplan | CB-WP-0006 | done | — | workplans/CB-WP-0006-instrument-the-table.md |
|
||||||
| workplan | CB-WP-0007 | in_progress | — | workplans/CB-WP-0007-session-shape.md |
|
| workplan | CB-WP-0007 | done | — | workplans/CB-WP-0007-session-shape.md |
|
||||||
| workplan | CB-WP-0008 | done | — | workplans/CB-WP-0008-ship-stage-0.md |
|
| workplan | CB-WP-0008 | done | — | workplans/CB-WP-0008-ship-stage-0.md |
|
||||||
| workplan | CB-WP-0009 | done | — | workplans/CB-WP-0009-adaptive-gates.md |
|
| workplan | CB-WP-0009 | done | — | workplans/CB-WP-0009-adaptive-gates.md |
|
||||||
| workplan | CB-WP-0010 | proposed | — | workplans/CB-WP-0010-consolidation.md |
|
| workplan | CB-WP-0010 | done | — | workplans/CB-WP-0010-consolidation.md |
|
||||||
|
| workplan | CB-WP-0011 | todo | — | workplans/CB-WP-0011-inspectable-table.md |
|
||||||
| task | CB-WP-0001-T01 | done | — | workplans/CB-WP-0001-inner-loop.md |
|
| task | CB-WP-0001-T01 | done | — | workplans/CB-WP-0001-inner-loop.md |
|
||||||
| task | CB-WP-0001-T02 | done | — | workplans/CB-WP-0001-inner-loop.md |
|
| task | CB-WP-0001-T02 | done | — | workplans/CB-WP-0001-inner-loop.md |
|
||||||
| task | CB-WP-0001-T03 | done | — | workplans/CB-WP-0001-inner-loop.md |
|
| task | CB-WP-0001-T03 | done | — | workplans/CB-WP-0001-inner-loop.md |
|
||||||
|
|
@ -72,8 +73,8 @@
|
||||||
| task | CB-WP-0007-T02 | cancel | — | workplans/CB-WP-0007-session-shape.md |
|
| task | CB-WP-0007-T02 | cancel | — | workplans/CB-WP-0007-session-shape.md |
|
||||||
| task | CB-WP-0007-T03 | done | — | workplans/CB-WP-0007-session-shape.md |
|
| task | CB-WP-0007-T03 | done | — | workplans/CB-WP-0007-session-shape.md |
|
||||||
| task | CB-WP-0007-T04 | cancel | — | workplans/CB-WP-0007-session-shape.md |
|
| task | CB-WP-0007-T04 | cancel | — | workplans/CB-WP-0007-session-shape.md |
|
||||||
| task | CB-WP-0007-T05 | todo | — | workplans/CB-WP-0007-session-shape.md |
|
| task | CB-WP-0007-T05 | cancel | — | workplans/CB-WP-0007-session-shape.md |
|
||||||
| task | CB-WP-0007-T06 | todo | — | workplans/CB-WP-0007-session-shape.md |
|
| task | CB-WP-0007-T06 | cancel | — | workplans/CB-WP-0007-session-shape.md |
|
||||||
| task | CB-WP-0008-T01 | done | — | workplans/CB-WP-0008-ship-stage-0.md |
|
| task | CB-WP-0008-T01 | done | — | workplans/CB-WP-0008-ship-stage-0.md |
|
||||||
| task | CB-WP-0008-T02 | done | — | workplans/CB-WP-0008-ship-stage-0.md |
|
| task | CB-WP-0008-T02 | done | — | workplans/CB-WP-0008-ship-stage-0.md |
|
||||||
| task | CB-WP-0008-T03 | done | — | workplans/CB-WP-0008-ship-stage-0.md |
|
| task | CB-WP-0008-T03 | done | — | workplans/CB-WP-0008-ship-stage-0.md |
|
||||||
|
|
@ -82,6 +83,9 @@
|
||||||
| task | CB-WP-0009-T02 | done | — | workplans/CB-WP-0009-adaptive-gates.md |
|
| task | CB-WP-0009-T02 | done | — | workplans/CB-WP-0009-adaptive-gates.md |
|
||||||
| task | CB-WP-0009-T03 | done | — | workplans/CB-WP-0009-adaptive-gates.md |
|
| task | CB-WP-0009-T03 | done | — | workplans/CB-WP-0009-adaptive-gates.md |
|
||||||
| task | CB-WP-0009-T04 | done | — | workplans/CB-WP-0009-adaptive-gates.md |
|
| task | CB-WP-0009-T04 | done | — | workplans/CB-WP-0009-adaptive-gates.md |
|
||||||
| task | CB-WP-0010-T01 | todo | — | workplans/CB-WP-0010-consolidation.md |
|
| task | CB-WP-0010-T01 | done | — | workplans/CB-WP-0010-consolidation.md |
|
||||||
| task | CB-WP-0010-T02 | todo | — | workplans/CB-WP-0010-consolidation.md |
|
| task | CB-WP-0010-T02 | done | — | workplans/CB-WP-0010-consolidation.md |
|
||||||
| task | CB-WP-0010-T03 | todo | — | workplans/CB-WP-0010-consolidation.md |
|
| task | CB-WP-0010-T03 | done | — | workplans/CB-WP-0010-consolidation.md |
|
||||||
|
| task | CB-WP-0011-T01 | todo | — | workplans/CB-WP-0011-inspectable-table.md |
|
||||||
|
| task | CB-WP-0011-T02 | todo | — | workplans/CB-WP-0011-inspectable-table.md |
|
||||||
|
| task | CB-WP-0011-T03 | todo | — | workplans/CB-WP-0011-inspectable-table.md |
|
||||||
|
|
|
||||||
580
tools/cb-play/src/inspect.rs
Normal file
580
tools/cb-play/src/inspect.rs
Normal file
|
|
@ -0,0 +1,580 @@
|
||||||
|
//! 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::<Vec<_>>()
|
||||||
|
.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<String> = 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<String> = 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::<Vec<_>>()
|
||||||
|
.join(", "),
|
||||||
|
c.score,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push_str(&format!(
|
||||||
|
" winners {}\n",
|
||||||
|
o.winners
|
||||||
|
.iter()
|
||||||
|
.map(|w| seat_name(*w))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.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<String>) {
|
||||||
|
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<String> = 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<String> {
|
||||||
|
let json = serde_json::to_value(fixture()).expect("view serializes");
|
||||||
|
let mut raw = Vec::new();
|
||||||
|
paths(&json, "", &mut raw);
|
||||||
|
let mut all: Vec<String> = 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
//! success — a session that ends without an outcome is a failure, and
|
//! success — a session that ends without an outcome is a failure, and
|
||||||
//! saying otherwise is how a stalled game passes for a played one.
|
//! saying otherwise is how a stalled game passes for a played one.
|
||||||
|
|
||||||
|
mod inspect;
|
||||||
mod table;
|
mod table;
|
||||||
|
|
||||||
use table::Config;
|
use table::Config;
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,9 @@ use cb_game_runtime::{Project, Setup, Viewer};
|
||||||
use cb_kernel::{Actor, PlayerId};
|
use cb_kernel::{Actor, PlayerId};
|
||||||
use games_ground::bot::{BotError, Choice, GreedyPolicy, Policy, RandomPolicy};
|
use games_ground::bot::{BotError, Choice, GreedyPolicy, Policy, RandomPolicy};
|
||||||
use games_ground::record::to_step;
|
use games_ground::record::to_step;
|
||||||
use games_ground::view::{GroundView, PlayerView, ProblemView, SelectionView};
|
|
||||||
use games_ground::{GroundCommand, GroundState};
|
use games_ground::{GroundCommand, GroundState};
|
||||||
|
|
||||||
|
use crate::inspect::{render, seat_name};
|
||||||
use std::io::{BufRead, Write};
|
use std::io::{BufRead, Write};
|
||||||
|
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
|
|
@ -58,19 +59,11 @@ pub struct Summary {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------- rendering
|
// ------------------------------------------------------------- rendering
|
||||||
|
//
|
||||||
fn suit_name(s: games_ground::Suit) -> &'static str {
|
// The table itself is rendered by `inspect`. What stays here is the
|
||||||
match s {
|
// vocabulary a *chooser* needs: how a legal command is described in the
|
||||||
games_ground::Suit::Clarify => "Clarify",
|
// menu. Rendering the state and naming a move are different jobs, and
|
||||||
games_ground::Suit::Repair => "Repair",
|
// T02 needs the first without the second.
|
||||||
games_ground::Suit::Boundary => "Boundary",
|
|
||||||
games_ground::Suit::Change => "Change",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn seat_name(p: PlayerId) -> String {
|
|
||||||
format!("P{}", p.0 + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn describe(command: &GroundCommand) -> String {
|
fn describe(command: &GroundCommand) -> String {
|
||||||
// Reuse the recorder's vocabulary rather than inventing a third one:
|
// Reuse the recorder's vocabulary rather than inventing a third one:
|
||||||
|
|
@ -90,109 +83,6 @@ fn describe(command: &GroundCommand) -> String {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_player(id: PlayerId, p: &PlayerView, is_viewer: bool) -> String {
|
|
||||||
let hand = match &p.hand {
|
|
||||||
Some(cards) => {
|
|
||||||
let names: Vec<&str> = cards.iter().map(|c| suit_name(c.suit)).collect();
|
|
||||||
format!("hand [{}]", names.join(", "))
|
|
||||||
}
|
|
||||||
None => format!("hand {} card(s)", p.hand_size),
|
|
||||||
};
|
|
||||||
format!(
|
|
||||||
" {}{} stress {} freedom {} darvo {:?} blame {} {hand}",
|
|
||||||
seat_name(id),
|
|
||||||
if is_viewer { " (you)" } else { " " },
|
|
||||||
p.stress,
|
|
||||||
if p.freedom_ready { "READY" } else { "SPENT" },
|
|
||||||
p.darvo,
|
|
||||||
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 {} deck {}\n",
|
|
||||||
view.round,
|
|
||||||
view.step,
|
|
||||||
seat_name(view.lead),
|
|
||||||
view.solution_deck_len,
|
|
||||||
));
|
|
||||||
for (id, p) in &view.players {
|
|
||||||
out.push_str(&render_player(*id, p, Some(*id) == view.viewer));
|
|
||||||
out.push('\n');
|
|
||||||
}
|
|
||||||
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,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
out.push_str(&format!(
|
|
||||||
" [{priority}] {} {}{}{}",
|
|
||||||
suit_name(*suit),
|
|
||||||
value,
|
|
||||||
if *denied { " DENIED" } 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 {} winners {:?}\n",
|
|
||||||
o.total,
|
|
||||||
o.threshold,
|
|
||||||
if o.group_success {
|
|
||||||
"SUCCESS"
|
|
||||||
} else {
|
|
||||||
"failure"
|
|
||||||
},
|
|
||||||
o.winners.iter().map(|w| seat_name(*w)).collect::<Vec<_>>(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------------------------------------------------------- policies
|
// --------------------------------------------------------------- policies
|
||||||
|
|
||||||
/// A seat driven from stdin. Implements the same `Policy` the bots do, so
|
/// A seat driven from stdin. Implements the same `Policy` the bots do, so
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ id: CB-WP-0011
|
||||||
kind: product
|
kind: product
|
||||||
title: "Stage 1, first slice: an inspector that shows everything"
|
title: "Stage 1, first slice: an inspector that shows everything"
|
||||||
status: todo
|
status: todo
|
||||||
|
state_hub_workstream_id: "1142442a-65f1-483a-800d-6ca8490c5f1a"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Purpose
|
# Purpose
|
||||||
|
|
@ -75,8 +76,9 @@ form.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CB-WP-0011-T01
|
id: CB-WP-0011-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "b8cb4c20-9d06-47a4-890c-bf8390015439"
|
||||||
```
|
```
|
||||||
|
|
||||||
Extend `render` to cover every field of `GroundView`, and — the load-
|
Extend `render` to cover every field of `GroundView`, and — the load-
|
||||||
|
|
@ -107,12 +109,33 @@ deal: most of the missing fields are empty at deal time, and a coverage
|
||||||
test run against a state where the fields are absent is the same lie in a
|
test run against a state where the fields are absent is the same lie in a
|
||||||
different costume.
|
different costume.
|
||||||
|
|
||||||
|
**Done 2026-08-02.** The renderer moved to `tools/cb-play/src/inspect.rs`
|
||||||
|
and now covers **42 of the 43 leaf paths** in a populated `GroundView`,
|
||||||
|
up from 24. The one omission is `players.*.hand` — `null` for a
|
||||||
|
non-viewer seat, where the absence is what `hand N card(s)` renders.
|
||||||
|
|
||||||
|
`every_view_field_is_classified` walks the serialized view for leaf
|
||||||
|
*paths* (not keys — `problem` occurs under a DARVO target, a GROUND
|
||||||
|
choice and a Selection, and a key-set walk would let one vouch for the
|
||||||
|
other two). Four controls, each run and each red for its stated reason:
|
||||||
|
|
||||||
|
| control | result |
|
||||||
|
|---|---|
|
||||||
|
| a field deleted from `render` | `players.*.protection is claimed rendered, but the output has no "protect 2"` |
|
||||||
|
| a field present in neither list | fired **for real on the first run** — `players.*.hand` |
|
||||||
|
| the walk returns no paths (EXPECT-VACUOUS) | `the walk found 0 leaf path(s) — it is not walking the view` |
|
||||||
|
| a classified path that no longer exists | `classified path(s) no longer exist in GroundView: ["outcome.nonexistent"]` |
|
||||||
|
|
||||||
|
The second is the one worth keeping: the gate caught a field I had missed
|
||||||
|
while writing the gate, before it had ever been committed.
|
||||||
|
|
||||||
## Task: `cb-play --inspect` walks a recorded game
|
## Task: `cb-play --inspect` walks a recorded game
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CB-WP-0011-T02
|
id: CB-WP-0011-T02
|
||||||
status: todo
|
status: todo
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "08f8a9c7-3dfe-4f72-b510-df9efa457370"
|
||||||
```
|
```
|
||||||
|
|
||||||
Stage 1 asks for a *debug inspector*. The inspector above renders one
|
Stage 1 asks for a *debug inspector*. The inspector above renders one
|
||||||
|
|
@ -143,6 +166,7 @@ hidden thing is actually hidden.
|
||||||
id: CB-WP-0011-T03
|
id: CB-WP-0011-T03
|
||||||
status: todo
|
status: todo
|
||||||
priority: medium
|
priority: medium
|
||||||
|
state_hub_task_id: "db722865-1233-4918-952d-0bf4761199e4"
|
||||||
```
|
```
|
||||||
|
|
||||||
Write `evidence/CB-EV-0009-inspectable-table.md` covering:
|
Write `evidence/CB-EV-0009-inspectable-table.md` covering:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue