clay-borg/tools/cb-play/src/hotseat.rs
tegwick 55212d7e0f 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

429 lines
16 KiB
Rust

//! 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>>,
}
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()),
})
}
/// 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()
}
/// 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 = document(&view, legal, &self.guard.endpoint(), Some(seat), may_pass);
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"),
}
}
}
}
/// 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)
}
}
}
}
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.
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");
assert!(
replies[0].contains("id=\"action-ground\""),
"the offered action was not on the page"
);
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"));
}
/// **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());
}
}