clay-borg/tools/cb-play/src/hotseat.rs

909 lines
37 KiB
Rust
Raw Normal View History

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>
2026-08-02 04:27:25 +02:00
//! Hot-seat play in a browser (ADR-0007, INTENT stage 1).
//!
//! One loopback listener, one tab, seats taking turns. The server blocks
//! inside [`Policy::choose`] until the page reports a pointer fact that
//! resolves to a legal command — so the game loop is the same
//! `games_ground::bot::play` loop the CLI and the bots run through, and a
//! browser seat is indistinguishable from a CLI seat to the driver.
//!
//! Every control in ADR-0007 §Decision 5 lives in `cb-render-html`; this
//! module is the socket and the turn-taking. It deliberately holds no
//! game logic beyond "which seat is being asked" — [`cb_render_html`]
//! decides what a drag means, and the aggregate decides whether the
//! result is legal.
use std::cell::RefCell;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::rc::Rc;
use cb_game_runtime::{Project, Viewer};
use cb_kernel::PlayerId;
use cb_render_html::{document, resolve, Guard, PointerFact, Request};
use games_ground::bot::{Choice, Policy};
use games_ground::{GroundCommand, GroundState};
/// The shared listener. One per process; every human seat borrows it.
pub struct Server {
listener: TcpListener,
guard: Guard,
log: RefCell<Vec<String>>,
/// The live account of the game, shared with the driver (CB-WP-0018
/// T02). Read at render time so the page shows what has happened.
journal: games_ground::bot::Journal,
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>
2026-08-02 04:27:25 +02:00
}
impl Server {
pub fn bind(port: u16) -> Result<Self, String> {
let listener = cb_render_html::serve::bind(port).map_err(|e| format!("bind: {e}"))?;
let port = listener
.local_addr()
.map_err(|e| format!("local_addr: {e}"))?
.port();
Ok(Self {
listener,
guard: Guard::mint(port),
log: RefCell::new(Vec::new()),
journal: games_ground::bot::Journal::default(),
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>
2026-08-02 04:27:25 +02:00
})
}
/// The URL to open, token and all. Printed once at start; the page
/// cannot mint one for itself.
pub fn url(&self) -> String {
self.guard.page_url()
}
/// The journal the driver appends to; hand it to `play_journaled`.
pub fn journal(&self) -> games_ground::bot::Journal {
self.journal.clone()
}
/// The log as the page renders it, in the recorder's vocabulary.
///
/// `record::to_step` is reused rather than phrased afresh: what the
/// player reads is what the scenario file will say. The effects are
/// the events the command actually produced, and a command that
/// produced none says so — that is the case CB-WP-0018 was reported
/// for.
fn log_lines(&self) -> Vec<cb_render_html::doc::LogLine> {
self.journal
.borrow()
.iter()
.map(|a| {
let step = games_ground::record::to_step(a.actor, &a.command);
let mut what = step.cmd.clone();
for (k, v) in &step.args {
let rendered = match v {
serde_yaml::Value::String(s) => s.clone(),
other => serde_yaml::to_string(other)
.unwrap_or_default()
.trim()
.to_string(),
};
what.push_str(&format!(" {k}={rendered}"));
}
cb_render_html::doc::LogLine {
who: match a.actor {
cb_kernel::Actor::Player(p) => format!("P{}", p.0 + 1),
cb_kernel::Actor::System => "the round".to_string(),
},
what,
effects: a.events.iter().map(event_line).collect(),
}
})
.collect()
}
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>
2026-08-02 04:27:25 +02:00
/// What was refused, for the evidence a session leaves behind.
pub fn refusals(&self) -> Vec<String> {
self.log.borrow().clone()
}
/// Serve until the page reports something that resolves to a move.
///
/// Requests that fail a control are answered and the loop continues —
/// a refused request must not end someone's game, and must not be
/// silently indistinguishable from one that did nothing.
pub fn next_choice(
&self,
state: &GroundState,
seat: PlayerId,
legal: &[GroundCommand],
may_pass: bool,
) -> Result<Choice, String> {
let view = state.project(Viewer::Player(seat));
loop {
let (mut stream, _) = self.listener.accept().map_err(|e| format!("accept: {e}"))?;
let raw = match read_request(&mut stream) {
Ok(r) => r,
Err(e) => {
respond(&mut stream, 400, "text/plain", &e);
continue;
}
};
let req = match Request::parse(&raw) {
Ok(r) => r,
Err(refusal) => {
self.log.borrow_mut().push(refusal.to_string());
respond(&mut stream, 400, "text/plain", &refusal.to_string());
continue;
}
};
if let Err(refusal) = self.guard.admit(&req) {
self.log.borrow_mut().push(refusal.to_string());
respond(&mut stream, 403, "text/plain", &refusal.to_string());
continue;
}
match (req.method.as_str(), req.path.as_str()) {
("GET", "/") => {
let page = cb_render_html::doc::document_with_log(
&view,
legal,
&self.guard.endpoint(),
Some(seat),
may_pass,
&self.log_lines(),
);
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>
2026-08-02 04:27:25 +02:00
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
}
("POST", "/command") => {
let fact = match PointerFact::parse(&req.body) {
Ok(f) => f,
Err(e) => {
respond(&mut stream, 400, "text/plain", &e);
continue;
}
};
if may_pass && fact.down == "pass" && fact.up == "pass" {
respond(&mut stream, 200, "text/plain", "ok: passed");
return Ok(Choice::Pass);
}
match resolve(&fact, legal, seat) {
Ok(i) => {
respond(&mut stream, 200, "text/plain", "ok");
return Ok(Choice::Command(i));
}
// Not an error: the player dragged somewhere that
// means nothing. Say so and keep the turn.
Err(why) => respond(&mut stream, 200, "text/plain", &why),
}
}
_ => respond(&mut stream, 404, "text/plain", "no such thing here"),
}
}
}
/// Serve the end of the game until the player has seen it.
///
/// **The defect this exists for (CB-WP-0018 T01):** `next_choice`
/// only accepts connections *inside* a decision point, so when
/// `play()` returned the listener died and the page's post-`ok`
/// reload was refused. Measured: a game ended normally at 5 rounds
/// and 30 commands, its whole result went to the terminal, and the
/// browser got `Connection refused`. **A crash and a win rendered
/// identically — as nothing.**
///
/// `final_view` is `None` when the game ended badly; then `message`
/// is the reason and the page says the game ended without a result
/// rather than drawing a table that never happened.
///
/// **How it ends, and why that needed deciding:** a server that never
/// exits is its own defect, and a timeout would race a player reading
/// the result. It serves until the page tells it the result has been
/// seen — the terminal page carries a `done` control and posts it —
/// with `linger` as a bound so an abandoned tab cannot hold the
/// process open forever.
pub fn serve_end(
&self,
final_view: Option<&games_ground::view::GroundView>,
message: &str,
linger: std::time::Duration,
) -> Result<(), String> {
let deadline = std::time::Instant::now() + linger;
self.listener
.set_nonblocking(true)
.map_err(|e| format!("nonblocking: {e}"))?;
let outcome = loop {
if std::time::Instant::now() >= deadline {
break Ok(());
}
let (mut stream, _) = match self.listener.accept() {
Ok(pair) => pair,
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(std::time::Duration::from_millis(25));
continue;
}
Err(e) => break Err(format!("accept: {e}")),
};
stream.set_nonblocking(false).ok();
let Ok(raw) = read_request(&mut stream) else {
continue;
};
let Ok(req) = Request::parse(&raw) else {
respond(&mut stream, 400, "text/plain", "bad request");
continue;
};
if let Err(refusal) = self.guard.admit(&req) {
self.log.borrow_mut().push(refusal.to_string());
respond(&mut stream, 403, "text/plain", &refusal.to_string());
continue;
}
match (req.method.as_str(), req.path.as_str()) {
("GET", "/") => {
let page = cb_render_html::doc::ending(
final_view,
message,
&self.guard.endpoint(),
&self.log_lines(),
);
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
}
("POST", "/command") => {
respond(&mut stream, 200, "text/plain", "closed");
break Ok(());
}
_ => respond(&mut stream, 404, "text/plain", "the game is over"),
}
};
self.listener.set_nonblocking(false).ok();
outcome
}
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>
2026-08-02 04:27:25 +02:00
}
/// One seat's view of the shared server.
pub struct SeatPolicy {
server: Rc<Server>,
failure: Rc<RefCell<Option<String>>>,
}
impl SeatPolicy {
pub fn new(server: Rc<Server>, failure: Rc<RefCell<Option<String>>>) -> Self {
Self { server, failure }
}
}
impl Policy for SeatPolicy {
fn name(&self) -> &'static str {
"browser"
}
fn choose(
&mut self,
state: &GroundState,
seat: PlayerId,
legal: &[GroundCommand],
may_pass: bool,
) -> Choice {
match self.server.next_choice(state, seat, legal, may_pass) {
Ok(c) => c,
Err(e) => {
// Same shape as the CLI's out-of-input path: record the
// real reason and return something the driver will
// reject, rather than inventing a move.
*self.failure.borrow_mut() = Some(e);
Choice::Command(usize::MAX)
}
}
}
}
/// One event, in words a player can read.
///
/// Deliberately terse and derived from the event itself, never from the
/// state afterwards: a log that re-narrates state is a second
/// implementation of the rules and will drift from the first.
fn event_line(e: &games_ground::GroundEvent) -> String {
use games_ground::GroundEvent as E;
let p = |x: &cb_kernel::PlayerId| format!("P{}", x.0 + 1);
match e {
E::ActionSelected { player, selection } => match (selection.target, selection.problem) {
(Some(t), _) => format!("{} chose {:?} on {}", p(player), selection.action, p(&t)),
(_, Some(n)) => format!("{} chose {:?} on problem {n}", p(player), selection.action),
_ => format!("{} chose {:?}", p(player), selection.action),
},
E::Revealed => "selections revealed".into(),
E::StressSet { player, stress } => format!("{} stress now {stress}", p(player)),
E::FreedomSpent { player } => format!("{} spent freedom", p(player)),
E::FreedomReadied { player } => format!("{} freedom ready", p(player)),
E::RelationFormed { pair, relation } => {
format!("{} and {} \u{2014} {relation:?}", p(&pair.0), p(&pair.1))
}
E::RelationBroken { pair } => format!("{} and {} no longer tied", p(&pair.0), p(&pair.1)),
E::AttackCancelled { attacker, target } => {
format!("{}'s attack on {} cancelled", p(attacker), p(target))
}
E::ProblemRevealed { problem } => format!("problem {problem} turned face up"),
E::SolutionDrawn { player, .. } => format!("{} drew a solution", p(player)),
E::SolutionDiscarded { player, card } => {
format!("{} spent a {:?} solution", p(player), card.suit)
}
E::ProblemClaimed { problem, by } => format!("problem {problem} claimed by {}", p(by)),
E::ProblemDenied { problem } => format!("problem {problem} denied"),
E::ProblemProtected { problem } => format!("problem {problem} protected"),
E::ProblemRestored { problem } => format!("problem {problem} restored"),
E::ProtectionGained { player } => format!("{} gained protection", p(player)),
E::BlameRemoved { player, owner } => {
format!("{} cleared {}'s blame", p(player), p(owner))
}
E::FocusPlaced { owner, target } => format!("{} focused on {}", p(owner), p(target)),
E::FocusFlippedToBlame { owner, target } => {
format!("{}'s focus on {} became blame", p(owner), p(target))
}
E::DarvoTriggered { player } => format!("{} entered DARVO", p(player)),
E::DarvoAdvanced { player, stage } => format!("{} DARVO \u{2192} {stage:?}", p(player)),
E::DarvoEnded { player } => format!("{} left DARVO", p(player)),
E::DarvoTargetChosen { player, .. } => format!("{} named a DARVO target", p(player)),
E::GroundModeChosen { player, mode, .. } => {
format!("{} grounded as {mode:?}", p(player))
}
E::SupportAnswered { player, response } => {
format!("{} answered support with {response:?}", p(player))
}
E::DeckReshuffled { .. } => "the discard was reshuffled into the deck".into(),
E::RoundEnded { round, next_lead } => {
format!("round {round} ended; {} leads next", p(next_lead))
}
E::StepAdvanced { step } => format!("step \u{2192} {step:?}"),
E::GameEnded { .. } => "the game ended".into(),
}
}
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>
2026-08-02 04:27:25 +02:00
fn read_request(stream: &mut TcpStream) -> Result<String, String> {
let mut buf = Vec::new();
let mut chunk = [0u8; 1024];
loop {
let n = stream.read(&mut chunk).map_err(|e| format!("read: {e}"))?;
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
// Once the head is in, read exactly the declared body and stop —
// otherwise a keep-alive connection blocks here forever.
if let Some(head_end) = find(&buf, b"\r\n\r\n") {
let head = String::from_utf8_lossy(&buf[..head_end]).to_string();
let want: usize = head
.split("\r\n")
.find_map(|l| {
let (k, v) = l.split_once(':')?;
k.eq_ignore_ascii_case("content-length")
.then(|| v.trim().parse().ok())?
})
.unwrap_or(0);
if buf.len() >= head_end + 4 + want {
break;
}
}
if buf.len() > 64 * 1024 {
return Err("request too large".to_string());
}
}
String::from_utf8(buf).map_err(|_| "request is not UTF-8".to_string())
}
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
haystack.windows(needle.len()).position(|w| w == needle)
}
fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &str) {
let reason = match status {
200 => "OK",
400 => "Bad Request",
403 => "Forbidden",
_ => "Not Found",
};
let head = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\n\
Content-Length: {}\r\nConnection: close\r\n\
X-Content-Type-Options: nosniff\r\n\r\n",
body.len()
);
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(body.as_bytes());
let _ = stream.flush();
}
#[cfg(test)]
mod tests {
use super::*;
use games_ground::Action;
/// One client thread issuing requests **in order**, each on its own
/// `Connection: close` socket and each fully read before the next is
/// sent. Two concurrent clients would race the server's `accept`, and
/// a test whose outcome depends on which socket wins is worse than no
/// test.
/// **The defect CB-WP-0018 T01 exists for.** After the game ends the
/// browser must get the result, not a refused connection.
///
/// Measured before the fix, driving a real game to completion over
/// HTTP: move 5 accepted, then `GET /` → `[Errno 111] Connection
/// refused`, while the whole outcome went to a terminal nobody was
/// reading. A crash and a win rendered identically — as nothing.
#[test]
fn the_end_of_the_game_reaches_the_browser() {
let server = Server::bind(0).expect("bind");
let port = server.listener.local_addr().unwrap().port();
let token = server.guard.token().to_string();
let client = converse(
port,
vec![
format!("GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"),
format!(
"POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\
Origin: http://127.0.0.1:{port}\r\nContent-Length: 17\r\n\r\n\
down=done&up=done"
),
],
);
let view = state().project(Viewer::Spectator);
server
.serve_end(
Some(&view),
"30 commands, hash f6c890a65271",
std::time::Duration::from_secs(20),
)
.expect("serve_end");
let replies = client.join().expect("client");
assert!(replies[0].contains("200 OK"), "{}", replies[0]);
assert!(replies[0].contains("game over"), "the page did not say so");
assert!(
replies[0].contains("30 commands"),
"the result did not reach the page"
);
// It must not auto-reload into a refused connection, which is how
// a completed game became a blank tab in the first place.
//
// Asserted on BEHAVIOUR, not on source text. The first draft
// grepped the page for "location.reload" and failed: the ending
// page reuses `SCRIPT`, whose reload is guarded by
// `t.indexOf('ok') === 0`. Grepping for the string would have
// forced a second script to satisfy a test rather than a
// requirement — the exact weak-control shape ADR-0010 D2 demoted.
// What matters is that the endpoint cannot answer "ok".
assert!(
!replies[1].contains("ok"),
"the ending endpoint answered something the page reloads on: {}",
replies[1]
);
assert!(replies[1].contains("closed"), "{}", replies[1]);
}
/// A game that ended badly must say so rather than draw a table for a
/// game that never happened.
#[test]
fn a_game_that_ended_badly_says_so_and_shows_no_table() {
let page = cb_render_html::doc::ending(None, "P1 ran out of input", "/command?t=x", &[]);
assert!(
page.contains("P1 ran out of input"),
"the reason is missing"
);
assert!(page.contains("without a result"));
assert!(
!page.contains("relationships"),
"a failed game drew a table anyway"
);
}
/// A writer the test can read while the game is still running.
#[derive(Clone)]
struct SharedOut(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl Write for SharedOut {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().expect("out").extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn http(port: u16, raw: &str) -> String {
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
s.write_all(raw.as_bytes()).expect("write");
let mut out = String::new();
let _ = s.read_to_string(&mut out);
out
}
/// **The chain, not the links.** `the_end_of_the_game_reaches_the_browser`
/// calls `serve_end` directly, so it passes even when `run_game` never
/// calls it — proven: deleting that call left it green. That is the
/// defect class this project keeps meeting (CB-EV-0012: *"every link
/// was tested and the chain was not"*), and it survived one round of
/// it here before this test existed.
///
/// So: run the real `play()` with a browser seat, drive a real game to
/// its end over a real socket, and require the last page to be the
/// ending rather than a refused connection.
#[test]
fn a_real_game_played_to_its_end_leaves_the_ending_on_screen() {
let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let out = SharedOut(buf.clone());
let game = std::thread::spawn(move || {
crate::table::play(
&crate::table::Config {
seed: 7,
players: 3,
human_seats: vec![0],
bot: "random".into(),
replay_dir: None,
record: None,
serve: Some(0),
},
std::io::Cursor::new(Vec::new()),
out,
)
});
// The URL is printed as soon as the listener binds.
let url = loop {
let text = String::from_utf8_lossy(&buf.lock().expect("out")).to_string();
if let Some(i) = text.find("http://127.0.0.1:") {
let rest = &text[i..];
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
break rest[..end].to_string();
}
std::thread::sleep(std::time::Duration::from_millis(20));
};
let port: u16 = url["http://127.0.0.1:".len()..]
.split('/')
.next()
.expect("port")
.parse()
.expect("port number");
let token = url.split("t=").nth(1).expect("token").to_string();
// Play until the page stops offering moves — which is the ending.
let mut last = String::new();
for _ in 0..60 {
last = http(
port,
&format!("GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"),
);
let keys = cb_render_html::jsrun::droppables(&last);
// A spatial move if one is offered; otherwise the numbered
// fallback or pass, which is what the page offers at steps
// with no draggable action. Breaking here instead would end
// the walk mid-game and assert nothing.
let spatial = keys
.iter()
.find(|(k, t)| k.starts_with("action-") && t.is_some())
.map(|(k, t)| {
(
k.clone(),
t.as_ref()
.expect("checked")
.split(' ')
.next()
.expect("a target")
.to_string(),
)
});
let button = keys
.iter()
.find(|(k, _)| k.starts_with("cmd-") || k == "pass")
.map(|(k, _)| (k.clone(), k.clone()));
let Some((down, up)) = spatial.or(button) else {
break;
};
let body = format!("down={down}&up={up}");
http(
port,
&format!(
"POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\
Origin: http://127.0.0.1:{port}\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
),
);
}
// Before CB-WP-0018 this GET was a refused connection and the
// whole result went to a terminal nobody was reading.
assert!(
last.contains("game over"),
"the last page the player saw was not the ending: {}",
&last[..last.len().min(300)]
);
assert!(last.contains("commands, hash"), "no result on the page");
// CB-WP-0018 T02: the log is on the page, and it came from the
// journal the driver filled -- not from re-reading the state.
assert!(last.contains("<h2>log</h2>"), "no log section");
assert!(
!last.contains("nothing has happened yet"),
"a finished game reported an empty log"
);
assert!(
last.contains("select_action"),
"the log is not in the recorder's vocabulary"
);
// Let the game thread finish: tell it the result has been seen.
let body = "down=done&up=done";
http(
port,
&format!(
"POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\
Origin: http://127.0.0.1:{port}\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
),
);
game.join().expect("game thread").expect("the game ran");
}
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>
2026-08-02 04:27:25 +02:00
fn converse(port: u16, requests: Vec<String>) -> std::thread::JoinHandle<Vec<String>> {
std::thread::spawn(move || {
requests
.into_iter()
.map(|raw| {
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
s.write_all(raw.as_bytes()).expect("write");
let mut out = String::new();
let _ = s.read_to_string(&mut out);
out
})
.collect()
})
}
fn get(token: &str) -> String {
format!(
"GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1\r\n\
Sec-Fetch-Site: same-origin\r\n\r\n"
)
}
fn post(token: &str, body: &str) -> String {
format!(
"POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1\r\n\
Sec-Fetch-Site: same-origin\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
)
}
fn ground_only() -> Vec<GroundCommand> {
vec![GroundCommand::SelectAction {
action: Action::Ground,
target: None,
problem: None,
}]
}
fn state() -> GroundState {
<GroundState as cb_game_runtime::ScenarioGame>::setup(
&cb_game_runtime::Setup {
players: 3,
preset: "standard-3p".to_string(),
patch: Default::default(),
},
7,
)
.expect("setup")
}
/// The whole loop, with a real socket and no browser: the page is
/// served, a pointer fact is posted, and the seat's choice comes back.
#[test]
fn a_drag_posted_over_the_socket_becomes_a_choice() {
let server = Server::bind(0).expect("bind");
let port = server.listener.local_addr().unwrap().port();
let token = server.url().rsplit("t=").next().unwrap().to_string();
let client = converse(
port,
vec![get(&token), post(&token, "down=action-ground&up=table")],
);
let choice = server
.next_choice(&state(), PlayerId(0), &ground_only(), false)
.expect("a choice");
assert_eq!(choice, Choice::Command(0));
let replies = client.join().expect("client thread");
assert!(replies[0].contains("200 OK"), "{}", replies[0]);
assert!(replies[0].contains("GROUND"), "the page was not the table");
CB-WP-0016: the drop target that was never there Provenance (tier S, one paragraph in lieu of survey and ADR): the human check that kept INTENT stage 1 open was run and the drag was broken. Root cause, worth more than the instance: drop targets were ids, and an id must be unique, so exactly one element could ever be seat-0. The relationship-graph circle took it and the seat card that every action card's own text points at -- 'drag Attack onto a seat' -- silently had none. A seat is drawn twice and both drawings are the seat; the document model could not express that. Drop keys are now data-drop. Any number of elements may carry the same key, so a seat is droppable on its card and on its graph node. Measured on a live server: seat-0/1/2 each appear twice, id survives only on cb-status which is the one element the script looks up, and down=action-attack&up=seat-1 returns ok. Second defect: a drop on nothing returned without posting and without touching the status line, so a broken target was indistinguishable from a working page. resolve already refuses rather than defaulting, which is right; refusing SILENTLY is not. The page now reports the raw fact -- 'took action-attack, let go over nothing droppable' -- which names elements, not moves, so ADR-0007 control 5 holds. And the honest part: the general check added here -- every offered affordance names a key that exists, driven through Policy::choose over four real bot games -- does NOT catch the reported defect. seat-0 did exist, on the graph circle. It is kept because a wholly absent target is a real class, and paired with a targeted regression test that does catch it. Three mutations, each red for its stated reason, including the reported defect reintroduced; only the targeted test fires on that one. A cb-play assertion matched id="action-ground" as a substring while describing itself as checking the page; rewritten through drop_keys. make all exits 0. Stage 1 stays open: verified by tests, mutation and a live server, not by a human dragging. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 20:48:18 +02:00
// Through the parser, not a raw attribute match: this assertion
// read `id="action-ground"` and CB-WP-0016 moved drop keys to
// `data-drop`, so a substring test drifts silently on the next
// rename while still describing itself as checking the page.
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>
2026-08-02 04:27:25 +02:00
assert!(
CB-WP-0016: the drop target that was never there Provenance (tier S, one paragraph in lieu of survey and ADR): the human check that kept INTENT stage 1 open was run and the drag was broken. Root cause, worth more than the instance: drop targets were ids, and an id must be unique, so exactly one element could ever be seat-0. The relationship-graph circle took it and the seat card that every action card's own text points at -- 'drag Attack onto a seat' -- silently had none. A seat is drawn twice and both drawings are the seat; the document model could not express that. Drop keys are now data-drop. Any number of elements may carry the same key, so a seat is droppable on its card and on its graph node. Measured on a live server: seat-0/1/2 each appear twice, id survives only on cb-status which is the one element the script looks up, and down=action-attack&up=seat-1 returns ok. Second defect: a drop on nothing returned without posting and without touching the status line, so a broken target was indistinguishable from a working page. resolve already refuses rather than defaulting, which is right; refusing SILENTLY is not. The page now reports the raw fact -- 'took action-attack, let go over nothing droppable' -- which names elements, not moves, so ADR-0007 control 5 holds. And the honest part: the general check added here -- every offered affordance names a key that exists, driven through Policy::choose over four real bot games -- does NOT catch the reported defect. seat-0 did exist, on the graph circle. It is kept because a wholly absent target is a real class, and paired with a targeted regression test that does catch it. Three mutations, each red for its stated reason, including the reported defect reintroduced; only the targeted test fires on that one. A cb-play assertion matched id="action-ground" as a substring while describing itself as checking the page; rewritten through drop_keys. make all exits 0. Stage 1 stays open: verified by tests, mutation and a live server, not by a human dragging. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 20:48:18 +02:00
cb_render_html::doc::drop_keys(&replies[0]).contains("action-ground"),
"the offered action was not a drop target on the page"
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>
2026-08-02 04:27:25 +02:00
);
assert!(replies[1].contains("200 OK"));
assert!(server.refusals().is_empty());
}
/// ADR-0007 control 1, end to end rather than in the unit: a page
/// without the token gets nothing, and the seat keeps its turn.
///
/// M-D1-MUT: make `admit` return `Ok(())` unconditionally and this
/// goes red — the token-less request is served the table. Run
/// 2026-08-02.
#[test]
fn a_token_less_request_over_the_socket_is_refused_and_the_turn_continues() {
let server = Server::bind(0).expect("bind");
let port = server.listener.local_addr().unwrap().port();
let token = server.url().rsplit("t=").next().unwrap().to_string();
let client = converse(
port,
vec![
"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nSec-Fetch-Site: same-origin\r\n\r\n"
.to_string(),
post(&token, "down=action-ground&up=table"),
],
);
// The turn survives the refusal and is decided by the good request.
let choice = server
.next_choice(&state(), PlayerId(0), &ground_only(), false)
.expect("a choice");
assert_eq!(choice, Choice::Command(0));
let replies = client.join().expect("client thread");
assert!(replies[0].contains("403 Forbidden"), "{}", replies[0]);
assert!(
!replies[0].contains("GROUND"),
"the table leaked to a token-less request"
);
assert!(replies[1].contains("200 OK"));
assert_eq!(
server.refusals(),
vec!["refused: no session token".to_string()]
);
}
/// A drag that means nothing must not end the turn, and must say so.
#[test]
fn a_meaningless_drag_keeps_the_turn() {
let server = Server::bind(0).expect("bind");
let port = server.listener.local_addr().unwrap().port();
let token = server.url().rsplit("t=").next().unwrap().to_string();
let client = converse(
port,
vec![
post(&token, "down=seat-1&up=seat-2"),
post(&token, "down=action-ground&up=table"),
],
);
let choice = server
.next_choice(&state(), PlayerId(0), &ground_only(), false)
.expect("a choice");
assert_eq!(choice, Choice::Command(0));
let replies = client.join().expect("client thread");
assert!(replies[0].contains("200 OK"));
assert!(
replies[0].contains("not a legal move"),
"a meaningless drag must be told it meant nothing: {}",
replies[0]
);
assert!(replies[1].contains("200 OK"));
}
CB-WP-0014-T01/T02: execute the JavaScript — and find AM-4b blind ADR-0009: embed quick-js; node is refused. Measured marginal cost against the dev-toolchain graph, under the positive control: boa_engine 896,410 rquickjs 69,985 quick-js 11,434 node 0 <- and that zero is the problem ADR-0007 D3's acquisition rule biting its author. CI runs on rust:1.97, which has no node, so the test would make our build fetch a JS runtime of tens of millions of unaudited lines while scoring zero on the only instrument that governs dependencies. A browser is exempt because a developer has one regardless of us; a CI-installed runtime is not. The loop is now closed: the real server serves the real page, QuickJS runs that page's own scripts, the gesture goes over a real socket, and the seat's Choice comes back. Before this, every link was tested and the chain was not — a page whose JavaScript sent something else entirely would have passed everything. Three controls, each red for its stated reason: the JS posting a command name instead of ids, the gesture not being delivered (EXPECT-VACUOUS), and the token stripped from the endpoint. A wrong assertion worth keeping: the first draft required the body not to contain "attack". It legitimately does — action-attack is the id of an element a finger landed on. An element may name an action; that is not the page deciding. The real test is the shape: exactly two fields, down and up, carrying two ids and nothing derived from them. AND the ADR's own cost argument was wrong. It claimed 35% of AM-4b's headroom; after landing AM-4b did not move at all. It measures games-ground --edges normal — one package, no dev edges. Measured, the workspace including dev edges is 725,258 lines against AM-4b's 317,021: 408,237 uncounted, MORE THAN THE TARGET ITSELF (criterion, clap, ciborium, quick-js). The decision stands on the acquisition rule; the affordability argument is withdrawn. Third defect in the AM-4 family. Also fixed structurally rather than by raising a limit: `make status` had grown past its 40-line readability gate as workplans accumulated. Closed workplans now collapse to one line, so the report is fixed-size. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 07:56:02 +02:00
/// **The loop, closed.** The real server serves the real page; a real
/// JavaScript engine runs the page's own script and produces a pointer
/// gesture; what it produces goes over a real socket; and the seat's
/// choice comes back.
///
/// Before ADR-0009 every link in that chain was tested and the chain
/// was not. The page was asserted against as a parsed document and the
/// socket was driven by synthetic HTTP that this test suite wrote
/// itself — so a page whose JavaScript sent something else entirely
/// would have passed everything.
#[test]
fn a_gesture_in_javascript_becomes_a_move_in_the_game() {
let server = Server::bind(0).expect("bind");
let port = server.listener.local_addr().unwrap().port();
let token = server.url().rsplit("t=").next().unwrap().to_string();
let tok = token.clone();
let client = std::thread::spawn(move || {
let token = tok;
// 1. fetch the page the server actually serves
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
s.write_all(get(&token).as_bytes()).expect("write");
let mut page = String::new();
let _ = s.read_to_string(&mut page);
assert!(page.contains("200 OK"), "{page}");
// 2. run ITS script, in a real engine, with a real gesture
let posts = cb_render_html::jsrun::gesture(&page, "action-ground", "table")
.expect("the served page's script runs");
assert_eq!(posts.len(), 1, "{posts:?}");
// 3. send exactly what the JavaScript produced — not what this
// test thinks it should have produced
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
s.write_all(post(&token, &posts[0].body).as_bytes())
.expect("write");
let mut reply = String::new();
let _ = s.read_to_string(&mut reply);
(posts[0].clone(), reply)
});
let choice = server
.next_choice(&state(), PlayerId(0), &ground_only(), false)
.expect("a choice");
assert_eq!(choice, Choice::Command(0));
let (posted, reply) = client.join().expect("client thread");
assert_eq!(posted.body, "down=action-ground&up=table");
// The endpoint the JS used carries the server's own token, which
// the page cannot mint — so this also proves the token round-trips
// through the emitted document.
assert!(posted.url.contains(&token), "{}", posted.url);
assert!(reply.contains("200 OK"), "{reply}");
assert!(server.refusals().is_empty(), "{:?}", server.refusals());
}
CB-WP-0014-T03: hot-seat evidenced; stage 1 open on one human check CB-EV-0012. Stage 1, deliverable by deliverable: relationship-graph visualization emitted and gated, NEVER SEEN drag-to-propose evidenced end to end debug inspector evidenced (CB-WP-0011) hot-seat play evidenced here Hot-seat was the one closest to being claimed on the strength of the code path existing. SeatPolicy hands every human seat a handle on one shared Server, so turn-taking "obviously" worked — and nothing drove more than one seat until now. The property that matters is not that two turns happen but that the same tab, asked twice, shows two different hands. Mutating the projection to serve P1's view to every seat turns it red. The stage stays open on ONE named blocker rather than a vague reservation: no browser is available to this loop, so the visualization is evidenced only as correctly emitted. Everything testable from here has been tested. What remains is `cb-play --serve 0`, open the URL, confirm the table reads and a drag works. INTENT carries that note now. The self-quoting rule from CB-EV-0011 §4 is ADOPTED: an evidence file quotes the previous pass's final cost and never its own. CB-WP-0013 reported itself at $5.78/34 mid-flight; final is $8.26/47, under by 43%. Four for four, always low. Meta budget 29% [OVER] soft 25%, driven by CB-WP-0013 in a trailing three with two cheap product passes; it was an instrument repair, which ADR-0006 D2 exempts. SH-1 at 347,720 [HARD] against a 300,000 ceiling. Compaction is the remedy and this session cannot do it for itself. CB-EV-0009's standing prediction is now live and testable for the first time in three passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 07:58:59 +02:00
/// **Hot-seat**: two seats, one browser, one listener — and each seat
/// is shown its own hand and nobody else's.
///
/// This is the deliverable that was closest to being claimed on the
/// strength of the code path existing. `SeatPolicy` gives every human
/// seat a handle on one shared `Server`, so turn-taking "obviously"
/// works; nothing drove more than one seat until this test.
///
/// The property that matters is not that two turns happen. It is that
/// the *projection follows the seat* — the same tab, asked twice,
/// must show two different hands.
#[test]
fn two_seats_take_turns_through_one_listener_and_see_different_hands() {
let server = Server::bind(0).expect("bind");
let port = server.listener.local_addr().unwrap().port();
let token = server.url().rsplit("t=").next().unwrap().to_string();
let tok = token.clone();
let client = std::thread::spawn(move || {
let mut pages = Vec::new();
for _ in 0..2 {
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
s.write_all(get(&tok).as_bytes()).expect("write");
let mut page = String::new();
let _ = s.read_to_string(&mut page);
pages.push(page);
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
s.write_all(post(&tok, "down=action-ground&up=table").as_bytes())
.expect("write");
let mut reply = String::new();
let _ = s.read_to_string(&mut reply);
}
pages
});
let state = state();
for seat in [0u8, 1] {
let choice = server
.next_choice(&state, PlayerId(seat), &ground_only(), false)
.expect("a choice");
assert_eq!(choice, Choice::Command(0), "seat P{}", seat + 1);
}
let pages = client.join().expect("client thread");
assert_eq!(pages.len(), 2);
for (i, page) in pages.iter().enumerate() {
let text = cb_render_html::text_of(page);
assert!(
text.contains(&format!("viewing as P{}", i + 1)),
"turn {i} was not served to P{}",
i + 1
);
// K13: exactly one open hand per page, and it is this seat's.
assert_eq!(
text.matches("cards)").count(),
1,
"turn {i} showed {} open hands",
text.matches("cards)").count()
);
}
// And the two turns were genuinely different views, not the same
// page served twice — which is how this test would pass vacuously.
assert_ne!(pages[0], pages[1], "both turns served an identical page");
}
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>
2026-08-02 04:27:25 +02:00
}