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:
parent
57639623da
commit
7a78c58404
6 changed files with 333 additions and 15 deletions
|
|
@ -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;
|
font:13px ui-monospace,monospace;box-shadow:0 6px 16px #000b;
|
||||||
transform:translate(-50%,-140%)}
|
transform:translate(-50%,-140%)}
|
||||||
.k{color:#89a}
|
.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}
|
#cb-status{margin-top:1rem;color:#fc9;min-height:1.2em}
|
||||||
svg{background:#1b1e26;border:1px solid #445;border-radius:6px}
|
svg{background:#1b1e26;border:1px solid #445;border-radius:6px}
|
||||||
";
|
";
|
||||||
|
|
@ -405,6 +408,18 @@ pub fn document(
|
||||||
endpoint: &str,
|
endpoint: &str,
|
||||||
seat: Option<PlayerId>,
|
seat: Option<PlayerId>,
|
||||||
may_pass: bool,
|
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 {
|
) -> String {
|
||||||
let mut s = String::with_capacity(8192);
|
let mut s = String::with_capacity(8192);
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
|
|
@ -433,6 +448,7 @@ pub fn document(
|
||||||
|
|
||||||
body(&mut s, view);
|
body(&mut s, view);
|
||||||
move_section(&mut s, legal, seat, may_pass);
|
move_section(&mut s, legal, seat, may_pass);
|
||||||
|
log_section(&mut s, log);
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
s,
|
s,
|
||||||
"<div id=\"cb-status\">ready</div>\
|
"<div id=\"cb-status\">ready</div>\
|
||||||
|
|
@ -529,7 +545,6 @@ fn body(s: &mut String, view: &GroundView) {
|
||||||
winners = esc(&winners),
|
winners = esc(&winners),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The "your move" section: action cards, numbered fallbacks, the table,
|
/// 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
|
/// 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
|
/// and the seat card the instruction text points at went without. A seat
|
||||||
/// is drawn twice and both drawings are the 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).
|
/// 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
|
/// `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
|
/// **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
|
/// the reload was refused, which is how a completed game became a blank
|
||||||
/// tab.
|
/// 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 mut s = String::with_capacity(4096);
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
s,
|
s,
|
||||||
|
|
@ -670,9 +726,10 @@ pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str) -> Strin
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
log_section(&mut s, log);
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
s,
|
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>\
|
<div id=\"cb-status\">the game is over</div>\
|
||||||
<script>window.CB_ENDPOINT={endpoint}</script><script>{SCRIPT}</script>",
|
<script>window.CB_ENDPOINT={endpoint}</script><script>{SCRIPT}</script>",
|
||||||
endpoint = json_string(endpoint),
|
endpoint = json_string(endpoint),
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
#[cfg(test)]
|
||||||
mod testfix;
|
mod testfix;
|
||||||
|
|
|
||||||
|
|
@ -409,10 +409,23 @@ const MAX_ATTEMPTS: usize = 8;
|
||||||
/// Seats are matched to `policies` by index: `PlayerId(n)` gets
|
/// Seats are matched to `policies` by index: `PlayerId(n)` gets
|
||||||
/// `policies[n]`.
|
/// `policies[n]`.
|
||||||
pub fn play<'a>(
|
pub fn play<'a>(
|
||||||
mut state: GroundState,
|
state: GroundState,
|
||||||
policies: &mut [Box<dyn Policy + 'a>],
|
policies: &mut [Box<dyn Policy + 'a>],
|
||||||
) -> Result<BotGame, BotError> {
|
) -> 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();
|
let seats: Vec<PlayerId> = state.players.keys().copied().collect();
|
||||||
for seat in &seats {
|
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.
|
/// What the driver accumulates while a game runs.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Log {
|
struct Log {
|
||||||
events: Vec<crate::GroundEvent>,
|
events: Vec<crate::GroundEvent>,
|
||||||
steps: Vec<(Actor, GroundCommand)>,
|
steps: Vec<(Actor, GroundCommand)>,
|
||||||
|
journal: Option<Journal>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Offer one seat its legal commands and apply what the policy picks.
|
/// Offer one seat its legal commands and apply what the policy picks.
|
||||||
|
|
@ -573,6 +608,13 @@ fn apply(
|
||||||
for event in &produced {
|
for event in &produced {
|
||||||
state.fold(event);
|
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.events.extend(produced);
|
||||||
log.steps.push((actor, cmd.clone()));
|
log.steps.push((actor, cmd.clone()));
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ pub struct Server {
|
||||||
listener: TcpListener,
|
listener: TcpListener,
|
||||||
guard: Guard,
|
guard: Guard,
|
||||||
log: RefCell<Vec<String>>,
|
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 {
|
impl Server {
|
||||||
|
|
@ -41,6 +44,7 @@ impl Server {
|
||||||
listener,
|
listener,
|
||||||
guard: Guard::mint(port),
|
guard: Guard::mint(port),
|
||||||
log: RefCell::new(Vec::new()),
|
log: RefCell::new(Vec::new()),
|
||||||
|
journal: games_ground::bot::Journal::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,6 +54,47 @@ impl Server {
|
||||||
self.guard.page_url()
|
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.
|
/// What was refused, for the evidence a session leaves behind.
|
||||||
pub fn refusals(&self) -> Vec<String> {
|
pub fn refusals(&self) -> Vec<String> {
|
||||||
self.log.borrow().clone()
|
self.log.borrow().clone()
|
||||||
|
|
@ -93,7 +138,14 @@ impl Server {
|
||||||
|
|
||||||
match (req.method.as_str(), req.path.as_str()) {
|
match (req.method.as_str(), req.path.as_str()) {
|
||||||
("GET", "/") => {
|
("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);
|
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
|
||||||
}
|
}
|
||||||
("POST", "/command") => {
|
("POST", "/command") => {
|
||||||
|
|
@ -184,6 +236,7 @@ impl Server {
|
||||||
final_view,
|
final_view,
|
||||||
message,
|
message,
|
||||||
&self.guard.endpoint(),
|
&self.guard.endpoint(),
|
||||||
|
&self.log_lines(),
|
||||||
);
|
);
|
||||||
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
|
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> {
|
fn read_request(stream: &mut TcpStream) -> Result<String, String> {
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
let mut chunk = [0u8; 1024];
|
let mut chunk = [0u8; 1024];
|
||||||
|
|
@ -361,8 +475,11 @@ mod tests {
|
||||||
/// game that never happened.
|
/// game that never happened.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_game_that_ended_badly_says_so_and_shows_no_table() {
|
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");
|
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("P1 ran out of input"),
|
||||||
|
"the reason is missing"
|
||||||
|
);
|
||||||
assert!(page.contains("without a result"));
|
assert!(page.contains("without a result"));
|
||||||
assert!(
|
assert!(
|
||||||
!page.contains("relationships"),
|
!page.contains("relationships"),
|
||||||
|
|
@ -492,6 +609,17 @@ mod tests {
|
||||||
&last[..last.len().min(300)]
|
&last[..last.len().min(300)]
|
||||||
);
|
);
|
||||||
assert!(last.contains("commands, hash"), "no result on the page");
|
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 the game thread finish: tell it the result has been seen.
|
||||||
let body = "down=done&up=done";
|
let body = "down=done&up=done";
|
||||||
|
|
|
||||||
|
|
@ -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
|
// A human seat that ran out of input reports *that*, not the
|
||||||
// out-of-range index it had to return to get here.
|
// 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.
|
// to stdout, and the page's post-`ok` reload got Connection refused.
|
||||||
if let Some(srv) = &server {
|
if let Some(srv) = &server {
|
||||||
let ended = game.state.project(Viewer::Spectator);
|
let ended = game.state.project(Viewer::Spectator);
|
||||||
let msg = format!(
|
let msg = format!("{} commands, hash {}", game.commands, &end_hash[..12]);
|
||||||
"{} commands, hash {}",
|
|
||||||
game.commands,
|
|
||||||
&end_hash[..12]
|
|
||||||
);
|
|
||||||
let _ = srv.serve_end(Some(&ended), &msg, END_LINGER);
|
let _ = srv.serve_end(Some(&ended), &msg, END_LINGER);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,7 +165,7 @@ against a face-down problem, or against a suit the seat cannot match?**
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CB-WP-0018-T02
|
id: CB-WP-0018-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
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"* —
|
command produced these events"* from *"this command produced none"* —
|
||||||
the second is the one the player needs and the harder one to render.
|
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: say where a drop goes, and what it means
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue