diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index c672249..34e6de8 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -38,6 +38,7 @@ | 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 | done | — | workplans/CB-WP-0030-a-number-that-does-not-move.md | +| workplan | CB-WP-0031 | done | — | workplans/CB-WP-0031-the-comment-box-outlives-the-game.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 | @@ -199,3 +200,4 @@ | 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 | +| task | CB-WP-0031-T01 | done | — | workplans/CB-WP-0031-the-comment-box-outlives-the-game.md | diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index 993e735..9387227 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -930,7 +930,7 @@ pub fn document( }, seat, may_pass, - &[], + Account::of(&[]), &[], ) } @@ -955,7 +955,7 @@ pub fn document_with_log( to: Endpoints<'_>, seat: Option, may_pass: bool, - log: &[LogLine], + log: Account<'_>, meta: &[String], ) -> String { let (endpoint, note_to) = (to.command, to.note); @@ -1305,13 +1305,81 @@ pub struct LogLine { pub effects: Vec, } +/// One thing the player said, placed in the account (CB-WP-0032). +/// +/// **`after` is a count of commands, not a round.** A note anchored to +/// `round`/`step` cannot be ordered against the log, because several +/// commands share a step and the log is a sequence of commands — placing +/// by round would put a note *somewhere in* the right round, which for a +/// remark about a specific move is the wrong position stated confidently. +/// The server knows exactly how many commands had been played when the +/// note was written, so that is what it records. +#[derive(Debug, Clone)] +pub struct LogNote { + /// Commands played when this was written; `0` is before any move. + pub after: usize, + pub round: u8, + /// Where in the round, or `after the end` for a post-game note. + pub step: String, + pub text: String, +} + +/// The account of the session: what happened, and what the player said +/// about it, in one sequence. +/// +/// **A struct rather than two parameters** because both pages take both +/// and `document_with_log` was already at seven arguments — and because +/// the two are only meaningful together, which is the point of showing +/// them interleaved. +#[derive(Clone, Copy)] +pub struct Account<'a> { + pub lines: &'a [LogLine], + pub notes: &'a [LogNote], +} + +impl<'a> Account<'a> { + /// An account with no commentary — for callers that have no notes. + pub fn of(lines: &'a [LogLine]) -> Self { + Self { lines, notes: &[] } + } +} + /// The game log, newest last, with the empty case spelled out. -fn log_section(s: &mut String, log: &[LogLine]) { +/// +/// **The player's own comments are interleaved** (CB-WP-0032) at the +/// point they were written, because a remark like *"why did that do +/// nothing?"* is about the move above it and loses its subject anywhere +/// else. +/// +/// **They are marked as the player's, and cannot be read as game +/// events.** A note carries no `who`/`what` and renders in its own +/// `.note` block with the player's words in quotes: the log is the +/// recorder's vocabulary — what the scenario file will say — and a +/// comment that looked like a log line would be a sentence the game never +/// produced, sitting in the account of what the game did. +fn log_section(s: &mut String, log: Account<'_>) { + fn note_block(s: &mut String, n: &LogNote) { + let _ = write!( + s, + "
you \u{2014} round {round}, \ + {step}
\u{201c}{text}\u{201d}
", + round = n.round, + step = esc(&n.step), + text = esc(&n.text), + ); + } + let notes_after = |k: usize, s: &mut String| { + for n in log.notes.iter().filter(|n| n.after == k) { + note_block(s, n); + } + }; s.push_str("

log

"); - if log.is_empty() { + if log.lines.is_empty() && log.notes.is_empty() { s.push_str("nothing has happened yet"); } - for line in log { + // Anything said before the first move still belongs in the account. + notes_after(0, s); + for (i, line) in log.lines.iter().enumerate() { let _ = write!( s, "
{who} {what}", @@ -1327,6 +1395,13 @@ fn log_section(s: &mut String, log: &[LogLine]) { } } s.push_str("
"); + notes_after(i + 1, s); + } + // A note can outrun the log: post-game notes are written when every + // command has been played, and one written against a longer log than + // this page has must still appear rather than be silently dropped. + for n in log.notes.iter().filter(|n| n.after > log.lines.len()) { + note_block(s, n); } s.push_str("
"); } @@ -1459,7 +1534,7 @@ pub fn ending( message: &str, endpoint: &str, note_to: Option<&str>, - log: &[LogLine], + log: Account<'_>, series: &[String], ) -> String { let mut s = String::with_capacity(4096); diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs index e613a3b..3885e1f 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -587,7 +587,7 @@ mod affordances { mod gamelog { //! CB-WP-0018 T02. - use crate::doc::{document_with_log, LogLine}; + use crate::doc::{document_with_log, Account, LogLine}; use cb_kernel::PlayerId; fn line(effects: &[&str]) -> LogLine { @@ -598,6 +598,104 @@ mod gamelog { } } + fn note(after: usize, text: &str) -> crate::doc::LogNote { + crate::doc::LogNote { + after, + round: 2, + step: "Select".into(), + text: text.into(), + } + } + + /// CB-WP-0032. A comment appears where it was written. + /// + /// **Position is the whole feature.** A remark like *"why did that do + /// nothing?"* is about the move above it; collected at the bottom it + /// is a list of sentences with no subjects. + #[test] + fn comments_appear_in_the_log_where_they_were_written() { + let lines = vec![line(&["e0"]), line(&["e1"]), line(&["e2"])]; + let html = document_with_log( + &crate::testfix::view(Some(PlayerId(0))), + &[], + crate::TEST_ENDPOINTS, + Some(PlayerId(0)), + false, + Account { + lines: &lines, + notes: &[ + note(0, "before I moved"), + note(2, "why did that do nothing"), + ], + }, + &[], + ); + let text = crate::text_of(&html); + let at = |needle: &str| { + text.find(needle) + .unwrap_or_else(|| panic!("missing {needle:?}")) + }; + // Written before any move: above the first effect. + assert!(at("before I moved") < at("e0"), "{text}"); + // Written after two commands: after the second, before the third. + assert!(at("e1") < at("why did that do nothing"), "{text}"); + assert!(at("why did that do nothing") < at("e2"), "{text}"); + } + + /// A comment must not be readable as something the game did. + /// + /// The log is the RECORDER's vocabulary — what the scenario file will + /// say. A note styled like a log line would be a sentence the game + /// never produced, sitting in the account of what the game produced, + /// and `ADR-0014 D4` turns on that distinction being visible. + #[test] + fn a_comment_is_not_dressed_as_a_game_event() { + let lines = vec![line(&["e0"])]; + let html = document_with_log( + &crate::testfix::view(Some(PlayerId(0))), + &[], + crate::TEST_ENDPOINTS, + Some(PlayerId(0)), + false, + Account { + lines: &lines, + notes: &[note(1, "select_action action=SOLVE")], + }, + &[], + ); + assert!( + html.contains("class=\"note\""), + "the note had no marking of its own" + ); + let text = crate::text_of(&html); + // Attributed to the player, and to a position. + assert!(text.contains("you \u{2014} round 2, Select"), "{text}"); + } + + /// A post-game note is written when every command has been played, so + /// its `after` can exceed a log this page happens to be showing. It + /// must still appear — a dropped comment is the failure the whole + /// note channel exists to avoid. + #[test] + fn a_comment_past_the_end_of_the_log_is_still_shown() { + let html = document_with_log( + &crate::testfix::view(Some(PlayerId(0))), + &[], + crate::TEST_ENDPOINTS, + Some(PlayerId(0)), + false, + Account { + lines: &[], + notes: &[note(9, "so that is why it failed")], + }, + &[], + ); + assert!( + crate::text_of(&html).contains("so that is why it failed"), + "a comment was silently dropped" + ); + } + fn page(log: &[LogLine]) -> String { document_with_log( &crate::testfix::view(Some(PlayerId(0))), @@ -605,7 +703,7 @@ mod gamelog { crate::TEST_ENDPOINTS, Some(PlayerId(0)), false, - log, + Account::of(log), &[], ) } @@ -1133,7 +1231,7 @@ mod notes { crate::TEST_ENDPOINTS, Some(PlayerId(0)), false, - &[], + crate::doc::Account::of(&[]), &[hostile.to_string()], ); assert!( @@ -1161,7 +1259,7 @@ mod notes { crate::TEST_ENDPOINTS, Some(PlayerId(0)), false, - &[], + crate::doc::Account::of(&[]), &[], ); assert!( @@ -1189,7 +1287,7 @@ mod two_columns { crate::TEST_ENDPOINTS, Some(PlayerId(0)), false, - &[], + crate::doc::Account::of(&[]), meta, ) } @@ -1360,7 +1458,14 @@ mod ending_page { use crate::{doc, jsrun}; fn page() -> String { - doc::ending(None, "the game ended", "/command?t=x", None, &[], &[]) + doc::ending( + None, + "the game ended", + "/command?t=x", + None, + crate::doc::Account::of(&[]), + &[], + ) } /// The label must say what the control DOES. Asserted on the rendered @@ -1439,7 +1544,14 @@ mod ending_page { #[test] fn click_targets_do_not_wear_the_drag_affordance() { let pages = [ - doc::ending(None, "m", "/command?t=x", None, &[], &[]), + doc::ending( + None, + "m", + "/command?t=x", + None, + crate::doc::Account::of(&[]), + &[], + ), doc::document( &crate::testfix::view(Some(PlayerId(0))), &[games_ground::GroundCommand::SelectAction { @@ -1490,7 +1602,14 @@ 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", None, &[], &[])) + crate::text_of(&doc::ending( + Some(v), + "m", + "/command?t=x", + None, + crate::doc::Account::of(&[]), + &[], + )) }; assert!( head(&won).contains("game solved"), @@ -1512,7 +1631,7 @@ mod ending_page { "P1 ran out of input", "/command?t=x", None, - &[], + crate::doc::Account::of(&[]), &[], )); assert!(!none.contains("game solved") && !none.contains("game over")); @@ -1526,7 +1645,14 @@ 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", None, &[], &[])); + let text = crate::text_of(&doc::ending( + Some(&v), + "m", + "/command?t=x", + None, + crate::doc::Account::of(&[]), + &[], + )); assert!( text.contains("problems solved"), @@ -1549,7 +1675,14 @@ 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", None, &[], &[])); + let text = crate::text_of(&doc::ending( + Some(&v), + "m", + "/command?t=x", + None, + crate::doc::Account::of(&[]), + &[], + )); assert!( text.contains("Lower combined Stress"), diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs index c27f50d..e2a9e53 100644 --- a/tools/cb-play/src/hotseat.rs +++ b/tools/cb-play/src/hotseat.rs @@ -47,6 +47,14 @@ pub struct Server { #[derive(Debug, Clone)] pub struct TrialNote { pub n: usize, + /// Commands played when this was written (CB-WP-0032), so the page + /// can put the comment where it was made. **Held in memory only.** + /// It is deliberately NOT a column in the trial log: `trials.py` + /// skips any row that is not five cells, silently, so a sixth column + /// would make `make trials` report zero notes for a file full of + /// them — the exact failure its own docstring says it exists to + /// prevent. + pub after: usize, pub round: u8, pub step: String, pub state_hash: String, @@ -146,10 +154,12 @@ impl Server { return Err("no trial log for this session — start with --trial".into()); }; let hash = cb_events::state_hash_hex(state); + let after = self.journal.borrow().len(); let mut log = self.notes.borrow_mut(); let n = log.len() + 1; log.push(TrialNote { n, + after, round: state.round, step: step.to_string(), state_hash: hash[..12].to_string(), @@ -173,6 +183,20 @@ impl Server { self.journal.clone() } + /// The player's own comments, positioned for the log (CB-WP-0032). + fn log_notes(&self) -> Vec { + self.notes + .borrow() + .iter() + .map(|n| cb_render_html::doc::LogNote { + after: n.after, + round: n.round, + step: n.step.clone(), + text: n.text.clone(), + }) + .collect() + } + /// The log as the page renders it, in the recorder's vocabulary. /// /// `record::to_step` is reused rather than phrased afresh: what the @@ -261,7 +285,10 @@ impl Server { }, Some(seat), may_pass, - &self.log_lines(), + cb_render_html::doc::Account { + lines: &self.log_lines(), + notes: &self.log_notes(), + }, // CB-WP-0027 T02: the meta panel's own content. // The running tally lives with the driver, so the // server is handed the lines rather than the state. @@ -394,7 +421,10 @@ impl Server { message, &self.guard.endpoint(), note_to.as_deref(), - &self.log_lines(), + cb_render_html::doc::Account { + lines: &self.log_lines(), + notes: &self.log_notes(), + }, &series_lines(tally), ); respond(&mut stream, 200, "text/html; charset=utf-8", &page); @@ -875,6 +905,8 @@ mod tests { // 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"), + // CB-WP-0032: and it comes BACK, in the log. + format!("GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n"), post(&token, "/command", "down=done&up=done"), ], ); @@ -906,9 +938,20 @@ mod tests { "the redirect dropped the token, which reads as a refusal: {}", replies[1] ); + // CB-WP-0032. Written, then read back on the very next page — + // the round trip, not just the write. + assert!( + replies[2].contains("so that is why it"), + "the note was saved but never shown back: {}", + replies[2] + ); + assert!( + replies[2].contains("class=\"note\""), + "the note was shown without marking it as the player's" + ); // The session survived the note. assert!( - replies[2].contains("closed"), + replies[3].contains("closed"), "the note ended the session, so nothing could follow it: {}", replies[2] ); @@ -932,7 +975,14 @@ mod tests { #[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, &[], &[]); + let page = cb_render_html::doc::ending( + None, + "it broke", + "/c", + None, + cb_render_html::doc::Account::of(&[]), + &[], + ); s.push_str(&page); assert!( !s.contains("id=\"cb-note\""), @@ -1013,7 +1063,7 @@ mod tests { "P1 ran out of input", "/command?t=x", None, - &[], + cb_render_html::doc::Account::of(&[]), &[], ); assert!( diff --git a/tools/trials.py b/tools/trials.py index c173ceb..dc7a888 100644 --- a/tools/trials.py +++ b/tools/trials.py @@ -27,6 +27,11 @@ END = "" # cannot expire ages into an apparent finding. NOTE_EXPIRY_DAYS = 30 +# The trial-log table's columns, named once. `write_trial_log` in +# `hotseat.rs` writes them; this reads them; a row that is not this shape +# is an error rather than a row to skip. +COLUMNS = ("n", "round", "step", "state_hash", "comment") + def parse(text): """Rows between the markers. A log without the block is an error, not @@ -41,9 +46,21 @@ def parse(text): if not line.startswith("|") or line.startswith("|---"): continue cells = [c.strip() for c in line.strip("|").split("|")] - if len(cells) != 5 or cells[0] == "n": + if cells[0] == "n": continue - rows.append(dict(zip(("n", "round", "step", "state_hash", "comment"), cells))) + # CB-WP-0032. A wrong column count RAISES; it used to `continue`. + # + # The docstring above already says silently reporting zero notes + # for a file full of them is the failure this tool exists to + # prevent -- and a `continue` here did exactly that for every row + # at once. Adding a sixth column to the trial log (which CB-WP-0032 + # considered, and did not do) would have emptied every existing + # log without a word. + if len(cells) != len(COLUMNS): + raise ValueError( + f"trial-log row has {len(cells)} columns, expected {len(COLUMNS)}: {line!r}" + ) + rows.append(dict(zip(COLUMNS, cells))) return rows @@ -53,8 +70,13 @@ def logs(root=TRIALS): for path in sorted(glob.glob(os.path.join(root, "*.md"))): try: rows = parse(open(path).read()) - except ValueError: - print(f" WARN {os.path.basename(path)}: no trial-log block — not a trial log?") + except ValueError as e: + # The REASON, not a guess at it. This said "no trial-log block + # -- not a trial log?" for every failure, which is now wrong + # for two of them: a malformed row is not a missing block, and + # telling a maintainer to check for the wrong thing costs more + # than saying nothing. + print(f" WARN {os.path.basename(path)}: {e}") continue recording = os.path.splitext(path)[0] + ".yaml" out.append((path, rows, recording if os.path.exists(recording) else None)) @@ -159,6 +181,28 @@ def self_test(): check("a trial log parses", len(parse(good)) == 1) check("the header row is not a note", all(r["n"] != "n" for r in parse(good))) + # CB-WP-0032. A row of the wrong shape must be LOUD. This used to + # `continue`, so a format change would have reported zero notes for + # every log at once -- the failure the parser's own docstring names. + six = good.replace("| 1 | 3 | Select | abc123def456 | why is SOLVE doing nothing |", + "| 1 | 3 | Select | abc123def456 | extra | why is SOLVE doing nothing |") + try: + parse(six) + check("a row with the wrong column count is an error", False) + except ValueError as e: + check("a row with the wrong column count is an error", "6 columns" in str(e), + "a silent skip would empty every log at once") + + # And the surrounding walk must report the REASON it failed. + with tempfile.TemporaryDirectory() as d: + with open(os.path.join(d, "2026-08-07-bad.md"), "w") as fh: + fh.write(six) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + logs(d) + check("the walk names the real reason", "6 columns" in buf.getvalue(), + "it used to blame a missing block for every failure") + try: parse("# Trial log\n\nno block here\n") check("a file with no block is an error, not an empty log", False) diff --git a/trials/2026-08-07-1558.md b/trials/2026-08-07-1558.md new file mode 100644 index 0000000..1bfb59b --- /dev/null +++ b/trials/2026-08-07-1558.md @@ -0,0 +1,17 @@ +# Trial log + +What the player said while playing, bound to the position they said it at +(CB-WP-0027, ADR-0014). The recording beside this file is the position; +`state_hash` is what reaches it. + +**These are raw notes and they stay here.** Nothing in this file travels to +`ground-game` (ADR-0014 D4) — a note reaches them only by being promoted to a +register finding, by a human, with the wording chosen then. + + + +| n | round | step | state_hash | comment | +|---|---|---|---|---| +| 1 | 5 | after the end | 8ade38824ca4 | The game was fine, but i can not the player i bond or not-bond with. We should change that. Having my hand cards above the seats close to the table would feel more natural to me. | + + 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 index 249e636..c681f1c 100644 --- a/workplans/CB-WP-0031-the-comment-box-outlives-the-game.md +++ b/workplans/CB-WP-0031-the-comment-box-outlives-the-game.md @@ -3,6 +3,7 @@ id: CB-WP-0031 kind: product title: "The comment box outlives the game" status: done +state_hub_workstream_id: "7b7450cd-3972-4c78-a53c-fba54b3fc02b" --- # Purpose @@ -69,6 +70,7 @@ answer `409` is a control that exists to be hit. id: CB-WP-0031-T01 status: done priority: high +state_hub_task_id: "6b92d470-b3a2-4cde-a307-d20cb6a45ab4" ``` **Controls:** diff --git a/workplans/CB-WP-0032-the-comments-in-the-account.md b/workplans/CB-WP-0032-the-comments-in-the-account.md new file mode 100644 index 0000000..960e8cd --- /dev/null +++ b/workplans/CB-WP-0032-the-comments-in-the-account.md @@ -0,0 +1,106 @@ +--- +id: CB-WP-0032 +kind: product +title: "The comments in the account" +status: done +--- + +# Purpose + +``` +structural tier S (one existing renderer gains a second input; no new + decision, no dependency, no budget moved) +chaos d8 = 6 → no override +declared tier S +``` + +**Declaration 3 of chaos window 3.** + +## The report + +> *"Commenting after the game is a good practice. We could show the +> comments in the log. I would think that as useful."* + +This closes the gap [CB-WP-0031](CB-WP-0031-the-comment-box-outlives-the-game.md) +named and did not fix: notes were written to a file and **shown back +nowhere**. `trials.py`'s own docstring calls that shape *"this project's +signature failure in a new medium"* (ADR-0014 D6) — and the note channel +had it, in the interface, for as long as it has existed. + +## The decision: what a comment is positioned against + +A note carries `round`, `step` and `state_hash`. **None of them can order +it against the log**, because the log is a sequence of *commands* and +several commands share a step. Positioning by round would put a comment +*somewhere in* the right round — for a remark about a specific move, the +wrong position stated confidently. + +So the server records **how many commands had been played** when the note +was written, which it knows exactly (`journal.len()`), and the page places +it there. `after = 0` is *before anyone moved*, which is a real thing to +have an opinion at. + +## The thing that must not happen + +**A comment must not be readable as something the game did.** The log is +the recorder's vocabulary — `record::to_step`, what the scenario file will +say — so a note styled as a log line would be a sentence the game never +produced, sitting in the account of what the game produced. ADR-0014 D4 +turns on that distinction being visible: raw notes stay in this repo, and +only a human promotes one to a finding. + +Notes render in their own `.note` block, attributed *"you — round N, +step"*, with the words in quotes. **The `.note` CSS already existed and was +never used by anything** — defined for this, and left unwired. + +## What was NOT done, and why it is the interesting half + +**No column was added to the trial log**, though `after` is genuinely the +anchor and persisting it would let a later reader interleave. + +`trials.py` skipped any row that was not five cells — with `continue`, in +silence. A sixth column would have made `make trials` report **zero notes +for every log at once**, which is verbatim the failure the parser's own +docstring says it exists to prevent. `after` is therefore held in memory +for the session and the file format is untouched. + +**The latent defect was fixed on its own terms**: a wrong column count now +raises, and the walk that wraps it reports the *real* reason instead of +blaming a missing block for every failure. + +## Task: show the comments where they were made + +```task +id: CB-WP-0032-T01 +status: done +priority: high +``` + +**Controls:** +- **placement is asserted, not just presence** — mutation-proven: append + the notes at the end instead of interleaving and the test goes red; +- **a comment cannot be read as a game event** — asserted on its own + marking and its attribution; +- **a comment past the end of the log still appears**, because a post-game + note is written when every command has been played and a dropped comment + is the failure the channel exists to avoid; +- **the round trip, not the write** — the `cb-play` test posts a note and + then GETs the page, which is the path a player takes. + +**Done 2026-08-07.** + +`Account { lines, notes }` replaces the bare `&[LogLine]` on both pages — +a struct because `document_with_log` was already at seven arguments, and +because the two are only meaningful together, which is the point. + +`trials.py` hardened as described above, with two new controls: a +wrong-shaped row raises, and the walk names the reason. + +## Not done here + +- **Nothing lets a player edit or delete a comment.** A note is a record + of what they thought at the time and rewriting it would defeat that, + but a typo is a real case and there is no answer for it yet. +- **The ending page shows the same interleaved account**, so a long game + puts the post-game comment at the bottom of a long log. Whether that is + where a reader looks for it is untested.