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
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue