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;