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

@ -32,6 +32,9 @@ pub struct Config {
/// Where to write the finished game as a scenario file. A session
/// somebody played becomes a regression test.
pub record: Option<std::path::PathBuf>,
/// Serve human seats in a browser instead of on the terminal
/// (ADR-0007). `Some(0)` lets the OS pick the port.
pub serve: Option<u16>,
}
impl Default for Config {
@ -43,6 +46,7 @@ impl Default for Config {
bot: "greedy".into(),
replay_dir: None,
record: None,
serve: None,
}
}
}
@ -213,10 +217,28 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
// Human seats and bot seats fill one policy vector; the driver does
// not know which is which, which is the point.
let failure: Failure = Default::default();
// ADR-0007: a browser seat and a CLI seat are both just a Policy, so
// the driver cannot tell them apart — which is the property that lets
// a browser game replay as a scenario like any other.
let server = match config.serve {
Some(port) => {
let s = std::rc::Rc::new(crate::hotseat::Server::bind(port)?);
let _ = writeln!(out.borrow_mut(), " open {}", s.url());
let _ = out.borrow_mut().flush();
Some(s)
}
None => None,
};
let mut policies: Vec<Box<dyn Policy + 'a>> = Vec::new();
for seat in 0..config.players {
if config.human_seats.contains(&seat) {
policies.push(Box::new(HumanPolicy::new(input, out, failure.clone())));
match &server {
Some(s) => policies.push(Box::new(crate::hotseat::SeatPolicy::new(
s.clone(),
failure.clone(),
))),
None => policies.push(Box::new(HumanPolicy::new(input, out, failure.clone()))),
}
} else {
policies.push(bot_policy(&config.bot, config.seed + u64::from(seat))?);
}
@ -248,6 +270,20 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
game.commands,
&end_hash[..12]
);
// ADR-0007 control 1 leaves evidence rather than only a 403: a
// session that was probed says so, so a player finds out from the
// transcript rather than from nothing at all.
if let Some(s) = &server {
let refusals = s.refusals();
if refusals.is_empty() {
let _ = writeln!(w, " no requests were refused this session");
} else {
let _ = writeln!(w, " {} request(s) refused:", refusals.len());
for r in &refusals {
let _ = writeln!(w, " {r}");
}
}
}
let _ = w.flush();
drop(w);