diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index ef1453e..351176f 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -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, 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, + 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, "
ready
\ @@ -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, +} + +/// The game log, newest last, with the empty case spelled out. +fn log_section(s: &mut String, log: &[LogLine]) { + s.push_str("

log

"); + if log.is_empty() { + s.push_str("nothing has happened yet"); + } + for line in log { + let _ = write!( + s, + "
{who} {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} no effect"); + } else { + for e in &line.effects { + let _ = write!(s, "
\u{2192} {}", esc(e)); + } + } + s.push_str("
"); + } + s.push_str("
"); +} + /// 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, - "
close — I have read this
\ + "
close \u{2014} I have read this
\
the game is over
\ ", endpoint = json_string(endpoint), diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs index 1b15091..58898b7 100644 --- a/crates/cb-render-html/src/lib.rs +++ b/crates/cb-render-html/src/lib.rs @@ -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; diff --git a/games/ground/src/bot.rs b/games/ground/src/bot.rs index b160058..551cad9 100644 --- a/games/ground/src/bot.rs +++ b/games/ground/src/bot.rs @@ -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], ) -> Result { - 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], + journal: Option, +) -> Result { + let mut log = Log { + journal, + ..Log::default() + }; let seats: Vec = 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, +} + +/// 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>>; + /// What the driver accumulates while a game runs. #[derive(Default)] struct Log { events: Vec, steps: Vec<(Actor, GroundCommand)>, + journal: Option, } /// 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(()) diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs index b4aec5e..a0659bb 100644 --- a/tools/cb-play/src/hotseat.rs +++ b/tools/cb-play/src/hotseat.rs @@ -28,6 +28,9 @@ pub struct Server { listener: TcpListener, guard: Guard, log: RefCell>, + /// 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 { + 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 { 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 { 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("

log

"), "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"; diff --git a/tools/cb-play/src/table.rs b/tools/cb-play/src/table.rs index dd21eae..61d80c4 100644 --- a/tools/cb-play/src/table.rs +++ b/tools/cb-play/src/table.rs @@ -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); } diff --git a/workplans/CB-WP-0018-the-browser-is-a-client.md b/workplans/CB-WP-0018-the-browser-is-a-client.md index 7f12c82..0955fed 100644 --- a/workplans/CB-WP-0018-the-browser-is-a-client.md +++ b/workplans/CB-WP-0018-the-browser-is-a-client.md @@ -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