CB-WP-0018-T02: a game log the player can read

bot::Journal -- a shared list of Applied { actor, command, events } the
driver appends to via play_journaled; play delegates with None so
nothing existing changed. BotGame.events only appears after play
returns, which is no use to a page rendered mid-game.

Phrased with record::to_step, the recorder's vocabulary, so what the
player reads is what the scenario file will say, and all 29 GroundEvent
variants now render in words instead of Debug.

A command that produced no events says 'no effect'; the mutation
dropping that branch goes red. Honest limitation recorded: the reported
SOLVE case is resolved inside the system's resolve command, which does
produce events for other seats, so it shows as a selection with no claim
following rather than an explicit 'no effect'. Making it explicit would
mean the renderer deciding why a rule did nothing -- a second
implementation of the rules, which this task's control forbids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-03 02:15:30 +02:00
parent 57639623da
commit 7a78c58404
6 changed files with 333 additions and 15 deletions

View file

@ -28,6 +28,9 @@ pub struct Server {
listener: TcpListener,
guard: Guard,
log: RefCell<Vec<String>>,
/// 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,
}
impl Server {
@ -41,6 +44,7 @@ impl Server {
listener,
guard: Guard::mint(port),
log: RefCell::new(Vec::new()),
journal: games_ground::bot::Journal::default(),
})
}
@ -50,6 +54,47 @@ impl Server {
self.guard.page_url()
}
/// The journal the driver appends to; hand it to `play_journaled`.
pub fn journal(&self) -> games_ground::bot::Journal {
self.journal.clone()
}
/// The log as the page renders it, in the recorder's vocabulary.
///
/// `record::to_step` is reused rather than phrased afresh: what the
/// player reads is what the scenario file will say. The effects are
/// the events the command actually produced, and a command that
/// produced none says so — that is the case CB-WP-0018 was reported
/// for.
fn log_lines(&self) -> Vec<cb_render_html::doc::LogLine> {
self.journal
.borrow()
.iter()
.map(|a| {
let step = games_ground::record::to_step(a.actor, &a.command);
let mut what = step.cmd.clone();
for (k, v) in &step.args {
let rendered = match v {
serde_yaml::Value::String(s) => s.clone(),
other => serde_yaml::to_string(other)
.unwrap_or_default()
.trim()
.to_string(),
};
what.push_str(&format!(" {k}={rendered}"));
}
cb_render_html::doc::LogLine {
who: match a.actor {
cb_kernel::Actor::Player(p) => format!("P{}", p.0 + 1),
cb_kernel::Actor::System => "the round".to_string(),
},
what,
effects: a.events.iter().map(event_line).collect(),
}
})
.collect()
}
/// What was refused, for the evidence a session leaves behind.
pub fn refusals(&self) -> Vec<String> {
self.log.borrow().clone()
@ -93,7 +138,14 @@ impl Server {
match (req.method.as_str(), req.path.as_str()) {
("GET", "/") => {
let page = document(&view, legal, &self.guard.endpoint(), Some(seat), may_pass);
let page = cb_render_html::doc::document_with_log(
&view,
legal,
&self.guard.endpoint(),
Some(seat),
may_pass,
&self.log_lines(),
);
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
}
("POST", "/command") => {
@ -184,6 +236,7 @@ impl Server {
final_view,
message,
&self.guard.endpoint(),
&self.log_lines(),
);
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
}
@ -236,6 +289,67 @@ impl Policy for SeatPolicy {
}
}
/// One event, in words a player can read.
///
/// Deliberately terse and derived from the event itself, never from the
/// state afterwards: a log that re-narrates state is a second
/// implementation of the rules and will drift from the first.
fn event_line(e: &games_ground::GroundEvent) -> String {
use games_ground::GroundEvent as E;
let p = |x: &cb_kernel::PlayerId| format!("P{}", x.0 + 1);
match e {
E::ActionSelected { player, selection } => match (selection.target, selection.problem) {
(Some(t), _) => format!("{} chose {:?} on {}", p(player), selection.action, p(&t)),
(_, Some(n)) => format!("{} chose {:?} on problem {n}", p(player), selection.action),
_ => format!("{} chose {:?}", p(player), selection.action),
},
E::Revealed => "selections revealed".into(),
E::StressSet { player, stress } => format!("{} stress now {stress}", p(player)),
E::FreedomSpent { player } => format!("{} spent freedom", p(player)),
E::FreedomReadied { player } => format!("{} freedom ready", p(player)),
E::RelationFormed { pair, relation } => {
format!("{} and {} \u{2014} {relation:?}", p(&pair.0), p(&pair.1))
}
E::RelationBroken { pair } => format!("{} and {} no longer tied", p(&pair.0), p(&pair.1)),
E::AttackCancelled { attacker, target } => {
format!("{}'s attack on {} cancelled", p(attacker), p(target))
}
E::ProblemRevealed { problem } => format!("problem {problem} turned face up"),
E::SolutionDrawn { player, .. } => format!("{} drew a solution", p(player)),
E::SolutionDiscarded { player, card } => {
format!("{} spent a {:?} solution", p(player), card.suit)
}
E::ProblemClaimed { problem, by } => format!("problem {problem} claimed by {}", p(by)),
E::ProblemDenied { problem } => format!("problem {problem} denied"),
E::ProblemProtected { problem } => format!("problem {problem} protected"),
E::ProblemRestored { problem } => format!("problem {problem} restored"),
E::ProtectionGained { player } => format!("{} gained protection", p(player)),
E::BlameRemoved { player, owner } => {
format!("{} cleared {}'s blame", p(player), p(owner))
}
E::FocusPlaced { owner, target } => format!("{} focused on {}", p(owner), p(target)),
E::FocusFlippedToBlame { owner, target } => {
format!("{}'s focus on {} became blame", p(owner), p(target))
}
E::DarvoTriggered { player } => format!("{} entered DARVO", p(player)),
E::DarvoAdvanced { player, stage } => format!("{} DARVO \u{2192} {stage:?}", p(player)),
E::DarvoEnded { player } => format!("{} left DARVO", p(player)),
E::DarvoTargetChosen { player, .. } => format!("{} named a DARVO target", p(player)),
E::GroundModeChosen { player, mode, .. } => {
format!("{} grounded as {mode:?}", p(player))
}
E::SupportAnswered { player, response } => {
format!("{} answered support with {response:?}", p(player))
}
E::DeckReshuffled { .. } => "the discard was reshuffled into the deck".into(),
E::RoundEnded { round, next_lead } => {
format!("round {round} ended; {} leads next", p(next_lead))
}
E::StepAdvanced { step } => format!("step \u{2192} {step:?}"),
E::GameEnded { .. } => "the game ended".into(),
}
}
fn read_request(stream: &mut TcpStream) -> Result<String, String> {
let mut buf = Vec::new();
let mut chunk = [0u8; 1024];
@ -361,8 +475,11 @@ mod tests {
/// game that never happened.
#[test]
fn a_game_that_ended_badly_says_so_and_shows_no_table() {
let page = cb_render_html::doc::ending(None, "P1 ran out of input", "/command?t=x");
assert!(page.contains("P1 ran out of input"), "the reason is missing");
let page = cb_render_html::doc::ending(None, "P1 ran out of input", "/command?t=x", &[]);
assert!(
page.contains("P1 ran out of input"),
"the reason is missing"
);
assert!(page.contains("without a result"));
assert!(
!page.contains("relationships"),
@ -492,6 +609,17 @@ mod tests {
&last[..last.len().min(300)]
);
assert!(last.contains("commands, hash"), "no result on the page");
// CB-WP-0018 T02: the log is on the page, and it came from the
// journal the driver filled -- not from re-reading the state.
assert!(last.contains("<h2>log</h2>"), "no log section");
assert!(
!last.contains("nothing has happened yet"),
"a finished game reported an empty log"
);
assert!(
last.contains("select_action"),
"the log is not in the recorder's vocabulary"
);
// Let the game thread finish: tell it the result has been seen.
let body = "down=done&up=done";

View file

@ -267,7 +267,13 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
}
}
let result = games_ground::bot::play(initial, &mut policies);
// CB-WP-0018 T02: the browser seat's page renders the journal as it
// fills, so a player sees what each command produced -- including the
// commands that produced nothing.
let result = match &server {
Some(srv) => games_ground::bot::play_journaled(initial, &mut policies, Some(srv.journal())),
None => games_ground::bot::play(initial, &mut policies),
};
// A human seat that ran out of input reports *that*, not the
// out-of-range index it had to return to get here.
@ -318,11 +324,7 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
// to stdout, and the page's post-`ok` reload got Connection refused.
if let Some(srv) = &server {
let ended = game.state.project(Viewer::Spectator);
let msg = format!(
"{} commands, hash {}",
game.commands,
&end_hash[..12]
);
let msg = format!("{} commands, hash {}", game.commands, &end_hash[..12]);
let _ = srv.serve_end(Some(&ended), &msg, END_LINGER);
}