Four of the maintainer's five playtest remarks. Three of the five turned
out to be data the projection already carried, rendered as text -- the
table's problem was legibility, not content, and the coverage gate passes
either way because it proves nothing is OMITTED, not that anything is
readable. That gap is named in the evidence rather than closed: the honest
control is a person playing it.
T01. The ending control was two defects wearing one button. The label said
"close -- I have read this" while hotseat.rs reads `done` as STOP THE
SERVER, and acknowledging it changed nothing -- the tab kept a full table
and a `play again` pointing at a closed port. Now labelled by its effect,
and the page seals itself on the `closed` reply: removeAttribute on every
control's data-drop, so they stop being droppable by the same rule that
made them droppable. removeAttribute rather than setAttribute(_, null),
which writes the truthy string "null" in a browser.
The reason it survived is structural. jsrun's fetch stub returned
{then: function(){return this}} and never invoked its callbacks, so every
line of the script reacting to the server was unreachable from every test
in this project -- a page that ignores the server was indistinguishable
from one that acts on it. Same finding as CB-WP-0016's "a stub too thin to
express a failure is how the failure survives", one layer deeper, at the
reply. The stub now delivers a real then-chain; gesture_with_reply reports
surviving controls; the seal is mutation-proven and a negative control
asserts `ok: dealing` does NOT seal.
T02. Draw and discard as offset stacks with counts. The shuffle question
the task required settling: it already works, at
games/ground/src/lib.rs:1419-1435, implementing the U4 default that
ground-game confirmed 2026-08-03. Nothing raised. The piles show the state
before it fires, which is derivable from the view; a claim that a
reshuffle HAS happened is not, and is not made. CB-WP-0026 applied that
ruling the same day this consumed it -- first time answering "is this
underdetermined?" was one lookup instead of a message.
T03. Each seat's play drawn as a card, sentence kept beside it. The
face-down back is a const with no parameters: SelectionView::Hidden
carries nothing, so there is no data path into the back to add later. The
leak test copies view.rs's own shape -- identical backs across two
different hidden situations, THEN assert a revealed play does show,
because without the second half the first passes for a renderer that draws
nothing.
T04. MatchTally lives in `play`, beside the listener and the seed. What
"cumulative" means was decided before anything was summed, and the answer
is that GROUND defines one game and no series: summed personal score and
games-won answer different questions, and a test asserts they can point at
different seats. Both shown, both labelled. Registered F15 as a NOTE --
the test shows the tallies can differ, which is arithmetic, not evidence
the ambiguity harms play, so GameDesign §3.1 bars reporting it. First use
of the note tier since D6 wrote it, and it came from building rather than
from play.
make all: exit 0. 41 render tests, 26 cb-play tests, loop-lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
1edb10d5a3
commit
8d58568013
7 changed files with 644 additions and 14 deletions
|
|
@ -200,6 +200,7 @@ impl Server {
|
|||
final_view: Option<&games_ground::view::GroundView>,
|
||||
message: &str,
|
||||
linger: std::time::Duration,
|
||||
tally: &crate::table::MatchTally,
|
||||
) -> Result<EndChoice, String> {
|
||||
let deadline = std::time::Instant::now() + linger;
|
||||
self.listener
|
||||
|
|
@ -237,6 +238,7 @@ impl Server {
|
|||
message,
|
||||
&self.guard.endpoint(),
|
||||
&self.log_lines(),
|
||||
&series_lines(tally),
|
||||
);
|
||||
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
|
||||
}
|
||||
|
|
@ -275,6 +277,142 @@ impl Server {
|
|||
}
|
||||
}
|
||||
|
||||
/// The running series, phrased for a reader.
|
||||
///
|
||||
/// **Two lines, because the rules define one game and not a series**
|
||||
/// (CB-WP-0024 T04). `personal` sums per seat; `winners` counts games
|
||||
/// won; they answer different questions and GROUND says which is *the*
|
||||
/// series score nowhere at all. Both are shown and both are named, so
|
||||
/// nothing here quietly becomes canon.
|
||||
pub fn series_lines(t: &crate::table::MatchTally) -> Vec<String> {
|
||||
if t.games <= 1 {
|
||||
// One game is not a series, and a "cumulative" panel over a single
|
||||
// result would just restate the outcome above it.
|
||||
return Vec::new();
|
||||
}
|
||||
let seats = |m: std::collections::BTreeMap<PlayerId, String>| {
|
||||
m.iter()
|
||||
.map(|(p, v)| format!("P{} {v}", p.0 + 1))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
};
|
||||
let personal: std::collections::BTreeMap<_, _> = t
|
||||
.personal
|
||||
.iter()
|
||||
.map(|(p, v)| (*p, format!("{v:+}")))
|
||||
.collect();
|
||||
let wins: std::collections::BTreeMap<_, _> = t
|
||||
.personal
|
||||
.keys()
|
||||
.map(|p| (*p, t.wins.get(p).copied().unwrap_or(0).to_string()))
|
||||
.collect();
|
||||
vec![
|
||||
format!("{} games this session", t.games),
|
||||
format!("summed personal score — {}", seats(personal)),
|
||||
format!("games won — {}", seats(wins)),
|
||||
format!(
|
||||
"table cleared its threshold {} of {} games",
|
||||
t.group_successes, t.games
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod series {
|
||||
use crate::table::MatchTally;
|
||||
use cb_kernel::PlayerId;
|
||||
use games_ground::view::OutcomeView;
|
||||
|
||||
fn outcome(p1: i32, p2: i32, success: bool, winners: Vec<PlayerId>) -> OutcomeView {
|
||||
OutcomeView {
|
||||
total: 0,
|
||||
threshold: 5,
|
||||
group_success: success,
|
||||
personal: [(PlayerId(0), p1), (PlayerId(1), p2)].into_iter().collect(),
|
||||
coalitions: vec![],
|
||||
mastery: None,
|
||||
winners,
|
||||
}
|
||||
}
|
||||
|
||||
fn after(games: &[OutcomeView]) -> MatchTally {
|
||||
let mut t = MatchTally::default();
|
||||
for o in games {
|
||||
t.record_for_test(o);
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// The weakest possible assertion, and the one that catches a tally
|
||||
/// reset by `play again`: two games must produce a total that is
|
||||
/// neither game's score alone.
|
||||
#[test]
|
||||
fn two_games_accumulate_rather_than_replacing() {
|
||||
let t = after(&[
|
||||
outcome(3, 1, true, vec![PlayerId(0)]),
|
||||
outcome(2, 4, false, vec![PlayerId(1)]),
|
||||
]);
|
||||
assert_eq!(t.games, 2);
|
||||
assert_eq!(t.personal[&PlayerId(0)], 5, "P1's scores did not sum");
|
||||
assert_eq!(t.personal[&PlayerId(1)], 5, "P2's scores did not sum");
|
||||
assert_eq!(t.group_successes, 1, "only one game cleared its threshold");
|
||||
assert_eq!(t.wins[&PlayerId(0)], 1);
|
||||
assert_eq!(t.wins[&PlayerId(1)], 1);
|
||||
}
|
||||
|
||||
/// **The two tallies disagree, and that is the point.** Summed
|
||||
/// personal score says the seats are level; games won says the same;
|
||||
/// but a third game separates them, and which one is "the series
|
||||
/// score" is a question GROUND does not answer. Showing one alone
|
||||
/// would be picking a winner the rules never named.
|
||||
#[test]
|
||||
fn the_two_tallies_can_disagree_about_who_is_ahead() {
|
||||
let t = after(&[
|
||||
outcome(9, 0, false, vec![PlayerId(0)]),
|
||||
outcome(0, 1, false, vec![PlayerId(1)]),
|
||||
outcome(0, 1, false, vec![PlayerId(1)]),
|
||||
]);
|
||||
assert!(
|
||||
t.personal[&PlayerId(0)] > t.personal[&PlayerId(1)],
|
||||
"P1 leads on summed score"
|
||||
);
|
||||
assert!(
|
||||
t.wins[&PlayerId(1)] > t.wins.get(&PlayerId(0)).copied().unwrap_or(0),
|
||||
"P2 leads on games won — the two answers point at different seats"
|
||||
);
|
||||
}
|
||||
|
||||
/// One game is not a series: the panel stays absent rather than
|
||||
/// restating the outcome directly above it.
|
||||
#[test]
|
||||
fn a_single_game_shows_no_series_panel() {
|
||||
assert!(super::series_lines(&after(&[outcome(1, 1, true, vec![])])).is_empty());
|
||||
assert!(
|
||||
!super::series_lines(&after(&[
|
||||
outcome(1, 1, true, vec![]),
|
||||
outcome(1, 1, true, vec![])
|
||||
]))
|
||||
.is_empty(),
|
||||
"two games must produce a panel"
|
||||
);
|
||||
}
|
||||
|
||||
/// Both tallies must be named on the page. An unlabelled number would
|
||||
/// become "the score" by default, which is the canonisation this
|
||||
/// deliberately avoids.
|
||||
#[test]
|
||||
fn the_page_says_which_tally_is_which() {
|
||||
let lines = super::series_lines(&after(&[
|
||||
outcome(1, 2, true, vec![PlayerId(1)]),
|
||||
outcome(3, 0, false, vec![PlayerId(0)]),
|
||||
]));
|
||||
let all = lines.join(" | ");
|
||||
assert!(all.contains("summed personal score"), "{all}");
|
||||
assert!(all.contains("games won"), "{all}");
|
||||
assert!(all.contains("cleared its threshold"), "{all}");
|
||||
}
|
||||
}
|
||||
|
||||
/// What the player asked for on the ending page.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EndChoice {
|
||||
|
|
@ -473,6 +611,7 @@ mod tests {
|
|||
Some(&view),
|
||||
"30 commands, hash f6c890a65271",
|
||||
std::time::Duration::from_secs(20),
|
||||
&crate::table::MatchTally::default(),
|
||||
)
|
||||
.expect("serve_end");
|
||||
let replies = client.join().expect("client");
|
||||
|
|
@ -563,7 +702,8 @@ 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", &[]);
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -52,6 +52,59 @@ impl Default for Config {
|
|||
}
|
||||
}
|
||||
|
||||
/// What carries across games in one browser session (CB-WP-0024 T04).
|
||||
///
|
||||
/// `play` already owned the session — one listener, an advancing seed,
|
||||
/// `run_game` in a loop — and kept only the last summary, so a second
|
||||
/// game started with no memory of the first.
|
||||
///
|
||||
/// ## Two tallies, because the rules define one game and not a series
|
||||
///
|
||||
/// `OutcomeView` offers `personal` (per seat), `group_success` (per
|
||||
/// table) and `winners`. Summing `personal` and counting `winners` answer
|
||||
/// **different questions**, and GROUND's dataset says nothing about how
|
||||
/// several games combine — there is no series in the rules.
|
||||
///
|
||||
/// So both are kept and both are labelled, rather than one being picked
|
||||
/// and quietly presented as *the* score. Registered as a note (kind
|
||||
/// `underdetermined`) in `specs/FindingRegister.md`: it may not be
|
||||
/// reported to ground-game until something demonstrates a problem, which
|
||||
/// is GameDesign §3.1's rule and the reason this is not being sent as a
|
||||
/// finding.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MatchTally {
|
||||
pub games: u32,
|
||||
/// Sum of each seat's per-game `personal` score.
|
||||
pub personal: std::collections::BTreeMap<cb_kernel::PlayerId, i32>,
|
||||
/// Games the table cleared its threshold.
|
||||
pub group_successes: u32,
|
||||
/// How often each seat appeared in `winners`.
|
||||
pub wins: std::collections::BTreeMap<cb_kernel::PlayerId, u32>,
|
||||
}
|
||||
|
||||
impl MatchTally {
|
||||
/// Test-visible alias for [`Self::record`]. The real call site is the
|
||||
/// driver; this exists so the series tests drive the same code rather
|
||||
/// than a reimplementation of it beside it.
|
||||
#[cfg(test)]
|
||||
pub fn record_for_test(&mut self, o: &games_ground::view::OutcomeView) {
|
||||
self.record(o);
|
||||
}
|
||||
|
||||
fn record(&mut self, o: &games_ground::view::OutcomeView) {
|
||||
self.games += 1;
|
||||
for (seat, score) in &o.personal {
|
||||
*self.personal.entry(*seat).or_insert(0) += *score;
|
||||
}
|
||||
if o.group_success {
|
||||
self.group_successes += 1;
|
||||
}
|
||||
for w in &o.winners {
|
||||
*self.wins.entry(*w).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a finished game reports back.
|
||||
/// The end-state hash is what makes a session comparable to its replay.
|
||||
#[derive(Debug)]
|
||||
|
|
@ -200,7 +253,11 @@ fn end_badly<T>(
|
|||
msg: String,
|
||||
) -> Result<T, String> {
|
||||
if let Some(s) = server {
|
||||
let _ = s.serve_end(None, &msg, END_LINGER);
|
||||
// A game that ended badly has no outcome, so it contributes
|
||||
// nothing to the series and the panel stays absent. Passing an
|
||||
// empty tally rather than the live one is deliberate: a crashed
|
||||
// game must not be counted as a played one.
|
||||
let _ = s.serve_end(None, &msg, END_LINGER, &MatchTally::default());
|
||||
}
|
||||
Err(msg)
|
||||
}
|
||||
|
|
@ -239,8 +296,12 @@ pub fn play<R: BufRead, W: Write>(config: &Config, input: R, out: W) -> Result<S
|
|||
};
|
||||
|
||||
let mut cfg: Config = config.clone();
|
||||
// CB-WP-0024 T04: the tally outlives the game, which is the whole
|
||||
// point — it is declared here, beside the listener and the seed,
|
||||
// because those are the other two things that survive `play again`.
|
||||
let mut tally = MatchTally::default();
|
||||
loop {
|
||||
let (summary, choice) = run_game(&cfg, &input, &out, server.clone())?;
|
||||
let (summary, choice) = run_game(&cfg, &input, &out, server.clone(), &mut tally)?;
|
||||
if choice != crate::hotseat::EndChoice::Again {
|
||||
return Ok(summary);
|
||||
}
|
||||
|
|
@ -253,6 +314,7 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
|
|||
input: &'a std::cell::RefCell<R>,
|
||||
out: &'a std::cell::RefCell<W>,
|
||||
server: Option<std::rc::Rc<crate::hotseat::Server>>,
|
||||
tally: &mut MatchTally,
|
||||
) -> Result<(Summary, crate::hotseat::EndChoice), String> {
|
||||
let setup = Setup {
|
||||
players: config.players,
|
||||
|
|
@ -342,8 +404,14 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
|
|||
let mut end_choice = crate::hotseat::EndChoice::Closed;
|
||||
if let Some(srv) = &server {
|
||||
let ended = game.state.project(Viewer::Spectator);
|
||||
// Recorded BEFORE the page is served, so the ending page shows a
|
||||
// tally that includes the game being looked at. A tally that
|
||||
// lagged by one game would be worse than none.
|
||||
if let Some(o) = &ended.outcome {
|
||||
tally.record(o);
|
||||
}
|
||||
let msg = format!("{} commands, hash {}", game.commands, &end_hash[..12]);
|
||||
end_choice = srv.serve_end(Some(&ended), &msg, END_LINGER)?;
|
||||
end_choice = srv.serve_end(Some(&ended), &msg, END_LINGER, tally)?;
|
||||
}
|
||||
|
||||
let scenario = games_ground::record::to_scenario(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue