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