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>
373 lines
13 KiB
Rust
373 lines
13 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"));
|
|
}
|
|
}
|