Compare commits

...

3 commits

Author SHA1 Message Date
0d2ab2286d CB-WP-0011-T03: evidence — the chaos roll fired, and it was right
Some checks failed
ci / check (push) Has been cancelled
CB-EV-0009. Tier S did not produce a worse outcome than tier L would
have. The roll deleted a survey that would have opened on 2D toolkit
selection; the decomposition it forced instead found that the existing
text renderer was showing 24 of 41 view fields. The structural trigger
fires on a property of the plan, not of the code, so nothing in the
tier derivation could have said the port was the wrong first question.

Recorded honestly in both directions: the pass also made an interface
change with no review, which is the cost side. One favourable fire is
not a calibration; the window stays open to 2026-09-30.

Corrects a number this pass asserted: the T01 commit message says
'42 of 43, up from 24'. Measured by splicing the old renderer back in,
it is 41 of 42 with 1 declared omitted, up from 24 -- and 16 fields
were genuinely absent, not 17, because outcome.winners was rendered in
a different format. Both original figures were counted by hand.

CHAOS gains its first caught entry. Cheapest pass per response yet
recorded (0.094 vs a previous best of 0.123).
2026-08-02 02:48:51 +02:00
b11fc91fd4 CB-WP-0011-T02: cb-play --inspect walks a recorded game
Renders the table after every step of a .cbreplay bundle or a scenario
YAML, from any seat's projection or a spectator's. This is the first
thing in the project that answers 'what did the table look like when it
went wrong?' without adding a dbg! and re-running.

INTERFACE CHANGE (flagged per InnerLoop chaos limits -- this is a
tier-S pass that touched a runtime crate): cb-game-runtime gains
replay::open, extracted out of replay::replay. Dev-only, behind the
scenarios feature, no type changed. The point of the extraction is that
the inspector and the replay gate share one bundle reader, controls
included, so the inspector cannot show a state a replay never reached.

Three M-D1-MUT controls, each red for its stated reason. The
load-bearing one asserts one rendered table per step: without it, a
walk that rendered nothing would still report a matching hash.
2026-08-02 02:45:55 +02:00
d2ca1046c0 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.
2026-08-02 02:38:38 +02:00
8 changed files with 1314 additions and 149 deletions

View file

@ -14,10 +14,11 @@
| 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-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-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-T02 | 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-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-T05 | todo | — | workplans/CB-WP-0007-session-shape.md |
| task | CB-WP-0007-T06 | 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 | 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-T02 | 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-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-0010-T01 | todo | — | workplans/CB-WP-0010-consolidation.md |
| task | CB-WP-0010-T02 | todo | — | workplans/CB-WP-0010-consolidation.md |
| task | CB-WP-0010-T03 | todo | — | workplans/CB-WP-0010-consolidation.md |
| task | CB-WP-0010-T01 | done | — | workplans/CB-WP-0010-consolidation.md |
| task | CB-WP-0010-T02 | done | — | 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 |

View file

@ -133,12 +133,18 @@ pub fn write_bundle(
Ok(bundle)
}
/// Re-execute a bundle and require it to reproduce the recorded hash.
/// Open a bundle for re-execution: the manifest, the restored initial
/// state, and the recorded steps in order.
///
/// Returns `Err` when the bundle is corrupt, incomplete, internally
/// inconsistent, or does not reproduce — all of which are the point. A
/// round-trip that cannot fail proves nothing.
pub fn replay<G>(bundle: &Path) -> Result<ReplayReport, String>
/// Extracted from [`replay`] by CB-WP-0011 T02 so the inspector walks a
/// bundle through **the same reader the replay gate uses**. A second
/// reader would let the inspector show states a replay never reached —
/// and it would be a duplicated fact, checked by nothing, in the one
/// place where being wrong is silent.
///
/// Every control [`replay`] performed before touching the command log is
/// performed here, including the one that makes the seed load-bearing.
pub fn open<G>(bundle: &Path) -> Result<(Manifest, G, Vec<CommandStep>), String>
where
G: ScenarioGame,
{
@ -165,17 +171,33 @@ where
&std::fs::read(bundle.join(INITIAL)).map_err(|e| err("read initial", e))?,
)
.map_err(|e| err("parse initial", e))?;
let mut state: G = serde_json::from_value(initial).map_err(|e| err("restore initial", e))?;
let state: G = serde_json::from_value(initial).map_err(|e| err("restore initial", e))?;
// K11 framing: a truncated or corrupt command log is rejected here.
let raw = std::fs::read(bundle.join(COMMANDS)).map_err(|e| err("read commands", e))?;
let records = parse(&raw).map_err(|e| err("command log", e))?;
let mut steps = Vec::with_capacity(records.len());
for record in &records {
steps.push(serde_json::from_slice(record).map_err(|e| err("decode step", e))?);
}
Ok((manifest, state, steps))
}
/// Re-execute a bundle and require it to reproduce the recorded hash.
///
/// Returns `Err` when the bundle is corrupt, incomplete, internally
/// inconsistent, or does not reproduce — all of which are the point. A
/// round-trip that cannot fail proves nothing.
pub fn replay<G>(bundle: &Path) -> Result<ReplayReport, String>
where
G: ScenarioGame,
{
let (manifest, mut state, steps) = open::<G>(bundle)?;
let mut applied = 0usize;
for record in &records {
let step: CommandStep =
serde_json::from_slice(record).map_err(|e| err("decode step", e))?;
let (actor, command) = G::parse_command(&step)?;
for step in &steps {
let (actor, command) = G::parse_command(step)?;
if let Ok(produced) = state.validate(actor, &command) {
for event in produced {
state.fold(&event);

View file

@ -0,0 +1,191 @@
# CB-EV-0009 — the chaos roll fired, and what it cost
CB-WP-0011 T03. Measured 2026-08-02 at `HEAD` after T02. Pass kind
`product`.
This is the first pass in which the chaos mechanism changed a tier, so
the first question is the one the calibration window exists to answer.
---
## 1. Did tier S produce a worse outcome than tier L would have?
**No — and the reason is not "tier L was unnecessary". It is that the
roll forced a decomposition that was better than the one I had planned.**
The declaration:
```
structural tier L (INTENT stage 1 creates a new capability port)
chaos d4 = 4 → override fires
override roll shuf -e S M L → S
```
Before the roll I had the shape of the tier-L pass in hand: a survey
(`CB-RES-0006`) whose leading constraint would have been **AM-4a's 3,750
lines of headroom** — 246,250 of a 250,000 target, measured — followed by
an ADR choosing a 2D toolkit and standing up `cb-render-api` /
`cb-render-null`.
Tier S has no survey and no ADR, and the hard gate — *no implementation
code for a capability exists before its ADR is committed* — is not a tier
weight and does not roll down. So the two rules met head-on, and the
resolution was to **split the declaration**: take the part of stage 1
that creates no port and adds no dependency, and leave the port to its
own declaration and its own roll.
What that split surfaced is the finding this pass acted on:
> `cb-play` already had a renderer. It had had one since stage 0. It was
> showing **24 of the 41** leaf paths a populated `GroundView` carries.
A tier-L pass would have opened by surveying 2D toolkits. It would have
been surveying how to *draw* a table whose text renderer was silently
dropping the DARVO state machine, the GROUND practice, the scoring mode,
the Focus tokens, the discard pile, per-seat protection, and every part
of the outcome except the headline. **The port was the wrong first
question**, and nothing in the structural tier derivation could have said
so, because the trigger fires on "creates a capability port" — a property
of the *plan*, not of the code.
**Stated against my own prior.** In the turn before the roll I recommended
tier L and said the AM-4a headroom "should lead the survey". The roll
deleted the survey and the pass was better for it. One data point, and it
is one data point — but it is the *first* evidence the CHAOS gate has
produced in six declarations, and it points the way the mechanism's
defenders hoped and I did not expect.
**What tier L would have caught that this pass did not.** Being honest in
the other direction: this pass made a real interface change —
`cb_game_runtime::replay::open`, extracted so the inspector and the replay
gate share one bundle reader — with no review and no ADR. It is small,
dev-only, and flagged in its commit per the chaos limits, and I believe it
is right. But "I believe it is right" is exactly what a tier-L review
exists not to accept, and that is the cost side of the ledger.
**Carried, not concluded.** Six of twelve declarations used, one override.
The retire condition in `gates.toml` asks whether an overridden tier ever
produces *a different outcome than the argued one*. It just did. That is
recorded as the gate's first `caught` entry, and the window stays open —
one favourable fire is not a calibration.
## 2. The field-coverage gap, before and after
| | leaf paths shown |
|---|---|
| before (stage-0 renderer) | **24** of 41 |
| after | **41** of 41, plus 1 declared omitted |
**Measured, not counted.** The old renderer was spliced back in beside the
new fixture and run. That matters, because the first two figures I wrote
for this — "42 of 43, up from 24", in the T01 commit message — were
counted by hand and both were wrong. The trusted-arithmetic class, in a
pass whose entire subject is a gate against unverified claims.
The measurement also corrects itself in the honest direction:
`outcome.winners.*` shows as missing under the new token but *was* being
rendered, in a different format. So **16 fields were genuinely absent**,
not 17. That is the number to quote.
The one deliberate omission is `players.*.hand` — `null` for every seat
but the viewer, where the absence is what `hand N card(s)` renders.
**The gate caught a field its own author had missed**, on its first run,
before it had ever been committed: `players.*.hand` was in neither list.
That is the entry worth having in `M-D1-MUT`'s ledger, more than the three
mutations I designed on purpose.
## 3. What no test could have caught, and why
Worth writing down as a class, because it is new here:
> Every assertion a renderer test naturally makes — *the output mentions
> the round*, *the output is non-empty*, *P2's hand does not appear* — is
> satisfied by a renderer that shows a third of the state.
This is the harness-does-nothing class in **presentation** form. The
harness runs, the assertions are real, and they are all satisfied by the
defect. What breaks it is asserting over the *shape of the input* rather
than the content of the output: walk the serialized view, require every
leaf path to be classified, and make silence cost a build.
Paths, not keys. `problem` occurs under a DARVO target, a GROUND choice
and a Selection; a key-set walk would let one of the three vouch for the
other two.
## 4. Cost, shape, and the meta budget
| pass | kind | responses | cost | $/response |
|---|---|---|---|---|
| CB-WP-0006 | meta | 158 | $57.22 | 0.362 |
| CB-WP-0007 | meta | 21 | $7.95 | 0.298 |
| CB-WP-0008 | product | 134 | $17.38 | 0.123 |
| CB-WP-0009 | meta | 46 | $11.31 | 0.246 |
| CB-WP-0010 | product | 26 | $4.08 | 0.157 |
| **CB-WP-0011** | **product** | **45** | **$4.23** | **0.094** |
**The cheapest pass per response yet recorded**, against a previous best
of 0.123. Two mechanical causes, both boring and both worth keeping:
- it opened immediately after a compaction (§ below), and
- every task had a `cargo test` between it and being wrong.
The CB-WP-0009 figure moved from $6.73 (as reported in CB-EV-0008 §4) to
$11.31. That is not a correction: CB-EV-0008 was written *during*
CB-WP-0009, and the responses after it are attributed to the pass they
belong to. A pass cannot measure its own final cost, and reporting one
mid-pass will always read low.
### Session shape
| metric | this pass | previous pass opened at | target |
|---|---|---|---|
| SH-1 mean context | **147,808** | 338,852 `[HARD]` | ≤ 200,000 |
| SH-2 p90 context | **149,262** | 339,342 `[SOFT]` | ≤ 300,000 |
| SH-3 batching | 0.0% `[SOFT]` | 0.0% | ≥ 20% |
Both context metrics went from breach to comfortably inside, and the
lever was one `/compact` before the pass opened. That is now the second
time the same lever has produced the cheapest pass on record
(CB-WP-0008 was the first). Two observations are not a law, but the
prediction is cheap and falsifiable: **the next pass opened above the
SH-1 hard line will cost more per response than 0.123.**
SH-3 stays at 0.0% against a 20% floor and remains unfalsified and
unremedied. It has now read 0.0% for five consecutive passes.
### Meta budget
**58% of the trailing three, against a soft 25%** — up from 45%, during
two consecutive `product` passes.
That is not an instrument defect, but it is a property worth naming: the
budget is a **cost share**, so two cheap product passes move it less than
one expensive meta pass moved it up. CB-WP-0009's $11.31 is 58% of the
$19.62 the window holds, and it leaves the window on the next pass.
**Falsifiable prediction:** if the next pass is `product`, the trailing-3
meta share drops to **0%**, because CB-WP-0009 will be the pass that
rolled off. If it does not, the windowing is wrong in a way neither
CB-EV-0007 §3 nor CB-EV-0008 §1 found.
No product work was displaced by meta work in this pass — nothing meta
was opened. The number is over the line and reported under the rule that
requires reporting it.
## 5. Open
- **Stage 1 is not shipped.** This is its inspectable half. INTENT's
stage-1 line stays open; the port, the visualization and
drag-to-propose are untouched.
- **The port declaration is still owed**, structurally tier L, with its
own chaos roll, and with AM-4a's 3,750-line headroom as its leading
constraint. That number is unchanged by this pass — nothing was added
to the shipped runtime.
- **CHAOS has its first `caught` entry** and stays open to 2026-09-30.
- **GATE-REVIEW still has none**, one pass older.
- **SH-3 at 0.0% for five passes.** Either the floor is wrong or the
behaviour is, and neither has been argued.
- **`cb-play` is now two tools in one binary.** Play and inspect share a
renderer and nothing else. If a third mode arrives, that is the second
use, and the split should be reconsidered then rather than now.

View file

@ -120,7 +120,9 @@ target = ""
checks = "d4 on each tier declaration, 12-declaration calibration window"
added = "2026-07-30"
review_by = "2026-09-30"
caught = []
caught = [
"CB-WP-0011: first fire in 6 declarations — d4=4 rolled stage 1 from structural L to S; the deleted survey would have opened on 2D toolkits while the existing text renderer was showing 24 of 41 view fields (CB-EV-0009 §1)",
]
retire_if = "the window closes with no overridden tier producing a different outcome than the argued one — the evaluation this window exists to make possible"
[[gate]]

View file

@ -0,0 +1,878 @@
//! 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_game_runtime::{Project, ScenarioGame};
use cb_kernel::{Aggregate, PlayerId};
use games_ground::view::{GroundView, PlayerView, ProblemView, SelectionView};
use games_ground::GroundState;
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
}
// ------------------------------------------------------------------ walk
/// Which seat's projection a walk renders.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Eyes {
Seat(PlayerId),
Spectator,
}
impl Eyes {
pub fn parse(raw: &str) -> Result<Self, String> {
if raw.eq_ignore_ascii_case("spectator") {
return Ok(Eyes::Spectator);
}
raw.parse::<u8>()
.map(|n| Eyes::Seat(PlayerId(n)))
.map_err(|_| format!("--as expects a 0-based seat or 'spectator', got {raw:?}"))
}
fn label(self) -> String {
match self {
Eyes::Seat(p) => format!("{} (their hand only)", seat_name(p)),
Eyes::Spectator => "a spectator (no hands)".into(),
}
}
fn viewer(self) -> cb_game_runtime::Viewer {
match self {
Eyes::Seat(p) => cb_game_runtime::Viewer::Player(p),
Eyes::Spectator => cb_game_runtime::Viewer::Spectator,
}
}
}
/// What a completed walk reports.
#[derive(Debug)]
pub struct Walk {
pub source: String,
pub steps: usize,
/// Steps the aggregate refused. A recorded game may legitimately
/// contain them — a scenario can assert a rejection — so they are
/// counted and shown, not treated as an error.
pub rejected: usize,
pub end_state_hash: String,
/// `Some` only for a bundle, which records the hash its own producer
/// computed. A scenario file may or may not pin one.
pub expected_hash: Option<String>,
}
/// Replay a recorded game and render the table after every step.
///
/// This is the answer to *"what did the table look like when it went
/// wrong?"* that previously required adding a `dbg!` and re-running.
///
/// The bundle path goes through `replay::open`, the same reader
/// `make replay-test` uses, so the states shown here are the states a
/// replay reaches — structurally, not by assertion. The hash is then
/// checked anyway, because a structural argument that is never executed
/// is the class of claim this project keeps finding to be wrong.
pub fn walk<W: std::io::Write>(
source: &std::path::Path,
eyes: Eyes,
out: &mut W,
) -> Result<Walk, String> {
let (mut state, steps, expected_hash) = load(source)?;
let name = source.display().to_string();
let _ = writeln!(
out,
"inspecting {name} as {} — {} step(s)",
eyes.label(),
steps.len()
);
let _ = write!(out, "{}", render(&state.project(eyes.viewer())));
let mut rejected = 0usize;
for (i, step) in steps.iter().enumerate() {
let (actor, command) = GroundState::parse_command(step)?;
match state.validate(actor, &command) {
Ok(produced) => {
for event in produced {
state.fold(&event);
}
let _ = writeln!(out, "\n[{}] {} {}", i + 1, step.actor, describe_step(step));
}
Err(rejection) => {
rejected += 1;
let _ = writeln!(
out,
"\n[{}] {} {} — REJECTED: {rejection:?}",
i + 1,
step.actor,
describe_step(step)
);
}
}
let _ = write!(out, "{}", render(&state.project(eyes.viewer())));
}
let end_state_hash = cb_events::state_hash_hex(&state);
if let Some(expected) = &expected_hash {
if &end_state_hash != expected {
return Err(format!(
"the walk did not reproduce the recorded end state: {end_state_hash} != {expected}"
));
}
}
let _ = writeln!(
out,
"\nend-state hash {end_state_hash}{}",
match &expected_hash {
Some(_) => " (matches the recording)",
None => " (the source pins no hash)",
}
);
Ok(Walk {
source: name,
steps: steps.len(),
rejected,
end_state_hash,
expected_hash,
})
}
/// A `.cbreplay` bundle or a scenario YAML. Both already reconstruct a
/// command sequence; neither needed a new format for this.
fn load(
source: &std::path::Path,
) -> Result<
(
GroundState,
Vec<cb_game_runtime::CommandStep>,
Option<String>,
),
String,
> {
if source.is_dir() {
let (manifest, state, steps) = cb_game_runtime::replay::open::<GroundState>(source)?;
return Ok((state, steps, Some(manifest.end_state_hash)));
}
let yaml =
std::fs::read_to_string(source).map_err(|e| format!("read {}: {e}", source.display()))?;
let file = cb_game_runtime::ScenarioFile::from_yaml(&yaml)
.map_err(|e| format!("parse {}: {e}", source.display()))?;
let state = GroundState::setup(&file.setup, file.seed)?;
Ok((state, file.commands, file.expect.state_hash))
}
fn describe_step(step: &cb_game_runtime::CommandStep) -> String {
let mut out = step.cmd.clone();
for (key, value) in &step.args {
let rendered = match value {
serde_yaml::Value::String(s) => s.clone(),
other => serde_yaml::to_string(other)
.unwrap_or_default()
.trim()
.to_string(),
};
out.push_str(&format!(" {key}={rendered}"));
}
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}");
}
// ------------------------------------------------------- the walk
/// A recorded game, produced the way a user produces one: play it
/// with bots and ask for a bundle. Fixtures written by hand would
/// test the reader against the writer's assumptions rather than
/// against what the writer actually writes.
fn recorded(tag: &str) -> (std::path::PathBuf, std::path::PathBuf, String) {
let dir = std::env::temp_dir().join(format!("cb-inspect-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("tmp");
let config = crate::table::Config {
seed: 42,
players: 3,
human_seats: vec![],
bot: "greedy".into(),
replay_dir: Some(dir.clone()),
record: Some(dir.join("session.yaml")),
};
let mut sink: Vec<u8> = Vec::new();
let summary =
crate::table::play(&config, "".as_bytes(), &mut sink).expect("the bot game runs");
(
summary.bundle.expect("bundle"),
summary.recorded.expect("scenario"),
summary.end_state_hash,
)
}
/// The acceptance: an inspector that shows a state the replay never
/// reached is worse than no inspector. Both source kinds are walked,
/// because "it works for bundles" was the shape of the last three
/// half-checked claims in this repo.
#[test]
fn a_walk_reproduces_the_recorded_end_state() {
let (bundle, scenario, hash) = recorded("walk");
let mut out: Vec<u8> = Vec::new();
let report = super::walk(&bundle, Eyes::Spectator, &mut out).expect("bundle walks");
assert_eq!(report.end_state_hash, hash);
assert_eq!(report.expected_hash.as_deref(), Some(hash.as_str()));
assert!(
report.steps > 5,
"a 3-player game is more than {} steps",
report.steps
);
// The render must have run once per step plus once for the
// initial state — otherwise the walk "succeeded" having shown
// nothing, which is the harness-does-nothing shape.
let text = String::from_utf8(out).expect("utf8");
assert_eq!(
text.matches(" problems:").count(),
report.steps + 1,
"one table per step plus the opening one\n{text}"
);
let mut out: Vec<u8> = Vec::new();
let from_yaml = super::walk(&scenario, Eyes::Spectator, &mut out).expect("scenario walks");
assert_eq!(from_yaml.end_state_hash, hash);
let _ = std::fs::remove_dir_all(bundle.parent().expect("tmp dir"));
}
/// The control for the assertion above. A bundle whose recorded hash
/// has been altered must make the walk fail, or the hash comparison
/// is decoration.
#[test]
fn a_walk_that_does_not_reproduce_fails() {
let (bundle, _, hash) = recorded("tamper");
let manifest = bundle.join("manifest.yaml");
let text = std::fs::read_to_string(&manifest).expect("read manifest");
std::fs::write(
&manifest,
text.replace(
&hash,
"0000000000000000000000000000000000000000000000000000000000000000",
),
)
.expect("write manifest");
let mut out: Vec<u8> = Vec::new();
let err =
super::walk(&bundle, Eyes::Spectator, &mut out).expect_err("tampered bundle must fail");
assert!(err.contains("did not reproduce"), "wrong reason: {err}");
let _ = std::fs::remove_dir_all(bundle.parent().expect("tmp dir"));
}
/// `--as` is a projection, not a filter applied afterwards. Seat 2
/// (P3) sees its own hand and counts for the rest.
///
/// Seated deliberately at the *last* seat: the equivalent stage-0
/// test was vacuous twice (CB-EV-0007) because it inspected a
/// position where the hidden thing had not yet been written.
#[test]
fn a_seat_walk_shows_that_seat_and_no_other() {
let (bundle, _, _) = recorded("eyes");
let mut out: Vec<u8> = Vec::new();
super::walk(&bundle, Eyes::Seat(PlayerId(2)), &mut out).expect("walks");
let seated = String::from_utf8(out).expect("utf8");
let mut out: Vec<u8> = Vec::new();
super::walk(&bundle, Eyes::Spectator, &mut out).expect("walks");
let spectator = String::from_utf8(out).expect("utf8");
// Exactly one seat shows cards, and it is P3.
let tables = seated.matches(" problems:").count();
assert_eq!(
seated.matches("hand [").count(),
tables,
"P3 shows a hand in every table and nobody else does\n{seated}"
);
for line in seated.lines().filter(|l| l.contains("hand [")) {
assert!(line.contains("P3 (you)"), "a hand leaked: {line}");
}
// A spectator sees none at all — the same walk, one argument
// apart, so a render that ignored `Eyes` would fail here.
assert!(!spectator.contains("hand ["), "{spectator}");
assert!(!spectator.contains("(you)"), "{spectator}");
assert_ne!(seated, spectator);
let _ = std::fs::remove_dir_all(bundle.parent().expect("tmp dir"));
}
#[test]
fn eyes_parse_and_bad_ones_are_refused() {
assert_eq!(Eyes::parse("2").unwrap(), Eyes::Seat(PlayerId(2)));
assert_eq!(Eyes::parse("SPECTATOR").unwrap(), Eyes::Spectator);
assert!(Eyes::parse("P3").is_err());
assert!(Eyes::parse("").is_err());
}
}

View file

@ -9,14 +9,17 @@
//! success — a session that ends without an outcome is a failure, and
//! saying otherwise is how a stalled game passes for a played one.
mod inspect;
mod table;
use table::Config;
const USAGE: &str = "\
usage: cb-play [--seed N] [--players N] [--seat N]... [--bot greedy|random]
[--replay DIR] [--all-bots]
[--replay DIR] [--all-bots] [--record FILE]
cb-play --inspect PATH [--as SEAT|spectator]
play:
--seed N game seed (default 1); the same seed replays identically
--players N 2..6 seats (default 3)
--seat N a seat you play, 0-based, repeatable (default 0)
@ -24,10 +27,34 @@ usage: cb-play [--seed N] [--players N] [--seat N]... [--bot greedy|random]
--bot KIND policy for every other seat: greedy (default) or random
--replay DIR write a .cbreplay bundle of the finished game to DIR
--record FILE write the finished game as a scenario YAML
inspect a recorded game — a .cbreplay bundle directory or a scenario YAML:
--inspect PATH render the table after every recorded step
--as WHO whose projection to render: a 0-based seat, or
spectator (default). A seat sees only its own hand.
The two modes take disjoint flags: inspecting reads a recording, it does
not deal one, so a seed or a seat count would be silently ignored.
";
fn parse_args(argv: &[String]) -> Result<Config, String> {
/// The two things this binary does. Separate rather than one `Config`
/// with optional halves: `--inspect --seed 9` is not a request anyone
/// can mean, and a flag that is accepted and ignored is worse than one
/// that is refused.
enum Mode {
Play(Config),
Inspect {
source: std::path::PathBuf,
eyes: inspect::Eyes,
},
}
fn parse_args(argv: &[String]) -> Result<Mode, String> {
let mut config = Config::default();
let mut source: Option<std::path::PathBuf> = None;
let mut eyes = inspect::Eyes::Spectator;
let mut play_flags: Vec<String> = Vec::new();
let mut inspect_flags: Vec<String> = Vec::new();
let mut seats: Vec<u8> = Vec::new();
let mut all_bots = false;
let mut i = 0;
@ -40,18 +67,21 @@ fn parse_args(argv: &[String]) -> Result<Config, String> {
let flag = argv[i].as_str();
match flag {
"--seed" => {
play_flags.push(flag.into());
config.seed = value(i, argv, flag)?
.parse()
.map_err(|e| format!("--seed: {e}"))?;
i += 2;
}
"--players" => {
play_flags.push(flag.into());
config.players = value(i, argv, flag)?
.parse()
.map_err(|e| format!("--players: {e}"))?;
i += 2;
}
"--seat" => {
play_flags.push(flag.into());
seats.push(
value(i, argv, flag)?
.parse()
@ -60,25 +90,54 @@ fn parse_args(argv: &[String]) -> Result<Config, String> {
i += 2;
}
"--bot" => {
play_flags.push(flag.into());
config.bot = value(i, argv, flag)?;
i += 2;
}
"--record" => {
play_flags.push(flag.into());
config.record = Some(value(i, argv, flag)?.into());
i += 2;
}
"--replay" => {
play_flags.push(flag.into());
config.replay_dir = Some(value(i, argv, flag)?.into());
i += 2;
}
"--all-bots" => {
all_bots = true;
play_flags.push(flag.into());
i += 1;
}
"--inspect" => {
source = Some(value(i, argv, flag)?.into());
inspect_flags.push(flag.into());
i += 2;
}
"--as" => {
eyes = inspect::Eyes::parse(&value(i, argv, flag)?)?;
inspect_flags.push(flag.into());
i += 2;
}
"-h" | "--help" => return Err(USAGE.into()),
other => return Err(format!("unknown flag {other:?}\n\n{USAGE}")),
}
}
if let Some(source) = source {
if !play_flags.is_empty() {
return Err(format!(
"--inspect reads a recording; it cannot also {}\n\n{USAGE}",
play_flags.join(", ")
));
}
return Ok(Mode::Inspect { source, eyes });
}
if !inspect_flags.is_empty() {
return Err(format!(
"{} only means something with --inspect\n\n{USAGE}",
inspect_flags.join(", ")
));
}
if !(2..=6).contains(&config.players) {
return Err(format!("GR-O01: {} players is outside 2–6", config.players));
}
@ -95,12 +154,12 @@ fn parse_args(argv: &[String]) -> Result<Config, String> {
config.players
));
}
Ok(config)
Ok(Mode::Play(config))
}
fn main() {
let argv: Vec<String> = std::env::args().skip(1).collect();
let config = match parse_args(&argv) {
let mode = match parse_args(&argv) {
Ok(c) => c,
Err(message) => {
eprintln!("{message}");
@ -108,6 +167,33 @@ fn main() {
}
};
let config = match mode {
Mode::Play(c) => c,
Mode::Inspect { source, eyes } => {
let mut out = std::io::stdout().lock();
match inspect::walk(&source, eyes, &mut out) {
Ok(walk) => {
println!(
" {}: {} step(s), {} rejected, hash {}{}",
walk.source,
walk.steps,
walk.rejected,
walk.end_state_hash,
match walk.expected_hash {
Some(_) => " — reproduces the recording",
None => " — the source pins no hash to check",
}
);
return;
}
Err(message) => {
eprintln!("cb-play: {message}");
std::process::exit(1);
}
}
}
};
let stdin = std::io::stdin();
let stdout = std::io::stdout();
match table::play(&config, stdin.lock(), stdout.lock()) {
@ -264,21 +350,43 @@ mod tests {
assert_eq!(summary.rounds, 5);
}
/// `parse_args` in play mode. Panics on an inspect-mode result, so a
/// flag that quietly switched modes could not pass for a play flag.
fn play_args(list: &[&str]) -> Result<Config, String> {
match parse_args(&args(list))? {
Mode::Play(c) => Ok(c),
Mode::Inspect { .. } => panic!("{list:?} parsed as inspect, not play"),
}
}
#[test]
fn flags_parse_and_bad_ones_are_refused() {
let c = parse_args(&args(&["--seed", "9", "--players", "4", "--seat", "2"])).unwrap();
let c = play_args(&["--seed", "9", "--players", "4", "--seat", "2"]).unwrap();
assert_eq!((c.seed, c.players, c.human_seats.clone()), (9, 4, vec![2]));
assert_eq!(parse_args(&args(&[])).unwrap().human_seats, vec![0]);
assert!(parse_args(&args(&["--all-bots"]))
.unwrap()
.human_seats
.is_empty());
assert_eq!(play_args(&[]).unwrap().human_seats, vec![0]);
assert!(play_args(&["--all-bots"]).unwrap().human_seats.is_empty());
// GR-O01's range, refused at the door rather than at setup.
assert!(parse_args(&args(&["--players", "7"])).is_err());
assert!(parse_args(&args(&["--players", "1"])).is_err());
// A seat nobody occupies would silently never be prompted.
assert!(parse_args(&args(&["--players", "3", "--seat", "3"])).is_err());
assert!(parse_args(&args(&["--seed"])).is_err());
// The two modes take disjoint flags in both directions. A flag
// accepted and ignored is how a user believes they inspected
// seed 9 when they inspected whatever the recording holds.
assert!(parse_args(&args(&["--inspect", "x", "--seed", "9"])).is_err());
assert!(parse_args(&args(&["--as", "2"])).is_err());
assert!(matches!(
parse_args(&args(&["--inspect", "x", "--as", "2"])).unwrap(),
Mode::Inspect { eyes, .. } if eyes == inspect::Eyes::Seat(cb_kernel::PlayerId(2))
));
assert!(matches!(
parse_args(&args(&["--inspect", "x"])).unwrap(),
Mode::Inspect {
eyes: inspect::Eyes::Spectator,
..
}
));
assert!(parse_args(&args(&["--nope"])).is_err());
}

View file

@ -16,8 +16,9 @@ use cb_game_runtime::{Project, Setup, Viewer};
use cb_kernel::{Actor, PlayerId};
use games_ground::bot::{BotError, Choice, GreedyPolicy, Policy, RandomPolicy};
use games_ground::record::to_step;
use games_ground::view::{GroundView, PlayerView, ProblemView, SelectionView};
use games_ground::{GroundCommand, GroundState};
use crate::inspect::{render, seat_name};
use std::io::{BufRead, Write};
pub struct Config {
@ -58,19 +59,11 @@ pub struct Summary {
}
// ------------------------------------------------------------- rendering
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",
}
}
fn seat_name(p: PlayerId) -> String {
format!("P{}", p.0 + 1)
}
//
// The table itself is rendered by `inspect`. What stays here is the
// vocabulary a *chooser* needs: how a legal command is described in the
// menu. Rendering the state and naming a move are different jobs, and
// T02 needs the first without the second.
fn describe(command: &GroundCommand) -> String {
// Reuse the recorder's vocabulary rather than inventing a third one:
@ -90,109 +83,6 @@ fn describe(command: &GroundCommand) -> String {
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
/// A seat driven from stdin. Implements the same `Policy` the bots do, so

View file

@ -2,7 +2,8 @@
id: CB-WP-0011
kind: product
title: "Stage 1, first slice: an inspector that shows everything"
status: todo
status: done
state_hub_workstream_id: "1142442a-65f1-483a-800d-6ca8490c5f1a"
---
# Purpose
@ -75,8 +76,9 @@ form.
```task
id: CB-WP-0011-T01
status: todo
status: done
priority: high
state_hub_task_id: "b8cb4c20-9d06-47a4-890c-bf8390015439"
```
Extend `render` to cover every field of `GroundView`, and — the load-
@ -107,12 +109,41 @@ 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
different costume.
**Done 2026-08-02.** The renderer moved to `tools/cb-play/src/inspect.rs`
and now covers **41 of the 42 leaf paths** in a populated `GroundView`.
The one omission is `players.*.hand` — `null` for a non-viewer seat,
where the absence is what `hand N card(s)` renders.
**Measured, not counted** (T03): the old renderer produced 24 of the 41
tokens. One of the 17 it missed, `outcome.winners.*`, it did in fact
show — in a different format (`["P1", "P2"]` rather than `winners P1,
P2`), so the token misses it. **16 fields were genuinely absent**, and
that is the number to quote. The commit message for this task says
"42 of 43, up from 24"; both figures were counted by hand before the
measurement and both were wrong.
`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
id: CB-WP-0011-T02
status: todo
status: done
priority: high
state_hub_task_id: "08f8a9c7-3dfe-4f72-b510-df9efa457370"
```
Stage 1 asks for a *debug inspector*. The inspector above renders one
@ -137,12 +168,42 @@ must not show anyone else's. The stage-0 test that checked this was
vacuous twice before it held (CB-EV-0007); seat the assertion where the
hidden thing is actually hidden.
**Done 2026-08-02.** `cb-play --inspect PATH [--as SEAT|spectator]`
walks a `.cbreplay` bundle or a scenario YAML and renders the table after
every step.
The design decision worth recording: `replay::replay` was split, and its
bundle reader extracted as `replay::open`, so **the inspector walks
through the same reader the replay gate uses** — including the control
that makes the recorded seed load-bearing. A second reader would let the
inspector show states a replay never reached, and it would be a
duplicated fact in the one place where being wrong is silent. The hash is
then asserted anyway, because a structural argument that is never
executed is the class of claim this project keeps finding to be wrong.
The two modes take **disjoint** flags in both directions
(`--inspect --seed 9` and a bare `--as 2` are both refused, exit 64): a
flag accepted and ignored is how a user comes to believe they inspected
seed 9 when they inspected whatever the recording holds.
Three controls, each red for its stated reason:
| control | result |
|---|---|
| the hash comparison removed | `a_walk_that_does_not_reproduce_fails` fails — the tampered bundle walks clean |
| `Eyes::Seat` resolved to `Viewer::Spectator` | `P3 shows a hand in every table and nobody else does` |
| the per-step render dropped | `one table per step plus the opening one` |
The third is the one that matters: without it a walk that rendered
nothing at all would still have reported a matching hash and passed.
## Task: evidence, and what the chaos roll cost
```task
id: CB-WP-0011-T03
status: todo
status: done
priority: medium
state_hub_task_id: "db722865-1233-4918-952d-0bf4761199e4"
```
Write `evidence/CB-EV-0009-inspectable-table.md` covering:
@ -164,3 +225,12 @@ Write `evidence/CB-EV-0009-inspectable-table.md` covering:
lands; the port half is untouched and INTENT's stage-1 line stays open.
Marking a stage complete because part of it works is the failure mode
stage 0 avoided by leaving the CLI player open for three passes.
**Done 2026-08-02.** [CB-EV-0009](../evidence/CB-EV-0009-inspectable-table.md).
The headline: tier S did **not** produce a worse outcome, and the reason
is not that tier L was unnecessary — the roll forced a decomposition that
found a defect the survey would have walked past. Recorded as the CHAOS
gate's first `caught` entry, with the window left open.
Also on record, against my own prior: I recommended tier L in the turn
before the roll, and the roll was right.