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

@ -170,6 +170,9 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf}
font:13px ui-monospace,monospace;box-shadow:0 6px 16px #000b;
transform:translate(-50%,-140%)}
.k{color:#89a}
.nil{color:#c88}
.eff{color:#8c9}
#cb-log{max-height:16rem;overflow-y:auto;font-size:13px}
#cb-status{margin-top:1rem;color:#fc9;min-height:1.2em}
svg{background:#1b1e26;border:1px solid #445;border-radius:6px}
";
@ -405,6 +408,18 @@ pub fn document(
endpoint: &str,
seat: Option<PlayerId>,
may_pass: bool,
) -> String {
document_with_log(view, legal, endpoint, seat, may_pass, &[])
}
/// The table, plus the game log (CB-WP-0018 T02).
pub fn document_with_log(
view: &GroundView,
legal: &[games_ground::GroundCommand],
endpoint: &str,
seat: Option<PlayerId>,
may_pass: bool,
log: &[LogLine],
) -> String {
let mut s = String::with_capacity(8192);
let _ = write!(
@ -433,6 +448,7 @@ pub fn document(
body(&mut s, view);
move_section(&mut s, legal, seat, may_pass);
log_section(&mut s, log);
let _ = write!(
s,
"<div id=\"cb-status\">ready</div>\
@ -529,7 +545,6 @@ fn body(s: &mut String, view: &GroundView) {
winners = esc(&winners),
);
}
}
/// The "your move" section: action cards, numbered fallbacks, the table,
@ -642,6 +657,47 @@ fn json_string(s: &str) -> String {
/// element could ever be `seat-0` — the relationship-graph circle took it
/// and the seat card the instruction text points at went without. A seat
/// is drawn twice and both drawings are the seat.
/// One line of the game log: what was done, and what it produced.
///
/// Built by the caller from `bot::Applied` so this crate stays free of
/// the driver, and phrased in the **recorder's** vocabulary via
/// `record::to_step` — CB-WP-0018 T02 forbids a fourth phrasing, because
/// what the player reads should be what the scenario file will say.
pub struct LogLine {
pub who: String,
pub what: String,
/// Rendered effects. **Empty is the case that matters:** a command
/// that produced no events is the one the player cannot otherwise
/// account for.
pub effects: Vec<String>,
}
/// The game log, newest last, with the empty case spelled out.
fn log_section(s: &mut String, log: &[LogLine]) {
s.push_str("<h2>log</h2><div class=\"card\" id=\"cb-log\">");
if log.is_empty() {
s.push_str("<span class=\"k\">nothing has happened yet</span>");
}
for line in log {
let _ = write!(
s,
"<div><span class=\"k\">{who}</span> {what}",
who = esc(&line.who),
what = esc(&line.what),
);
if line.effects.is_empty() {
// The silence CB-WP-0018 was reported for, said out loud.
s.push_str(" \u{2014} <span class=\"nil\">no effect</span>");
} else {
for e in &line.effects {
let _ = write!(s, "<br><span class=\"eff\">\u{2192} {}</span>", esc(e));
}
}
s.push_str("</div>");
}
s.push_str("</div>");
}
/// The page a finished game leaves behind (CB-WP-0018 T01).
///
/// `view` is `None` when the game ended badly: then there is no result to
@ -651,7 +707,7 @@ fn json_string(s: &str) -> String {
/// **This page does not auto-reload.** The old one reloaded on `ok` and
/// the reload was refused, which is how a completed game became a blank
/// tab.
pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str) -> String {
pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str, log: &[LogLine]) -> String {
let mut s = String::with_capacity(4096);
let _ = write!(
s,
@ -670,9 +726,10 @@ pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str) -> Strin
);
}
}
log_section(&mut s, log);
let _ = write!(
s,
"<div class=\"card btn pick\" data-drop=\"done\">close I have read this</div>\
"<div class=\"card btn pick\" data-drop=\"done\">close \u{2014} I have read this</div>\
<div id=\"cb-status\">the game is over</div>\
<script>window.CB_ENDPOINT={endpoint}</script><script>{SCRIPT}</script>",
endpoint = json_string(endpoint),

View file

@ -573,5 +573,59 @@ mod affordances {
}
}
#[cfg(test)]
mod gamelog {
//! CB-WP-0018 T02.
use crate::doc::{document_with_log, LogLine};
use cb_kernel::PlayerId;
fn line(effects: &[&str]) -> LogLine {
LogLine {
who: "P1".into(),
what: "select_action action=SOLVE problem=1".into(),
effects: effects.iter().map(|s| (*s).to_string()).collect(),
}
}
fn page(log: &[LogLine]) -> String {
document_with_log(
&crate::testfix::view(Some(PlayerId(0))),
&[],
"/command?t=x",
Some(PlayerId(0)),
false,
log,
)
}
/// **The case the pass was reported for.** A SOLVE that cannot be
/// fulfilled produces no events, and a log built only from events
/// would render nothing for it — reproducing the silence the
/// maintainer hit when the same move did nothing three rounds
/// running.
#[test]
fn a_command_that_produced_nothing_says_so() {
let html = page(&[line(&[])]);
assert!(
html.contains("no effect"),
"a command with no events rendered as if it had done something"
);
let text = crate::text_of(&html);
assert!(text.contains("select_action action=SOLVE problem=1"));
}
#[test]
fn effects_are_listed_and_an_empty_log_says_it_is_empty() {
let text = crate::text_of(&page(&[line(&["problem 1 claimed by P1"])]));
assert!(text.contains("problem 1 claimed by P1"));
assert!(
!text.contains("no effect"),
"a command WITH effects was marked as having none"
);
assert!(crate::text_of(&page(&[])).contains("nothing has happened yet"));
}
}
#[cfg(test)]
mod testfix;

View file

@ -409,10 +409,23 @@ const MAX_ATTEMPTS: usize = 8;
/// Seats are matched to `policies` by index: `PlayerId(n)` gets
/// `policies[n]`.
pub fn play<'a>(
mut state: GroundState,
state: GroundState,
policies: &mut [Box<dyn Policy + 'a>],
) -> Result<BotGame, BotError> {
let mut log = Log::default();
play_journaled(state, policies, None)
}
/// The same, appending every applied command and its events to `journal`
/// as it goes, for a caller rendering the game while it runs.
pub fn play_journaled<'a>(
mut state: GroundState,
policies: &mut [Box<dyn Policy + 'a>],
journal: Option<Journal>,
) -> Result<BotGame, BotError> {
let mut log = Log {
journal,
..Log::default()
};
let seats: Vec<PlayerId> = state.players.keys().copied().collect();
for seat in &seats {
@ -504,11 +517,33 @@ pub fn play<'a>(
})
}
/// One command and everything it produced.
///
/// **The empty case is the load-bearing one** (CB-WP-0018 T02). GR-A02's
/// resolver silently `continue`s when a SOLVE cannot be fulfilled, so a
/// player can select it three rounds running and change nothing. A journal
/// built only from events would show nothing for those and reproduce the
/// silence; keeping the command with an empty `events` is what lets a
/// reader say *"this happened and did nothing"*.
#[derive(Debug, Clone)]
pub struct Applied {
pub actor: Actor,
pub command: GroundCommand,
pub events: Vec<crate::GroundEvent>,
}
/// A live account of a game in progress, shared with whoever is watching.
///
/// `BotGame.events` is the same information but only after `play` returns,
/// which is no use to a page rendered mid-game.
pub type Journal = std::rc::Rc<std::cell::RefCell<Vec<Applied>>>;
/// What the driver accumulates while a game runs.
#[derive(Default)]
struct Log {
events: Vec<crate::GroundEvent>,
steps: Vec<(Actor, GroundCommand)>,
journal: Option<Journal>,
}
/// Offer one seat its legal commands and apply what the policy picks.
@ -573,6 +608,13 @@ fn apply(
for event in &produced {
state.fold(event);
}
if let Some(j) = &log.journal {
j.borrow_mut().push(Applied {
actor,
command: cmd.clone(),
events: produced.clone(),
});
}
log.events.extend(produced);
log.steps.push((actor, cmd.clone()));
Ok(())

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

View file

@ -165,7 +165,7 @@ against a face-down problem, or against a suit the seat cannot match?**
```task
id: CB-WP-0018-T02
status: todo
status: done
priority: high
```
@ -196,6 +196,41 @@ for them and reproduce the silence. So the log must distinguish *"this
command produced these events"* from *"this command produced none"*
the second is the one the player needs and the harder one to render.
**Done 2026-08-03.** `bot::Journal` — a shared list of `Applied { actor,
command, events }` the driver appends to, via the new `play_journaled`.
`play` delegates to it with `None`, so nothing existing changed. The
journal exists because `BotGame.events` only appears *after* `play`
returns, which is no use to a page rendered mid-game.
The log is phrased with `record::to_step` — the recorder's vocabulary, so
what the player reads is what the scenario file will say — and every one
of the 29 `GroundEvent` variants now renders in words rather than `{:?}`.
From a live game:
```
P1 select_action action=INVESTIGATE problem=2
→ P1 chose Investigate on problem 2
the round resolve
→ problem 2 turned face up
→ P1 drew a solution
→ step → Resolve
the round end_round
→ round 2 ended; P2 leads next
```
**A command that produced no events says `no effect`**, which is the whole
point; the mutation that drops that branch goes red.
**An honest limitation.** The reported case — SOLVE doing nothing — is
*not* rendered as `no effect`, because the SOLVE is resolved inside the
system's `resolve` command, which does produce events for other seats. The
player now sees the selection and sees no claim follow it, which is a
large improvement on silence but is still an inference. Making it explicit
would mean the renderer deciding *why* a rule did nothing, which is a
second implementation of the rules — exactly what this task's control
forbids. Left as an inference deliberately, and owed to `ground-game` as
the question of whether the move should be offered at all.
## Task: say where a drop goes, and what it means
```task