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"));