clay-borg/tools/cb-play/src/hotseat.rs
tegwick f768bc4a41
Some checks failed
ci / check (push) Failing after 4s
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

495 lines
19 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());
}
/// **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");
}
}