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:
parent
5e06a7d01e
commit
bf24affa84
8 changed files with 584 additions and 55 deletions
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue