CB-WP-0027 T01-T04: the commentary track
The meta view beside the table, and a note channel that provably cannot carry a move. T01 (ADR-0014). ADR-0007 D5 is SCOPED, NOT AMENDED, and the reason it was easy is that PointerFact::parse already refuses any unrecognised field -- a comment could not reach the command path even by accident. So /command carries pointer facts, /note carries text, and Note has no code path to GroundCommand. Comments live in trials/<date>-<slug>.md, not in ScenarioFile: a scenario is executed, replayed and hashed, and prose in it is data the runner must ignore, which is how a format rots. The state hash binds; round and step are for reading. And the retention question, decided before any comment was written: RAW NOTES NEVER LEAVE clay-borg. A note reaches ground-game only by being promoted to a register finding, by a human, with the wording chosen then -- "the DARVO sequence is infuriating" is useful signal and a bad way to open a message to the game's designer. T02. CSS grid, minmax(0,1fr) on both tracks -- load-bearing, because a grid child defaults to min-content width and without it the SVG table refuses to shrink and pushes the meta column off-screen, looking correct on the developer's monitor and broken everywhere else. Single-column fallback under 64rem. The running tally moved into the panel so it is visible WHILE PLAYING; it only appeared on the ending page before, and a score you see once the game is over informs nothing. T03. A plain <form method="post">, so the box works with the script disabled; the command channel needs JavaScript because a drag is not a form submission, a comment is one. 303 See Other so a reload does not re-post. esc()'s first hostile input: <script>alert(1)</script> renders escaped AND STILL READABLE -- escaping that eats the player's words is its own defect. Verified over real HTTP: note posted 303, hostile note stored as text, empty note refused 400, game did not advance. T04. tools/trials.py and make trials. THE REPORT'S DESIGN CHANGED BECAUSE I RAN IT: the first version called any note without a recording an orphan, so a live session reported every note as broken -- the recording is only written at game end. A metric that cries wolf is one nobody reads, which is the exact failure this pass exists to prevent. Now ok / pending / orphan, and only orphan is a target-0 number. The self-test exercises the REPORTING path, not just the parser, because design-baseline.py had a green self-test and an unexercised reporting path and that is where it rotted. And a latent Makefile defect surfaced: make trials did nothing, because trials is also a directory and Make saw an up-to-date file. design, difficulty and trials -- added by CB-WP-0022, CB-WP-0025 and this pass -- were ALL missing from .PHONY; only the one that collided revealed it. make all: exit 0. 49 render tests, 26 cb-play, loop-lint clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
5c6e322d5f
commit
4fb506fcd1
11 changed files with 1002 additions and 10 deletions
15
Makefile
15
Makefile
|
|
@ -24,7 +24,13 @@ TOOLS := $(REPO)/tools
|
||||||
# Every cargo recipe runs at the repo root; the shell does not persist cd.
|
# Every cargo recipe runs at the repo root; the shell does not persist cd.
|
||||||
IN_REPO := cd $(REPO) &&
|
IN_REPO := cd $(REPO) &&
|
||||||
|
|
||||||
.PHONY: check test sim bench bench-test coverage dep-weight cost cost-test cost-pin cost-budget shape-budget cost-mix loop-lint self-tests env-test task-done status facts-check facts-gen mutation-check size-metrics runtime-metrics build-time am6 am7 am8 edition-check replay-test loc play gate-review all
|
.PHONY: check test sim bench bench-test coverage dep-weight cost cost-test cost-pin cost-budget shape-budget cost-mix loop-lint self-tests env-test task-done status facts-check facts-gen mutation-check size-metrics runtime-metrics build-time am6 am7 am8 edition-check replay-test loc play gate-review all \
|
||||||
|
design difficulty trials
|
||||||
|
# `design`, `difficulty` and `trials` were added by CB-WP-0022, CB-WP-0025
|
||||||
|
# and CB-WP-0027 and none was declared here. Only `trials` revealed it, by
|
||||||
|
# colliding with the trials/ DIRECTORY -- Make saw an up-to-date file and
|
||||||
|
# ran nothing. The other two work by luck: no file happens to share their
|
||||||
|
# name. A target that is a command, not a file, belongs on this line.
|
||||||
|
|
||||||
## fmt + clippy (deny warnings) + HashMap deny-lint
|
## fmt + clippy (deny warnings) + HashMap deny-lint
|
||||||
check:
|
check:
|
||||||
|
|
@ -125,6 +131,7 @@ self-tests:
|
||||||
$(PY) $(TOOLS)/runtime-metrics.py --self-test
|
$(PY) $(TOOLS)/runtime-metrics.py --self-test
|
||||||
$(PY) $(TOOLS)/replay-test.py --self-test
|
$(PY) $(TOOLS)/replay-test.py --self-test
|
||||||
$(PY) $(TOOLS)/design.py --self-test
|
$(PY) $(TOOLS)/design.py --self-test
|
||||||
|
$(PY) $(TOOLS)/trials.py --self-test
|
||||||
cargo run --release -q -p games-ground --example difficulty -- --self-test
|
cargo run --release -q -p games-ground --example difficulty -- --self-test
|
||||||
$(PY) $(TOOLS)/edition-check.py --self-test
|
$(PY) $(TOOLS)/edition-check.py --self-test
|
||||||
|
|
||||||
|
|
@ -161,6 +168,12 @@ facts-check:
|
||||||
facts-gen:
|
facts-gen:
|
||||||
$(PY) $(TOOLS)/facts.py --gen
|
$(PY) $(TOOLS)/facts.py --gen
|
||||||
|
|
||||||
|
# CB-WP-0027 T04: what the players said, and where. Surfacing is the
|
||||||
|
# deliverable -- a commentary feature nobody can read is this project's
|
||||||
|
# signature failure in a new medium (ADR-0014 D6).
|
||||||
|
trials:
|
||||||
|
@$(PY) $(TOOLS)/trials.py
|
||||||
|
|
||||||
# CB-WP-0025 T06: the difficulty table (specs/RetrospectiveAnalysis.md §4).
|
# CB-WP-0025 T06: the difficulty table (specs/RetrospectiveAnalysis.md §4).
|
||||||
# Winnable fraction from the solver plus a PLURAL policy panel -- a single
|
# Winnable fraction from the solver plus a PLURAL policy panel -- a single
|
||||||
# policy's win rate may not be reported as a difficulty (§4.1).
|
# policy's win rate may not be reported as a difficulty (§4.1).
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,25 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf}
|
||||||
styling alone would leave a dead control that still looks alive to
|
styling alone would leave a dead control that still looks alive to
|
||||||
anything reading the DOM. */
|
anything reading the DOM. */
|
||||||
.sealed{opacity:.3;cursor:default;pointer-events:none;filter:grayscale(1)}
|
.sealed{opacity:.3;cursor:default;pointer-events:none;filter:grayscale(1)}
|
||||||
|
/* CB-WP-0027 T02. `minmax(0,...)` on both tracks, because a grid child
|
||||||
|
defaults to min-content width and the SVG table would refuse to shrink,
|
||||||
|
pushing the meta column off-screen instead of narrowing.
|
||||||
|
The single-column fallback is deliberate rather than incidental: the
|
||||||
|
page was responsive by accident before this. */
|
||||||
|
.cb-cols{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,24rem);
|
||||||
|
gap:1.2rem;align-items:start}
|
||||||
|
.cb-game{min-width:0}
|
||||||
|
.cb-meta{min-width:0;border-left:1px solid #2a3140;padding-left:1.1rem}
|
||||||
|
#cb-note{display:flex;flex-direction:column;gap:.5rem}
|
||||||
|
#cb-note textarea{width:100%;box-sizing:border-box;font:inherit;color:#dde;
|
||||||
|
background:#12141a;border:1px solid #445;border-radius:5px;padding:.5rem}
|
||||||
|
#cb-note button{align-self:flex-start;font:inherit;cursor:pointer;color:#dde;
|
||||||
|
background:#332a3a;border:1px solid #a7d;border-radius:5px;padding:.35rem .8rem}
|
||||||
|
.note{border-left:2px solid #a7d;padding-left:.6rem;margin:.4rem 0;white-space:pre-wrap}
|
||||||
|
@media (max-width:64rem){
|
||||||
|
.cb-cols{grid-template-columns:minmax(0,1fr)}
|
||||||
|
.cb-meta{border-left:none;border-top:1px solid #2a3140;padding-left:0;padding-top:1rem}
|
||||||
|
}
|
||||||
/* CB-WP-0024 T03: the card a seat played, in that seat's area. */
|
/* CB-WP-0024 T03: the card a seat played, in that seat's area. */
|
||||||
.played{display:block;margin:.3rem 0}
|
.played{display:block;margin:.3rem 0}
|
||||||
.k{color:#89a}
|
.k{color:#89a}
|
||||||
|
|
@ -679,7 +698,7 @@ pub fn document(
|
||||||
seat: Option<PlayerId>,
|
seat: Option<PlayerId>,
|
||||||
may_pass: bool,
|
may_pass: bool,
|
||||||
) -> String {
|
) -> String {
|
||||||
document_with_log(view, legal, endpoint, seat, may_pass, &[])
|
document_with_log(view, legal, endpoint, seat, may_pass, &[], &[])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The table, plus the game log (CB-WP-0018 T02).
|
/// The table, plus the game log (CB-WP-0018 T02).
|
||||||
|
|
@ -690,6 +709,7 @@ pub fn document_with_log(
|
||||||
seat: Option<PlayerId>,
|
seat: Option<PlayerId>,
|
||||||
may_pass: bool,
|
may_pass: bool,
|
||||||
log: &[LogLine],
|
log: &[LogLine],
|
||||||
|
meta: &[String],
|
||||||
) -> String {
|
) -> String {
|
||||||
let mut s = String::with_capacity(8192);
|
let mut s = String::with_capacity(8192);
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
|
|
@ -716,9 +736,16 @@ pub fn document_with_log(
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// CB-WP-0027 T02: the table on the left, everything *about* the table
|
||||||
|
// on the right. The log moves right because it is commentary on the
|
||||||
|
// game rather than part of it.
|
||||||
|
s.push_str("<div class=\"cb-cols\"><div class=\"cb-game\">");
|
||||||
body(&mut s, view);
|
body(&mut s, view);
|
||||||
move_section(&mut s, legal, seat, may_pass);
|
move_section(&mut s, legal, seat, may_pass);
|
||||||
|
s.push_str("</div><div class=\"cb-meta\">");
|
||||||
|
meta_section(&mut s, meta);
|
||||||
log_section(&mut s, log);
|
log_section(&mut s, log);
|
||||||
|
s.push_str("</div></div>");
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
s,
|
s,
|
||||||
"<div id=\"cb-status\">ready</div>\
|
"<div id=\"cb-status\">ready</div>\
|
||||||
|
|
@ -729,6 +756,40 @@ pub fn document_with_log(
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The meta panel's own content: whatever the caller wants a player to see
|
||||||
|
/// *about* the session rather than about the position.
|
||||||
|
///
|
||||||
|
/// Empty is a legitimate state — a first game has no tally and may have no
|
||||||
|
/// notes — and renders as nothing rather than as an empty heading.
|
||||||
|
fn meta_section(s: &mut String, meta: &[String]) {
|
||||||
|
// CB-WP-0027 T03: the comment box. Always present — the panel's
|
||||||
|
// purpose is that a player can say something at any moment, and a box
|
||||||
|
// that appears only sometimes trains them not to look for it.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
s.push_str(
|
||||||
|
"<h2>what are you thinking?</h2>\
|
||||||
|
<form class=\"card\" method=\"post\" action=\"/note\" 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>",
|
||||||
|
);
|
||||||
|
if meta.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s.push_str("<h2>this session</h2><div class=\"card\">");
|
||||||
|
for (i, line) in meta.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
s.push_str("<br>");
|
||||||
|
}
|
||||||
|
s.push_str(&esc(line));
|
||||||
|
}
|
||||||
|
s.push_str("</div>");
|
||||||
|
}
|
||||||
|
|
||||||
/// The table itself: problems, relationships, seats, solutions, outcome.
|
/// The table itself: problems, relationships, seats, solutions, outcome.
|
||||||
///
|
///
|
||||||
/// Factored out of [`document`] so [`ending`] shows the SAME table rather
|
/// Factored out of [`document`] so [`ending`] shows the SAME table rather
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,91 @@ impl PointerFact {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What a test player wrote, and where they wrote it (CB-WP-0027,
|
||||||
|
/// ADR-0014 D1).
|
||||||
|
///
|
||||||
|
/// **A second channel that cannot carry a move.** ADR-0007 D5 governs the
|
||||||
|
/// *command* channel and is untouched: [`PointerFact`] still admits two
|
||||||
|
/// ids and refuses every other field. This type carries text, and there is
|
||||||
|
/// **no code path from it to a `GroundCommand`** — [`crate::resolve`]
|
||||||
|
/// takes a `PointerFact` and nothing else, so a note cannot become a move
|
||||||
|
/// by any route, including a future careless one.
|
||||||
|
///
|
||||||
|
/// If this struct ever grows a field the engine reads, that separation is
|
||||||
|
/// gone and ADR-0007 says to revisit the decision rather than widen the
|
||||||
|
/// control.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Note {
|
||||||
|
/// The player's own words, verbatim. Never interpreted, only stored
|
||||||
|
/// and escaped on render.
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Note {
|
||||||
|
/// Parse the wire form `note=<text>`, percent-decoded.
|
||||||
|
///
|
||||||
|
/// Refuses unknown fields for the same reason `PointerFact` does: a
|
||||||
|
/// parser that ignores what it does not understand cannot tell a typo
|
||||||
|
/// from an attack.
|
||||||
|
pub fn parse(body: &str) -> Result<Self, String> {
|
||||||
|
let mut text = None;
|
||||||
|
for pair in body.split('&') {
|
||||||
|
match pair.split_once('=') {
|
||||||
|
Some(("note", v)) => text = Some(percent_decode(v)),
|
||||||
|
_ => return Err(format!("unrecognised field in note: {pair:?}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match text {
|
||||||
|
// An empty note is refused rather than stored: a blank line in
|
||||||
|
// the trial log is noise in the register (CB-WP-0027 T03).
|
||||||
|
Some(t) if !t.trim().is_empty() => Ok(Self {
|
||||||
|
text: t.trim().to_string(),
|
||||||
|
}),
|
||||||
|
_ => Err("an empty note is not recorded".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `application/x-www-form-urlencoded` decoding, enough for one field.
|
||||||
|
///
|
||||||
|
/// Hand-rolled because this crate has **no third-party dependencies at
|
||||||
|
/// all** and ADR-0007 §D3 requires an argument before it acquires one —
|
||||||
|
/// which a form decoder does not merit.
|
||||||
|
fn percent_decode(s: &str) -> String {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
match bytes[i] {
|
||||||
|
b'+' => {
|
||||||
|
out.push(b' ');
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
b'%' if i + 2 < bytes.len() => {
|
||||||
|
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
|
||||||
|
match u8::from_str_radix(hex, 16) {
|
||||||
|
Ok(b) => {
|
||||||
|
out.push(b);
|
||||||
|
i += 3;
|
||||||
|
}
|
||||||
|
// A malformed escape is kept literally rather than
|
||||||
|
// dropped: losing characters silently is how a note
|
||||||
|
// stops meaning what its author wrote.
|
||||||
|
Err(_) => {
|
||||||
|
out.push(bytes[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b => {
|
||||||
|
out.push(b);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out).into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn action_id(a: Action) -> &'static str {
|
pub fn action_id(a: Action) -> &'static str {
|
||||||
match a {
|
match a {
|
||||||
Action::Investigate => "action-investigate",
|
Action::Investigate => "action-investigate",
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ pub mod jsrun;
|
||||||
pub mod serve;
|
pub mod serve;
|
||||||
|
|
||||||
pub use doc::{document, text_of};
|
pub use doc::{document, text_of};
|
||||||
pub use input::{resolve, PointerFact};
|
pub use input::{resolve, Note, PointerFact};
|
||||||
pub use serve::{Guard, Refusal, Request};
|
pub use serve::{Guard, Refusal, Request};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -599,6 +599,7 @@ mod gamelog {
|
||||||
Some(PlayerId(0)),
|
Some(PlayerId(0)),
|
||||||
false,
|
false,
|
||||||
log,
|
log,
|
||||||
|
&[],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -706,6 +707,191 @@ mod piles {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CB-WP-0027 T03 — the note channel, and the two things it must not do.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod notes {
|
||||||
|
use cb_kernel::PlayerId;
|
||||||
|
|
||||||
|
use crate::doc::{document_with_log, text_of};
|
||||||
|
use crate::input::{resolve, Note, PointerFact};
|
||||||
|
|
||||||
|
/// **The load-bearing control (ADR-0014 D1).** A note whose text is a
|
||||||
|
/// perfectly well-formed pointer fact must not become a move.
|
||||||
|
///
|
||||||
|
/// Structural, not vigilance: `Note::parse` returns a `Note`,
|
||||||
|
/// `resolve` takes a `PointerFact`, and nothing converts between
|
||||||
|
/// them. This asserts the behaviour anyway, because "the types don't
|
||||||
|
/// connect" is a claim about code layout until something checks it.
|
||||||
|
#[test]
|
||||||
|
fn a_note_that_looks_like_a_move_is_not_one() {
|
||||||
|
let hostile = "note=down%3Daction-solve%26up%3Dproblem-1";
|
||||||
|
let note = Note::parse(hostile).expect("it parses as a note");
|
||||||
|
assert_eq!(
|
||||||
|
note.text, "down=action-solve&up=problem-1",
|
||||||
|
"the text is stored verbatim, uninterpreted"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the command parser refuses the same body outright — the two
|
||||||
|
// channels do not overlap even at the wire level.
|
||||||
|
assert!(
|
||||||
|
PointerFact::parse(hostile).is_err(),
|
||||||
|
"the command channel accepted a note body"
|
||||||
|
);
|
||||||
|
// The reverse, so this is not vacuous: a real pointer fact IS a
|
||||||
|
// command, and is NOT a note.
|
||||||
|
assert!(PointerFact::parse("down=a&up=b").is_ok());
|
||||||
|
assert!(
|
||||||
|
Note::parse("down=a&up=b").is_err(),
|
||||||
|
"the note channel accepted a pointer fact"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nothing in the crate turns a Note into a command. `resolve`'s
|
||||||
|
// signature is the proof; this pins it against a careless change.
|
||||||
|
let legal: Vec<games_ground::GroundCommand> = vec![];
|
||||||
|
assert!(resolve(&PointerFact::new("x", "y"), &legal, PlayerId(0)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty note is refused, not stored — a blank row is noise in the
|
||||||
|
/// register.
|
||||||
|
#[test]
|
||||||
|
fn an_empty_note_is_refused() {
|
||||||
|
assert!(Note::parse("note=").is_err());
|
||||||
|
assert!(Note::parse("note=%20%20").is_err(), "whitespace is empty");
|
||||||
|
assert_eq!(
|
||||||
|
Note::parse("note=%20hello%20").expect("real text").text,
|
||||||
|
"hello",
|
||||||
|
"surrounding whitespace is trimmed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Percent and `+` decoding, since the form posts urlencoded.
|
||||||
|
#[test]
|
||||||
|
fn the_players_words_survive_the_wire() {
|
||||||
|
let n = Note::parse("note=why+is+SOLVE+doing+nothing%3F").expect("parses");
|
||||||
|
assert_eq!(n.text, "why is SOLVE doing nothing?");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The first hostile input this renderer has handled.** Until now
|
||||||
|
/// `esc()` escaped suit names.
|
||||||
|
#[test]
|
||||||
|
fn a_note_containing_markup_renders_as_text() {
|
||||||
|
let hostile = "<script>alert(1)</script> & \"quoted\"";
|
||||||
|
let html = document_with_log(
|
||||||
|
&crate::testfix::view(Some(PlayerId(0))),
|
||||||
|
&[],
|
||||||
|
"/command?t=x",
|
||||||
|
Some(PlayerId(0)),
|
||||||
|
false,
|
||||||
|
&[],
|
||||||
|
&[hostile.to_string()],
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!html.contains("<script>alert(1)</script>"),
|
||||||
|
"a note's markup reached the document unescaped"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
html.contains("<script>"),
|
||||||
|
"the note should still be visible, escaped"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text_of(&html).contains("alert(1)"),
|
||||||
|
"escaping must not eat the player's words — they still read what they wrote"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The comment box is always offered, and the page works without it
|
||||||
|
/// being used. A control that appears only sometimes trains a player
|
||||||
|
/// not to look for it.
|
||||||
|
#[test]
|
||||||
|
fn the_comment_box_is_always_there() {
|
||||||
|
let html = document_with_log(
|
||||||
|
&crate::testfix::view(Some(PlayerId(0))),
|
||||||
|
&[],
|
||||||
|
"/command?t=x",
|
||||||
|
Some(PlayerId(0)),
|
||||||
|
false,
|
||||||
|
&[],
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert!(html.contains("action=\"/note\""), "no comment box");
|
||||||
|
assert!(
|
||||||
|
html.contains("method=\"post\""),
|
||||||
|
"a plain form, so it works with the script disabled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CB-WP-0027 T02 — the table on the left, the meta on the right.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod two_columns {
|
||||||
|
use cb_kernel::PlayerId;
|
||||||
|
|
||||||
|
use crate::doc::document_with_log;
|
||||||
|
|
||||||
|
fn page(meta: &[String]) -> String {
|
||||||
|
document_with_log(
|
||||||
|
&crate::testfix::view(Some(PlayerId(0))),
|
||||||
|
&[],
|
||||||
|
"/command?t=x",
|
||||||
|
Some(PlayerId(0)),
|
||||||
|
false,
|
||||||
|
&[],
|
||||||
|
meta,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The columns exist, and the game is in the left one.
|
||||||
|
#[test]
|
||||||
|
fn the_table_is_in_the_game_column_and_the_log_is_not() {
|
||||||
|
let html = page(&[]);
|
||||||
|
let game = html
|
||||||
|
.split("class=\"cb-game\"")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.split("class=\"cb-meta\"").next())
|
||||||
|
.expect("a game column followed by a meta column");
|
||||||
|
assert!(
|
||||||
|
game.contains("<h2>problems</h2>"),
|
||||||
|
"the table must be in the game column"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!game.contains("<h2>log</h2>"),
|
||||||
|
"the log belongs in the meta column — it is commentary on the game, not part of it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **A player who writes nothing must not be worse off.** An empty
|
||||||
|
/// meta panel renders as nothing, not as an empty heading.
|
||||||
|
#[test]
|
||||||
|
fn an_empty_meta_panel_adds_no_furniture() {
|
||||||
|
assert!(
|
||||||
|
!page(&[]).contains("this session"),
|
||||||
|
"an empty session panel drew a heading with nothing under it"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
page(&["2 games this session".into()]).contains("this session"),
|
||||||
|
"a non-empty panel must appear — otherwise the test above passes for a panel that never renders"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The single-column fallback is deliberate, not incidental. Asserted
|
||||||
|
/// on the stylesheet because there is no browser here to resize —
|
||||||
|
/// which is a weaker test than laying it out, and is said so rather
|
||||||
|
/// than dressed up.
|
||||||
|
#[test]
|
||||||
|
fn the_layout_collapses_to_one_column_on_a_narrow_viewport() {
|
||||||
|
let html = page(&[]);
|
||||||
|
assert!(
|
||||||
|
html.contains("@media (max-width:64rem)"),
|
||||||
|
"no narrow-viewport rule: the two-column layout would overlap on a laptop"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
html.contains("minmax(0,1fr)"),
|
||||||
|
"grid children default to min-content width; without minmax(0,…) the SVG table \
|
||||||
|
refuses to shrink and pushes the meta column off-screen"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// CB-WP-0024 T03 — what the other seats played, drawn as cards.
|
/// CB-WP-0024 T03 — what the other seats played, drawn as cards.
|
||||||
///
|
///
|
||||||
/// The maintainer could follow the other players only by reading the log.
|
/// The maintainer could follow the other players only by reading the log.
|
||||||
|
|
|
||||||
197
decisions/ADR-0014-the-commentary-track.md
Normal file
197
decisions/ADR-0014-the-commentary-track.md
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
# ADR-0014: a second channel that cannot carry a move, and a trial log that a command can read
|
||||||
|
|
||||||
|
status: accepted
|
||||||
|
date: 2026-08-06
|
||||||
|
decided by: agent, under the standing loop authorization
|
||||||
|
tier: M (structural M — touches ADR-0007 D5's input contract and the
|
||||||
|
recorded-session format; chaos d8=7 → no override). Tier M merges survey
|
||||||
|
and decision into one document, which this is.
|
||||||
|
references: [CB-WP-0027](../workplans/CB-WP-0027-the-commentary-track.md),
|
||||||
|
[ADR-0007](ADR-0007-render-html-not-a-port.md) D5,
|
||||||
|
[ADR-0012](ADR-0012-the-design-instrument.md) D6 (notes),
|
||||||
|
[GameDesign.md](../specs/GameDesign.md) §3.1 and §5
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
A test player wants to say *why* they played a move, and *what confused
|
||||||
|
them*, while the position is still on screen — and have it reviewable
|
||||||
|
later.
|
||||||
|
|
||||||
|
**GameDesign §5 already specifies the artifact**: a trial is a
|
||||||
|
`--record`ed session plus a sibling observation log. What it does not
|
||||||
|
specify is how the log gets written, and as written it asks the player to
|
||||||
|
reconstruct their reasoning afterwards from memory. That is why no trial
|
||||||
|
log exists yet.
|
||||||
|
|
||||||
|
## The survey, such as it is
|
||||||
|
|
||||||
|
**The candidates are all in this repo**, so there is nothing external to
|
||||||
|
benchmark against — which is why tier M's merged form is enough.
|
||||||
|
|
||||||
|
| existing mechanism | what it does | why it is not this |
|
||||||
|
|---|---|---|
|
||||||
|
| `provisional: true` + owner + date | marks an undecided *rule* | about the dataset, not about play |
|
||||||
|
| the finding register | records findings with reproductions | needs the reproduction this is meant to supply |
|
||||||
|
| `<!-- design-register:begin -->` + a table | a machine-readable block inside a human document | **the right shape — reused below** |
|
||||||
|
| the game log | what happened | not why, and not how it felt |
|
||||||
|
| `.cbreplay` bundles | the position, replayable | the anchor, not the annotation |
|
||||||
|
|
||||||
|
**The one thing worth stealing is the register's own idiom**: a table
|
||||||
|
between HTML-comment markers inside a readable Markdown file, parsed by a
|
||||||
|
small tool. It survived contact in `FindingRegister.md`, `design.py`
|
||||||
|
already parses that shape, and it means a trial log is readable by a human
|
||||||
|
without a tool and by a tool without a parser library.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D1 — a comment is not a command, and the types must make that true
|
||||||
|
|
||||||
|
**ADR-0007 D5 is scoped, not amended.**
|
||||||
|
|
||||||
|
D5 says *JavaScript may not construct commands — the page reports raw
|
||||||
|
pointer facts, and Rust decides what they mean.* That rule is about the
|
||||||
|
**command channel** and it stays exactly as it is.
|
||||||
|
|
||||||
|
The relevant fact is that `PointerFact::parse` **refuses any unrecognised
|
||||||
|
field**:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
_ => return Err(format!("unrecognised field in pointer fact: {pair:?}")),
|
||||||
|
```
|
||||||
|
|
||||||
|
So a comment cannot be smuggled through the existing path even by
|
||||||
|
accident. **Rather than widening that parser, the comment gets its own
|
||||||
|
one**, and the separation is structural:
|
||||||
|
|
||||||
|
> **`POST /command` carries pointer facts. `POST /note` carries text.**
|
||||||
|
> `Note` is a distinct type with **no code path to `GroundCommand`** —
|
||||||
|
> `resolve()` takes a `PointerFact` and nothing else, so a note cannot
|
||||||
|
> become a move by any route, including a future careless one.
|
||||||
|
|
||||||
|
**Control:** a test posts a note whose body is a well-formed pointer fact
|
||||||
|
(`down=action-solve&up=problem-1`) and asserts **no command results and
|
||||||
|
the game does not advance**. Without it, "the channels are separate" is a
|
||||||
|
claim about code layout rather than about behaviour.
|
||||||
|
|
||||||
|
**What would falsify D1:** if `Note` ever acquires a field the engine
|
||||||
|
reads, the separation is gone and ADR-0007 says to revisit the decision
|
||||||
|
rather than widen the control. The note type carries text, a position, and
|
||||||
|
a timestamp. Nothing else.
|
||||||
|
|
||||||
|
## D2 — comments live in the trial log, not in the scenario
|
||||||
|
|
||||||
|
**Not in `ScenarioFile`.** It is `deny_unknown_fields` and CB-WP-0026 just
|
||||||
|
demonstrated why that is right — but the reason to keep prose out is not
|
||||||
|
strictness, it is that **a scenario is a game record and must stay one.**
|
||||||
|
Scenarios are executed by `make sim`, replayed by `replay-test`, and
|
||||||
|
compared by hash. A player's opinion in that file would be data the runner
|
||||||
|
must ignore, which is how a format rots.
|
||||||
|
|
||||||
|
**In `trials/<date>-<slug>.md`**, exactly as GameDesign §5 already says,
|
||||||
|
with the recording beside it as `trials/<date>-<slug>.yaml`.
|
||||||
|
|
||||||
|
The log is a readable Markdown file with one machine-readable block:
|
||||||
|
|
||||||
|
```
|
||||||
|
<!-- trial-log:begin -->
|
||||||
|
| n | round | step | state_hash | comment |
|
||||||
|
<!-- trial-log:end -->
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reusing `FindingRegister.md`'s idiom deliberately** — same marker shape,
|
||||||
|
same table form, so `design.py`'s parser is the model and a reader who has
|
||||||
|
seen one has seen both.
|
||||||
|
|
||||||
|
## D3 — the binding is the state hash, with round and step for humans
|
||||||
|
|
||||||
|
A comment records **`state_hash`** (`cb_events::state_hash_hex`), plus
|
||||||
|
`round` and `step`.
|
||||||
|
|
||||||
|
- **The hash is the binding.** It is what already makes a session
|
||||||
|
comparable to its replay (`Summary::end_state_hash`), so a comment keyed
|
||||||
|
to it names a position a reader can *reach*, not merely describe.
|
||||||
|
- **Round and step are for reading.** *"Round 3, Resolve"* orients a human
|
||||||
|
instantly; a hash does not.
|
||||||
|
|
||||||
|
**A comment whose hash no longer appears in its recording is reported as
|
||||||
|
`orphaned`, not deleted.** The position may have moved because the engine
|
||||||
|
changed, and that is worth knowing — it is the same reasoning that made
|
||||||
|
`gr-e01` a rewrite rather than a deletion (CB-WP-0021 T03), and the same
|
||||||
|
signal as a reproduction that has gone green (GameDesign §1.3).
|
||||||
|
|
||||||
|
## D4 — comments stay here; only findings travel
|
||||||
|
|
||||||
|
**The retention question, decided before any comment is written.**
|
||||||
|
|
||||||
|
These are the maintainer's own words about his own game, written in the
|
||||||
|
moment, and some will be unflattering about the design, the engine, or
|
||||||
|
both. That is the point — a commentary track that people self-censor into
|
||||||
|
is worthless.
|
||||||
|
|
||||||
|
> **Raw comments never leave clay-borg.** Nothing auto-forwards to
|
||||||
|
> `ground-game`.
|
||||||
|
>
|
||||||
|
> A comment reaches `ground-game` **only** by being promoted to a register
|
||||||
|
> finding and reported through the existing path — which requires a human
|
||||||
|
> to promote it, and requires the finding to meet GameDesign §1.
|
||||||
|
|
||||||
|
So the trial log is a private notebook, and the register is the published
|
||||||
|
surface. **The promotion step is where wording gets chosen deliberately**,
|
||||||
|
which is the right place for it: *"the DARVO sequence is infuriating"* is
|
||||||
|
useful signal and a bad way to open a message to the game's designer.
|
||||||
|
|
||||||
|
**Who decides: the maintainer, per comment, at promotion time.** Not a
|
||||||
|
rule, not a default, and not the agent.
|
||||||
|
|
||||||
|
## D5 — a comment is a note, and inherits the note tier
|
||||||
|
|
||||||
|
A promoted comment enters the register as a **note** (ADR-0012 D6): kind
|
||||||
|
by judgment, state `note`, 30-day expiry on the existing machinery.
|
||||||
|
|
||||||
|
**It is not automatically a finding**, even though it has a position.
|
||||||
|
GameDesign §1 wants a reproduction that *shows the claimed thing* — a
|
||||||
|
recording proves the position existed, not that anything is wrong with it.
|
||||||
|
*"This felt pointless"* plus a replayable state is a strong note and still
|
||||||
|
a note.
|
||||||
|
|
||||||
|
**What promotes it further** is the same as for any other note: an
|
||||||
|
artifact demonstrating the defect. The trial log makes that cheap, because
|
||||||
|
the position is already recorded and someone can go and build a scenario
|
||||||
|
from it.
|
||||||
|
|
||||||
|
## D6 — surfacing is the deliverable
|
||||||
|
|
||||||
|
`make trials` reports every comment with its position and age, across all
|
||||||
|
trial logs, and flags orphans.
|
||||||
|
|
||||||
|
**Storage without surfacing would make this the third instance of this
|
||||||
|
project's signature failure** — after the four-day unread message and the
|
||||||
|
ten uncollected rulings. The workplan says no task may be called done
|
||||||
|
while comments are write-only, and this ADR agrees: **T04 is the pass, T03
|
||||||
|
is plumbing.**
|
||||||
|
|
||||||
|
**Control:** the reporting path is exercised by `--self-test`, not only
|
||||||
|
the parser. `design-baseline.py` had a green self-test and an unexercised
|
||||||
|
reporting path, and the reporting path is where it rotted (ADR-0012 D8).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `cb-render-html` gains a `Note` type and a `/note` route; `PointerFact`
|
||||||
|
is untouched.
|
||||||
|
- `trials/` is created, with `.md` + `.yaml` per trial.
|
||||||
|
- `make trials` reports them; `--self-test` covers the reporting path.
|
||||||
|
- `specs/GameDesign.md` §5 gains the log's concrete format — it specified
|
||||||
|
the artifact and not its shape.
|
||||||
|
- Comments are escaped on render (`esc`), and this is the **first
|
||||||
|
user-authored text this renderer has handled**.
|
||||||
|
|
||||||
|
## What was rejected
|
||||||
|
|
||||||
|
| rejected | why |
|
||||||
|
|---|---|
|
||||||
|
| comments inside `ScenarioFile` | a scenario is a game record; prose there is data the runner must ignore |
|
||||||
|
| widening `PointerFact` to carry text | D5's parser refusing unknown fields is a control, not an inconvenience |
|
||||||
|
| free-form prose with no machine-readable block | unreadable by a command, which is the failure mode this exists to avoid |
|
||||||
|
| auto-forwarding comments to `ground-game` | invites self-censorship, and the promotion step is where wording belongs |
|
||||||
|
| treating a positioned comment as a finding | a recording proves the position existed, not that anything is wrong |
|
||||||
|
| deleting orphaned comments | a moved position is a signal, per CB-WP-0021 T03 |
|
||||||
|
|
@ -13,13 +13,14 @@
|
||||||
//! result is legal.
|
//! result is legal.
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
use std::fmt::Write as _;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::net::{TcpListener, TcpStream};
|
use std::net::{TcpListener, TcpStream};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use cb_game_runtime::{Project, Viewer};
|
use cb_game_runtime::{Project, Viewer};
|
||||||
use cb_kernel::PlayerId;
|
use cb_kernel::PlayerId;
|
||||||
use cb_render_html::{resolve, Guard, PointerFact, Request};
|
use cb_render_html::{resolve, Guard, Note, PointerFact, Request};
|
||||||
use games_ground::bot::{Choice, Policy};
|
use games_ground::bot::{Choice, Policy};
|
||||||
use games_ground::{GroundCommand, GroundState};
|
use games_ground::{GroundCommand, GroundState};
|
||||||
|
|
||||||
|
|
@ -31,9 +32,71 @@ pub struct Server {
|
||||||
/// The live account of the game, shared with the driver (CB-WP-0018
|
/// The live account of the game, shared with the driver (CB-WP-0018
|
||||||
/// T02). Read at render time so the page shows what has happened.
|
/// T02). Read at render time so the page shows what has happened.
|
||||||
journal: games_ground::bot::Journal,
|
journal: games_ground::bot::Journal,
|
||||||
|
/// What the meta panel shows about the session (CB-WP-0027 T02).
|
||||||
|
/// Written by the driver between games, read at render time.
|
||||||
|
meta: RefCell<Vec<String>>,
|
||||||
|
/// Where the trial log is written, if this session is a trial.
|
||||||
|
trial: Option<std::path::PathBuf>,
|
||||||
|
/// Notes so far, so the log is rewritten whole rather than appended
|
||||||
|
/// to — a partial write leaves a file that neither a human nor
|
||||||
|
/// `make trials` can read.
|
||||||
|
notes: RefCell<Vec<TrialNote>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One thing a player said, and where they said it (ADR-0014 D3).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TrialNote {
|
||||||
|
pub n: usize,
|
||||||
|
pub round: u8,
|
||||||
|
pub step: String,
|
||||||
|
pub state_hash: String,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the trial log: readable Markdown with one machine-readable block.
|
||||||
|
///
|
||||||
|
/// **Reusing `FindingRegister.md`'s idiom deliberately** (ADR-0014 D2) —
|
||||||
|
/// a table between HTML-comment markers, so a human reads the file and a
|
||||||
|
/// tool reads the block, and neither needs the other's cooperation.
|
||||||
|
pub fn write_trial_log(path: &std::path::Path, notes: &[TrialNote]) -> Result<(), String> {
|
||||||
|
let mut s = String::new();
|
||||||
|
s.push_str("# Trial log\n\n");
|
||||||
|
s.push_str(
|
||||||
|
"What the player said while playing, bound to the position they said it at\n\
|
||||||
|
(CB-WP-0027, ADR-0014). The recording beside this file is the position;\n\
|
||||||
|
`state_hash` is what reaches it.\n\n\
|
||||||
|
**These are raw notes and they stay here.** Nothing in this file travels to\n\
|
||||||
|
`ground-game` (ADR-0014 D4) — a note reaches them only by being promoted to a\n\
|
||||||
|
register finding, by a human, with the wording chosen then.\n\n",
|
||||||
|
);
|
||||||
|
s.push_str("<!-- trial-log:begin -->\n\n");
|
||||||
|
s.push_str("| n | round | step | state_hash | comment |\n|---|---|---|---|---|\n");
|
||||||
|
for note in notes {
|
||||||
|
// Pipes and newlines would break the table, so they are replaced
|
||||||
|
// rather than escaped: the note is prose, and a reader losing a
|
||||||
|
// literal `|` matters less than a log nothing can parse.
|
||||||
|
let text = note.text.replace(['\n', '\r'], " ").replace('|', "/");
|
||||||
|
let _ = writeln!(
|
||||||
|
s,
|
||||||
|
"| {} | {} | {} | {} | {} |",
|
||||||
|
note.n, note.round, note.step, note.state_hash, text
|
||||||
|
);
|
||||||
|
}
|
||||||
|
s.push_str("\n<!-- trial-log:end -->\n");
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
std::fs::create_dir_all(dir).map_err(|e| format!("trial dir: {e}"))?;
|
||||||
|
}
|
||||||
|
std::fs::write(path, s).map_err(|e| format!("write trial log: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
|
/// Where this session's trial log goes. Without one, the note
|
||||||
|
/// channel refuses rather than dropping what the player wrote.
|
||||||
|
pub fn with_trial(mut self, path: Option<std::path::PathBuf>) -> Self {
|
||||||
|
self.trial = path;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn bind(port: u16) -> Result<Self, String> {
|
pub fn bind(port: u16) -> Result<Self, String> {
|
||||||
let listener = cb_render_html::serve::bind(port).map_err(|e| format!("bind: {e}"))?;
|
let listener = cb_render_html::serve::bind(port).map_err(|e| format!("bind: {e}"))?;
|
||||||
let port = listener
|
let port = listener
|
||||||
|
|
@ -45,6 +108,9 @@ impl Server {
|
||||||
guard: Guard::mint(port),
|
guard: Guard::mint(port),
|
||||||
log: RefCell::new(Vec::new()),
|
log: RefCell::new(Vec::new()),
|
||||||
journal: games_ground::bot::Journal::default(),
|
journal: games_ground::bot::Journal::default(),
|
||||||
|
meta: RefCell::new(Vec::new()),
|
||||||
|
trial: None,
|
||||||
|
notes: RefCell::new(Vec::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,6 +120,42 @@ impl Server {
|
||||||
self.guard.page_url()
|
self.guard.page_url()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Append a note to the trial log, bound to the position it was
|
||||||
|
/// written at (ADR-0014 D2/D3).
|
||||||
|
///
|
||||||
|
/// **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> {
|
||||||
|
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,
|
||||||
|
// and this is the failure mode the whole pass exists to avoid.
|
||||||
|
return Err("no trial log for this session — start with --trial".into());
|
||||||
|
};
|
||||||
|
let hash = cb_events::state_hash_hex(state);
|
||||||
|
let mut log = self.notes.borrow_mut();
|
||||||
|
let n = log.len() + 1;
|
||||||
|
log.push(TrialNote {
|
||||||
|
n,
|
||||||
|
round: state.round,
|
||||||
|
step: format!("{:?}", state.step),
|
||||||
|
state_hash: hash[..12].to_string(),
|
||||||
|
text: note.text.clone(),
|
||||||
|
});
|
||||||
|
write_trial_log(path, &log)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set what the meta panel shows about the session (CB-WP-0027 T02).
|
||||||
|
///
|
||||||
|
/// Called by the driver between games, so the running tally is visible
|
||||||
|
/// **while playing** rather than only on the ending page — which is
|
||||||
|
/// where it was, and a tally you only see once the game is over
|
||||||
|
/// informs nothing.
|
||||||
|
pub fn set_meta(&self, lines: Vec<String>) {
|
||||||
|
*self.meta.borrow_mut() = lines;
|
||||||
|
}
|
||||||
|
|
||||||
/// The journal the driver appends to; hand it to `play_journaled`.
|
/// The journal the driver appends to; hand it to `play_journaled`.
|
||||||
pub fn journal(&self) -> games_ground::bot::Journal {
|
pub fn journal(&self) -> games_ground::bot::Journal {
|
||||||
self.journal.clone()
|
self.journal.clone()
|
||||||
|
|
@ -145,6 +247,10 @@ impl Server {
|
||||||
Some(seat),
|
Some(seat),
|
||||||
may_pass,
|
may_pass,
|
||||||
&self.log_lines(),
|
&self.log_lines(),
|
||||||
|
// 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.
|
||||||
|
&self.meta.borrow(),
|
||||||
);
|
);
|
||||||
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
|
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
|
||||||
}
|
}
|
||||||
|
|
@ -170,6 +276,23 @@ impl Server {
|
||||||
Err(why) => respond(&mut stream, 200, "text/plain", &why),
|
Err(why) => respond(&mut stream, 200, "text/plain", &why),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// CB-WP-0027 T03 / ADR-0014 D1. A SECOND CHANNEL, and it
|
||||||
|
// cannot carry a move: `Note::parse` returns a `Note`,
|
||||||
|
// `resolve` takes a `PointerFact`, and there is no
|
||||||
|
// 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.
|
||||||
|
respond_seeother(&mut stream, "/");
|
||||||
|
}
|
||||||
|
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"),
|
_ => respond(&mut stream, 404, "text/plain", "no such thing here"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -554,6 +677,15 @@ fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||||
haystack.windows(needle.len()).position(|w| w == needle)
|
haystack.windows(needle.len()).position(|w| w == needle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 303 See Other, so the browser re-GETs the table after a form POST
|
||||||
|
/// rather than leaving the POST in history — a reload would otherwise
|
||||||
|
/// re-submit the note and duplicate it.
|
||||||
|
fn respond_seeother(stream: &mut TcpStream, to: &str) {
|
||||||
|
let head = format!("HTTP/1.1 303 See Other\r\nLocation: {to}\r\nContent-Length: 0\r\n\r\n");
|
||||||
|
let _ = stream.write_all(head.as_bytes());
|
||||||
|
let _ = stream.flush();
|
||||||
|
}
|
||||||
|
|
||||||
fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &str) {
|
fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &str) {
|
||||||
let reason = match status {
|
let reason = match status {
|
||||||
200 => "OK",
|
200 => "OK",
|
||||||
|
|
@ -658,6 +790,7 @@ mod tests {
|
||||||
replay_dir: None,
|
replay_dir: None,
|
||||||
record: None,
|
record: None,
|
||||||
serve: Some(0),
|
serve: Some(0),
|
||||||
|
trial: None,
|
||||||
},
|
},
|
||||||
std::io::Cursor::new(Vec::new()),
|
std::io::Cursor::new(Vec::new()),
|
||||||
out,
|
out,
|
||||||
|
|
@ -825,6 +958,7 @@ mod tests {
|
||||||
replay_dir: None,
|
replay_dir: None,
|
||||||
record: None,
|
record: None,
|
||||||
serve: Some(0),
|
serve: Some(0),
|
||||||
|
trial: None,
|
||||||
},
|
},
|
||||||
std::io::Cursor::new(Vec::new()),
|
std::io::Cursor::new(Vec::new()),
|
||||||
out,
|
out,
|
||||||
|
|
|
||||||
|
|
@ -762,6 +762,7 @@ mod tests {
|
||||||
replay_dir: Some(dir.clone()),
|
replay_dir: Some(dir.clone()),
|
||||||
record: Some(dir.join("session.yaml")),
|
record: Some(dir.join("session.yaml")),
|
||||||
serve: None,
|
serve: None,
|
||||||
|
trial: None,
|
||||||
};
|
};
|
||||||
let mut sink: Vec<u8> = Vec::new();
|
let mut sink: Vec<u8> = Vec::new();
|
||||||
let summary =
|
let summary =
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ play:
|
||||||
--bot KIND policy for every other seat: greedy (default) or random
|
--bot KIND policy for every other seat: greedy (default) or random
|
||||||
--replay DIR write a .cbreplay bundle of the finished game to DIR
|
--replay DIR write a .cbreplay bundle of the finished game to DIR
|
||||||
--record FILE write the finished game as a scenario YAML
|
--record FILE write the finished game as a scenario YAML
|
||||||
|
--trial FILE write a trial log: what the player said, bound to where
|
||||||
--serve PORT play human seats in a browser on 127.0.0.1:PORT instead
|
--serve PORT play human seats in a browser on 127.0.0.1:PORT instead
|
||||||
of on the terminal; 0 lets the OS pick. Prints a URL
|
of on the terminal; 0 lets the OS pick. Prints a URL
|
||||||
carrying a per-process token — without it the page is
|
carrying a per-process token — without it the page is
|
||||||
|
|
@ -104,6 +105,13 @@ fn parse_args(argv: &[String]) -> Result<Mode, String> {
|
||||||
config.record = Some(value(i, argv, flag)?.into());
|
config.record = Some(value(i, argv, flag)?.into());
|
||||||
i += 2;
|
i += 2;
|
||||||
}
|
}
|
||||||
|
// CB-WP-0027: a trial is a recorded session PLUS what the
|
||||||
|
// player said while playing (GameDesign §5, ADR-0014).
|
||||||
|
"--trial" => {
|
||||||
|
play_flags.push(flag.into());
|
||||||
|
config.trial = Some(value(i, argv, flag)?.into());
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
"--replay" => {
|
"--replay" => {
|
||||||
play_flags.push(flag.into());
|
play_flags.push(flag.into());
|
||||||
config.replay_dir = Some(value(i, argv, flag)?.into());
|
config.replay_dir = Some(value(i, argv, flag)?.into());
|
||||||
|
|
@ -260,6 +268,7 @@ mod tests {
|
||||||
replay_dir: None,
|
replay_dir: None,
|
||||||
record: None,
|
record: None,
|
||||||
serve: None,
|
serve: None,
|
||||||
|
trial: None,
|
||||||
};
|
};
|
||||||
let script = "0\n".repeat(400);
|
let script = "0\n".repeat(400);
|
||||||
let mut out: Vec<u8> = Vec::new();
|
let mut out: Vec<u8> = Vec::new();
|
||||||
|
|
@ -299,6 +308,7 @@ mod tests {
|
||||||
replay_dir: None,
|
replay_dir: None,
|
||||||
record: None,
|
record: None,
|
||||||
serve: None,
|
serve: None,
|
||||||
|
trial: None,
|
||||||
};
|
};
|
||||||
let mut out: Vec<u8> = Vec::new();
|
let mut out: Vec<u8> = Vec::new();
|
||||||
table::play(&config, "0\n".repeat(200).as_bytes(), &mut out).expect("game");
|
table::play(&config, "0\n".repeat(200).as_bytes(), &mut out).expect("game");
|
||||||
|
|
@ -361,6 +371,7 @@ mod tests {
|
||||||
replay_dir: None,
|
replay_dir: None,
|
||||||
record: None,
|
record: None,
|
||||||
serve: None,
|
serve: None,
|
||||||
|
trial: None,
|
||||||
};
|
};
|
||||||
let mut out: Vec<u8> = Vec::new();
|
let mut out: Vec<u8> = Vec::new();
|
||||||
let summary = table::play(&config, "".as_bytes(), &mut out).expect("bot game");
|
let summary = table::play(&config, "".as_bytes(), &mut out).expect("bot game");
|
||||||
|
|
@ -421,6 +432,7 @@ mod tests {
|
||||||
replay_dir: Some(dir.clone()),
|
replay_dir: Some(dir.clone()),
|
||||||
record: Some(dir.join("session.yaml")),
|
record: Some(dir.join("session.yaml")),
|
||||||
serve: None,
|
serve: None,
|
||||||
|
trial: None,
|
||||||
};
|
};
|
||||||
let mut out: Vec<u8> = Vec::new();
|
let mut out: Vec<u8> = Vec::new();
|
||||||
let summary = table::play(&config, "".as_bytes(), &mut out).expect("game");
|
let summary = table::play(&config, "".as_bytes(), &mut out).expect("game");
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ pub struct Config {
|
||||||
/// Serve human seats in a browser instead of on the terminal
|
/// Serve human seats in a browser instead of on the terminal
|
||||||
/// (ADR-0007). `Some(0)` lets the OS pick the port.
|
/// (ADR-0007). `Some(0)` lets the OS pick the port.
|
||||||
pub serve: Option<u16>,
|
pub serve: Option<u16>,
|
||||||
|
/// Where the trial log goes (CB-WP-0027). Without it the note channel
|
||||||
|
/// refuses rather than dropping what the player wrote.
|
||||||
|
pub trial: Option<std::path::PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
|
|
@ -48,6 +51,7 @@ impl Default for Config {
|
||||||
replay_dir: None,
|
replay_dir: None,
|
||||||
record: None,
|
record: None,
|
||||||
serve: None,
|
serve: None,
|
||||||
|
trial: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -287,7 +291,9 @@ pub fn play<R: BufRead, W: Write>(config: &Config, input: R, out: W) -> Result<S
|
||||||
// pointing at a dead port. Caught by `play_again_deals_a_second_game`.
|
// pointing at a dead port. Caught by `play_again_deals_a_second_game`.
|
||||||
let server = match config.serve {
|
let server = match config.serve {
|
||||||
Some(port) => {
|
Some(port) => {
|
||||||
let s = std::rc::Rc::new(crate::hotseat::Server::bind(port)?);
|
let s = std::rc::Rc::new(
|
||||||
|
crate::hotseat::Server::bind(port)?.with_trial(config.trial.clone()),
|
||||||
|
);
|
||||||
let _ = writeln!(out.borrow_mut(), " open {}", s.url());
|
let _ = writeln!(out.borrow_mut(), " open {}", s.url());
|
||||||
let _ = out.borrow_mut().flush();
|
let _ = out.borrow_mut().flush();
|
||||||
Some(s)
|
Some(s)
|
||||||
|
|
@ -301,6 +307,11 @@ pub fn play<R: BufRead, W: Write>(config: &Config, input: R, out: W) -> Result<S
|
||||||
// because those are the other two things that survive `play again`.
|
// because those are the other two things that survive `play again`.
|
||||||
let mut tally = MatchTally::default();
|
let mut tally = MatchTally::default();
|
||||||
loop {
|
loop {
|
||||||
|
// CB-WP-0027 T02: the panel shows the series *during* the next
|
||||||
|
// game, not only after it.
|
||||||
|
if let Some(s) = &server {
|
||||||
|
s.set_meta(crate::hotseat::series_lines(&tally));
|
||||||
|
}
|
||||||
let (summary, choice) = run_game(&cfg, &input, &out, server.clone(), &mut tally)?;
|
let (summary, choice) = run_game(&cfg, &input, &out, server.clone(), &mut tally)?;
|
||||||
if choice != crate::hotseat::EndChoice::Again {
|
if choice != crate::hotseat::EndChoice::Again {
|
||||||
return Ok(summary);
|
return Ok(summary);
|
||||||
|
|
|
||||||
201
tools/trials.py
Normal file
201
tools/trials.py
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""trials — what the players said, and where (CB-WP-0027 T04).
|
||||||
|
|
||||||
|
**Surfacing is the deliverable, not storage.** ADR-0014 D6: a commentary
|
||||||
|
feature that stores comments and shows them nowhere would be this
|
||||||
|
project's signature failure in a new medium — after the message that sat
|
||||||
|
unread for four days and the ten rulings that arrived and were never
|
||||||
|
collected.
|
||||||
|
|
||||||
|
So this reports every note from every trial log, with its position and its
|
||||||
|
age, and flags the ones whose position can no longer be reached.
|
||||||
|
|
||||||
|
Reuses `design.py`'s parsing shape because the trial log deliberately
|
||||||
|
reuses `FindingRegister.md`'s idiom: a table between HTML-comment markers
|
||||||
|
inside a readable Markdown file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, re, sys, glob, datetime
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
TRIALS = os.path.join(ROOT, "trials")
|
||||||
|
|
||||||
|
BEGIN = "<!-- trial-log:begin -->"
|
||||||
|
END = "<!-- trial-log:end -->"
|
||||||
|
|
||||||
|
# GameDesign §3.1's figure, shared rather than reinvented: a note that
|
||||||
|
# cannot expire ages into an apparent finding.
|
||||||
|
NOTE_EXPIRY_DAYS = 30
|
||||||
|
|
||||||
|
|
||||||
|
def parse(text):
|
||||||
|
"""Rows between the markers. A log without the block is an error, not
|
||||||
|
an empty log — silently reporting zero notes for a file full of them
|
||||||
|
is precisely the failure this tool exists to prevent."""
|
||||||
|
if BEGIN not in text or END not in text:
|
||||||
|
raise ValueError("no trial-log block")
|
||||||
|
block = text.split(BEGIN)[1].split(END)[0]
|
||||||
|
rows = []
|
||||||
|
for line in block.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
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":
|
||||||
|
continue
|
||||||
|
rows.append(dict(zip(("n", "round", "step", "state_hash", "comment"), cells)))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def logs(root=TRIALS):
|
||||||
|
"""Every trial log, with its recording beside it if there is one."""
|
||||||
|
out = []
|
||||||
|
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?")
|
||||||
|
continue
|
||||||
|
recording = os.path.splitext(path)[0] + ".yaml"
|
||||||
|
out.append((path, rows, recording if os.path.exists(recording) else None))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def reachability(row, recording):
|
||||||
|
"""Can this note's position be reached? One of three answers.
|
||||||
|
|
||||||
|
**"No recording" and "the hash is not in the recording" are different
|
||||||
|
states and must not be one number.** A note written mid-game is
|
||||||
|
pending — the recording is only written when the game ends — while a
|
||||||
|
note whose hash is absent from a recording that *exists* means the
|
||||||
|
position moved. Collapsing them makes every live session report
|
||||||
|
orphans, and a metric that cries wolf is one nobody reads, which is
|
||||||
|
the failure this whole pass is about.
|
||||||
|
|
||||||
|
Orphans are reported, never deleted (ADR-0014 D3): a moved position is
|
||||||
|
worth knowing, by the same reasoning that rewrote `gr-e01` rather than
|
||||||
|
retiring it.
|
||||||
|
"""
|
||||||
|
if not recording:
|
||||||
|
return "pending"
|
||||||
|
return "ok" if row["state_hash"] in open(recording).read() else "orphan"
|
||||||
|
|
||||||
|
|
||||||
|
def age_days(path, today):
|
||||||
|
"""Dated from the log's filename (`YYYY-MM-DD-slug.md`), falling back
|
||||||
|
to mtime — the name is the intent, the mtime is what happened."""
|
||||||
|
m = re.match(r"(\d{4}-\d{2}-\d{2})", os.path.basename(path))
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
return (today - datetime.date.fromisoformat(m.group(1))).days
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return (today - datetime.date.fromtimestamp(os.path.getmtime(path))).days
|
||||||
|
|
||||||
|
|
||||||
|
def report(root=TRIALS, today=None):
|
||||||
|
today = today or datetime.date.today()
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
print("trials — no trials/ directory yet\n")
|
||||||
|
print(" Play one: cb-play --serve 0 --record trials/<date>-<slug>.yaml \\")
|
||||||
|
print(" --trial trials/<date>-<slug>.md")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
found = logs(root)
|
||||||
|
print("trials — what the players said, and where\n")
|
||||||
|
total, orphans, pending, expired = 0, 0, 0, 0
|
||||||
|
for path, rows, recording in found:
|
||||||
|
name = os.path.basename(path)
|
||||||
|
age = age_days(path, today)
|
||||||
|
print(f" {name} ({len(rows)} note(s), {age}d"
|
||||||
|
f"{', recording not written yet' if not recording else ''})")
|
||||||
|
for r in rows:
|
||||||
|
total += 1
|
||||||
|
state = reachability(r, recording)
|
||||||
|
orphans += state == "orphan"
|
||||||
|
pending += state == "pending"
|
||||||
|
expired += age > NOTE_EXPIRY_DAYS
|
||||||
|
mark = {"orphan": "orphan", "pending": " .. ", "ok": " "}[state]
|
||||||
|
print(f" {mark} r{r['round']:<2} {r['step']:<8} {r['state_hash']:<12} "
|
||||||
|
f"{r['comment'][:70]}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print(f" trial logs {len(found)}")
|
||||||
|
print(f" notes {total}")
|
||||||
|
print(f" positions unreachable {orphans} target 0"
|
||||||
|
+ (" <-- the recording exists but the position moved" if orphans else ""))
|
||||||
|
if pending:
|
||||||
|
print(f" awaiting a recording {pending}"
|
||||||
|
" (normal during a live session; the recording lands at game end)")
|
||||||
|
print(f" notes past {NOTE_EXPIRY_DAYS} days {expired} target 0")
|
||||||
|
if total and not orphans:
|
||||||
|
print("\n Every note points at a position a reader can reach.")
|
||||||
|
print("\n Raw notes stay in this repo (ADR-0014 D4). A note reaches"
|
||||||
|
"\n ground-game only by being promoted to a register finding, by a"
|
||||||
|
"\n human, with the wording chosen then.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def self_test():
|
||||||
|
"""Positive controls, including for the REPORTING path.
|
||||||
|
|
||||||
|
`design-baseline.py` had a green self-test and an unexercised
|
||||||
|
reporting path, and the reporting path is where it rotted (ADR-0012
|
||||||
|
D8). So this runs `report` against a fixture and checks what it says.
|
||||||
|
"""
|
||||||
|
import tempfile, io, contextlib
|
||||||
|
|
||||||
|
ok = True
|
||||||
|
|
||||||
|
def check(name, cond, detail=""):
|
||||||
|
nonlocal ok
|
||||||
|
ok = ok and bool(cond)
|
||||||
|
print(f" [{'ok ' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
|
||||||
|
|
||||||
|
good = (f"# Trial log\n\n{BEGIN}\n\n"
|
||||||
|
"| n | round | step | state_hash | comment |\n|---|---|---|---|---|\n"
|
||||||
|
"| 1 | 3 | Select | abc123def456 | why is SOLVE doing nothing |\n"
|
||||||
|
f"\n{END}\n")
|
||||||
|
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)))
|
||||||
|
|
||||||
|
try:
|
||||||
|
parse("# Trial log\n\nno block here\n")
|
||||||
|
check("a file with no block is an error, not an empty log", False)
|
||||||
|
except ValueError:
|
||||||
|
check("a file with no block is an error, not an empty log", True,
|
||||||
|
"silently reporting zero is the failure this tool prevents")
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
open(os.path.join(d, "2026-08-06-x.md"), "w").write(good)
|
||||||
|
# No .yaml beside it → the position cannot be reached.
|
||||||
|
rows = parse(good)
|
||||||
|
check("a note with no recording is PENDING, not orphaned",
|
||||||
|
reachability(rows[0], None) == "pending",
|
||||||
|
"a live session must not report orphans")
|
||||||
|
rec = os.path.join(d, "2026-08-06-x.yaml")
|
||||||
|
open(rec, "w").write("state_hash: abc123def456\n")
|
||||||
|
check("a note whose hash is in the recording is reachable",
|
||||||
|
reachability(rows[0], rec) == "ok",
|
||||||
|
"without this the check could always say orphan")
|
||||||
|
open(rec, "w").write("state_hash: something-else\n")
|
||||||
|
check("a note whose hash is absent from a REAL recording is an orphan",
|
||||||
|
reachability(rows[0], rec) == "orphan",
|
||||||
|
"the position moved \u2014 the case the flag exists for")
|
||||||
|
|
||||||
|
# THE control design-baseline.py lacked: exercise the reporting
|
||||||
|
# path and assert on what it printed.
|
||||||
|
buf = io.StringIO()
|
||||||
|
with contextlib.redirect_stdout(buf):
|
||||||
|
report(d, datetime.date(2026, 8, 6))
|
||||||
|
out = buf.getvalue()
|
||||||
|
check("the reporting path runs and names the note",
|
||||||
|
"why is SOLVE doing nothing" in out, "not just the parser")
|
||||||
|
check("the reporting path counts", "notes 1" in out)
|
||||||
|
|
||||||
|
print("trials self-test (positive control)")
|
||||||
|
return 0 if ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(self_test() if "--self-test" in sys.argv else report())
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
id: CB-WP-0027
|
id: CB-WP-0027
|
||||||
kind: product
|
kind: product
|
||||||
title: "The commentary track: the meta view beside the table, and what the player says while playing"
|
title: "The commentary track: the meta view beside the table, and what the player says while playing"
|
||||||
status: ready
|
status: active
|
||||||
state_hub_workstream_id: "e011da8d-de9e-48bf-a3f2-a9e715ef222f"
|
state_hub_workstream_id: "e011da8d-de9e-48bf-a3f2-a9e715ef222f"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -80,7 +80,7 @@ write-only.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CB-WP-0027-T01
|
id: CB-WP-0027-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "3586f3af-ad45-4bc6-bea2-b6eaedf05fbf"
|
state_hub_task_id: "3586f3af-ad45-4bc6-bea2-b6eaedf05fbf"
|
||||||
```
|
```
|
||||||
|
|
@ -117,11 +117,40 @@ about his own game**, and one of them will eventually be unflattering
|
||||||
about the design or about the engine. State whether they are private to
|
about the design or about the engine. State whether they are private to
|
||||||
the repo, whether they travel to `ground-game`, and who decides.
|
the repo, whether they travel to `ground-game`, and who decides.
|
||||||
|
|
||||||
|
**Done 2026-08-06.**
|
||||||
|
[ADR-0014](../decisions/ADR-0014-the-commentary-track.md), six decisions.
|
||||||
|
|
||||||
|
**(b) turned out to be the easy one, because the control already exists.**
|
||||||
|
`PointerFact::parse` **refuses any unrecognised field**, so a comment
|
||||||
|
cannot reach the command path even by accident. **D5 is scoped, not
|
||||||
|
amended**: `/command` carries pointer facts, `/note` carries text, and
|
||||||
|
`Note` has **no code path to `GroundCommand`** — `resolve()` takes a
|
||||||
|
`PointerFact` and nothing else. The control is a test that posts a note
|
||||||
|
whose body is a well-formed pointer fact and asserts the game does not
|
||||||
|
advance; without it, "separate channels" is a claim about code layout.
|
||||||
|
|
||||||
|
**(a) the trial log, not the scenario** — and the reason is not
|
||||||
|
strictness. A scenario is executed, replayed and hashed; prose in it is
|
||||||
|
data the runner must ignore, which is how a format rots. The log reuses
|
||||||
|
`FindingRegister.md`'s idiom: a table between HTML-comment markers, so
|
||||||
|
`design.py`'s parser is the model.
|
||||||
|
|
||||||
|
**(c) the state hash binds; round and step are for reading.** An orphaned
|
||||||
|
comment — hash no longer in its recording — is **reported, not deleted**,
|
||||||
|
same reasoning as `gr-e01`'s rewrite and a green reproduction's alarm.
|
||||||
|
|
||||||
|
**Retention: raw comments never leave clay-borg.** A comment reaches
|
||||||
|
`ground-game` only by being promoted to a register finding, by a human, at
|
||||||
|
which point the wording is chosen deliberately. *"The DARVO sequence is
|
||||||
|
infuriating"* is useful signal and a bad way to open a message to the
|
||||||
|
game's designer. **The maintainer decides per comment** — not a rule, not
|
||||||
|
a default, not the agent.
|
||||||
|
|
||||||
## Task: the table on the left, the meta on the right
|
## Task: the table on the left, the meta on the right
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CB-WP-0027-T02
|
id: CB-WP-0027-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "0971185c-b72c-43ec-a356-c221ef6d8165"
|
state_hub_task_id: "0971185c-b72c-43ec-a356-c221ef6d8165"
|
||||||
```
|
```
|
||||||
|
|
@ -150,11 +179,29 @@ not part of the table.
|
||||||
- the game column must be usable with the meta column collapsed. A player
|
- the game column must be usable with the meta column collapsed. A player
|
||||||
who does not want to write anything must not be worse off.
|
who does not want to write anything must not be worse off.
|
||||||
|
|
||||||
|
**Done 2026-08-06.** CSS grid, `minmax(0,1fr)` on both tracks and a
|
||||||
|
single-column fallback under 64rem.
|
||||||
|
|
||||||
|
**`minmax(0, …)` is the load-bearing part.** A grid child defaults to
|
||||||
|
min-content width, so without it the SVG table refuses to shrink and
|
||||||
|
pushes the meta column off-screen instead of narrowing — the layout would
|
||||||
|
look correct on the developer's monitor and be broken everywhere else.
|
||||||
|
|
||||||
|
**The running tally moved into the panel**, so it is visible *while
|
||||||
|
playing*. It only appeared on the ending page before, and a session score
|
||||||
|
you see once the game is over informs nothing.
|
||||||
|
|
||||||
|
Three tests: the log is in the meta column and the table is not; an empty
|
||||||
|
panel draws **no furniture** (with the inverse, so it does not pass for a
|
||||||
|
panel that never renders); and the narrow-viewport rule exists — asserted
|
||||||
|
on the stylesheet, which is **weaker than laying it out**, and said so
|
||||||
|
rather than dressed up.
|
||||||
|
|
||||||
## Task: capture what the player says, bound to where they said it
|
## Task: capture what the player says, bound to where they said it
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CB-WP-0027-T03
|
id: CB-WP-0027-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "4a23ba0d-9b23-4381-b6b0-2b8959106270"
|
state_hub_task_id: "4a23ba0d-9b23-4381-b6b0-2b8959106270"
|
||||||
```
|
```
|
||||||
|
|
@ -178,11 +225,33 @@ first time that comment is load-bearing rather than precautionary.
|
||||||
- **the game is playable with comments disabled**, and a test says so. The
|
- **the game is playable with comments disabled**, and a test says so. The
|
||||||
commentary track must not become a dependency of playing.
|
commentary track must not become a dependency of playing.
|
||||||
|
|
||||||
|
**Done 2026-08-06.** `Note` in `input.rs`, a `/note` route, `--trial`, and
|
||||||
|
`trials/<date>-<slug>.md` written whole on every note.
|
||||||
|
|
||||||
|
**The separation is structural, and tested anyway.** A note whose text is
|
||||||
|
a well-formed pointer fact (`down=action-solve&up=problem-1`) parses as a
|
||||||
|
note, is stored verbatim, and **the command channel refuses the same body
|
||||||
|
outright** — with the inverse asserted too, so the test is not vacuous.
|
||||||
|
|
||||||
|
**A plain `<form method="post">`**, so the comment box works with the
|
||||||
|
script disabled. The command channel needs JavaScript because a drag is
|
||||||
|
not a form submission; a comment is one, and making it depend on the
|
||||||
|
script would add a failure mode for nothing. The reply is **303 See
|
||||||
|
Other**, so a reload does not re-post the note.
|
||||||
|
|
||||||
|
**Verified over real HTTP**, not only in tests: two columns served, a note
|
||||||
|
posted (303), a hostile note stored as text (303), an empty note refused
|
||||||
|
(400), and the game did not advance.
|
||||||
|
|
||||||
|
**`esc()`'s first hostile input.** `<script>alert(1)</script>` renders
|
||||||
|
escaped **and still readable** — the test asserts the player's words
|
||||||
|
survive, because escaping that eats the text is its own defect.
|
||||||
|
|
||||||
## Task: surface them, or this pass has failed
|
## Task: surface them, or this pass has failed
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CB-WP-0027-T04
|
id: CB-WP-0027-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "df8afbef-6ff7-4aa2-9ff7-b3fa05f676cc"
|
state_hub_task_id: "df8afbef-6ff7-4aa2-9ff7-b3fa05f676cc"
|
||||||
```
|
```
|
||||||
|
|
@ -209,6 +278,28 @@ session that produced them, by a command, without opening a file by hand.
|
||||||
`tools/design.py` accepts — if the register cannot express it, the shape
|
`tools/design.py` accepts — if the register cannot express it, the shape
|
||||||
is wrong and that is a finding about ADR-0012, not a bug.
|
is wrong and that is a finding about ADR-0012, not a bug.
|
||||||
|
|
||||||
|
**Done 2026-08-06.** `tools/trials.py`, `make trials`, wired into
|
||||||
|
`make self-tests`.
|
||||||
|
|
||||||
|
**The report distinguishes three states, and that distinction came from
|
||||||
|
running it.** The first version called any note without a recording an
|
||||||
|
**orphan** — so a live session reported every note as broken, because the
|
||||||
|
recording is only written at game end. A metric that cries wolf is one
|
||||||
|
nobody reads, **which is the exact failure this pass exists to prevent**.
|
||||||
|
Now: `ok`, `pending` (no recording yet), `orphan` (the recording exists
|
||||||
|
and the position is not in it). Only the third is a target-0 number.
|
||||||
|
|
||||||
|
**The self-test exercises the reporting path**, not just the parser — it
|
||||||
|
runs `report` against a fixture and asserts on what it printed.
|
||||||
|
`design-baseline.py` had a green self-test and an unexercised reporting
|
||||||
|
path, and that is where it rotted (ADR-0012 D8).
|
||||||
|
|
||||||
|
**A latent Makefile defect surfaced.** `make trials` did nothing: `trials`
|
||||||
|
is also a *directory*, so Make saw an up-to-date file. `design`,
|
||||||
|
`difficulty` and `trials` — added by CB-WP-0022, CB-WP-0025 and this pass
|
||||||
|
— were **all** missing from `.PHONY`; only the one that collided revealed
|
||||||
|
it. All three are declared now.
|
||||||
|
|
||||||
## Task: evidence
|
## Task: evidence
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue