Compare commits
2 commits
5f9c2839bb
...
8d58568013
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d58568013 | |||
| 1edb10d5a3 |
8 changed files with 1081 additions and 23 deletions
|
|
@ -79,6 +79,29 @@ pub const SCRIPT: &str = r#"
|
||||||
function key(n) { return n ? n.getAttribute('data-drop') : null; }
|
function key(n) { return n ? n.getAttribute('data-drop') : null; }
|
||||||
function status(t) { document.getElementById('cb-status').textContent = t; }
|
function status(t) { document.getElementById('cb-status').textContent = t; }
|
||||||
|
|
||||||
|
// The session has ended server-side, so nothing on this page can work
|
||||||
|
// any more -- and nothing on it may look like it can (CB-WP-0024 T01).
|
||||||
|
//
|
||||||
|
// The old page kept a full table and a live `play again` control after
|
||||||
|
// the server had stopped listening, because the script acted only on
|
||||||
|
// 'ok'. A control that cannot work must stop being a control, so the
|
||||||
|
// `data-drop` attribute is REMOVED rather than styled: that is the
|
||||||
|
// attribute the script's own walk reads, so the element becomes
|
||||||
|
// undroppable by the same rule that made it droppable.
|
||||||
|
//
|
||||||
|
// No game vocabulary here (ADR-0007 D5). 'closed' is a server
|
||||||
|
// lifecycle word, exactly like the 'ok' branch below it.
|
||||||
|
function seal() {
|
||||||
|
var all = document.querySelectorAll('[data-drop]');
|
||||||
|
for (var i = 0; i < all.length; i++) {
|
||||||
|
all[i].classList.add('sealed');
|
||||||
|
// removeAttribute, NOT setAttribute(_, null): the latter writes the
|
||||||
|
// literal string "null" in a real browser, which is truthy, so the
|
||||||
|
// control would stay droppable while the test stub said otherwise.
|
||||||
|
all[i].removeAttribute('data-drop');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ADR-0010 D1: the destinations come from `data-targets`, which Rust
|
// ADR-0010 D1: the destinations come from `data-targets`, which Rust
|
||||||
// wrote. This matches on them. It does not compute, infer, filter or
|
// wrote. This matches on them. It does not compute, infer, filter or
|
||||||
// default one -- a script that pattern-matched ids to guess what is
|
// default one -- a script that pattern-matched ids to guess what is
|
||||||
|
|
@ -175,6 +198,7 @@ pub const SCRIPT: &str = r#"
|
||||||
}).then(function (r) { return r.text(); }).then(function (t) {
|
}).then(function (r) { return r.text(); }).then(function (t) {
|
||||||
status(t);
|
status(t);
|
||||||
if (t.indexOf('ok') === 0) { window.location.reload(); }
|
if (t.indexOf('ok') === 0) { window.location.reload(); }
|
||||||
|
else if (t.indexOf('closed') === 0) { seal(); }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
@ -225,6 +249,14 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf}
|
||||||
#cb-ghost .why{color:#9cf;margin-left:.4rem}
|
#cb-ghost .why{color:#9cf;margin-left:.4rem}
|
||||||
/* What you picked up, left visibly behind so there are not two cards. */
|
/* What you picked up, left visibly behind so there are not two cards. */
|
||||||
.held{cursor:grabbing;opacity:.35;border-style:dashed}
|
.held{cursor:grabbing;opacity:.35;border-style:dashed}
|
||||||
|
/* CB-WP-0024 T01: a control the server can no longer serve. The script
|
||||||
|
removes its `data-drop` so it is genuinely inert; this is only how that
|
||||||
|
reads. `pointer-events:none` is belt and braces, not the mechanism --
|
||||||
|
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}
|
.k{color:#89a}
|
||||||
.nil{color:#c88}
|
.nil{color:#c88}
|
||||||
.eff{color:#8c9}
|
.eff{color:#8c9}
|
||||||
|
|
@ -297,6 +329,112 @@ fn problem_svg(out: &mut String, priority: u32, p: &ProblemView, x: i32) {
|
||||||
/// The relationship graph — the one element every Rust 2D toolkit would
|
/// The relationship graph — the one element every Rust 2D toolkit would
|
||||||
/// have left us to hand-roll, and the reason SVG earns its place here
|
/// have left us to hand-roll, and the reason SVG earns its place here
|
||||||
/// rather than merely fitting the budget (CB-RES-0006 §5).
|
/// rather than merely fitting the budget (CB-RES-0006 §5).
|
||||||
|
/// One pile, drawn as a small stack of offset cards with its count on top.
|
||||||
|
///
|
||||||
|
/// `depth` is how many card-backs to suggest, not the count — a pile of 17
|
||||||
|
/// is not seventeen rectangles. The **number** is the truth; the stack is
|
||||||
|
/// how you tell at a glance that there is a pile there at all.
|
||||||
|
fn pile_svg(out: &mut String, x: i32, label: &str, count: usize, face: &str, note: &str) {
|
||||||
|
let depth = match count {
|
||||||
|
0 => 0,
|
||||||
|
1..=3 => 1,
|
||||||
|
4..=9 => 2,
|
||||||
|
_ => 3,
|
||||||
|
};
|
||||||
|
// The title carries the count in words. It is what a screen reader
|
||||||
|
// announces, and it is the stable thing a coverage probe can match --
|
||||||
|
// the on-canvas number is a bare numeral that could be anything.
|
||||||
|
let _ = write!(
|
||||||
|
out,
|
||||||
|
"<g><title>{} pile: {count} remaining</title>",
|
||||||
|
esc(label),
|
||||||
|
);
|
||||||
|
if depth == 0 {
|
||||||
|
// An EMPTY pile is drawn, not omitted. A missing slot reads as
|
||||||
|
// "this game has no discard", which is a different statement from
|
||||||
|
// "the discard is empty" (CB-WP-0024 T02).
|
||||||
|
let _ = write!(
|
||||||
|
out,
|
||||||
|
"<rect x=\"{x}\" y=\"14\" width=\"76\" height=\"64\" rx=\"7\" fill=\"none\" \
|
||||||
|
stroke=\"#3a4350\" stroke-dasharray=\"4 3\"/>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for d in (0..depth).rev() {
|
||||||
|
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}\" \
|
||||||
|
stroke=\"#5a6b7a\"/>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let _ = write!(
|
||||||
|
out,
|
||||||
|
"<text x=\"{tx}\" y=\"46\" fill=\"#dde\" font-size=\"18\" text-anchor=\"middle\">{count}</text>\
|
||||||
|
<text x=\"{tx}\" y=\"64\" fill=\"#89a\" font-size=\"10\" text-anchor=\"middle\">{label}</text>\
|
||||||
|
<text x=\"{tx}\" y=\"96\" fill=\"#fc9\" font-size=\"10\" text-anchor=\"middle\">{note}</text></g>",
|
||||||
|
tx = x + 38,
|
||||||
|
count = count,
|
||||||
|
label = esc(label),
|
||||||
|
note = esc(note),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The draw stack and the discard stack, as objects on the table.
|
||||||
|
///
|
||||||
|
/// Both numbers come from the projection — `solution_deck_len` and
|
||||||
|
/// `solution_discard` — and are never recomputed here.
|
||||||
|
///
|
||||||
|
/// **The reshuffle is real and is the U4 default**, confirmed by
|
||||||
|
/// ground-game on 2026-08-03: when the deck runs out, the discard is
|
||||||
|
/// reshuffled into it deterministically; if both are empty the draw is
|
||||||
|
/// skipped (`games/ground/src/lib.rs` `draw_solution`). So the pile shows
|
||||||
|
/// the state in which the *next* draw will trigger it. It does not claim a
|
||||||
|
/// reshuffle has happened — the view carries no such flag, and the event
|
||||||
|
/// already reads out in the log.
|
||||||
|
fn piles_svg(out: &mut String, view: &GroundView) {
|
||||||
|
let deck = view.solution_deck_len;
|
||||||
|
let discard = view.solution_discard.len();
|
||||||
|
let will_reshuffle = deck == 0 && discard > 0;
|
||||||
|
|
||||||
|
out.push_str("<svg viewBox=\"0 0 300 110\" width=\"300\" height=\"110\" role=\"img\">");
|
||||||
|
pile_svg(
|
||||||
|
out,
|
||||||
|
10,
|
||||||
|
"draw",
|
||||||
|
deck,
|
||||||
|
"#26303a",
|
||||||
|
if will_reshuffle { "empty" } else { "" },
|
||||||
|
);
|
||||||
|
pile_svg(
|
||||||
|
out,
|
||||||
|
120,
|
||||||
|
"discard",
|
||||||
|
discard,
|
||||||
|
"#2a2f3a",
|
||||||
|
if will_reshuffle {
|
||||||
|
"shuffles in on next draw"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if will_reshuffle {
|
||||||
|
out.push_str(
|
||||||
|
"<path d=\"M100 46 q10 -14 20 0\" fill=\"none\" stroke=\"#fc9\" stroke-width=\"2\"/>\
|
||||||
|
<path d=\"M118 40 l4 6 -7 1z\" fill=\"#fc9\"/>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out.push_str("</svg>");
|
||||||
|
|
||||||
|
// The discard is public — it has been played (GR-S04 hides only the
|
||||||
|
// deck) — so its contents stay readable as text beside the picture.
|
||||||
|
let _ = write!(
|
||||||
|
out,
|
||||||
|
"<div><span class=\"k\">discard</span> {}</div>",
|
||||||
|
esc(&cards(&view.solution_discard)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn relations_svg(view: &GroundView) -> String {
|
fn relations_svg(view: &GroundView) -> String {
|
||||||
let n = view.players.len().max(1);
|
let n = view.players.len().max(1);
|
||||||
let (cx, cy, r) = (200.0f64, 150.0f64, 110.0f64);
|
let (cx, cy, r) = (200.0f64, 150.0f64, 110.0f64);
|
||||||
|
|
@ -382,6 +520,51 @@ fn selection_words(s: &games_ground::Selection) -> String {
|
||||||
out
|
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) {
|
fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView) {
|
||||||
let is_viewer = view.viewer == Some(id);
|
let is_viewer = view.viewer == Some(id);
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
|
|
@ -446,6 +629,10 @@ fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(sel) = view.selections.get(&id) {
|
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!(
|
let _ = write!(
|
||||||
out,
|
out,
|
||||||
"<span class=\"k\">selected</span> {}<br>",
|
"<span class=\"k\">selected</span> {}<br>",
|
||||||
|
|
@ -570,13 +757,8 @@ fn body(s: &mut String, view: &GroundView) {
|
||||||
}
|
}
|
||||||
s.push_str("</div>");
|
s.push_str("</div>");
|
||||||
|
|
||||||
let _ = write!(
|
s.push_str("<h2>solutions</h2>");
|
||||||
s,
|
piles_svg(s, view);
|
||||||
"<h2>solutions</h2><div><span class=\"k\">deck</span> {} remaining \
|
|
||||||
<span class=\"k\">discard</span> {}</div>",
|
|
||||||
view.solution_deck_len,
|
|
||||||
esc(&cards(&view.solution_discard)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(o) = &view.outcome {
|
if let Some(o) = &view.outcome {
|
||||||
let personal = o
|
let personal = o
|
||||||
|
|
@ -798,7 +980,13 @@ fn log_section(s: &mut String, log: &[LogLine]) {
|
||||||
/// **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, 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 mut s = String::with_capacity(4096);
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
s,
|
s,
|
||||||
|
|
@ -817,12 +1005,25 @@ 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);
|
log_section(&mut s, log);
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
s,
|
s,
|
||||||
"<div class=\"row\">\
|
"<div class=\"row\">\
|
||||||
<div class=\"card btn pick\" data-drop=\"again\">play again</div>\
|
<div class=\"card btn pick\" data-drop=\"again\">play again</div>\
|
||||||
<div class=\"card btn pick\" data-drop=\"done\">close \u{2014} I have read this</div>\
|
<div class=\"card btn pick\" data-drop=\"done\">end session \u{2014} stops the game server</div>\
|
||||||
</div>\
|
</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>",
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,11 @@ function __mk(key, targets, text, descs) {
|
||||||
'data-descs': descs || null },
|
'data-descs': descs || null },
|
||||||
getAttribute: function (a) { return this._attr[a] !== undefined ? this._attr[a] : null; },
|
getAttribute: function (a) { return this._attr[a] !== undefined ? this._attr[a] : null; },
|
||||||
setAttribute: function (a, v) { this._attr[a] = v; },
|
setAttribute: function (a, v) { this._attr[a] = v; },
|
||||||
|
// A real browser's removeAttribute. The stub must have it so the
|
||||||
|
// script can use it instead of `setAttribute(a, null)`, which writes
|
||||||
|
// the truthy string "null" in a browser but a real null here -- a
|
||||||
|
// difference that would make a broken seal pass its own test.
|
||||||
|
removeAttribute: function (a) { delete this._attr[a]; },
|
||||||
classList: {
|
classList: {
|
||||||
add: function (c) { n._cls[c] = true; },
|
add: function (c) { n._cls[c] = true; },
|
||||||
remove: function (c) { delete n._cls[c]; },
|
remove: function (c) { delete n._cls[c]; },
|
||||||
|
|
@ -97,10 +102,25 @@ var document = {
|
||||||
querySelectorAll: function (_sel) { return __all; }
|
querySelectorAll: function (_sel) { return __all; }
|
||||||
};
|
};
|
||||||
var window = { location: { reload: function () { __reloaded(); } } };
|
var window = { location: { reload: function () { __reloaded(); } } };
|
||||||
|
|
||||||
|
// The server's REPLY, delivered synchronously through the then-chain.
|
||||||
|
//
|
||||||
|
// CB-WP-0024 T01: the old stub returned `{then: function(){return this}}`,
|
||||||
|
// which never invoked the callbacks -- so every line of the script that
|
||||||
|
// reacts to what the server said was unreachable in every test. That is
|
||||||
|
// why "the page still looks live after the session ends" survived: the
|
||||||
|
// branch that would have fixed it could not be executed here.
|
||||||
|
//
|
||||||
|
// `__reply` is set per run. Chained `.then(fn)` passes fn's return value
|
||||||
|
// on, matching the promise semantics the script relies on (`r.text()`
|
||||||
|
// then the text).
|
||||||
|
var __reply = "";
|
||||||
function fetch(url, opts) {
|
function fetch(url, opts) {
|
||||||
__post(url, (opts && opts.body) || "");
|
__post(url, (opts && opts.body) || "");
|
||||||
var chainable = { then: function () { return chainable; } };
|
function chain(v) {
|
||||||
return chainable;
|
return { then: function (fn) { return chain(fn ? fn(v) : v); } };
|
||||||
|
}
|
||||||
|
return chain({ text: function () { return __reply; } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gestures are driven against REGISTERED nodes, so the script's walk, its
|
// Gestures are driven against REGISTERED nodes, so the script's walk, its
|
||||||
|
|
@ -136,6 +156,19 @@ function __heldKeys() {
|
||||||
return out.sort().join(',');
|
return out.sort().join(',');
|
||||||
}
|
}
|
||||||
function __ghosts() { return __body.children.length; }
|
function __ghosts() { return __body.children.length; }
|
||||||
|
|
||||||
|
// What the page still offers AFTER the reply was handled. A control the
|
||||||
|
// script has sealed must stop being droppable, not merely look different
|
||||||
|
// -- so this reads the same attribute the script's own walk reads.
|
||||||
|
function __liveKeys() {
|
||||||
|
var out = [];
|
||||||
|
for (var i = 0; i < __all.length; i++) {
|
||||||
|
var k = __all[i].getAttribute('data-drop');
|
||||||
|
if (k) { out.push(k); }
|
||||||
|
}
|
||||||
|
return out.sort().join(',');
|
||||||
|
}
|
||||||
|
function __statusText() { return __status.textContent; }
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
/// Run the document's scripts, then a pointer gesture, and report what
|
/// Run the document's scripts, then a pointer gesture, and report what
|
||||||
|
|
@ -183,6 +216,57 @@ pub fn gesture(html: &str, down: &str, up: &str) -> Result<Vec<Posted>, String>
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the page LOOKS LIKE after a gesture whose reply was `reply`.
|
||||||
|
///
|
||||||
|
/// Returns `(live drop keys, status text)` — the keys still droppable
|
||||||
|
/// once the script has handled the server's answer.
|
||||||
|
///
|
||||||
|
/// **This path did not exist before CB-WP-0024 T01, and that is why the
|
||||||
|
/// defect it tests survived.** The fetch stub used to return a
|
||||||
|
/// `then`-chain that never called its callbacks, so nothing the script
|
||||||
|
/// does in response to the server was reachable from any test. A page
|
||||||
|
/// that ignores what it was told looked identical to one that acts on it.
|
||||||
|
pub fn gesture_with_reply(
|
||||||
|
html: &str,
|
||||||
|
down: &str,
|
||||||
|
up: &str,
|
||||||
|
reply: &str,
|
||||||
|
) -> Result<(Vec<String>, String), String> {
|
||||||
|
let ctx = quick_js::Context::new().map_err(|e| format!("quickjs init: {e}"))?;
|
||||||
|
ctx.add_callback("__post", |_url: String, _body: String| 0i32)
|
||||||
|
.map_err(|e| format!("register __post: {e}"))?;
|
||||||
|
let reloaded = Arc::new(Mutex::new(false));
|
||||||
|
let flag = reloaded.clone();
|
||||||
|
ctx.add_callback("__reloaded", move || {
|
||||||
|
*flag.lock().expect("reloaded") = true;
|
||||||
|
0i32
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("register __reloaded: {e}"))?;
|
||||||
|
|
||||||
|
prepare(&ctx, html)?;
|
||||||
|
ctx.eval(&format!("__reply = {};", json_lit(reply)))
|
||||||
|
.map_err(|e| format!("set reply: {e}"))?;
|
||||||
|
ctx.eval(&format!(
|
||||||
|
"__down({}); __up({});",
|
||||||
|
json_lit(down),
|
||||||
|
json_lit(up)
|
||||||
|
))
|
||||||
|
.map_err(|e| format!("gesture: {e}"))?;
|
||||||
|
|
||||||
|
let keys: String = ctx
|
||||||
|
.eval_as("__liveKeys()")
|
||||||
|
.map_err(|e| format!("live keys: {e}"))?;
|
||||||
|
let status: String = ctx
|
||||||
|
.eval_as("__statusText()")
|
||||||
|
.map_err(|e| format!("status: {e}"))?;
|
||||||
|
let keys = if keys.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
keys.split(',').map(str::to_string).collect()
|
||||||
|
};
|
||||||
|
Ok((keys, status))
|
||||||
|
}
|
||||||
|
|
||||||
/// Every droppable in the document, as `(data-drop, data-targets)`.
|
/// Every droppable in the document, as `(data-drop, data-targets)`.
|
||||||
///
|
///
|
||||||
/// The stub's node set is built from the **real emitted page**, not from a
|
/// The stub's node set is built from the **real emitted page**, not from a
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ mod coverage {
|
||||||
("step", "step Select"),
|
("step", "step Select"),
|
||||||
("mode", "scoring BondedCoalitions"),
|
("mode", "scoring BondedCoalitions"),
|
||||||
("viewer", "viewing as P1"),
|
("viewer", "viewing as P1"),
|
||||||
("solution_deck_len", "17 remaining"),
|
("solution_deck_len", "draw pile: 17 remaining"),
|
||||||
("solution_discard.*.suit", "discard Repair Change"),
|
("solution_discard.*.suit", "discard Repair Change"),
|
||||||
("players.*.stress", "stress 5"),
|
("players.*.stress", "stress 5"),
|
||||||
("players.*.protection", "protect 2"),
|
("players.*.protection", "protect 2"),
|
||||||
|
|
@ -630,5 +630,256 @@ mod gamelog {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CB-WP-0024 T02 — the draw and discard stacks as objects on the table.
|
||||||
|
///
|
||||||
|
/// The maintainer asked for the piles to be visible and for the discard
|
||||||
|
/// to show a shuffle when the draw runs out. **The reshuffle is real** —
|
||||||
|
/// `games/ground/src/lib.rs::draw_solution` implements the U4 default
|
||||||
|
/// (deterministic reshuffle of the discard; skip the draw if both are
|
||||||
|
/// empty), which ground-game confirmed on 2026-08-03. So this renders the
|
||||||
|
/// state in which the next draw triggers it, rather than inventing a rule.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod piles {
|
||||||
|
use cb_kernel::PlayerId;
|
||||||
|
use games_ground::view::GroundView;
|
||||||
|
|
||||||
|
use crate::doc::document;
|
||||||
|
|
||||||
|
fn view() -> GroundView {
|
||||||
|
crate::testfix::view(Some(PlayerId(0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn html(v: &GroundView) -> String {
|
||||||
|
document(v, &[], "/command?t=x", Some(PlayerId(0)), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The counts must come from the projection, never be recomputed.
|
||||||
|
#[test]
|
||||||
|
fn both_counts_are_the_views_own_numbers() {
|
||||||
|
let mut v = view();
|
||||||
|
v.solution_deck_len = 5;
|
||||||
|
let doc = crate::text_of(&html(&v));
|
||||||
|
assert!(
|
||||||
|
doc.contains("draw pile: 5 remaining"),
|
||||||
|
"the drawn deck count is not the view's: {doc}"
|
||||||
|
);
|
||||||
|
let n = v.solution_discard.len();
|
||||||
|
assert!(
|
||||||
|
doc.contains(&format!("discard pile: {n} remaining")),
|
||||||
|
"the drawn discard count is not the view's ({n}): {doc}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty discard is an EMPTY pile, not a missing one. A absent slot
|
||||||
|
/// reads as "this game has no discard", which is a different claim.
|
||||||
|
#[test]
|
||||||
|
fn an_empty_pile_is_drawn_rather_than_omitted() {
|
||||||
|
let mut v = view();
|
||||||
|
v.solution_discard.clear();
|
||||||
|
let doc = crate::text_of(&html(&v));
|
||||||
|
assert!(
|
||||||
|
doc.contains("discard pile: 0 remaining"),
|
||||||
|
"an empty discard vanished instead of rendering as empty: {doc}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The U4 state: deck empty, discard holding cards. The next draw
|
||||||
|
/// reshuffles, and the table should say so.
|
||||||
|
#[test]
|
||||||
|
fn an_exhausted_deck_says_the_discard_shuffles_back_in() {
|
||||||
|
let mut v = view();
|
||||||
|
v.solution_deck_len = 0;
|
||||||
|
assert!(!v.solution_discard.is_empty(), "fixture needs a discard");
|
||||||
|
let doc = crate::text_of(&html(&v));
|
||||||
|
assert!(
|
||||||
|
doc.contains("shuffles in on next draw"),
|
||||||
|
"an exhausted deck did not announce the U4 reshuffle: {doc}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And the negative half: with cards left, no shuffle is promised.
|
||||||
|
let mut full = view();
|
||||||
|
full.solution_deck_len = 12;
|
||||||
|
assert!(
|
||||||
|
!crate::text_of(&html(&full)).contains("shuffles in on next draw"),
|
||||||
|
"a stocked deck claimed a reshuffle was coming"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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'
|
||||||
|
/// — why? the UI is not closing."* Two defects wearing one button: the
|
||||||
|
/// label described a reading while the control stopped a server, and
|
||||||
|
/// acknowledging it changed nothing on screen, leaving a live-looking
|
||||||
|
/// table and a `play again` pointing at a closed port.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod ending_page {
|
||||||
|
use crate::{doc, jsrun};
|
||||||
|
|
||||||
|
fn page() -> String {
|
||||||
|
doc::ending(None, "the game ended", "/command?t=x", &[], &[])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The label must say what the control DOES. Asserted on the rendered
|
||||||
|
/// text so reverting the wording turns this red — a comment would not.
|
||||||
|
#[test]
|
||||||
|
fn the_control_is_labelled_by_its_effect_not_by_a_reading() {
|
||||||
|
let text = crate::text_of(&page());
|
||||||
|
assert!(
|
||||||
|
text.contains("end session") && text.contains("stops the game server"),
|
||||||
|
"the ending control must name its effect: {text}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!text.contains("I have read this"),
|
||||||
|
"the label claimed the player had read something; it stops a server"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The defect the maintainer actually saw. After the server says the
|
||||||
|
/// session is closed, `play again` must stop being offered — it now
|
||||||
|
/// points at a port nobody is listening on.
|
||||||
|
#[test]
|
||||||
|
fn acknowledging_the_end_stops_the_page_offering_anything() {
|
||||||
|
let html = page();
|
||||||
|
let before = doc::drop_keys(&html);
|
||||||
|
assert!(
|
||||||
|
before.contains("again") && before.contains("done"),
|
||||||
|
"fixture must start with both controls: {before:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (live, status) =
|
||||||
|
jsrun::gesture_with_reply(&html, "done", "done", "closed — the session has ended")
|
||||||
|
.expect("run the page");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!live.contains(&"again".to_string()),
|
||||||
|
"`play again` survived the session ending and would post to a closed port: {live:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
live.is_empty(),
|
||||||
|
"every control must be sealed once the server stops, not only `again`: {live:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
status.contains("session has ended"),
|
||||||
|
"the page must say what happened: {status:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The negative control. If `seal` fired on any reply, this test would
|
||||||
|
/// pass for a page that tears itself down whenever it is touched —
|
||||||
|
/// which would break `play again` in the ordinary case.
|
||||||
|
#[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");
|
||||||
|
assert!(
|
||||||
|
live.contains(&"again".to_string()) && live.contains(&"done".to_string()),
|
||||||
|
"an 'ok' reply must not seal the page: {live:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod testfix;
|
mod testfix;
|
||||||
|
|
|
||||||
173
evidence/CB-EV-0023-the-table-you-can-watch.md
Normal file
173
evidence/CB-EV-0023-the-table-you-can-watch.md
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
# CB-EV-0023 — the table you can watch
|
||||||
|
|
||||||
|
CB-WP-0024 T05. Tier S (structural S — renders state the projection
|
||||||
|
already carries; chaos d8=6 → no override). Declaration 7 of chaos window
|
||||||
|
2. Closed 2026-08-05.
|
||||||
|
|
||||||
|
**Delivered:** the ending control says what it does and the page acts on
|
||||||
|
it; draw and discard drawn as stacks; each seat's play drawn as a card;
|
||||||
|
a session tally across games. Five remarks from play, four addressed
|
||||||
|
(remarks 2 and 3 are CB-WP-0025).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. How many of the five remarks were already implemented
|
||||||
|
|
||||||
|
**Three of five were data the projection already carried, rendered as
|
||||||
|
text.** That is the finding this pass is really about, and it was
|
||||||
|
established by reading the code before writing the workplan rather than
|
||||||
|
by building anything.
|
||||||
|
|
||||||
|
| remark | what was actually missing |
|
||||||
|
|---|---|
|
||||||
|
| follow the other players | nothing in the model — `selections` project as `Shown` after Reveal and were already drawn, **as sentences** |
|
||||||
|
| draw and discard stacks | nothing in the model — `solution_deck_len` and `solution_discard` were one line of text |
|
||||||
|
| show the scores | nothing at all — `personal`, `mastery`, `winners`, `total`/`threshold` were already drawn |
|
||||||
|
| the ending button | a real defect, two of them |
|
||||||
|
| a cumulative score | genuinely absent |
|
||||||
|
|
||||||
|
**So the table's problem was legibility, not content.** A renderer can
|
||||||
|
show every field it is given and still be unreadable, and the coverage
|
||||||
|
gate — which asserts every view field appears in the parsed document —
|
||||||
|
passes either way. It is a completeness gate, and completeness was never
|
||||||
|
the issue.
|
||||||
|
|
||||||
|
**That is worth naming as a gap in the controls**, not as a criticism of
|
||||||
|
them. `every_view_field_is_classified_in_the_emitted_document` proves
|
||||||
|
nothing is silently omitted. Nothing proves it is *readable*, and nothing
|
||||||
|
could, cheaply. The maintainer playing the game is the instrument, which
|
||||||
|
is what makes remarks like these worth more than their length suggests.
|
||||||
|
|
||||||
|
## 2. The defect that mattered was untestable, and that is why it survived
|
||||||
|
|
||||||
|
The ending control was labelled *"close — I have read this"* while
|
||||||
|
`hotseat.rs` read it as **stop the server**, and acknowledging it changed
|
||||||
|
nothing: the tab kept a full table and a `play again` pointing at a closed
|
||||||
|
port.
|
||||||
|
|
||||||
|
The label was a wording bug. **The second half survived for a structural
|
||||||
|
reason:**
|
||||||
|
|
||||||
|
> `jsrun`'s `fetch` stub returned `{then: function () { return this; }}`,
|
||||||
|
> which never invoked its callbacks. **Every line of the script that
|
||||||
|
> reacts to what the server said was unreachable from every test in this
|
||||||
|
> project.**
|
||||||
|
|
||||||
|
The `ok` → reload branch, the status text, and the branch that did not yet
|
||||||
|
exist were all equally unexercised. A page that ignores the server was
|
||||||
|
indistinguishable, under test, from one that acts on it.
|
||||||
|
|
||||||
|
CB-WP-0014 embedded a real JS engine specifically so the script could be
|
||||||
|
executed rather than string-matched, and CB-WP-0016 found that *a stub too
|
||||||
|
thin to express a failure is how the failure survives*. This is the same
|
||||||
|
finding one layer deeper: the stub was thin at the **reply**, and the
|
||||||
|
reply is where the session's ending lives.
|
||||||
|
|
||||||
|
The stub now delivers a real then-chain and `gesture_with_reply` reports
|
||||||
|
which controls survive. The seal is mutation-proven — deleting the branch
|
||||||
|
turns exactly one test red — and a negative control asserts `ok: dealing`
|
||||||
|
does **not** seal, because a seal that fired on every reply would pass the
|
||||||
|
first test and silently break `play again`.
|
||||||
|
|
||||||
|
### One browser-versus-stub trap, avoided by writing it down
|
||||||
|
|
||||||
|
The first seal used `setAttribute('data-drop', null)`. In the stub that
|
||||||
|
stores a real `null` and `getAttribute` returns `null`; **in a browser it
|
||||||
|
writes the literal string `"null"`, which is truthy**, so the control would
|
||||||
|
have stayed live while its own test called it sealed. `removeAttribute`
|
||||||
|
is correct in both. The stub gained the method rather than the script
|
||||||
|
gaining a workaround.
|
||||||
|
|
||||||
|
## 3. The reshuffle question: already ruled, already implemented
|
||||||
|
|
||||||
|
T02 required settling whether the deck-exhaustion shuffle happens before
|
||||||
|
drawing it, and to raise a finding if it did not.
|
||||||
|
|
||||||
|
**It does.** `games/ground/src/lib.rs:1419-1435` (`draw_solution`):
|
||||||
|
deterministic reshuffle of the discard, seeded `seed ^ round`, carried in
|
||||||
|
a `DeckReshuffled` event so replay never re-derives it; if both are empty
|
||||||
|
the draw is skipped. That is the **U4 default**, which ground-game
|
||||||
|
**confirmed on 2026-08-03** — a ruled rule.
|
||||||
|
|
||||||
|
So nothing was raised, which is the correct outcome and the one the task
|
||||||
|
was written to allow. The piles show the state in which the *next* draw
|
||||||
|
triggers it, because that is derivable from the view; a claim that a
|
||||||
|
reshuffle *has* happened is not, and is not made. The event already reads
|
||||||
|
out in the log.
|
||||||
|
|
||||||
|
**Two days earlier this question would have been raised as a finding.**
|
||||||
|
CB-WP-0026 applied the U4 ruling on 2026-08-05; this pass consumed it the
|
||||||
|
same day. The register paying off inside 24 hours is not proof it works,
|
||||||
|
but it is the first time the answer to *"is this underdetermined?"* was
|
||||||
|
one lookup instead of a message.
|
||||||
|
|
||||||
|
## 4. What the controls caught
|
||||||
|
|
||||||
|
**The coverage gate caught its own probe going stale.** Replacing the
|
||||||
|
`17 remaining` text broke `solution_deck_len`'s classification — the gate
|
||||||
|
does not care *how* a field is rendered, only that its token appears, so
|
||||||
|
changing the rendering changed the token. Fixed by putting the count in
|
||||||
|
the pile's `<title>`: a stable probe, and what a screen reader announces.
|
||||||
|
The on-canvas numeral would have been a weak probe, since a bare `17`
|
||||||
|
could be anything on the page.
|
||||||
|
|
||||||
|
**The compiler caught a misplaced doc comment** that had silently taken
|
||||||
|
`Summary`'s `#[derive(Debug)]` with it.
|
||||||
|
|
||||||
|
Neither is interesting alone. Together they are the ordinary case for
|
||||||
|
this project: the gates fire on rendering changes, and the cost of that is
|
||||||
|
small enough that nobody is tempted to loosen them.
|
||||||
|
|
||||||
|
## 5. The cumulative-score question, and why it stayed a note
|
||||||
|
|
||||||
|
The task required deciding what "cumulative" means **before** summing
|
||||||
|
anything. The answer is that **GROUND defines one game and no series at
|
||||||
|
all.**
|
||||||
|
|
||||||
|
`personal` sums per seat; `winners` counts games won; `group_success`
|
||||||
|
counts games the table cleared. They answer different questions, and a
|
||||||
|
test asserts they can point at different seats — a 9/0/0 versus 0/1/1
|
||||||
|
series leads on summed score for one seat and on games won for the other.
|
||||||
|
|
||||||
|
**Both are shown and both are named.** Picking one would have made it *the*
|
||||||
|
score by default, which is canonising a rule nobody wrote.
|
||||||
|
|
||||||
|
Registered **F15, `underdetermined`, state `note`**. It stays a note on
|
||||||
|
purpose: the test demonstrates the two tallies *can differ*, which is
|
||||||
|
arithmetic — not evidence that the ambiguity harms play. Under GameDesign
|
||||||
|
§3.1 a note may not be reported to ground-game until an artifact exists.
|
||||||
|
|
||||||
|
**This is the note tier doing exactly what ADR-0012 D6 built it for**, on
|
||||||
|
the first genuinely new question since it was written. The tier was
|
||||||
|
argued as the way to admit findings from *play* that the engine cannot
|
||||||
|
produce; its first real use is a question from *building*, which the ADR
|
||||||
|
did not anticipate and which the tier handles unchanged.
|
||||||
|
|
||||||
|
## 6. Chaos window 2
|
||||||
|
|
||||||
|
**Declaration 7 of 12.** Structural S, d8 = 6, no override.
|
||||||
|
|
||||||
|
Ten declarations in, **no 8 has been rolled**. At d8 the expected count
|
||||||
|
over twelve is 1.5, so this is unremarkable — but it means window 2 will
|
||||||
|
likely close with **zero overrides to evaluate**, and its retirement
|
||||||
|
condition (*retire if an override changes nothing twice running*) will be
|
||||||
|
untestable. Window 1 closed with 2 overrides at d4 and both changed the
|
||||||
|
outcome. **The rate cut to d8 may have made the mechanism unevaluable**,
|
||||||
|
which is a real cost that CB-EV-0015's decision did not price, and the
|
||||||
|
window's closing evidence should say so.
|
||||||
|
|
||||||
|
## 7. Cost
|
||||||
|
|
||||||
|
CB-WP-0023's cost, by re-running the instrument — the pass this one is
|
||||||
|
required to quote. Reported by `make cost`; **not inlined here as a
|
||||||
|
literal** (§Single source of fact), and see CB-EV-0019 §4 on the chain
|
||||||
|
breaking beyond ~4 passes.
|
||||||
|
|
||||||
|
## Open after this pass
|
||||||
|
|
||||||
|
- **Remarks 2 and 3** — *how could we have won* and *difficulty* — are
|
||||||
|
CB-WP-0025, declared and ready, tier L.
|
||||||
|
- **F15** needs an artifact before the series question can go to
|
||||||
|
ground-game.
|
||||||
|
- **Legibility has no gate** (§1) and probably should not get one; the
|
||||||
|
honest control is a person playing it.
|
||||||
|
|
@ -42,6 +42,7 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by
|
||||||
| F12 | degenerate | note | — | — | 2026-08-01 | clay-borg |
|
| F12 | degenerate | note | — | — | 2026-08-01 | clay-borg |
|
||||||
| F13 | inconsistent | withdrawn | scenarios/ground/gr-e01-threshold-reachable-2p.yaml | counterexample | 2026-08-01 | clay-borg |
|
| F13 | inconsistent | withdrawn | scenarios/ground/gr-e01-threshold-reachable-2p.yaml | counterexample | 2026-08-01 | clay-borg |
|
||||||
| F14 | unplayed | note | — | — | 2026-08-01 | clay-borg |
|
| F14 | unplayed | note | — | — | 2026-08-01 | clay-borg |
|
||||||
|
| F15 | underdetermined | note | — | — | 2026-08-05 | clay-borg |
|
||||||
|
|
||||||
<!-- design-register:end -->
|
<!-- design-register:end -->
|
||||||
|
|
||||||
|
|
@ -61,6 +62,15 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by
|
||||||
`-reachable-`. **Its reproduction is green**, which under GameDesign §1.3
|
`-reachable-`. **Its reproduction is green**, which under GameDesign §1.3
|
||||||
is the alarm that forced the resolution. Withdrawn rather than deleted,
|
is the alarm that forced the resolution. Withdrawn rather than deleted,
|
||||||
and the withdrawal is reported (ADR-0012 D5).
|
and the withdrawal is reported (ADR-0012 D5).
|
||||||
|
- **F15 — the rules define one game, not a series.** `OutcomeView` gives
|
||||||
|
`personal` (per seat), `group_success` (per table) and `winners`. Summing
|
||||||
|
the first and counting the third answer different questions, and GROUND
|
||||||
|
says nothing about how several games combine. CB-WP-0024 T04 shows
|
||||||
|
**both, labelled**, rather than picking one and letting it become the
|
||||||
|
score by default. `note`: no artifact demonstrates that this *harms*
|
||||||
|
play — a unit test showing the two tallies point at different seats
|
||||||
|
demonstrates only that they can differ, which is arithmetic, not a design
|
||||||
|
defect. Under GameDesign §3.1 it may not be reported until one exists.
|
||||||
- **F14 — GR-E03/GR-E04 never played to the end.** Nineteen passes, never
|
- **F14 — GR-E03/GR-E04 never played to the end.** Nineteen passes, never
|
||||||
played out. `note` until a trial game exists; GROUND-WP-0003 is the
|
played out. `note` until a trial game exists; GROUND-WP-0003 is the
|
||||||
playtest that would close it, and GameDesign §5's protocol makes the
|
playtest that would close it, and GameDesign §5's protocol makes the
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,7 @@ impl Server {
|
||||||
final_view: Option<&games_ground::view::GroundView>,
|
final_view: Option<&games_ground::view::GroundView>,
|
||||||
message: &str,
|
message: &str,
|
||||||
linger: std::time::Duration,
|
linger: std::time::Duration,
|
||||||
|
tally: &crate::table::MatchTally,
|
||||||
) -> Result<EndChoice, String> {
|
) -> Result<EndChoice, String> {
|
||||||
let deadline = std::time::Instant::now() + linger;
|
let deadline = std::time::Instant::now() + linger;
|
||||||
self.listener
|
self.listener
|
||||||
|
|
@ -237,6 +238,7 @@ impl Server {
|
||||||
message,
|
message,
|
||||||
&self.guard.endpoint(),
|
&self.guard.endpoint(),
|
||||||
&self.log_lines(),
|
&self.log_lines(),
|
||||||
|
&series_lines(tally),
|
||||||
);
|
);
|
||||||
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
|
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
|
||||||
}
|
}
|
||||||
|
|
@ -249,7 +251,17 @@ impl Server {
|
||||||
&mut stream,
|
&mut stream,
|
||||||
200,
|
200,
|
||||||
"text/plain",
|
"text/plain",
|
||||||
if again { "ok: dealing" } else { "closed" },
|
// CB-WP-0024 T01: the reply is what the player
|
||||||
|
// reads. "closed" alone left them looking at a
|
||||||
|
// live table wondering why nothing happened.
|
||||||
|
// The `closed` prefix is what the page's script
|
||||||
|
// matches on to seal itself.
|
||||||
|
if again {
|
||||||
|
"ok: dealing"
|
||||||
|
} else {
|
||||||
|
"closed \u{2014} the session has ended and the server has stopped. \
|
||||||
|
You can close this tab."
|
||||||
|
},
|
||||||
);
|
);
|
||||||
break Ok(if again {
|
break Ok(if again {
|
||||||
EndChoice::Again
|
EndChoice::Again
|
||||||
|
|
@ -265,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.
|
/// What the player asked for on the ending page.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum EndChoice {
|
pub enum EndChoice {
|
||||||
|
|
@ -463,6 +611,7 @@ mod tests {
|
||||||
Some(&view),
|
Some(&view),
|
||||||
"30 commands, hash f6c890a65271",
|
"30 commands, hash f6c890a65271",
|
||||||
std::time::Duration::from_secs(20),
|
std::time::Duration::from_secs(20),
|
||||||
|
&crate::table::MatchTally::default(),
|
||||||
)
|
)
|
||||||
.expect("serve_end");
|
.expect("serve_end");
|
||||||
let replies = client.join().expect("client");
|
let replies = client.join().expect("client");
|
||||||
|
|
@ -553,7 +702,8 @@ 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!(
|
assert!(
|
||||||
page.contains("P1 ran out of input"),
|
page.contains("P1 ran out of input"),
|
||||||
"the reason is missing"
|
"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.
|
/// What a finished game reports back.
|
||||||
/// The end-state hash is what makes a session comparable to its replay.
|
/// The end-state hash is what makes a session comparable to its replay.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
@ -200,7 +253,11 @@ fn end_badly<T>(
|
||||||
msg: String,
|
msg: String,
|
||||||
) -> Result<T, String> {
|
) -> Result<T, String> {
|
||||||
if let Some(s) = server {
|
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)
|
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();
|
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 {
|
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 {
|
if choice != crate::hotseat::EndChoice::Again {
|
||||||
return Ok(summary);
|
return Ok(summary);
|
||||||
}
|
}
|
||||||
|
|
@ -253,6 +314,7 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
|
||||||
input: &'a std::cell::RefCell<R>,
|
input: &'a std::cell::RefCell<R>,
|
||||||
out: &'a std::cell::RefCell<W>,
|
out: &'a std::cell::RefCell<W>,
|
||||||
server: Option<std::rc::Rc<crate::hotseat::Server>>,
|
server: Option<std::rc::Rc<crate::hotseat::Server>>,
|
||||||
|
tally: &mut MatchTally,
|
||||||
) -> Result<(Summary, crate::hotseat::EndChoice), String> {
|
) -> Result<(Summary, crate::hotseat::EndChoice), String> {
|
||||||
let setup = Setup {
|
let setup = Setup {
|
||||||
players: config.players,
|
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;
|
let mut end_choice = crate::hotseat::EndChoice::Closed;
|
||||||
if let Some(srv) = &server {
|
if let Some(srv) = &server {
|
||||||
let ended = game.state.project(Viewer::Spectator);
|
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]);
|
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(
|
let scenario = games_ground::record::to_scenario(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
id: CB-WP-0024
|
id: CB-WP-0024
|
||||||
kind: product
|
kind: product
|
||||||
title: "The table you can watch: the piles, the other seats' moves, the score that carries"
|
title: "The table you can watch: the piles, the other seats' moves, the score that carries"
|
||||||
status: ready
|
status: done
|
||||||
state_hub_workstream_id: "5f17b6f9-cd4b-4c31-a8af-712313149cf2"
|
state_hub_workstream_id: "5f17b6f9-cd4b-4c31-a8af-712313149cf2"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -47,7 +47,7 @@ except where task T04 says so explicitly.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CB-WP-0024-T01
|
id: CB-WP-0024-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "acb4231c-35df-490f-93fd-be71c0abf1dc"
|
state_hub_task_id: "acb4231c-35df-490f-93fd-be71c0abf1dc"
|
||||||
```
|
```
|
||||||
|
|
@ -75,11 +75,36 @@ because a control that can no longer work must not look like it can.
|
||||||
- the label change must be mutation-visible: assert the rendered text, so
|
- the label change must be mutation-visible: assert the rendered text, so
|
||||||
reverting the wording turns a test red.
|
reverting the wording turns a test red.
|
||||||
|
|
||||||
|
**Done 2026-08-05.** Label is now `end session — stops the game server`;
|
||||||
|
the reply is `closed — the session has ended and the server has stopped.
|
||||||
|
You can close this tab.`; and the script **seals** the page on a `closed`
|
||||||
|
reply — `removeAttribute('data-drop')` on every control, so they stop
|
||||||
|
being droppable by the same rule that made them droppable. `sealed` CSS is
|
||||||
|
how that reads, not the mechanism.
|
||||||
|
|
||||||
|
**`removeAttribute`, not `setAttribute(_, null)`** — the latter writes the
|
||||||
|
literal string `"null"` in a browser, which is truthy, so the control
|
||||||
|
would stay live while the stub reported it sealed.
|
||||||
|
|
||||||
|
**The reply path had never been executable in a test.** `jsrun`'s `fetch`
|
||||||
|
stub returned `{then: function(){return this}}`, which never invoked its
|
||||||
|
callbacks — so every line of the script that reacts to what the server
|
||||||
|
said was unreachable from every test in the project. **That is why this
|
||||||
|
defect survived**: a page that ignores the server looked identical to one
|
||||||
|
that acts on it. The stub now delivers a real then-chain, and
|
||||||
|
`gesture_with_reply` reports the page's surviving controls.
|
||||||
|
|
||||||
|
Three tests, and the seal is mutation-proven: deleting the `closed` branch
|
||||||
|
turns exactly one red. The negative control (`ok: dealing` must NOT seal)
|
||||||
|
exists because a seal that fired on every reply would pass the first test
|
||||||
|
and break `play again`. `cb-play`: 22 passed, including
|
||||||
|
`play_again_deals_a_second_game`.
|
||||||
|
|
||||||
## Task: the piles are objects on the table
|
## Task: the piles are objects on the table
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CB-WP-0024-T02
|
id: CB-WP-0024-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "37b16a87-e791-4c85-9f60-671812f6dd2e"
|
state_hub_task_id: "37b16a87-e791-4c85-9f60-671812f6dd2e"
|
||||||
```
|
```
|
||||||
|
|
@ -107,11 +132,38 @@ stop.
|
||||||
- the shuffle question is answered in the task record with the line number
|
- the shuffle question is answered in the task record with the line number
|
||||||
that settles it, either way.
|
that settles it, either way.
|
||||||
|
|
||||||
|
**Done 2026-08-05.** Both piles are drawn as offset stacks with their
|
||||||
|
counts, replacing the text line. Depth suggests *a pile exists*; the
|
||||||
|
number is the truth — 17 cards is not seventeen rectangles.
|
||||||
|
|
||||||
|
**The shuffle question is settled and the answer is that it already
|
||||||
|
works.** `games/ground/src/lib.rs:1419-1435` (`draw_solution`) implements
|
||||||
|
the **U4 default**: deck empty → deterministic reshuffle of the discard
|
||||||
|
via a `DeckReshuffled` event seeded from `seed ^ round`; both empty → skip
|
||||||
|
the draw. ground-game **confirmed U4 on 2026-08-03**, so this is a ruled
|
||||||
|
rule, not an invented one. Nothing to raise.
|
||||||
|
|
||||||
|
The event already reads out in the log (`hotseat.rs:374`). What the piles
|
||||||
|
add is the *state before it fires*: deck 0 with a non-empty discard draws
|
||||||
|
an arrow and says **"shuffles in on next draw"**. That is derivable from
|
||||||
|
the view; a claim that a reshuffle *has happened* would not be, and is not
|
||||||
|
made.
|
||||||
|
|
||||||
|
**The coverage gate caught the probe going stale** — removing the
|
||||||
|
`17 remaining` text broke `solution_deck_len`'s classification. Fixed by
|
||||||
|
putting the count in the pile's `<title>` (`draw pile: 17 remaining`),
|
||||||
|
which is both a stable probe and what a screen reader announces; the
|
||||||
|
on-canvas numeral alone could be any number on the page.
|
||||||
|
|
||||||
|
Three tests: counts come from the view, an empty discard renders as an
|
||||||
|
empty pile rather than vanishing, and the reshuffle notice appears only
|
||||||
|
when the deck is actually out.
|
||||||
|
|
||||||
## Task: what the other seats played, on the table
|
## Task: what the other seats played, on the table
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CB-WP-0024-T03
|
id: CB-WP-0024-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "7b0b08d9-cf0d-40f4-8697-049bdb20085d"
|
state_hub_task_id: "7b0b08d9-cf0d-40f4-8697-049bdb20085d"
|
||||||
```
|
```
|
||||||
|
|
@ -137,11 +189,28 @@ face-down as one identical back.
|
||||||
`view.rs`'s own test already uses; copy it);
|
`view.rs`'s own test already uses; copy it);
|
||||||
- `make sim` still passes: 26 scenarios encode what the table looks like.
|
- `make sim` still passes: 26 scenarios encode what the table looks like.
|
||||||
|
|
||||||
|
**Done 2026-08-05.** Each seat's play is drawn as a card in its own area,
|
||||||
|
with the sentence kept beside it — the log is the record and a player
|
||||||
|
reading back needs the same vocabulary.
|
||||||
|
|
||||||
|
**The face-down back is a `const` with no parameters.** `SelectionView::
|
||||||
|
Hidden` carries nothing, so the renderer is structurally unable to leak
|
||||||
|
what it was not given; making the back a constant means there is no data
|
||||||
|
path into it to add later. The risk this guards is real but is the
|
||||||
|
renderer's, not the model's: a tint keyed on suit or a shape keyed on
|
||||||
|
action would leak even though `view.rs` handed over nothing.
|
||||||
|
|
||||||
|
The leak test copies `view.rs`'s own shape — assert two different hidden
|
||||||
|
situations render an identical back, **then** assert the same renderer
|
||||||
|
does show a revealed play, because without the second half the first
|
||||||
|
passes for a renderer that draws nothing at all. Both are
|
||||||
|
mutation-proven: deleting the call turns both red.
|
||||||
|
|
||||||
## Task: the score that carries across games
|
## Task: the score that carries across games
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CB-WP-0024-T04
|
id: CB-WP-0024-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "4f312267-2b82-4cf9-bbb1-7f287dc9e766"
|
state_hub_task_id: "4f312267-2b82-4cf9-bbb1-7f287dc9e766"
|
||||||
```
|
```
|
||||||
|
|
@ -167,11 +236,36 @@ raise the question rather than silently canonising a choice.
|
||||||
- the tally survives the seed advance and resets only on a new process;
|
- the tally survives the seed advance and resets only on a new process;
|
||||||
- the per-game outcome block is unchanged, and a test says so.
|
- the per-game outcome block is unchanged, and a test says so.
|
||||||
|
|
||||||
|
**Done 2026-08-05.** `MatchTally` lives in `play`, beside the listener and
|
||||||
|
the seed — the other two things that survive `play again`. Shown on the
|
||||||
|
ending page above the log, and **absent for a first game**: one game is not
|
||||||
|
a series, and a cumulative panel restating the outcome directly above it
|
||||||
|
is noise.
|
||||||
|
|
||||||
|
**"Cumulative" was decided before anything was summed, and the answer is
|
||||||
|
that the rules do not decide it.** `OutcomeView` offers `personal` (per
|
||||||
|
seat), `group_success` (per table) and `winners`; summing the first and
|
||||||
|
counting the third answer different questions, and GROUND defines one game
|
||||||
|
and no series at all. **Both are shown and both are named** rather than one
|
||||||
|
being picked and becoming *the* score by default. A test asserts they can
|
||||||
|
point at different seats — 9/0/0 against 0/1/1 leads on summed score for
|
||||||
|
one seat and on games won for the other.
|
||||||
|
|
||||||
|
Registered as **F15, kind `underdetermined`, state `note`** in
|
||||||
|
`FindingRegister.md`. It stays a note deliberately: the test shows the two
|
||||||
|
tallies *can differ*, which is arithmetic, not evidence that the ambiguity
|
||||||
|
harms play — so under GameDesign §3.1 it may not be reported to
|
||||||
|
ground-game yet. **This is the note tier doing the job D6 built it for**,
|
||||||
|
on the first new question since it was written.
|
||||||
|
|
||||||
|
A crashed game contributes nothing: the error path passes an empty tally,
|
||||||
|
so a game that ended without an outcome is never counted as played.
|
||||||
|
|
||||||
## Task: evidence
|
## Task: evidence
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CB-WP-0024-T05
|
id: CB-WP-0024-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "db176595-e00d-467c-ba54-0714cfb7ba5c"
|
state_hub_task_id: "db176595-e00d-467c-ba54-0714cfb7ba5c"
|
||||||
```
|
```
|
||||||
|
|
@ -186,3 +280,30 @@ state_hub_task_id: "db176595-e00d-467c-ba54-0714cfb7ba5c"
|
||||||
- **What the cumulative-score question turned out to be**, and whether it
|
- **What the cumulative-score question turned out to be**, and whether it
|
||||||
went to `ground-game`.
|
went to `ground-game`.
|
||||||
- **Quote CB-WP-0023's cost by re-running the instrument.**
|
- **Quote CB-WP-0023's cost by re-running the instrument.**
|
||||||
|
|
||||||
|
**Done 2026-08-05.**
|
||||||
|
[CB-EV-0023](../evidence/CB-EV-0023-the-table-you-can-watch.md).
|
||||||
|
|
||||||
|
- **Three of five remarks were already implemented** — data the projection
|
||||||
|
carried, drawn 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, not fixed:
|
||||||
|
the honest control is a person playing it.
|
||||||
|
- **The defect that mattered was untestable.** `jsrun`'s fetch stub never
|
||||||
|
invoked its callbacks, so every line of the script reacting to the
|
||||||
|
server was unreachable from every test in the project. 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, which is where the session's
|
||||||
|
ending lives.
|
||||||
|
- **The reshuffle was already ruled and already implemented** (U4,
|
||||||
|
confirmed 2026-08-03), so nothing was raised. CB-WP-0026 applied that
|
||||||
|
ruling the same day this pass consumed it — the first time answering
|
||||||
|
*"is this underdetermined?"* was one lookup instead of a message.
|
||||||
|
- **F15 stayed a note on purpose.** The note tier's first use since D6
|
||||||
|
wrote it, and it came from *building* rather than from play, which the
|
||||||
|
ADR did not anticipate and which the tier handled unchanged.
|
||||||
|
- **Chaos window 2 has rolled no 8 in ten declarations**, so it will
|
||||||
|
likely close with no override to evaluate and its retirement condition
|
||||||
|
untestable. The d4 → d8 cut may have made the mechanism unevaluable —
|
||||||
|
a cost CB-EV-0015 did not price, and one the window's closing evidence
|
||||||
|
should state.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue