CB-WP-0031: the comment box outlives the game
Some checks failed
ci / check (push) Failing after 4s

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 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-07 13:08:56 +02:00
parent b9efa327ab
commit 499d9fe3d7
7 changed files with 512 additions and 45 deletions

View file

@ -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,
"<h2>what are you thinking?</h2>\
<form class=\"card\" method=\"post\" action=\"{note_to}\" id=\"cb-note\">\
<textarea name=\"note\" rows=\"4\" placeholder=\"{placeholder}\"></textarea>\
<button type=\"submit\">note it</button></form>",
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,
"<h2>what are you thinking?</h2>\
<form class=\"card\" method=\"post\" action=\"{note_to}\" id=\"cb-note\">\
<textarea name=\"note\" rows=\"4\" placeholder=\"why this move, what is \
unclear, what is annoying \u{2014} bound to this position\"></textarea>\
<button type=\"submit\">note it</button></form>",
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("</div>");
}
// 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(
"<div class=\"card\">No comment box: the game stopped without a \
final position, so there is nothing to bind a note to.</div>",
),
}
// 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.

View file

@ -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"),