CB-WP-0020: the table you can read
Some checks failed
ci / check (push) Failing after 3s

Six of seven perceptual defects fixed; item 1 already passed.

T01, at the maintainer's instruction: a legal target restyles its
EXISTING border rather than drawing a new box. outline + outline-offset
drew a second rectangle, which an SVG viewport clips (the missing top and
left edges) and which made a seat's highlight card-sized. A border
already in the layout cannot move the layout.

T02: the ghost was a textContent copy of the card, which is why the line
break collapsed and it read as a second card, and why showing the
explanation destroyed the label. It is now a pill, the explanation is
appended beside the label, and the left-behind element is dimmed and
dashed. The stub grew innerHTML so a test can assert BOTH are present --
it could previously only see that something was displayed.

T03: NOT reproduced and recorded as not reproduced. The likeliest cause
is which element the browser reports -- for touch and pen the pointer is
captured to the pointerdown target, making every drop look like a
drop-on-itself, which is the other half of the report. elementFromPoint
is correct under both explanations. Separately the refusal was written in
element ids on the one surface a player reads when something goes wrong;
it now speaks the game's words and a test forbids id leakage.

T04: seat selections rendered as Debug. The coverage gate then failed my
first fix for dropping a field when target and problem were both set --
the aggregate does not produce that shape and the gate was right not to
care.

T05: the headline reads from group_success. 'Play again' is real, and its
first version was useless: run_game bound a fresh listener per game, so a
second game moved to a new port and left the tab pointing at a dead one.
One listener per session now, and the test asserts the second game is a
DIFFERENT deal.

Chaos d8=8 fired the first override at the new rate and drew S, changing
nothing -- one half of window 2's retirement condition.

CB-WP-0019 settled at $38.54/117 against $34.80/107. Eight for eight,
and the first under 20%.

make all exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-03 20:20:38 +02:00
parent 5e06a7d01e
commit bf24affa84
8 changed files with 584 additions and 55 deletions

View file

@ -200,14 +200,14 @@ impl Server {
final_view: Option<&games_ground::view::GroundView>,
message: &str,
linger: std::time::Duration,
) -> Result<(), String> {
) -> Result<EndChoice, String> {
let deadline = std::time::Instant::now() + linger;
self.listener
.set_nonblocking(true)
.map_err(|e| format!("nonblocking: {e}"))?;
let outcome = loop {
if std::time::Instant::now() >= deadline {
break Ok(());
break Ok(EndChoice::Closed);
}
let (mut stream, _) = match self.listener.accept() {
Ok(pair) => pair,
@ -241,8 +241,21 @@ impl Server {
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
}
("POST", "/command") => {
respond(&mut stream, 200, "text/plain", "closed");
break Ok(());
// CB-WP-0020 T05: the browser could not start a second
// game without going back to a terminal, which made it
// a strictly worse client than the CLI it replaces.
let again = req.body.contains("again");
respond(
&mut stream,
200,
"text/plain",
if again { "ok: dealing" } else { "closed" },
);
break Ok(if again {
EndChoice::Again
} else {
EndChoice::Closed
});
}
_ => respond(&mut stream, 404, "text/plain", "the game is over"),
}
@ -252,6 +265,13 @@ impl Server {
}
}
/// What the player asked for on the ending page.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EndChoice {
Closed,
Again,
}
/// One seat's view of the shared server.
pub struct SeatPolicy {
server: Rc<Server>,
@ -471,6 +491,64 @@ mod tests {
assert!(replies[1].contains("closed"), "{}", replies[1]);
}
/// **CB-WP-0020 T05.** "play again" must deal a second game, not just
/// answer politely and exit — the browser could not start a second
/// game without going back to a terminal, which made it a strictly
/// worse client than the CLI it replaces.
#[test]
fn play_again_deals_a_second_game() {
let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let out = SharedOut(buf.clone());
let game = std::thread::spawn(move || {
crate::table::play(
&crate::table::Config {
seed: 3,
players: 3,
human_seats: vec![0],
bot: "random".into(),
replay_dir: None,
record: None,
serve: Some(0),
},
std::io::Cursor::new(Vec::new()),
out,
)
});
let (port, token) = wait_for_url(&buf);
// Play one game out, ask for another, play that one out too.
let mut deals = 0;
for round in 0..2 {
drive_to_end(port, &token);
deals += 1;
let want = if round == 0 { "again" } else { "done" };
let body = format!("down={want}&up={want}");
let reply = http(
port,
&format!(
"POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\
Origin: http://127.0.0.1:{port}\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
),
);
if round == 0 {
assert!(reply.contains("dealing"), "play again refused: {reply}");
}
}
assert_eq!(deals, 2, "the second game was never dealt");
game.join().expect("game thread").expect("the games ran");
// Two games, and they must not be the same deal — "again" that
// re-dealt the identical game would satisfy a naive count.
let text = String::from_utf8_lossy(&buf.lock().expect("out")).to_string();
let hashes: Vec<&str> = text
.match_indices("game over \u{2014} ")
.map(|(i, _)| &text[i..i + 60])
.collect();
assert!(hashes.len() >= 2, "only {} game(s) finished", hashes.len());
assert_ne!(hashes[0], hashes[1], "play again re-dealt the same game");
}
/// A game that ended badly must say so rather than draw a table for a
/// game that never happened.
#[test]
@ -501,6 +579,70 @@ mod tests {
}
}
/// Block until the server prints its URL, then return (port, token).
fn wait_for_url(buf: &std::sync::Arc<std::sync::Mutex<Vec<u8>>>) -> (u16, String) {
loop {
let text = String::from_utf8_lossy(&buf.lock().expect("out")).to_string();
if let Some(i) = text.find("http://127.0.0.1:") {
let rest = &text[i..];
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
let url = &rest[..end];
let port = url["http://127.0.0.1:".len()..]
.split('/')
.next()
.expect("port")
.parse()
.expect("port number");
return (port, url.split("t=").nth(1).expect("token").to_string());
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
/// Play the offered moves until the page stops offering any, which is
/// the ending. Returns the last page.
fn drive_to_end(port: u16, token: &str) -> String {
let mut last = String::new();
for _ in 0..60 {
last = http(
port,
&format!("GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"),
);
let keys = cb_render_html::jsrun::droppables(&last);
let spatial = keys
.iter()
.find(|(k, t)| k.starts_with("action-") && t.is_some())
.map(|(k, t)| {
(
k.clone(),
t.as_ref()
.expect("checked")
.split(' ')
.next()
.expect("t")
.to_string(),
)
});
let button = keys
.iter()
.find(|(k, _)| k.starts_with("cmd-") || k == "pass")
.map(|(k, _)| (k.clone(), k.clone()));
let Some((down, up)) = spatial.or(button) else {
return last;
};
let body = format!("down={down}&up={up}");
http(
port,
&format!(
"POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\
Origin: http://127.0.0.1:{port}\r\nContent-Length: {}\r\n\r\n{body}",
body.len()
),
);
}
last
}
fn http(port: u16, raw: &str) -> String {
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
s.write_all(raw.as_bytes()).expect("write");
@ -776,9 +918,19 @@ mod tests {
let replies = client.join().expect("client thread");
assert!(replies[0].contains("200 OK"));
// CB-WP-0020 T03: the refusal is in the game's words now, not the
// DOM's. It read `action-attack -> action-attack is not a legal
// move here` on the surface a player reads when something goes
// wrong — element ids, the same defect the log had.
assert!(
replies[0].contains("not a legal move"),
"a meaningless drag must be told it meant nothing: {}",
replies[0].contains("not a move you can make")
|| replies[0].contains("needs to be dropped on"),
"a meaningless drag must be told it meant nothing, in words: {}",
replies[0]
);
assert!(
!replies[0].contains("action-"),
"the refusal leaked an element id: {}",
replies[0]
);
assert!(replies[1].contains("200 OK"));

View file

@ -21,6 +21,7 @@ use games_ground::{GroundCommand, GroundState};
use crate::inspect::{render, seat_name};
use std::io::{BufRead, Write};
#[derive(Clone)]
pub struct Config {
pub seed: u64,
pub players: u8,
@ -220,14 +221,39 @@ pub fn play<R: BufRead, W: Write>(config: &Config, input: R, out: W) -> Result<S
// borrow them, so ownership stays here and the game runs one frame in.
let input = std::cell::RefCell::new(input);
let out = std::cell::RefCell::new(out);
run_game(config, &input, &out)
// CB-WP-0020 T05: a browser session can deal a second game. The seed
// advances, because replaying the identical deal is not "again".
// CB-WP-0020 T05: ONE listener for the session, not one per game.
// The first version bound a fresh port inside `run_game`, so "play
// again" moved the game to a new URL and the player's tab was left
// pointing at a dead port. Caught by `play_again_deals_a_second_game`.
let server = match config.serve {
Some(port) => {
let s = std::rc::Rc::new(crate::hotseat::Server::bind(port)?);
let _ = writeln!(out.borrow_mut(), " open {}", s.url());
let _ = out.borrow_mut().flush();
Some(s)
}
None => None,
};
let mut cfg: Config = config.clone();
loop {
let (summary, choice) = run_game(&cfg, &input, &out, server.clone())?;
if choice != crate::hotseat::EndChoice::Again {
return Ok(summary);
}
cfg.seed = cfg.seed.wrapping_add(1);
}
}
fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
config: &Config,
input: &'a std::cell::RefCell<R>,
out: &'a std::cell::RefCell<W>,
) -> Result<Summary, String> {
server: Option<std::rc::Rc<crate::hotseat::Server>>,
) -> Result<(Summary, crate::hotseat::EndChoice), String> {
let setup = Setup {
players: config.players,
preset: format!("standard-{}p", config.players),
@ -243,15 +269,6 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
// ADR-0007: a browser seat and a CLI seat are both just a Policy, so
// the driver cannot tell them apart — which is the property that lets
// a browser game replay as a scenario like any other.
let server = match config.serve {
Some(port) => {
let s = std::rc::Rc::new(crate::hotseat::Server::bind(port)?);
let _ = writeln!(out.borrow_mut(), " open {}", s.url());
let _ = out.borrow_mut().flush();
Some(s)
}
None => None,
};
let mut policies: Vec<Box<dyn Policy + 'a>> = Vec::new();
for seat in 0..config.players {
if config.human_seats.contains(&seat) {
@ -322,10 +339,11 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
// CB-WP-0018 T01: the browser sees the end of its own game. Measured
// before this: a game ended at 5 rounds / 30 commands, the result went
// to stdout, and the page's post-`ok` reload got Connection refused.
let mut end_choice = crate::hotseat::EndChoice::Closed;
if let Some(srv) = &server {
let ended = game.state.project(Viewer::Spectator);
let msg = format!("{} commands, hash {}", game.commands, &end_hash[..12]);
let _ = srv.serve_end(Some(&ended), &msg, END_LINGER);
end_choice = srv.serve_end(Some(&ended), &msg, END_LINGER)?;
}
let scenario = games_ground::record::to_scenario(
@ -361,11 +379,14 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
}
};
Ok(Summary {
rounds: game.rounds,
end_state_hash: end_hash,
scenario,
bundle,
recorded,
})
Ok((
Summary {
rounds: game.rounds,
end_state_hash: end_hash,
scenario,
bundle,
recorded,
},
end_choice,
))
}