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;
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue