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>
429 lines
16 KiB
Rust
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());
|
|
}
|
|
}
|