CB-WP-0018-T01: the browser sees the end of its own game
Server::serve_end plus doc::ending, wired into both of run_game's exits. Where the browser used to get Connection refused it now gets the ending page with the result and the final table. It serves until the page posts 'done' (the page carries a close control), with a 600s linger so an abandoned tab cannot hold the process open. document() split into body() and move_section() so the ending shows the same table rather than a second rendering of it. The control had to be built twice and the first was worthless: the_end_of_the_game_reaches_the_browser calls serve_end directly, and deleting the call from run_game left it GREEN -- it tested the link and not the chain, which is CB-EV-0012's finding recurring. a_real_game_played_to_its_end_leaves_the_ending_on_screen runs the real play() with a browser seat, drives a real game to its end over a real socket, and goes red under that mutation printing an empty page -- the reported symptom exactly. A weak assertion of mine caught by itself: the first draft grepped the page for location.reload, which would have forced a second script to satisfy a test rather than a requirement. It now asserts the ending endpoint cannot answer 'ok'. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
78e82497ee
commit
57639623da
4 changed files with 433 additions and 17 deletions
|
|
@ -431,6 +431,24 @@ pub fn document(
|
|||
},
|
||||
);
|
||||
|
||||
body(&mut s, view);
|
||||
move_section(&mut s, legal, seat, may_pass);
|
||||
let _ = write!(
|
||||
s,
|
||||
"<div id=\"cb-status\">ready</div>\
|
||||
<script>window.CB_ENDPOINT={endpoint}</script><script>{SCRIPT}</script>\
|
||||
</body></html>",
|
||||
endpoint = json_string(endpoint),
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
/// The table itself: problems, relationships, seats, solutions, outcome.
|
||||
///
|
||||
/// Factored out of [`document`] so [`ending`] shows the SAME table rather
|
||||
/// than a second rendering of it — two renderings of one state is how
|
||||
/// they drift.
|
||||
fn body(s: &mut String, view: &GroundView) {
|
||||
s.push_str(
|
||||
"<h2>problems</h2><svg width=\"760\" height=\"100\" role=\"img\" aria-label=\"problems\">",
|
||||
);
|
||||
|
|
@ -440,7 +458,7 @@ pub fn document(
|
|||
);
|
||||
}
|
||||
for (i, (priority, p)) in view.problems.iter().enumerate() {
|
||||
problem_svg(&mut s, *priority, p, 10 + (i as i32) * 130);
|
||||
problem_svg(s, *priority, p, 10 + (i as i32) * 130);
|
||||
}
|
||||
s.push_str("</svg>");
|
||||
|
||||
|
|
@ -449,7 +467,7 @@ pub fn document(
|
|||
|
||||
s.push_str("<h2>seats</h2><div class=\"row\">");
|
||||
for (id, p) in &view.players {
|
||||
player_card(&mut s, *id, p, view);
|
||||
player_card(s, *id, p, view);
|
||||
}
|
||||
s.push_str("</div>");
|
||||
|
||||
|
|
@ -512,6 +530,16 @@ pub fn document(
|
|||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// The "your move" section: action cards, numbered fallbacks, the table,
|
||||
/// and pass. Emits nothing when the seat has nothing legal to do.
|
||||
fn move_section(
|
||||
s: &mut String,
|
||||
legal: &[games_ground::GroundCommand],
|
||||
seat: Option<PlayerId>,
|
||||
may_pass: bool,
|
||||
) {
|
||||
if !legal.is_empty() {
|
||||
s.push_str("<h2>your move</h2><div class=\"row\">");
|
||||
for a in [
|
||||
|
|
@ -572,14 +600,6 @@ pub fn document(
|
|||
"<div class=\"card btn pick\" data-drop=\"pass\">pass \u{2014} decline to act</div>",
|
||||
);
|
||||
}
|
||||
|
||||
let _ = write!(
|
||||
s,
|
||||
"<div id=\"cb-status\">ready</div>\
|
||||
<script>window.CB_ENDPOINT={};</script><script>{SCRIPT}</script></body></html>",
|
||||
json_string(endpoint),
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
/// Quote a string as a JSON literal, so a token can never end the script.
|
||||
|
|
@ -622,6 +642,44 @@ fn json_string(s: &str) -> String {
|
|||
/// element could ever be `seat-0` — the relationship-graph circle took it
|
||||
/// and the seat card the instruction text points at went without. A seat
|
||||
/// is drawn twice and both drawings are the seat.
|
||||
/// The page a finished game leaves behind (CB-WP-0018 T01).
|
||||
///
|
||||
/// `view` is `None` when the game ended badly: then there is no result to
|
||||
/// draw and saying so is the whole point. Drawing a table for a game that
|
||||
/// crashed would be the same lie the empty page told, dressed up.
|
||||
///
|
||||
/// **This page does not auto-reload.** The old one reloaded on `ok` and
|
||||
/// the reload was refused, which is how a completed game became a blank
|
||||
/// tab.
|
||||
pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str) -> String {
|
||||
let mut s = String::with_capacity(4096);
|
||||
let _ = write!(
|
||||
s,
|
||||
"<!doctype html><meta charset=\"utf-8\">\
|
||||
<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
|
||||
<title>GROUND — game over</title><style>{STYLE}</style>\
|
||||
<h1>game over</h1><div class=\"card\">{msg}</div>",
|
||||
msg = esc(message),
|
||||
);
|
||||
match view {
|
||||
Some(v) => body(&mut s, v),
|
||||
None => {
|
||||
s.push_str(
|
||||
"<div class=\"card\">The game ended without a result, so there \
|
||||
is no final table to show. The reason is above.</div>",
|
||||
);
|
||||
}
|
||||
}
|
||||
let _ = write!(
|
||||
s,
|
||||
"<div class=\"card btn pick\" data-drop=\"done\">close — I have read this</div>\
|
||||
<div id=\"cb-status\">the game is over</div>\
|
||||
<script>window.CB_ENDPOINT={endpoint}</script><script>{SCRIPT}</script>",
|
||||
endpoint = json_string(endpoint),
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
pub fn drop_keys(html: &str) -> std::collections::BTreeSet<String> {
|
||||
let mut out = std::collections::BTreeSet::new();
|
||||
let mut rest = html;
|
||||
|
|
|
|||
|
|
@ -122,6 +122,81 @@ impl Server {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serve the end of the game until the player has seen it.
|
||||
///
|
||||
/// **The defect this exists for (CB-WP-0018 T01):** `next_choice`
|
||||
/// only accepts connections *inside* a decision point, so when
|
||||
/// `play()` returned the listener died and the page's post-`ok`
|
||||
/// reload was refused. Measured: a game ended normally at 5 rounds
|
||||
/// and 30 commands, its whole result went to the terminal, and the
|
||||
/// browser got `Connection refused`. **A crash and a win rendered
|
||||
/// identically — as nothing.**
|
||||
///
|
||||
/// `final_view` is `None` when the game ended badly; then `message`
|
||||
/// is the reason and the page says the game ended without a result
|
||||
/// rather than drawing a table that never happened.
|
||||
///
|
||||
/// **How it ends, and why that needed deciding:** a server that never
|
||||
/// exits is its own defect, and a timeout would race a player reading
|
||||
/// the result. It serves until the page tells it the result has been
|
||||
/// seen — the terminal page carries a `done` control and posts it —
|
||||
/// with `linger` as a bound so an abandoned tab cannot hold the
|
||||
/// process open forever.
|
||||
pub fn serve_end(
|
||||
&self,
|
||||
final_view: Option<&games_ground::view::GroundView>,
|
||||
message: &str,
|
||||
linger: std::time::Duration,
|
||||
) -> Result<(), 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(());
|
||||
}
|
||||
let (mut stream, _) = match self.listener.accept() {
|
||||
Ok(pair) => pair,
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(25));
|
||||
continue;
|
||||
}
|
||||
Err(e) => break Err(format!("accept: {e}")),
|
||||
};
|
||||
stream.set_nonblocking(false).ok();
|
||||
let Ok(raw) = read_request(&mut stream) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(req) = Request::parse(&raw) else {
|
||||
respond(&mut stream, 400, "text/plain", "bad request");
|
||||
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 = cb_render_html::doc::ending(
|
||||
final_view,
|
||||
message,
|
||||
&self.guard.endpoint(),
|
||||
);
|
||||
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
|
||||
}
|
||||
("POST", "/command") => {
|
||||
respond(&mut stream, 200, "text/plain", "closed");
|
||||
break Ok(());
|
||||
}
|
||||
_ => respond(&mut stream, 404, "text/plain", "the game is over"),
|
||||
}
|
||||
};
|
||||
self.listener.set_nonblocking(false).ok();
|
||||
outcome
|
||||
}
|
||||
}
|
||||
|
||||
/// One seat's view of the shared server.
|
||||
|
|
@ -225,6 +300,212 @@ mod tests {
|
|||
/// 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.
|
||||
/// **The defect CB-WP-0018 T01 exists for.** After the game ends the
|
||||
/// browser must get the result, not a refused connection.
|
||||
///
|
||||
/// Measured before the fix, driving a real game to completion over
|
||||
/// HTTP: move 5 accepted, then `GET /` → `[Errno 111] Connection
|
||||
/// refused`, while the whole outcome went to a terminal nobody was
|
||||
/// reading. A crash and a win rendered identically — as nothing.
|
||||
#[test]
|
||||
fn the_end_of_the_game_reaches_the_browser() {
|
||||
let server = Server::bind(0).expect("bind");
|
||||
let port = server.listener.local_addr().unwrap().port();
|
||||
let token = server.guard.token().to_string();
|
||||
let client = converse(
|
||||
port,
|
||||
vec![
|
||||
format!("GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"),
|
||||
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: 17\r\n\r\n\
|
||||
down=done&up=done"
|
||||
),
|
||||
],
|
||||
);
|
||||
let view = state().project(Viewer::Spectator);
|
||||
server
|
||||
.serve_end(
|
||||
Some(&view),
|
||||
"30 commands, hash f6c890a65271",
|
||||
std::time::Duration::from_secs(20),
|
||||
)
|
||||
.expect("serve_end");
|
||||
let replies = client.join().expect("client");
|
||||
|
||||
assert!(replies[0].contains("200 OK"), "{}", replies[0]);
|
||||
assert!(replies[0].contains("game over"), "the page did not say so");
|
||||
assert!(
|
||||
replies[0].contains("30 commands"),
|
||||
"the result did not reach the page"
|
||||
);
|
||||
// It must not auto-reload into a refused connection, which is how
|
||||
// a completed game became a blank tab in the first place.
|
||||
//
|
||||
// Asserted on BEHAVIOUR, not on source text. The first draft
|
||||
// grepped the page for "location.reload" and failed: the ending
|
||||
// page reuses `SCRIPT`, whose reload is guarded by
|
||||
// `t.indexOf('ok') === 0`. Grepping for the string would have
|
||||
// forced a second script to satisfy a test rather than a
|
||||
// requirement — the exact weak-control shape ADR-0010 D2 demoted.
|
||||
// What matters is that the endpoint cannot answer "ok".
|
||||
assert!(
|
||||
!replies[1].contains("ok"),
|
||||
"the ending endpoint answered something the page reloads on: {}",
|
||||
replies[1]
|
||||
);
|
||||
assert!(replies[1].contains("closed"), "{}", replies[1]);
|
||||
}
|
||||
|
||||
/// A game that ended badly must say so rather than draw a table for a
|
||||
/// game that never happened.
|
||||
#[test]
|
||||
fn a_game_that_ended_badly_says_so_and_shows_no_table() {
|
||||
let page = cb_render_html::doc::ending(None, "P1 ran out of input", "/command?t=x");
|
||||
assert!(page.contains("P1 ran out of input"), "the reason is missing");
|
||||
assert!(page.contains("without a result"));
|
||||
assert!(
|
||||
!page.contains("relationships"),
|
||||
"a failed game drew a table anyway"
|
||||
);
|
||||
}
|
||||
|
||||
/// A writer the test can read while the game is still running.
|
||||
#[derive(Clone)]
|
||||
struct SharedOut(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
|
||||
|
||||
impl Write for SharedOut {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().expect("out").extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
let mut out = String::new();
|
||||
let _ = s.read_to_string(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// **The chain, not the links.** `the_end_of_the_game_reaches_the_browser`
|
||||
/// calls `serve_end` directly, so it passes even when `run_game` never
|
||||
/// calls it — proven: deleting that call left it green. That is the
|
||||
/// defect class this project keeps meeting (CB-EV-0012: *"every link
|
||||
/// was tested and the chain was not"*), and it survived one round of
|
||||
/// it here before this test existed.
|
||||
///
|
||||
/// So: run the real `play()` with a browser seat, drive a real game to
|
||||
/// its end over a real socket, and require the last page to be the
|
||||
/// ending rather than a refused connection.
|
||||
#[test]
|
||||
fn a_real_game_played_to_its_end_leaves_the_ending_on_screen() {
|
||||
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: 7,
|
||||
players: 3,
|
||||
human_seats: vec![0],
|
||||
bot: "random".into(),
|
||||
replay_dir: None,
|
||||
record: None,
|
||||
serve: Some(0),
|
||||
},
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
out,
|
||||
)
|
||||
});
|
||||
|
||||
// The URL is printed as soon as the listener binds.
|
||||
let url = 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());
|
||||
break rest[..end].to_string();
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
};
|
||||
let port: u16 = url["http://127.0.0.1:".len()..]
|
||||
.split('/')
|
||||
.next()
|
||||
.expect("port")
|
||||
.parse()
|
||||
.expect("port number");
|
||||
let token = url.split("t=").nth(1).expect("token").to_string();
|
||||
|
||||
// Play until the page stops offering moves — which is the ending.
|
||||
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);
|
||||
// A spatial move if one is offered; otherwise the numbered
|
||||
// fallback or pass, which is what the page offers at steps
|
||||
// with no draggable action. Breaking here instead would end
|
||||
// the walk mid-game and assert nothing.
|
||||
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("a target")
|
||||
.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 {
|
||||
break;
|
||||
};
|
||||
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()
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Before CB-WP-0018 this GET was a refused connection and the
|
||||
// whole result went to a terminal nobody was reading.
|
||||
assert!(
|
||||
last.contains("game over"),
|
||||
"the last page the player saw was not the ending: {}",
|
||||
&last[..last.len().min(300)]
|
||||
);
|
||||
assert!(last.contains("commands, hash"), "no result on the page");
|
||||
|
||||
// Let the game thread finish: tell it the result has been seen.
|
||||
let body = "down=done&up=done";
|
||||
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()
|
||||
),
|
||||
);
|
||||
game.join().expect("game thread").expect("the game ran");
|
||||
}
|
||||
|
||||
fn converse(port: u16, requests: Vec<String>) -> std::thread::JoinHandle<Vec<String>> {
|
||||
std::thread::spawn(move || {
|
||||
requests
|
||||
|
|
|
|||
|
|
@ -181,6 +181,29 @@ impl<R: BufRead, W: Write> Policy for HumanPolicy<'_, R, W> {
|
|||
}
|
||||
}
|
||||
|
||||
/// How long the terminal page stays available for an abandoned tab.
|
||||
///
|
||||
/// A server that never exits is its own defect; a short timeout races a
|
||||
/// player reading the result. So it serves until the page posts `done`,
|
||||
/// with this only as the bound.
|
||||
const END_LINGER: std::time::Duration = std::time::Duration::from_secs(600);
|
||||
|
||||
/// Show the browser that the game ended badly, then return the error.
|
||||
///
|
||||
/// CB-WP-0018 T01: this path used to `return Err(...)` straight to a
|
||||
/// terminal nobody was reading, and the browser got a refused connection —
|
||||
/// identical to a normal win. An error the only interface cannot see is
|
||||
/// not reported.
|
||||
fn end_badly<T>(
|
||||
server: &Option<std::rc::Rc<crate::hotseat::Server>>,
|
||||
msg: String,
|
||||
) -> Result<T, String> {
|
||||
if let Some(s) = server {
|
||||
let _ = s.serve_end(None, &msg, END_LINGER);
|
||||
}
|
||||
Err(msg)
|
||||
}
|
||||
|
||||
fn bot_policy<'a>(kind: &str, seed: u64) -> Result<Box<dyn Policy + 'a>, String> {
|
||||
match kind {
|
||||
"greedy" => Ok(Box::new(GreedyPolicy)),
|
||||
|
|
@ -250,14 +273,17 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
|
|||
// out-of-range index it had to return to get here.
|
||||
let human_failure = failure.borrow().clone();
|
||||
let game = match (result, human_failure) {
|
||||
(_, Some(msg)) => return Err(msg),
|
||||
(_, Some(msg)) => return end_badly(&server, msg),
|
||||
(Err(BotError::IllegalChoice { seat, offered, .. }), None) => {
|
||||
return Err(format!(
|
||||
"{} chose a command outside the {offered} offered",
|
||||
seat_name(seat)
|
||||
))
|
||||
return end_badly(
|
||||
&server,
|
||||
format!(
|
||||
"{} chose a command outside the {offered} offered",
|
||||
seat_name(seat)
|
||||
),
|
||||
)
|
||||
}
|
||||
(Err(e), None) => return Err(e.to_string()),
|
||||
(Err(e), None) => return end_badly(&server, e.to_string()),
|
||||
(Ok(game), None) => game,
|
||||
};
|
||||
|
||||
|
|
@ -287,6 +313,19 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
|
|||
let _ = w.flush();
|
||||
drop(w);
|
||||
|
||||
// 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.
|
||||
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);
|
||||
}
|
||||
|
||||
let scenario = games_ground::record::to_scenario(
|
||||
"ground/cb-play-session",
|
||||
config.seed,
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ refuses to say what happened and the player is left to infer it.
|
|||
|
||||
```task
|
||||
id: CB-WP-0018-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
|
|
@ -80,6 +80,44 @@ asserts the final GET returns a page naming the outcome, **not** a refused
|
|||
connection. And the error path, driven by a game that fails, asserting the
|
||||
reason reaches the page.
|
||||
|
||||
**Done 2026-08-03.** `Server::serve_end` plus `doc::ending`, wired into
|
||||
both of `run_game`'s exits. Verified live: where the browser used to get
|
||||
`Connection refused` it now gets an 8,998-byte page reading *"GROUND —
|
||||
game over … 30 commands, hash f6c890a65271"* with the final table.
|
||||
|
||||
**How it ends:** it serves until the page posts `done` — the ending page
|
||||
carries a *"close — I have read this"* control — with a 600 s linger as
|
||||
the bound, so an abandoned tab cannot hold the process open and a player
|
||||
reading the result is not raced by a timeout.
|
||||
|
||||
`document()` was split into `body()` and `move_section()` so the ending
|
||||
shows the **same** table rather than a second rendering of it; two
|
||||
renderings of one state is how they drift.
|
||||
|
||||
**The control had to be built twice, and the first one was worthless.**
|
||||
`the_end_of_the_game_reaches_the_browser` calls `serve_end` directly —
|
||||
and deleting the call from `run_game` left it **green**. It tested the
|
||||
link and not the chain, which is CB-EV-0012's finding recurring
|
||||
(*"every link was tested and the chain was not"*).
|
||||
|
||||
`a_real_game_played_to_its_end_leaves_the_ending_on_screen` runs the real
|
||||
`play()` with a browser seat, drives a real game to its end over a real
|
||||
socket, and requires the last page to be the ending. Under the same
|
||||
mutation it goes red — and prints an **empty page**, which is exactly the
|
||||
symptom that was reported.
|
||||
|
||||
A second control: a game that ended badly says why and draws **no** table,
|
||||
because drawing a table for a game that never happened is the same lie the
|
||||
empty page told.
|
||||
|
||||
**And a weak assertion of mine, caught by itself.** The first version
|
||||
grepped the page for `location.reload`. The ending page reuses `SCRIPT`,
|
||||
whose reload is guarded by `t.indexOf('ok') === 0`, and the ending
|
||||
endpoint answers `closed` — so the grep would have forced a second script
|
||||
to satisfy a test rather than a requirement. That is the source-text
|
||||
control shape ADR-0010 D2 demoted. It now asserts the endpoint cannot
|
||||
answer `ok`.
|
||||
|
||||
## The second report, and what it actually is
|
||||
|
||||
Reported: *"the cards I play by pulling them on a target will not be
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue