From 499d9fe3d77f62c721201c0d9aea174e2f463200 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 7 Aug 2026 13:08:56 +0200 Subject: [PATCH] CB-WP-0031: the comment box outlives the game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note channel closed at the moment it is worth most — a player who has just seen the outcome is the one with something to say, and that reading was collectable at every moment of the game except the one after it. Two independent defects: doc::ending never rendered the box, and serve_end had no POST /note arm, so even a hand-built post fell through to 404. Fixing either alone leaves the channel shut, so the test asserts both and is mutation-proven to fail on each half separately. A post-game note binds to the final position but is not an observation made at the last decision point. RoundStep::End is the last step of a ROUND, not the end of the game, so record_note now takes the step as an argument and the post-game path passes "after the end" — otherwise an after-the-fact reading is filed as an in-play one, which is the wrong-subject family ADR-0018 was written for. A note does not end the session: every other POST in that loop breaks it, and a player must be able to write a second one and then still play again. Co-Authored-By: Claude Opus 5 --- WORK-RECORDS.md | 10 +- crates/cb-render-html/src/doc.rs | 61 ++++- crates/cb-render-html/src/lib.rs | 11 +- tools/cb-play/src/hotseat.rs | 220 +++++++++++++++--- tools/cb-play/src/table.rs | 4 +- trials/2026-08-07-1252.yaml | 145 ++++++++++++ ...-0031-the-comment-box-outlives-the-game.md | 106 +++++++++ 7 files changed, 512 insertions(+), 45 deletions(-) create mode 100644 trials/2026-08-07-1252.yaml create mode 100644 workplans/CB-WP-0031-the-comment-box-outlives-the-game.md diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 7bd4a2b..c672249 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -37,7 +37,7 @@ | workplan | CB-WP-0027 | done | — | workplans/CB-WP-0027-the-commentary-track.md | | workplan | CB-WP-0028 | done | — | workplans/CB-WP-0028-the-table-you-sit-at.md | | workplan | CB-WP-0029 | done | — | workplans/CB-WP-0029-the-tokens-on-the-table.md | -| workplan | CB-WP-0030 | ready | — | workplans/CB-WP-0030-a-number-that-does-not-move.md | +| workplan | CB-WP-0030 | done | — | workplans/CB-WP-0030-a-number-that-does-not-move.md | | task | CB-WP-0001-T01 | done | — | workplans/CB-WP-0001-inner-loop.md | | task | CB-WP-0001-T02 | done | — | workplans/CB-WP-0001-inner-loop.md | | task | CB-WP-0001-T03 | done | — | workplans/CB-WP-0001-inner-loop.md | @@ -195,7 +195,7 @@ | task | CB-WP-0029-T02 | done | — | workplans/CB-WP-0029-the-tokens-on-the-table.md | | task | CB-WP-0029-T03 | done | — | workplans/CB-WP-0029-the-tokens-on-the-table.md | | task | CB-WP-0029-T04 | done | — | workplans/CB-WP-0029-the-tokens-on-the-table.md | -| task | CB-WP-0030-T01 | todo | — | workplans/CB-WP-0030-a-number-that-does-not-move.md | -| task | CB-WP-0030-T02 | todo | — | workplans/CB-WP-0030-a-number-that-does-not-move.md | -| task | CB-WP-0030-T03 | todo | — | workplans/CB-WP-0030-a-number-that-does-not-move.md | -| task | CB-WP-0030-T04 | todo | — | workplans/CB-WP-0030-a-number-that-does-not-move.md | +| task | CB-WP-0030-T01 | done | — | workplans/CB-WP-0030-a-number-that-does-not-move.md | +| task | CB-WP-0030-T02 | done | — | workplans/CB-WP-0030-a-number-that-does-not-move.md | +| task | CB-WP-0030-T03 | done | — | workplans/CB-WP-0030-a-number-that-does-not-move.md | +| task | CB-WP-0030-T04 | done | — | workplans/CB-WP-0030-a-number-that-does-not-move.md | diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index 6cc199f..993e735 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -1004,6 +1004,33 @@ pub fn document_with_log( s } +/// The comment box, shared by the playing page and the ending page. +/// +/// **Extracted because the ending page needs the same box** (CB-WP-0031). +/// A second copy would be a second thing to forget: the playing page's +/// form already had the token-carrying action and the works-without-script +/// property, and a hand-written twin on the ending page is how one of them +/// quietly stops posting anywhere useful. +/// +/// The `placeholder` differs, and only that — what a player has to say +/// mid-turn and what they have to say having seen the result are not the +/// same prompt. +fn note_form(s: &mut String, note_to: &str, placeholder: &str) { + // A plain form POSTing to /note, so it works with the script disabled. + // The command channel needs JavaScript because a drag is not a form + // submission; a comment is, and making it depend on the script would + // add a failure mode for no gain. + let _ = write!( + s, + "

what are you thinking?

\ +
\ + \ +
", + note_to = esc(note_to), + placeholder = esc(placeholder), + ); +} + /// The meta panel's own content: whatever the caller wants a player to see /// *about* the session rather than about the position. /// @@ -1018,14 +1045,10 @@ fn meta_section(s: &mut String, meta: &[String], note_to: &str) { // The command channel needs JavaScript because a drag is not a form // submission; a comment is, and making it depend on the script would // add a failure mode for no gain. - let _ = write!( + note_form( s, - "

what are you thinking?

\ -
\ - \ -
", - note_to = esc(note_to), + note_to, + "why this move, what is unclear, what is annoying \u{2014} bound to this position", ); if meta.is_empty() { return; @@ -1423,10 +1446,19 @@ fn rankings(s: &mut String, view: &GroundView) { /// **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. +/// +/// **`note_to` is `Some` when a note can still be anchored** (CB-WP-0031). +/// The comment box used to stop existing the moment the game ended, which +/// removed the channel exactly when a player has most to say — they have +/// just seen the outcome, and *"so that's why that failed"* is the reading +/// the whole trial protocol wants. It is `None` only when there is no +/// final position to bind a note to, and then the page says so rather than +/// showing a box that would refuse. pub fn ending( view: Option<&GroundView>, message: &str, endpoint: &str, + note_to: Option<&str>, log: &[LogLine], series: &[String], ) -> String { @@ -1478,6 +1510,21 @@ pub fn ending( } s.push_str(""); } + // CB-WP-0031: above the log and below the result, because that is the + // order the thought arrives in — you see how it ended, you say what + // you make of it, and the account is there to check yourself against. + match note_to { + Some(to) => note_form( + &mut s, + to, + "how did that go \u{2014} what surprised you, what was unclear, \ + what would you do differently", + ), + None => s.push_str( + "
No comment box: the game stopped without a \ + final position, so there is nothing to bind a note to.
", + ), + } // Observations 6 and 7: the controls and the full log belong beside // the result, not under it. And the controls go BELOW the log — you // read what happened, then decide what to do next. diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs index 7663d4e..e613a3b 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -1360,7 +1360,7 @@ mod ending_page { use crate::{doc, jsrun}; fn page() -> String { - doc::ending(None, "the game ended", "/command?t=x", &[], &[]) + doc::ending(None, "the game ended", "/command?t=x", None, &[], &[]) } /// The label must say what the control DOES. Asserted on the rendered @@ -1439,7 +1439,7 @@ mod ending_page { #[test] fn click_targets_do_not_wear_the_drag_affordance() { let pages = [ - doc::ending(None, "m", "/command?t=x", &[], &[]), + doc::ending(None, "m", "/command?t=x", None, &[], &[]), doc::document( &crate::testfix::view(Some(PlayerId(0))), &[games_ground::GroundCommand::SelectAction { @@ -1490,7 +1490,7 @@ mod ending_page { o.group_success = false; } let head = |v: &games_ground::view::GroundView| { - crate::text_of(&doc::ending(Some(v), "m", "/command?t=x", &[], &[])) + crate::text_of(&doc::ending(Some(v), "m", "/command?t=x", None, &[], &[])) }; assert!( head(&won).contains("game solved"), @@ -1511,6 +1511,7 @@ mod ending_page { None, "P1 ran out of input", "/command?t=x", + None, &[], &[], )); @@ -1525,7 +1526,7 @@ mod ending_page { fn a_cooperative_game_shows_contributions_and_refuses_to_rank_them() { let mut v = crate::testfix::view(None); v.mode = games_ground::ScoringMode::SharedGround; - let text = crate::text_of(&doc::ending(Some(&v), "m", "/command?t=x", &[], &[])); + let text = crate::text_of(&doc::ending(Some(&v), "m", "/command?t=x", None, &[], &[])); assert!( text.contains("problems solved"), @@ -1548,7 +1549,7 @@ mod ending_page { fn a_ranked_mode_cites_the_games_own_tiebreak() { let mut v = crate::testfix::view(None); v.mode = games_ground::ScoringMode::BondedCoalitions; - let text = crate::text_of(&doc::ending(Some(&v), "m", "/command?t=x", &[], &[])); + let text = crate::text_of(&doc::ending(Some(&v), "m", "/command?t=x", None, &[], &[])); assert!( text.contains("Lower combined Stress"), diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs index ae39180..c27f50d 100644 --- a/tools/cb-play/src/hotseat.rs +++ b/tools/cb-play/src/hotseat.rs @@ -126,7 +126,19 @@ impl Server { /// **The state hash is the binding.** Round and step orient a reader; /// the hash is what lets one *reach* the position, because it is the /// same value that makes a session comparable to its replay. - fn record_note(&self, state: &GroundState, note: &Note) -> Result<(), String> { + /// `step` is passed in rather than read off the state (CB-WP-0031), + /// because a note written after the game has ended is not a note taken + /// at the final decision point and must not be logged as one. The + /// position it binds to is genuinely the final state — `round` and + /// `state_hash` are that state's — but *when the player said it* is a + /// different fact, and conflating them would file an after-the-fact + /// reading as an in-play observation. + /// + /// `"after the end"` cannot collide with a real step: `RoundStep`'s + /// `Debug` values are bare identifiers (`Select`, `Reveal`, `Resolve`, + /// `End`), and **`End` is the last step of a round, not the end of the + /// game** — which is exactly the confusion this label avoids. + fn record_note(&self, state: &GroundState, step: &str, note: &Note) -> Result<(), String> { let Some(path) = self.trial.as_ref() else { // No trial log configured: refuse rather than drop. A note // that vanishes is worse than a note that was never offered, @@ -139,7 +151,7 @@ impl Server { log.push(TrialNote { n, round: state.round, - step: format!("{:?}", state.step), + step: step.to_string(), state_hash: hash[..12].to_string(), text: note.text.clone(), }); @@ -285,21 +297,23 @@ impl Server { // conversion between them. The game does not advance here // and the loop keeps waiting for the seat's actual move. ("POST", "/note") => match Note::parse(&req.body) { - Ok(note) => match self.record_note(state, ¬e) { - Ok(()) => { - // 303 so the browser re-GETs the table rather - // than leaving a form POST in history — a - // reload would otherwise re-submit the note. - // - // WITH THE TOKEN. Redirecting to bare `/` sent - // the browser to a request control 1 refuses, - // so the note was saved and the player was - // shown "no session token" — which reads as - // the note having failed. - respond_seeother(&mut stream, &self.guard.page_path()); + Ok(note) => { + match self.record_note(state, &format!("{:?}", state.step), ¬e) { + Ok(()) => { + // 303 so the browser re-GETs the table rather + // than leaving a form POST in history — a + // reload would otherwise re-submit the note. + // + // WITH THE TOKEN. Redirecting to bare `/` sent + // the browser to a request control 1 refuses, + // so the note was saved and the player was + // shown "no session token" — which reads as + // the note having failed. + respond_seeother(&mut stream, &self.guard.page_path()); + } + Err(e) => respond(&mut stream, 500, "text/plain", &e), } - Err(e) => respond(&mut stream, 500, "text/plain", &e), - }, + } Err(why) => respond(&mut stream, 400, "text/plain", &why), }, _ => respond(&mut stream, 404, "text/plain", "no such thing here"), @@ -327,9 +341,18 @@ impl Server { /// 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. + /// + /// **`final_state` is what makes the comment box work here** + /// (CB-WP-0031). This loop had no `/note` arm at all, so a note posted + /// from the ending page fell through to `404 the game is over` — and + /// the ending page did not render the box in the first place, so the + /// channel closed at the moment a player has just learned how it went. + /// `None` means the game stopped without a position, and then the box + /// is not offered rather than offered and refused. pub fn serve_end( &self, final_view: Option<&games_ground::view::GroundView>, + final_state: Option<&GroundState>, message: &str, linger: std::time::Duration, tally: &crate::table::MatchTally, @@ -365,15 +388,44 @@ impl Server { } match (req.method.as_str(), req.path.as_str()) { ("GET", "/") => { + let note_to = final_state.map(|_| self.guard.note_endpoint()); let page = cb_render_html::doc::ending( final_view, message, &self.guard.endpoint(), + note_to.as_deref(), &self.log_lines(), &series_lines(tally), ); respond(&mut stream, 200, "text/html; charset=utf-8", &page); } + // CB-WP-0031. The same second channel as in `next_choice`, + // and it still cannot carry a move — here it could not + // anyway, because the game is finished. + // + // **It does not `break`.** Every other POST here ends the + // loop; a note must leave the session exactly as it found + // it, so the player can write a second one, or read the + // log again, or then press `play again`. + ("POST", "/note") => { + let Some(state) = final_state else { + respond( + &mut stream, + 409, + "text/plain", + "the game stopped without a final position, so a note \ + has nothing to bind to", + ); + continue; + }; + match Note::parse(&req.body) { + Ok(note) => match self.record_note(state, "after the end", ¬e) { + Ok(()) => respond_seeother(&mut stream, &self.guard.page_path()), + Err(e) => respond(&mut stream, 500, "text/plain", &e), + }, + Err(why) => respond(&mut stream, 400, "text/plain", &why), + } + } ("POST", "/command") => { // CB-WP-0020 T05: the browser could not start a second // game without going back to a terminal, which made it @@ -750,6 +802,7 @@ mod tests { server .serve_end( Some(&view), + Some(&state()), "30 commands, hash f6c890a65271", std::time::Duration::from_secs(20), &crate::table::MatchTally::default(), @@ -793,6 +846,104 @@ mod tests { assert!(replies[1].contains("closed"), "{}", replies[1]); } + /// CB-WP-0031. The comment box must survive the end of the game. + /// + /// **Two things go wrong independently and this asserts both.** The + /// ending page did not render the form, and `serve_end` had no + /// `/note` arm — so even a hand-built POST fell through to + /// `404 the game is over`. Fixing either alone leaves the channel + /// shut, so a test that only checked the markup would have passed + /// against a server that still refused. + /// + /// **And the note must not end the session.** Every other POST in + /// this loop breaks it; a player writing what they thought must be + /// able to write a second one and then still press `play again`, + /// which is what the trailing command here proves. + #[test] + fn a_note_can_be_written_after_the_game_has_ended() { + let dir = std::env::temp_dir().join(format!("cb-note-end-{}", std::process::id())); + let log = dir.join("trial.md"); + let server = Server::bind(0).expect("bind").with_trial(Some(log.clone())); + 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"), + // The body is its own binding and the length is computed + // from it. Writing `Content-Length: 21` by hand beside a + // 22-byte body is a test that fails for a reason having + // nothing to do with the feature. + post(&token, "/note", "note=so+that+is+why+it"), + post(&token, "/command", "down=done&up=done"), + ], + ); + let view = state().project(Viewer::Spectator); + server + .serve_end( + Some(&view), + Some(&state()), + "30 commands", + std::time::Duration::from_secs(20), + &crate::table::MatchTally::default(), + ) + .expect("serve_end"); + let replies = client.join().expect("client"); + + assert!( + replies[0].contains("id=\"cb-note\""), + "the ending page offered no comment box" + ); + // 303 back to the TOKEN-CARRYING path. Redirecting to bare `/` + // is the defect that made a SAVED note read as a failed one. + assert!( + replies[1].contains("303"), + "the note was refused: {}", + replies[1] + ); + assert!( + replies[1].contains(&format!("t={token}")), + "the redirect dropped the token, which reads as a refusal: {}", + replies[1] + ); + // The session survived the note. + assert!( + replies[2].contains("closed"), + "the note ended the session, so nothing could follow it: {}", + replies[2] + ); + + let written = std::fs::read_to_string(&log).expect("trial log"); + assert!(written.contains("so that is why it"), "{written}"); + // The WHEN is not the final step. `RoundStep::End` is the last + // step of a round; filing an after-the-fact reading under it + // would claim the player said this at a decision point. + assert!( + written.contains("after the end"), + "a post-game note was logged as an in-play one: {written}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The other half of the same decision: no position, no box. + /// + /// A crashed game has nothing to bind a note to, and offering a box + /// that would answer 409 is worse than not offering one. + #[test] + fn a_game_that_stopped_offers_no_comment_box() { + let mut s = String::new(); + let page = cb_render_html::doc::ending(None, "it broke", "/c", None, &[], &[]); + s.push_str(&page); + assert!( + !s.contains("id=\"cb-note\""), + "offered a box with nothing to bind to" + ); + assert!( + s.contains("nothing to bind a note to"), + "did not say why: {s}" + ); + } + /// **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 @@ -857,8 +1008,14 @@ mod tests { /// 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", &[], &[]); + let page = cb_render_html::doc::ending( + None, + "P1 ran out of input", + "/command?t=x", + None, + &[], + &[], + ); assert!( page.contains("P1 ran out of input"), "the reason is missing" @@ -1105,9 +1262,17 @@ mod tests { ) } - fn post(token: &str, body: &str) -> String { + /// One form POST. **`Content-Length` is derived from the body**, which + /// is the whole reason to have this rather than hand-written request + /// literals: the first draft of the post-game note test wrote + /// `Content-Length: 21` beside a 22-byte body, the server read 21 of + /// them, and the note came back `400 unrecognised field` — a failure + /// that looked exactly like the feature being broken. + /// + /// `path` because the note channel is a second endpoint (CB-WP-0031). + fn post(token: &str, path: &str, body: &str) -> String { format!( - "POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1\r\n\ + "POST {path}?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() ) @@ -1142,7 +1307,10 @@ mod tests { let token = server.url().rsplit("t=").next().unwrap().to_string(); let client = converse( port, - vec![get(&token), post(&token, "down=action-ground&up=table")], + vec![ + get(&token), + post(&token, "/command", "down=action-ground&up=table"), + ], ); let choice = server @@ -1181,7 +1349,7 @@ mod tests { 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"), + post(&token, "/command", "down=action-ground&up=table"), ], ); @@ -1213,8 +1381,8 @@ mod tests { let client = converse( port, vec![ - post(&token, "down=seat-1&up=seat-2"), - post(&token, "down=action-ground&up=table"), + post(&token, "/command", "down=seat-1&up=seat-2"), + post(&token, "/command", "down=action-ground&up=table"), ], ); @@ -1277,7 +1445,7 @@ mod tests { // 3. send exactly what the JavaScript produced — not what this // test thinks it should have produced let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect"); - s.write_all(post(&token, &posts[0].body).as_bytes()) + s.write_all(post(&token, "/command", &posts[0].body).as_bytes()) .expect("write"); let mut reply = String::new(); let _ = s.read_to_string(&mut reply); @@ -1327,7 +1495,7 @@ mod tests { pages.push(page); let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect"); - s.write_all(post(&tok, "down=action-ground&up=table").as_bytes()) + s.write_all(post(&tok, "/command", "down=action-ground&up=table").as_bytes()) .expect("write"); let mut reply = String::new(); let _ = s.read_to_string(&mut reply); diff --git a/tools/cb-play/src/table.rs b/tools/cb-play/src/table.rs index 5752c06..c1b0a7c 100644 --- a/tools/cb-play/src/table.rs +++ b/tools/cb-play/src/table.rs @@ -270,7 +270,7 @@ fn end_badly( // nothing to the series and the panel stays absent. Passing an // empty tally rather than the live one is deliberate: a crashed // game must not be counted as a played one. - let _ = s.serve_end(None, &msg, END_LINGER, &MatchTally::default()); + let _ = s.serve_end(None, None, &msg, END_LINGER, &MatchTally::default()); } Err(msg) } @@ -434,7 +434,7 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( tally.record(o); } let msg = format!("{} commands, hash {}", game.commands, &end_hash[..12]); - end_choice = srv.serve_end(Some(&ended), &msg, END_LINGER, tally)?; + end_choice = srv.serve_end(Some(&ended), Some(&game.state), &msg, END_LINGER, tally)?; } let scenario = games_ground::record::to_scenario( diff --git a/trials/2026-08-07-1252.yaml b/trials/2026-08-07-1252.yaml new file mode 100644 index 0000000..21eda29 --- /dev/null +++ b/trials/2026-08-07-1252.yaml @@ -0,0 +1,145 @@ +scenario: ground/cb-play-session +description: recorded by cb-play (CB-WP-0008 T02) +covers: [] +provisional: false +provisional_owner: '' +provisional_raised: '' +ruled: '' +ruled_by: '' +ruled_note: '' +encodes_u_item: '' +seed: 4 +setup: + players: 3 + preset: standard-3p + patch: {} +commands: +- actor: P1 + cmd: select_action + args: + action: INVESTIGATE + problem: 2 +- actor: P2 + cmd: select_action + args: + action: SOLVE + problem: 1 +- actor: P3 + cmd: select_action + args: + action: INVESTIGATE + problem: 2 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: INVESTIGATE + problem: 3 +- actor: P2 + cmd: select_action + args: + action: INVESTIGATE + problem: 3 +- actor: P3 + cmd: select_action + args: + action: SOLVE + problem: 2 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: SOLVE + problem: 3 +- actor: P2 + cmd: select_action + args: + action: SOLVE + problem: 3 +- actor: P3 + cmd: select_action + args: + action: SOLVE + problem: 3 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: INVESTIGATE + problem: 4 +- actor: P2 + cmd: select_action + args: + action: INVESTIGATE + problem: 4 +- actor: P3 + cmd: select_action + args: + action: INVESTIGATE + problem: 4 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +- actor: P1 + cmd: select_action + args: + action: SOLVE + problem: 4 +- actor: P2 + cmd: select_action + args: + action: SOLVE + problem: 4 +- actor: P3 + cmd: select_action + args: + action: SUPPORT + target: P1 +- actor: SYSTEM + cmd: reveal + args: {} +- actor: P1 + cmd: respond_to_support + args: + response: accept_bond +- actor: SYSTEM + cmd: resolve + args: {} +- actor: SYSTEM + cmd: end_round + args: {} +expect: + events: [] + state: {} + rejects: [] + state_hash: ba880bbda3730915e6a8dc268b0ad63f848b5ac3e53a21ca6e99d17c33c6527a diff --git a/workplans/CB-WP-0031-the-comment-box-outlives-the-game.md b/workplans/CB-WP-0031-the-comment-box-outlives-the-game.md new file mode 100644 index 0000000..249e636 --- /dev/null +++ b/workplans/CB-WP-0031-the-comment-box-outlives-the-game.md @@ -0,0 +1,106 @@ +--- +id: CB-WP-0031 +kind: product +title: "The comment box outlives the game" +status: done +--- + +# Purpose + +``` +structural tier S (one channel reopened on an existing page; no new + decision, no new dependency, no budget moved) +chaos d8 = 2 → no override +declared tier S +``` + +**Declaration 2 of chaos window 3**, opened 2026-08-07 by ADR-0017. + +## The report + +> *"Before we move on, i want to be able to add comments after the game +> finished."* + +## What was actually broken, and it was two things + +**The channel closed at the moment it is worth most.** A player who has +just seen the outcome is the one with something to say — *"so that is why +that failed"*, *"I never understood what that card did"* — and that is the +reading the whole trial protocol (GameDesign §5) exists to collect. It was +collectable at every moment of the game **except** the one after it. + +Two independent defects, either of which alone shuts the channel: + +| where | what | +|---|---| +| `doc::ending` | never rendered the comment box at all | +| `hotseat::serve_end` | had no `POST /note` arm — a hand-built post fell through to `404 the game is over` | + +**Fixing either alone leaves it shut**, which is why the test asserts both +and why a markup-only test would have passed against a server that still +refused. + +## The thing that needed deciding: what a post-game note is bound to + +A note is bound to a position by `state_hash` (ADR-0014 D3), and the +position is genuinely the final state. But **`round` and `step` say *when +the player said it***, and a note written after the outcome is on screen +is not an observation made at the last decision point. + +`RoundStep` has an `End` variant — **the last step of a round, not the end +of the game.** Logging a post-game note under it would file an +after-the-fact reading as an in-play one: a value that is correct about the +wrong subject, which is the family ADR-0018 was written for two hours +earlier. + +So `record_note` takes the step **as an argument** rather than reading it +off the state, and the post-game path passes `"after the end"` — which +cannot collide with a `Debug` identifier. + +## No box where there is nothing to bind to + +A game that stopped without a result has no final position. The box is +**not offered** there, and the page says why. Offering one that would +answer `409` is a control that exists to be hit. + +## Task: reopen the channel + +```task +id: CB-WP-0031-T01 +status: done +priority: high +``` + +**Controls:** +- **the test fails on each half separately** — mutation-proven: blanking + the form gives *"the ending page offered no comment box"*, removing the + server arm gives `404`; both green when restored; +- **a note must not end the session** — every other POST in `serve_end` + breaks the loop, and a player must be able to write a second note and + then still press `play again`. The test posts a command after the note + and requires it to still work; +- **the redirect carries the token**, which is the defect that twice made + a *saved* note read as a failed one; +- **the log distinguishes when it was written** from the position it binds + to. + +**Done 2026-08-07.** + +`note_form` extracted and shared by both pages — a hand-written twin is +how one of them quietly stops posting anywhere useful. The placeholder +differs, and only that: what a player has to say mid-turn and what they +have to say having seen the result are not the same prompt. + +**A test-harness defect worth recording.** The first draft wrote +`Content-Length: 21` beside a 22-byte body; the server read 21 bytes and +answered `400 unrecognised field`, which looked exactly like the feature +being broken. The fix was to derive the length from the body in the shared +`post` helper — **a hand-written length is a second copy of a fact the +body already carries**, the same shape as the 62 untagged literals +`facts-check` counts. + +## Not done here + +- **Notes are still not shown back to the player.** Neither page echoes + what has been written; the trial log is the only place they exist. That + is a real gap and it is not this report.