//! 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>, } impl Server { pub fn bind(port: u16) -> Result { 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 { 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 { 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, failure: Rc>>, } impl SeatPolicy { pub fn new(server: Rc, failure: Rc>>) -> 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 { 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 { 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) -> std::thread::JoinHandle> { 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 { vec![GroundCommand::SelectAction { action: Action::Ground, target: None, problem: None, }] } fn state() -> GroundState { ::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")); } }