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.
|
||||
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
|
||||
check:
|
||||
|
|
@ -125,6 +131,7 @@ self-tests:
|
|||
$(PY) $(TOOLS)/runtime-metrics.py --self-test
|
||||
$(PY) $(TOOLS)/replay-test.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
|
||||
$(PY) $(TOOLS)/edition-check.py --self-test
|
||||
|
||||
|
|
@ -161,6 +168,12 @@ facts-check:
|
|||
facts-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).
|
||||
# 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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
anything reading the DOM. */
|
||||
.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. */
|
||||
.played{display:block;margin:.3rem 0}
|
||||
.k{color:#89a}
|
||||
|
|
@ -679,7 +698,7 @@ pub fn document(
|
|||
seat: Option<PlayerId>,
|
||||
may_pass: bool,
|
||||
) -> 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).
|
||||
|
|
@ -690,6 +709,7 @@ pub fn document_with_log(
|
|||
seat: Option<PlayerId>,
|
||||
may_pass: bool,
|
||||
log: &[LogLine],
|
||||
meta: &[String],
|
||||
) -> String {
|
||||
let mut s = String::with_capacity(8192);
|
||||
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);
|
||||
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);
|
||||
s.push_str("</div></div>");
|
||||
let _ = write!(
|
||||
s,
|
||||
"<div id=\"cb-status\">ready</div>\
|
||||
|
|
@ -729,6 +756,40 @@ pub fn document_with_log(
|
|||
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.
|
||||
///
|
||||
/// 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 {
|
||||
match a {
|
||||
Action::Investigate => "action-investigate",
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ pub mod jsrun;
|
|||
pub mod serve;
|
||||
|
||||
pub use doc::{document, text_of};
|
||||
pub use input::{resolve, PointerFact};
|
||||
pub use input::{resolve, Note, PointerFact};
|
||||
pub use serve::{Guard, Refusal, Request};
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -599,6 +599,7 @@ mod gamelog {
|
|||
Some(PlayerId(0)),
|
||||
false,
|
||||
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.
|
||||
///
|
||||
/// 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.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::fmt::Write as _;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::rc::Rc;
|
||||
|
||||
use cb_game_runtime::{Project, Viewer};
|
||||
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::{GroundCommand, GroundState};
|
||||
|
||||
|
|
@ -31,9 +32,71 @@ pub struct Server {
|
|||
/// 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.
|
||||
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 {
|
||||
/// 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> {
|
||||
let listener = cb_render_html::serve::bind(port).map_err(|e| format!("bind: {e}"))?;
|
||||
let port = listener
|
||||
|
|
@ -45,6 +108,9 @@ impl Server {
|
|||
guard: Guard::mint(port),
|
||||
log: RefCell::new(Vec::new()),
|
||||
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()
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
pub fn journal(&self) -> games_ground::bot::Journal {
|
||||
self.journal.clone()
|
||||
|
|
@ -145,6 +247,10 @@ impl Server {
|
|||
Some(seat),
|
||||
may_pass,
|
||||
&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);
|
||||
}
|
||||
|
|
@ -170,6 +276,23 @@ impl Server {
|
|||
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"),
|
||||
}
|
||||
}
|
||||
|
|
@ -554,6 +677,15 @@ fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
|||
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) {
|
||||
let reason = match status {
|
||||
200 => "OK",
|
||||
|
|
@ -658,6 +790,7 @@ mod tests {
|
|||
replay_dir: None,
|
||||
record: None,
|
||||
serve: Some(0),
|
||||
trial: None,
|
||||
},
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
out,
|
||||
|
|
@ -825,6 +958,7 @@ mod tests {
|
|||
replay_dir: None,
|
||||
record: None,
|
||||
serve: Some(0),
|
||||
trial: None,
|
||||
},
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
out,
|
||||
|
|
|
|||
|
|
@ -762,6 +762,7 @@ mod tests {
|
|||
replay_dir: Some(dir.clone()),
|
||||
record: Some(dir.join("session.yaml")),
|
||||
serve: None,
|
||||
trial: None,
|
||||
};
|
||||
let mut sink: Vec<u8> = Vec::new();
|
||||
let summary =
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ play:
|
|||
--bot KIND policy for every other seat: greedy (default) or random
|
||||
--replay DIR write a .cbreplay bundle of the finished game to DIR
|
||||
--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
|
||||
of on the terminal; 0 lets the OS pick. Prints a URL
|
||||
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());
|
||||
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" => {
|
||||
play_flags.push(flag.into());
|
||||
config.replay_dir = Some(value(i, argv, flag)?.into());
|
||||
|
|
@ -260,6 +268,7 @@ mod tests {
|
|||
replay_dir: None,
|
||||
record: None,
|
||||
serve: None,
|
||||
trial: None,
|
||||
};
|
||||
let script = "0\n".repeat(400);
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
|
|
@ -299,6 +308,7 @@ mod tests {
|
|||
replay_dir: None,
|
||||
record: None,
|
||||
serve: None,
|
||||
trial: None,
|
||||
};
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
table::play(&config, "0\n".repeat(200).as_bytes(), &mut out).expect("game");
|
||||
|
|
@ -361,6 +371,7 @@ mod tests {
|
|||
replay_dir: None,
|
||||
record: None,
|
||||
serve: None,
|
||||
trial: None,
|
||||
};
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
let summary = table::play(&config, "".as_bytes(), &mut out).expect("bot game");
|
||||
|
|
@ -421,6 +432,7 @@ mod tests {
|
|||
replay_dir: Some(dir.clone()),
|
||||
record: Some(dir.join("session.yaml")),
|
||||
serve: None,
|
||||
trial: None,
|
||||
};
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
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
|
||||
/// (ADR-0007). `Some(0)` lets the OS pick the port.
|
||||
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 {
|
||||
|
|
@ -48,6 +51,7 @@ impl Default for Config {
|
|||
replay_dir: None,
|
||||
record: 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`.
|
||||
let server = match config.serve {
|
||||
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 _ = out.borrow_mut().flush();
|
||||
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`.
|
||||
let mut tally = MatchTally::default();
|
||||
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)?;
|
||||
if choice != crate::hotseat::EndChoice::Again {
|
||||
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
|
||||
kind: product
|
||||
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"
|
||||
---
|
||||
|
||||
|
|
@ -80,7 +80,7 @@ write-only.
|
|||
|
||||
```task
|
||||
id: CB-WP-0027-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
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
|
||||
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
|
||||
id: CB-WP-0027-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
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
|
||||
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
|
||||
id: CB-WP-0027-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
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
|
||||
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
|
||||
id: CB-WP-0027-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
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
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue