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
|
|
@ -255,6 +255,8 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf}
|
|||
styling alone would leave a dead control that still looks alive to
|
||||
anything reading the DOM. */
|
||||
.sealed{opacity:.3;cursor:default;pointer-events:none;filter:grayscale(1)}
|
||||
/* CB-WP-0024 T03: the card a seat played, in that seat's area. */
|
||||
.played{display:block;margin:.3rem 0}
|
||||
.k{color:#89a}
|
||||
.nil{color:#c88}
|
||||
.eff{color:#8c9}
|
||||
|
|
@ -358,8 +360,8 @@ fn pile_svg(out: &mut String, x: i32, label: &str, count: usize, face: &str, not
|
|||
);
|
||||
}
|
||||
for d in (0..depth).rev() {
|
||||
let dx = x + (d as i32) * 3;
|
||||
let dy = 14 - (d as i32) * 3;
|
||||
let dx = x + d * 3;
|
||||
let dy = 14 - d * 3;
|
||||
let _ = write!(
|
||||
out,
|
||||
"<rect x=\"{dx}\" y=\"{dy}\" width=\"76\" height=\"64\" rx=\"7\" fill=\"{face}\" \
|
||||
|
|
@ -518,6 +520,51 @@ fn selection_words(s: &games_ground::Selection) -> String {
|
|||
out
|
||||
}
|
||||
|
||||
/// The card a seat has played, as a card.
|
||||
///
|
||||
/// **The face-down back is a constant.** It takes no argument, because
|
||||
/// `SelectionView::Hidden` carries nothing and this function must not be
|
||||
/// able to leak what it does not receive. GR-R02/R04 hide another seat's
|
||||
/// choice until Reveal, and `view.rs`'s own test asserts the projection
|
||||
/// obeys that — but a renderer can leak what the model did not, by
|
||||
/// tinting the back with the suit or shaping it by the action. So there is
|
||||
/// exactly one back, with no data path into it.
|
||||
const CARD_BACK: &str = "<svg viewBox=\"0 0 84 56\" width=\"84\" height=\"56\" class=\"played\" \
|
||||
role=\"img\"><title>face down</title>\
|
||||
<rect x=\"2\" y=\"2\" width=\"80\" height=\"52\" rx=\"7\" fill=\"#2a2f3a\" stroke=\"#5a6b7a\"/>\
|
||||
<path d=\"M14 14 h56 M14 28 h56 M14 42 h56\" stroke=\"#3d4756\" stroke-width=\"3\"/></svg>";
|
||||
|
||||
fn played_svg(out: &mut String, sel: &SelectionView) {
|
||||
let s = match sel {
|
||||
SelectionView::Hidden => {
|
||||
out.push_str(CARD_BACK);
|
||||
return;
|
||||
}
|
||||
SelectionView::Shown(s) => s,
|
||||
};
|
||||
let mut sub = String::new();
|
||||
if let Some(t) = s.target {
|
||||
let _ = write!(sub, "\u{2192}{}", seat_name(t));
|
||||
}
|
||||
if let Some(n) = s.problem {
|
||||
if !sub.is_empty() {
|
||||
sub.push(' ');
|
||||
}
|
||||
let _ = write!(sub, "#{n}");
|
||||
}
|
||||
let _ = write!(
|
||||
out,
|
||||
"<svg viewBox=\"0 0 84 56\" width=\"84\" height=\"56\" class=\"played\" role=\"img\">\
|
||||
<title>played {label}</title>\
|
||||
<rect x=\"2\" y=\"2\" width=\"80\" height=\"52\" rx=\"7\" fill=\"#243\" stroke=\"#5a7\"/>\
|
||||
<text x=\"42\" y=\"27\" fill=\"#dfe\" font-size=\"12\" text-anchor=\"middle\">{label}</text>\
|
||||
<text x=\"42\" y=\"43\" fill=\"#9cb\" font-size=\"10\" text-anchor=\"middle\">{sub}</text>\
|
||||
</svg>",
|
||||
label = esc(&format!("{:?}", s.action)),
|
||||
sub = esc(&sub),
|
||||
);
|
||||
}
|
||||
|
||||
fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView) {
|
||||
let is_viewer = view.viewer == Some(id);
|
||||
let _ = write!(
|
||||
|
|
@ -582,6 +629,10 @@ fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView
|
|||
}
|
||||
}
|
||||
if let Some(sel) = view.selections.get(&id) {
|
||||
// The picture, then the words. CB-WP-0024 T03 adds the card; the
|
||||
// sentence stays, because the log is the record and a player
|
||||
// reading back through it needs the same vocabulary.
|
||||
played_svg(out, sel);
|
||||
let _ = write!(
|
||||
out,
|
||||
"<span class=\"k\">selected</span> {}<br>",
|
||||
|
|
@ -929,7 +980,13 @@ fn log_section(s: &mut String, log: &[LogLine]) {
|
|||
/// **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, log: &[LogLine]) -> String {
|
||||
pub fn ending(
|
||||
view: Option<&GroundView>,
|
||||
message: &str,
|
||||
endpoint: &str,
|
||||
log: &[LogLine],
|
||||
series: &[String],
|
||||
) -> String {
|
||||
let mut s = String::with_capacity(4096);
|
||||
let _ = write!(
|
||||
s,
|
||||
|
|
@ -948,6 +1005,19 @@ pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str, log: &[L
|
|||
);
|
||||
}
|
||||
}
|
||||
// CB-WP-0024 T04. Above the log, because it is a result and the log is
|
||||
// the account. Empty for a first game — one game is not a series, and
|
||||
// a "cumulative" panel restating the outcome above it is noise.
|
||||
if !series.is_empty() {
|
||||
s.push_str("<h2>this session</h2><div class=\"card\">");
|
||||
for (i, line) in series.iter().enumerate() {
|
||||
if i > 0 {
|
||||
s.push_str("<br>");
|
||||
}
|
||||
s.push_str(&esc(line));
|
||||
}
|
||||
s.push_str("</div>");
|
||||
}
|
||||
log_section(&mut s, log);
|
||||
let _ = write!(
|
||||
s,
|
||||
|
|
|
|||
|
|
@ -706,6 +706,106 @@ mod piles {
|
|||
}
|
||||
}
|
||||
|
||||
/// CB-WP-0024 T03 — what the other seats played, drawn as cards.
|
||||
///
|
||||
/// The maintainer could follow the other players only by reading the log.
|
||||
/// The data was already projected and already rendered — as sentences.
|
||||
/// This adds the picture without touching the hiding rule, which is the
|
||||
/// only part that could do harm.
|
||||
#[cfg(test)]
|
||||
mod played_cards {
|
||||
use cb_kernel::PlayerId;
|
||||
use games_ground::view::{GroundView, SelectionView};
|
||||
use games_ground::{Action, Selection};
|
||||
|
||||
use crate::doc::document;
|
||||
|
||||
fn html(v: &GroundView) -> String {
|
||||
document(v, &[], "/command?t=x", Some(PlayerId(0)), false)
|
||||
}
|
||||
|
||||
/// A seat's revealed play is drawn, not only written.
|
||||
#[test]
|
||||
fn a_revealed_play_is_drawn_as_a_card() {
|
||||
let mut v = crate::testfix::view(Some(PlayerId(0)));
|
||||
v.selections.insert(
|
||||
PlayerId(1),
|
||||
SelectionView::Shown(Selection {
|
||||
action: Action::Solve,
|
||||
target: None,
|
||||
problem: Some(3),
|
||||
}),
|
||||
);
|
||||
let doc = html(&v);
|
||||
assert!(
|
||||
doc.contains("played Solve"),
|
||||
"the revealed play was not drawn as a card"
|
||||
);
|
||||
assert!(
|
||||
crate::text_of(&doc).contains("selected Solve"),
|
||||
"the sentence must survive alongside the picture — the log is the record"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The leak test.** Nothing in the emitted document may vary with
|
||||
/// another seat's hidden selection.
|
||||
///
|
||||
/// The shape is `view.rs`'s own
|
||||
/// `a_seat_never_sees_another_seats_face_down_selection`: assert the
|
||||
/// absence, then assert the same view AFTER reveal shows it —
|
||||
/// otherwise the first assertion passes for a renderer that draws
|
||||
/// nothing at all.
|
||||
#[test]
|
||||
fn a_hidden_play_renders_identically_whatever_it_is() {
|
||||
let render_hidden = |action, problem| {
|
||||
let mut v = crate::testfix::view(Some(PlayerId(0)));
|
||||
// The seat HAS chosen; the viewer may not see what.
|
||||
v.selections.insert(PlayerId(1), SelectionView::Hidden);
|
||||
// A different real choice underneath, which must not reach us.
|
||||
v.selections.insert(
|
||||
PlayerId(2),
|
||||
SelectionView::Shown(Selection {
|
||||
action,
|
||||
target: None,
|
||||
problem,
|
||||
}),
|
||||
);
|
||||
html(&v)
|
||||
};
|
||||
|
||||
// Two different hidden situations must produce the same markup for
|
||||
// the hidden seat. Compare the card backs directly.
|
||||
let a = render_hidden(Action::Solve, Some(1));
|
||||
let b = render_hidden(Action::Attack, Some(9));
|
||||
let back = |h: &str| {
|
||||
let i = h
|
||||
.find("<title>face down</title>")
|
||||
.expect("a face-down card");
|
||||
h[i.saturating_sub(200)..i + 200].to_string()
|
||||
};
|
||||
assert_eq!(
|
||||
back(&a),
|
||||
back(&b),
|
||||
"the face-down card differed between two games — it varies with something"
|
||||
);
|
||||
|
||||
// The other half: revealed, the same renderer DOES show it.
|
||||
let mut shown = crate::testfix::view(Some(PlayerId(0)));
|
||||
shown.selections.insert(
|
||||
PlayerId(1),
|
||||
SelectionView::Shown(Selection {
|
||||
action: Action::Attack,
|
||||
target: Some(PlayerId(2)),
|
||||
problem: None,
|
||||
}),
|
||||
);
|
||||
assert!(
|
||||
html(&shown).contains("played Attack"),
|
||||
"the assertion above would pass for a renderer that draws nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// CB-WP-0024 T01 — the ending page's one control.
|
||||
///
|
||||
/// The maintainer reported it as *"the button says 'I need to read this'
|
||||
|
|
@ -718,7 +818,7 @@ mod ending_page {
|
|||
use crate::{doc, jsrun};
|
||||
|
||||
fn page() -> String {
|
||||
doc::ending(None, "the game ended", "/command?t=x", &[])
|
||||
doc::ending(None, "the game ended", "/command?t=x", &[], &[])
|
||||
}
|
||||
|
||||
/// The label must say what the control DOES. Asserted on the rendered
|
||||
|
|
@ -772,8 +872,8 @@ mod ending_page {
|
|||
#[test]
|
||||
fn a_dealing_reply_leaves_the_controls_alone() {
|
||||
let html = page();
|
||||
let (live, _) =
|
||||
jsrun::gesture_with_reply(&html, "again", "again", "ok: dealing").expect("run the page");
|
||||
let (live, _) = jsrun::gesture_with_reply(&html, "again", "again", "ok: dealing")
|
||||
.expect("run the page");
|
||||
assert!(
|
||||
live.contains(&"again".to_string()) && live.contains(&"done".to_string()),
|
||||
"an 'ok' reply must not seal the page: {live:?}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue