CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit

Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat
play, at a measured marginal AM-4a cost of zero.

  games-ground shipped:  23 third-party crates
  cb-render-html:        23 third-party crates
  new crates introduced:  0

Measured, not asserted — the survey's own lesson. AM-4a is unmoved at
246,250; own source is 7,636 -> 9,652.

What shipped:
  crates/cb-render-html  doc.rs (HTML/SVG emission, incl. the relationship
                         graph), input.rs (pointer facts -> commands),
                         serve.rs (Guard, Request, loopback bind)
  tools/cb-play          hotseat.rs + `--serve PORT`

Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null.
The renderer targets the existing Project trait; the port waits for
stage 2's wgpu implementation to be its second use.

The six controls, all live, all mutation-checked (8 mutations, each red
for its stated reason):

  1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind
  4   a token-less request is refused, in the unit AND over a real socket
  5   JS may not construct commands — the page reports pointer facts, Rust
      resolves them against the legal list the aggregate already offered,
      and a test asserts the emitted script contains no game vocabulary
  6   the coverage gate crosses the language boundary: it walks the
      serialized view for leaf paths and requires each token to appear in
      the PARSED emitted document, with a test that the parse really is a
      parse (script/style contents must not count as rendered)

The gate fired on its author again, on its first run: ground_choices.*.
choice, ground_choices.*.problem and players.*.blame_from were in neither
list. The last is the one worth keeping — an EMPTY vector is a leaf path
of its own, and it now renders as an explicit absence.

Also, a mutation that did not go red: removing the Sec-Fetch-Site arm
alone left the cross-site test green, because the Origin check caught it
independently. Both had to be removed before the control bit. Recorded
because a control that passes for a reason you did not intend has not
been demonstrated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-02 04:27:25 +02:00
parent 883b608f36
commit 84d688688d
14 changed files with 2058 additions and 2 deletions

View file

@ -9,6 +9,7 @@
//! 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 hotseat;
mod inspect;
mod table;
@ -16,7 +17,7 @@ use table::Config;
const USAGE: &str = "\
usage: cb-play [--seed N] [--players N] [--seat N]... [--bot greedy|random]
[--replay DIR] [--all-bots] [--record FILE]
[--replay DIR] [--all-bots] [--record FILE] [--serve PORT]
cb-play --inspect PATH [--as SEAT|spectator]
play:
@ -27,6 +28,10 @@ play:
--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
--serve PORT play human seats in a browser on 127.0.0.1:PORT instead
of on the terminal; 0 lets the OS pick. Prints a URL
carrying a per-process token without it the page is
refused (ADR-0007).
inspect a recorded game a .cbreplay bundle directory or a scenario YAML:
--inspect PATH render the table after every recorded step
@ -104,6 +109,15 @@ fn parse_args(argv: &[String]) -> Result<Mode, String> {
config.replay_dir = Some(value(i, argv, flag)?.into());
i += 2;
}
"--serve" => {
play_flags.push(flag.into());
config.serve = Some(
value(i, argv, flag)?
.parse()
.map_err(|e| format!("--serve: {e}"))?,
);
i += 2;
}
"--all-bots" => {
all_bots = true;
play_flags.push(flag.into());
@ -245,6 +259,7 @@ mod tests {
bot: "greedy".into(),
replay_dir: None,
record: None,
serve: None,
};
let script = "0\n".repeat(400);
let mut out: Vec<u8> = Vec::new();
@ -283,6 +298,7 @@ mod tests {
bot: "greedy".into(),
replay_dir: None,
record: None,
serve: None,
};
let mut out: Vec<u8> = Vec::new();
table::play(&config, "0\n".repeat(200).as_bytes(), &mut out).expect("game");
@ -344,6 +360,7 @@ mod tests {
bot: "random".into(),
replay_dir: None,
record: None,
serve: None,
};
let mut out: Vec<u8> = Vec::new();
let summary = table::play(&config, "".as_bytes(), &mut out).expect("bot game");
@ -403,6 +420,7 @@ mod tests {
bot: "greedy".into(),
replay_dir: Some(dir.clone()),
record: Some(dir.join("session.yaml")),
serve: None,
};
let mut out: Vec<u8> = Vec::new();
let summary = table::play(&config, "".as_bytes(), &mut out).expect("game");