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:
tegwick 2026-08-06 10:37:46 +02:00
parent 5c6e322d5f
commit 4fb506fcd1
11 changed files with 1002 additions and 10 deletions

View file

@ -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, &note) {
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,

View file

@ -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 =

View file

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

View file

@ -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
View 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())