Compare commits
3 commits
c1d140626e
...
0d2ab2286d
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d2ab2286d | |||
| b11fc91fd4 | |||
| d2ca1046c0 |
8 changed files with 1314 additions and 149 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
191
evidence/CB-EV-0009-inspectable-table.md
Normal file
191
evidence/CB-EV-0009-inspectable-table.md
Normal 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.
|
||||
|
|
@ -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]]
|
||||
|
|
|
|||
878
tools/cb-play/src/inspect.rs
Normal file
878
tools/cb-play/src/inspect.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue