CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit
Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat
play, at a measured marginal AM-4a cost of zero.
games-ground shipped: 23 third-party crates
cb-render-html: 23 third-party crates
new crates introduced: 0
Measured, not asserted — the survey's own lesson. AM-4a is unmoved at
246,250; own source is 7,636 -> 9,652.
What shipped:
crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship
graph), input.rs (pointer facts -> commands),
serve.rs (Guard, Request, loopback bind)
tools/cb-play hotseat.rs + `--serve PORT`
Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null.
The renderer targets the existing Project trait; the port waits for
stage 2's wgpu implementation to be its second use.
The six controls, all live, all mutation-checked (8 mutations, each red
for its stated reason):
1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind
4 a token-less request is refused, in the unit AND over a real socket
5 JS may not construct commands — the page reports pointer facts, Rust
resolves them against the legal list the aggregate already offered,
and a test asserts the emitted script contains no game vocabulary
6 the coverage gate crosses the language boundary: it walks the
serialized view for leaf paths and requires each token to appear in
the PARSED emitted document, with a test that the parse really is a
parse (script/style contents must not count as rendered)
The gate fired on its author again, on its first run: ground_choices.*.
choice, ground_choices.*.problem and players.*.blame_from were in neither
list. The last is the one worth keeping — an EMPTY vector is a leaf path
of its own, and it now renders as an explicit absence.
Also, a mutation that did not go red: removing the Sec-Fetch-Site arm
alone left the cross-site test green, because the Origin check caught it
independently. Both had to be removed before the control bit. Recorded
because a control that passes for a reason you did not intend has not
been demonstrated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
|
|
|
//! `cb-render-html` — stage 1's renderer (ADR-0007).
|
|
|
|
|
//!
|
|
|
|
|
//! Emits HTML with inline SVG and one small piece of inline JavaScript;
|
|
|
|
|
//! the browser draws it. **Marginal AM-4a cost: zero** — this crate has no
|
|
|
|
|
//! third-party dependencies at all, and adding one requires an argument
|
|
|
|
|
//! against ADR-0007 §Decision 3.
|
|
|
|
|
//!
|
|
|
|
|
//! ## What this is not
|
|
|
|
|
//!
|
|
|
|
|
//! It is **not** `cb-render-api`, and there is no `cb-render-null`.
|
|
|
|
|
//! ADR-0007 Decision 2 withdrew the port: a capability port designed
|
|
|
|
|
//! against one implementation that emits whole documents acquires a
|
|
|
|
|
//! document's shape, and stage 2's `wgpu` renderer would find it
|
|
|
|
|
//! unimplementable. INTENT's rule is the one that binds —
|
|
|
|
|
//!
|
|
|
|
|
//! > *No concept becomes canonical merely because it looks general. It
|
|
|
|
|
//! > becomes canonical after surviving a second concrete use.*
|
|
|
|
|
//!
|
|
|
|
|
//! — so this renders against the existing [`cb_game_runtime::Project`]
|
|
|
|
|
//! trait, which is a real interface with real implementations, and the
|
|
|
|
|
//! port is declared at stage 2 when there are two.
|
|
|
|
|
//!
|
|
|
|
|
//! ## The controls, and why they exist
|
|
|
|
|
//!
|
|
|
|
|
//! CB-WP-0011 established that a renderer's defect class is **silent
|
|
|
|
|
//! omission**: every assertion a renderer test naturally makes is
|
|
|
|
|
//! satisfied by a renderer showing a third of the state. This crate moves
|
|
|
|
|
//! the interactive half of stage 1 into a language `cargo test`,
|
|
|
|
|
//! `clippy` and `M-D1-MUT` cannot reach, so two controls carry that
|
|
|
|
|
//! finding across the boundary:
|
|
|
|
|
//!
|
|
|
|
|
//! * [`input`] — **JavaScript may not construct commands.** The page
|
|
|
|
|
//! reports raw pointer facts; Rust decides what they mean, against the
|
|
|
|
|
//! legal list the aggregate already offered.
|
|
|
|
|
//! * the coverage gate below — asserts over the **parsed emitted
|
|
|
|
|
//! document**, not over the Rust that emits it. Asserting over the
|
|
|
|
|
//! emitting code would reproduce CB-WP-0011's original defect one layer
|
|
|
|
|
//! up.
|
|
|
|
|
//!
|
|
|
|
|
//! And [`serve`] carries the four that make a loopback listener safe to
|
|
|
|
|
//! have, of which the load-bearing one is a test that a token-less request
|
|
|
|
|
//! is refused.
|
|
|
|
|
|
|
|
|
|
pub mod doc;
|
|
|
|
|
pub mod input;
|
CB-WP-0014-T01/T02: execute the JavaScript — and find AM-4b blind
ADR-0009: embed quick-js; node is refused. Measured marginal cost against
the dev-toolchain graph, under the positive control:
boa_engine 896,410
rquickjs 69,985
quick-js 11,434
node 0 <- and that zero is the problem
ADR-0007 D3's acquisition rule biting its author. CI runs on rust:1.97,
which has no node, so the test would make our build fetch a JS runtime of
tens of millions of unaudited lines while scoring zero on the only
instrument that governs dependencies. A browser is exempt because a
developer has one regardless of us; a CI-installed runtime is not.
The loop is now closed: the real server serves the real page, QuickJS
runs that page's own scripts, the gesture goes over a real socket, and
the seat's Choice comes back. Before this, every link was tested and the
chain was not — a page whose JavaScript sent something else entirely
would have passed everything.
Three controls, each red for its stated reason: the JS posting a command
name instead of ids, the gesture not being delivered (EXPECT-VACUOUS),
and the token stripped from the endpoint.
A wrong assertion worth keeping: the first draft required the body not to
contain "attack". It legitimately does — action-attack is the id of an
element a finger landed on. An element may name an action; that is not
the page deciding. The real test is the shape: exactly two fields, down
and up, carrying two ids and nothing derived from them.
AND the ADR's own cost argument was wrong. It claimed 35% of AM-4b's
headroom; after landing AM-4b did not move at all. It measures
games-ground --edges normal — one package, no dev edges. Measured, the
workspace including dev edges is 725,258 lines against AM-4b's 317,021:
408,237 uncounted, MORE THAN THE TARGET ITSELF (criterion, clap,
ciborium, quick-js). The decision stands on the acquisition rule; the
affordability argument is withdrawn. Third defect in the AM-4 family.
Also fixed structurally rather than by raising a limit: `make status` had
grown past its 40-line readability gate as workplans accumulated. Closed
workplans now collapse to one line, so the report is fixed-size.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 07:56:02 +02:00
|
|
|
#[cfg(any(test, feature = "js-harness"))]
|
|
|
|
|
pub mod jsrun;
|
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit
Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat
play, at a measured marginal AM-4a cost of zero.
games-ground shipped: 23 third-party crates
cb-render-html: 23 third-party crates
new crates introduced: 0
Measured, not asserted — the survey's own lesson. AM-4a is unmoved at
246,250; own source is 7,636 -> 9,652.
What shipped:
crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship
graph), input.rs (pointer facts -> commands),
serve.rs (Guard, Request, loopback bind)
tools/cb-play hotseat.rs + `--serve PORT`
Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null.
The renderer targets the existing Project trait; the port waits for
stage 2's wgpu implementation to be its second use.
The six controls, all live, all mutation-checked (8 mutations, each red
for its stated reason):
1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind
4 a token-less request is refused, in the unit AND over a real socket
5 JS may not construct commands — the page reports pointer facts, Rust
resolves them against the legal list the aggregate already offered,
and a test asserts the emitted script contains no game vocabulary
6 the coverage gate crosses the language boundary: it walks the
serialized view for leaf paths and requires each token to appear in
the PARSED emitted document, with a test that the parse really is a
parse (script/style contents must not count as rendered)
The gate fired on its author again, on its first run: ground_choices.*.
choice, ground_choices.*.problem and players.*.blame_from were in neither
list. The last is the one worth keeping — an EMPTY vector is a leaf path
of its own, and it now renders as an explicit absence.
Also, a mutation that did not go red: removing the Sec-Fetch-Site arm
alone left the cross-site test green, because the Origin check caught it
independently. Both had to be removed before the control bit. Recorded
because a control that passes for a reason you did not intend has not
been demonstrated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
|
|
|
pub mod serve;
|
|
|
|
|
|
|
|
|
|
pub use doc::{document, text_of};
|
CB-WP-0027 T01-T04: the commentary track
The meta view beside the table, and a note channel that provably cannot
carry a move.
T01 (ADR-0014). ADR-0007 D5 is SCOPED, NOT AMENDED, and the reason it was
easy is that PointerFact::parse already refuses any unrecognised field --
a comment could not reach the command path even by accident. So /command
carries pointer facts, /note carries text, and Note has no code path to
GroundCommand. Comments live in trials/<date>-<slug>.md, not in
ScenarioFile: a scenario is executed, replayed and hashed, and prose in it
is data the runner must ignore, which is how a format rots. The state hash
binds; round and step are for reading. And the retention question, decided
before any comment was written: RAW NOTES NEVER LEAVE clay-borg. A note
reaches ground-game only by being promoted to a register finding, by a
human, with the wording chosen then -- "the DARVO sequence is infuriating"
is useful signal and a bad way to open a message to the game's designer.
T02. CSS grid, minmax(0,1fr) on both tracks -- load-bearing, because a
grid child defaults to min-content width and without it the SVG table
refuses to shrink and pushes the meta column off-screen, looking correct
on the developer's monitor and broken everywhere else. Single-column
fallback under 64rem. The running tally moved into the panel so it is
visible WHILE PLAYING; it only appeared on the ending page before, and a
score you see once the game is over informs nothing.
T03. A plain <form method="post">, so the box works with the script
disabled; the command channel needs JavaScript because a drag is not a
form submission, a comment is one. 303 See Other so a reload does not
re-post. esc()'s first hostile input: <script>alert(1)</script> renders
escaped AND STILL READABLE -- escaping that eats the player's words is its
own defect. Verified over real HTTP: note posted 303, hostile note stored
as text, empty note refused 400, game did not advance.
T04. tools/trials.py and make trials. THE REPORT'S DESIGN CHANGED BECAUSE
I RAN IT: the first version called any note without a recording an orphan,
so a live session reported every note as broken -- the recording is only
written at game end. A metric that cries wolf is one nobody reads, which
is the exact failure this pass exists to prevent. Now ok / pending /
orphan, and only orphan is a target-0 number. The self-test exercises the
REPORTING path, not just the parser, because design-baseline.py had a
green self-test and an unexercised reporting path and that is where it
rotted.
And a latent Makefile defect surfaced: make trials did nothing, because
trials is also a directory and Make saw an up-to-date file. design,
difficulty and trials -- added by CB-WP-0022, CB-WP-0025 and this pass --
were ALL missing from .PHONY; only the one that collided revealed it.
make all: exit 0. 49 render tests, 26 cb-play, loop-lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:37:46 +02:00
|
|
|
pub use input::{resolve, Note, PointerFact};
|
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit
Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat
play, at a measured marginal AM-4a cost of zero.
games-ground shipped: 23 third-party crates
cb-render-html: 23 third-party crates
new crates introduced: 0
Measured, not asserted — the survey's own lesson. AM-4a is unmoved at
246,250; own source is 7,636 -> 9,652.
What shipped:
crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship
graph), input.rs (pointer facts -> commands),
serve.rs (Guard, Request, loopback bind)
tools/cb-play hotseat.rs + `--serve PORT`
Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null.
The renderer targets the existing Project trait; the port waits for
stage 2's wgpu implementation to be its second use.
The six controls, all live, all mutation-checked (8 mutations, each red
for its stated reason):
1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind
4 a token-less request is refused, in the unit AND over a real socket
5 JS may not construct commands — the page reports pointer facts, Rust
resolves them against the legal list the aggregate already offered,
and a test asserts the emitted script contains no game vocabulary
6 the coverage gate crosses the language boundary: it walks the
serialized view for leaf paths and requires each token to appear in
the PARSED emitted document, with a test that the parse really is a
parse (script/style contents must not count as rendered)
The gate fired on its author again, on its first run: ground_choices.*.
choice, ground_choices.*.problem and players.*.blame_from were in neither
list. The last is the one worth keeping — an EMPTY vector is a leaf path
of its own, and it now renders as an explicit absence.
Also, a mutation that did not go red: removing the Sec-Fetch-Site arm
alone left the cross-site test green, because the Origin check caught it
independently. Both had to be removed before the control bit. Recorded
because a control that passes for a reason you did not intend has not
been demonstrated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
|
|
|
pub use serve::{Guard, Refusal, Request};
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod coverage {
|
|
|
|
|
//! **ADR-0007 control 6.** The HTML counterpart of `cb-play`'s
|
|
|
|
|
//! `every_view_field_is_classified`.
|
|
|
|
|
//!
|
|
|
|
|
//! Same shape as CB-WP-0011's gate, one layer further out: walk the
|
|
|
|
|
//! serialized `GroundView` for every leaf *path*, and require each to
|
|
|
|
|
//! be either rendered — with a token that must appear in the **parsed
|
|
|
|
|
//! document** — or omitted with a stated reason. A new field on
|
|
|
|
|
//! `GroundView` that is in neither list fails the build.
|
|
|
|
|
//!
|
|
|
|
|
//! Paths, not keys: `problem` occurs under a DARVO target, a GROUND
|
|
|
|
|
//! choice and a Selection, and a key-set walk would let one vouch for
|
|
|
|
|
//! the other two.
|
|
|
|
|
|
|
|
|
|
use cb_kernel::PlayerId;
|
|
|
|
|
use games_ground::view::GroundView;
|
|
|
|
|
|
|
|
|
|
use crate::doc::{document, text_of};
|
|
|
|
|
|
|
|
|
|
/// Every leaf path in the serialized view.
|
|
|
|
|
fn paths(v: &serde_json::Value, prefix: &str, out: &mut Vec<String>) {
|
|
|
|
|
match v {
|
|
|
|
|
serde_json::Value::Object(m) if !m.is_empty() => {
|
|
|
|
|
for (k, val) in m {
|
|
|
|
|
let p = if prefix.is_empty() {
|
|
|
|
|
k.clone()
|
|
|
|
|
} else {
|
|
|
|
|
format!("{prefix}.{k}")
|
|
|
|
|
};
|
|
|
|
|
paths(val, &p, out);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
serde_json::Value::Array(a) if !a.is_empty() => {
|
|
|
|
|
for item in a {
|
|
|
|
|
paths(item, &format!("{prefix}.*"), out);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => out.push(prefix.to_string()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Collapse map keys to `*` so the classification is over fields, not
|
|
|
|
|
/// over whichever seats and priorities the fixture happens to hold.
|
|
|
|
|
fn normalize(path: &str) -> String {
|
|
|
|
|
const MAPS: &[&str] = &[
|
|
|
|
|
"players",
|
|
|
|
|
"relations",
|
|
|
|
|
"problems",
|
|
|
|
|
"focus",
|
|
|
|
|
"selections",
|
|
|
|
|
"ground_modes",
|
|
|
|
|
"ground_choices",
|
|
|
|
|
"support_responses",
|
|
|
|
|
"darvo_targets",
|
|
|
|
|
"personal",
|
|
|
|
|
];
|
|
|
|
|
let mut parts: Vec<String> = Vec::new();
|
|
|
|
|
let mut prev_is_map = false;
|
|
|
|
|
for seg in path.split('.') {
|
|
|
|
|
if prev_is_map && seg != "*" {
|
|
|
|
|
parts.push("*".to_string());
|
|
|
|
|
} else {
|
|
|
|
|
parts.push(seg.to_string());
|
|
|
|
|
}
|
|
|
|
|
prev_is_map = MAPS.contains(&seg);
|
|
|
|
|
}
|
|
|
|
|
parts.join(".")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `(leaf path, a token the parsed document must contain)`.
|
|
|
|
|
const RENDERED: &[(&str, &str)] = &[
|
|
|
|
|
("round", "round 3"),
|
|
|
|
|
("lead", "lead P2"),
|
|
|
|
|
("step", "step Select"),
|
|
|
|
|
("mode", "scoring BondedCoalitions"),
|
|
|
|
|
("viewer", "viewing as P1"),
|
CB-WP-0024 T01/T02: the ending control says what it does, and the piles are objects
T01. The maintainer asked why the button says "I need to read this" and
why nothing closes. Two defects behind one control: the label described a
reading while the control STOPS THE SERVER (hotseat.rs reads `done` and
breaks its loop), and acknowledging it changed nothing on screen -- the
tab kept a live table and a `play again` pointing at a closed port.
Label is now "end session -- stops the game server". The reply says the
session has ended and the tab can be closed. 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. CSS is how
that reads, not the mechanism. removeAttribute rather than
setAttribute(_, null) -- the latter writes the truthy string "null" in a
browser, so the control would stay live while the stub called 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 reacting to the server was
unreachable from every test in this project. That is why the defect
survived: a page ignoring the server looked identical to one acting on it.
The stub now delivers a real then-chain and gesture_with_reply reports
which controls survive. The seal is mutation-proven -- deleting the
`closed` branch turns exactly one test red -- and a negative control
asserts an `ok: dealing` reply does NOT seal, since a seal that fired on
every reply would pass the first test and break `play again`.
T02. Draw and discard drawn as offset stacks with their counts. The
shuffle question the task required answering is settled and the answer is
that it already works: games/ground/src/lib.rs:1419-1435 implements the U4
default -- deterministic reshuffle of the discard seeded from seed ^ round,
skip the draw if both are empty -- and ground-game CONFIRMED U4 on
2026-08-03. A ruled rule, not an invented one, nothing to raise. The event
already reads out in the log; what the piles add is the state before it
fires, which is derivable from the view. A claim that a reshuffle HAS
happened would not be, and is not made.
The coverage gate caught its own probe going stale when the "17 remaining"
text was replaced. The count now lives in the pile's <title> -- a stable
probe and what a screen reader announces, where the on-canvas numeral
could be any number on the page.
39 tests pass; cb-play 22 including play_again_deals_a_second_game.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:23:09 +02:00
|
|
|
("solution_deck_len", "draw pile: 17 remaining"),
|
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit
Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat
play, at a measured marginal AM-4a cost of zero.
games-ground shipped: 23 third-party crates
cb-render-html: 23 third-party crates
new crates introduced: 0
Measured, not asserted — the survey's own lesson. AM-4a is unmoved at
246,250; own source is 7,636 -> 9,652.
What shipped:
crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship
graph), input.rs (pointer facts -> commands),
serve.rs (Guard, Request, loopback bind)
tools/cb-play hotseat.rs + `--serve PORT`
Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null.
The renderer targets the existing Project trait; the port waits for
stage 2's wgpu implementation to be its second use.
The six controls, all live, all mutation-checked (8 mutations, each red
for its stated reason):
1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind
4 a token-less request is refused, in the unit AND over a real socket
5 JS may not construct commands — the page reports pointer facts, Rust
resolves them against the legal list the aggregate already offered,
and a test asserts the emitted script contains no game vocabulary
6 the coverage gate crosses the language boundary: it walks the
serialized view for leaf paths and requires each token to appear in
the PARSED emitted document, with a test that the parse really is a
parse (script/style contents must not count as rendered)
The gate fired on its author again, on its first run: ground_choices.*.
choice, ground_choices.*.problem and players.*.blame_from were in neither
list. The last is the one worth keeping — an EMPTY vector is a leaf path
of its own, and it now renders as an explicit absence.
Also, a mutation that did not go red: removing the Sec-Fetch-Site arm
alone left the cross-site test green, because the Origin check caught it
independently. Both had to be removed before the control bit. Recorded
because a control that passes for a reason you did not intend has not
been demonstrated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
|
|
|
("solution_discard.*.suit", "discard Repair Change"),
|
|
|
|
|
("players.*.stress", "stress 5"),
|
|
|
|
|
("players.*.protection", "protect 2"),
|
|
|
|
|
("players.*.darvo", "darvo Reverse"),
|
|
|
|
|
("players.*.freedom_ready", "READY"),
|
|
|
|
|
("players.*.freedom_gate_lifted", "gate lifted"),
|
|
|
|
|
("players.*.blame_from.*", "blamed by P3"),
|
|
|
|
|
// The empty case is a leaf path of its own, and renders as an
|
|
|
|
|
// explicit absence rather than as nothing at all.
|
|
|
|
|
("players.*.blame_from", "blamed by none"),
|
|
|
|
|
("players.*.hand.*.suit", "hand Clarify Boundary"),
|
|
|
|
|
("players.*.hand_size", "cards)"),
|
|
|
|
|
("relations.*", "Rivalry"),
|
|
|
|
|
("problems.*.state", "face down"),
|
|
|
|
|
("problems.*.suit", "Change 6"),
|
|
|
|
|
("problems.*.value", "Change 6"),
|
|
|
|
|
("problems.*.denied", "denied"),
|
|
|
|
|
("problems.*.claimed_by", "claimed by P2"),
|
|
|
|
|
("problems.*.protected_this_round", "protected"),
|
|
|
|
|
("focus.*", "\u{2192}P3"),
|
|
|
|
|
("selections.*.state", "face down"),
|
CB-WP-0020: the table you can read
Six of seven perceptual defects fixed; item 1 already passed.
T01, at the maintainer's instruction: a legal target restyles its
EXISTING border rather than drawing a new box. outline + outline-offset
drew a second rectangle, which an SVG viewport clips (the missing top and
left edges) and which made a seat's highlight card-sized. A border
already in the layout cannot move the layout.
T02: the ghost was a textContent copy of the card, which is why the line
break collapsed and it read as a second card, and why showing the
explanation destroyed the label. It is now a pill, the explanation is
appended beside the label, and the left-behind element is dimmed and
dashed. The stub grew innerHTML so a test can assert BOTH are present --
it could previously only see that something was displayed.
T03: NOT reproduced and recorded as not reproduced. The likeliest cause
is which element the browser reports -- for touch and pen the pointer is
captured to the pointerdown target, making every drop look like a
drop-on-itself, which is the other half of the report. elementFromPoint
is correct under both explanations. Separately the refusal was written in
element ids on the one surface a player reads when something goes wrong;
it now speaks the game's words and a test forbids id leakage.
T04: seat selections rendered as Debug. The coverage gate then failed my
first fix for dropping a field when target and problem were both set --
the aggregate does not produce that shape and the gate was right not to
care.
T05: the headline reads from group_success. 'Play again' is real, and its
first version was useless: run_game bound a fresh listener per game, so a
second game moved to a new port and left the tab pointing at a dead one.
One listener per session now, and the test asserts the second game is a
DIFFERENT deal.
Chaos d8=8 fired the first override at the new rate and drew S, changing
nothing -- one half of window 2's retirement condition.
CB-WP-0019 settled at $38.54/117 against $34.80/107. Eight for eight,
and the first under 20%.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:20:38 +02:00
|
|
|
// CB-WP-0020 T04: words, not Debug. These tokens were
|
|
|
|
|
// `action: Attack` / `target: Some(PlayerId(1))` /
|
|
|
|
|
// `problem: Some(7)` — the shape a player was being shown.
|
|
|
|
|
("selections.*.action", "selected Attack"),
|
|
|
|
|
("selections.*.target", "Attack on P2"),
|
|
|
|
|
("selections.*.problem", "for problem 7"),
|
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit
Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat
play, at a measured marginal AM-4a cost of zero.
games-ground shipped: 23 third-party crates
cb-render-html: 23 third-party crates
new crates introduced: 0
Measured, not asserted — the survey's own lesson. AM-4a is unmoved at
246,250; own source is 7,636 -> 9,652.
What shipped:
crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship
graph), input.rs (pointer facts -> commands),
serve.rs (Guard, Request, loopback bind)
tools/cb-play hotseat.rs + `--serve PORT`
Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null.
The renderer targets the existing Project trait; the port waits for
stage 2's wgpu implementation to be its second use.
The six controls, all live, all mutation-checked (8 mutations, each red
for its stated reason):
1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind
4 a token-less request is refused, in the unit AND over a real socket
5 JS may not construct commands — the page reports pointer facts, Rust
resolves them against the legal list the aggregate already offered,
and a test asserts the emitted script contains no game vocabulary
6 the coverage gate crosses the language boundary: it walks the
serialized view for leaf paths and requires each token to appear in
the PARSED emitted document, with a test that the parse really is a
parse (script/style contents must not count as rendered)
The gate fired on its author again, on its first run: ground_choices.*.
choice, ground_choices.*.problem and players.*.blame_from were in neither
list. The last is the one worth keeping — an EMPTY vector is a leaf path
of its own, and it now renders as an explicit absence.
Also, a mutation that did not go red: removing the Sec-Fetch-Site arm
alone left the cross-site test green, because the Origin check caught it
independently. Both had to be removed before the control bit. Recorded
because a control that passes for a reason you did not intend has not
been demonstrated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
|
|
|
("ground_modes.*", "ground mode Gr"),
|
|
|
|
|
("ground_choices.*.choice", "ProtectProblem"),
|
|
|
|
|
("ground_choices.*.problem", "problem: 7 }"),
|
|
|
|
|
("support_responses.*", "support AcceptBond"),
|
|
|
|
|
("darvo_targets.*.problem", "problem: Some(1)"),
|
|
|
|
|
("darvo_targets.*.player", "player: Some(PlayerId(1))"),
|
|
|
|
|
("outcome.total", "total 9"),
|
|
|
|
|
("outcome.threshold", "of 12"),
|
|
|
|
|
("outcome.group_success", "failure"),
|
|
|
|
|
("outcome.personal.*", "P1 +3"),
|
|
|
|
|
("outcome.coalitions.*.members.*", "members: [PlayerId(1)"),
|
|
|
|
|
("outcome.coalitions.*.score", "score: 4"),
|
|
|
|
|
("outcome.mastery", "mastery +1"),
|
|
|
|
|
("outcome.winners.*", "winners P1"),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/// Deliberate omissions, each with a reason.
|
|
|
|
|
const OMITTED: &[(&str, &str)] = &[(
|
|
|
|
|
"players.*.hand",
|
|
|
|
|
"null for a non-viewer seat; the absence is rendered as a count",
|
|
|
|
|
)];
|
|
|
|
|
|
|
|
|
|
fn fixture() -> GroundView {
|
|
|
|
|
crate::testfix::view(Some(PlayerId(0)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn rendered_document(view: &GroundView) -> String {
|
|
|
|
|
text_of(&document(
|
|
|
|
|
view,
|
|
|
|
|
&[],
|
|
|
|
|
"/command?t=x",
|
|
|
|
|
Some(PlayerId(0)),
|
|
|
|
|
false,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn every_view_field_is_classified_in_the_emitted_document() {
|
|
|
|
|
let view = fixture();
|
|
|
|
|
let json = serde_json::to_value(&view).expect("view serializes");
|
|
|
|
|
let mut raw = Vec::new();
|
|
|
|
|
paths(&json, "", &mut raw);
|
|
|
|
|
let all: std::collections::BTreeSet<String> = raw.iter().map(|p| normalize(p)).collect();
|
|
|
|
|
|
|
|
|
|
// EXPECT-VACUOUS floor. A coverage test over zero paths passes
|
|
|
|
|
// trivially, and that is precisely how this check would rot.
|
|
|
|
|
assert!(
|
|
|
|
|
all.len() >= 30,
|
|
|
|
|
"the walk found {} leaf path(s) — it is not walking the view",
|
|
|
|
|
all.len()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let claimed: std::collections::BTreeSet<&str> = RENDERED
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(p, _)| *p)
|
|
|
|
|
.chain(OMITTED.iter().map(|(p, _)| *p))
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
let unclassified: Vec<&String> = all
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|p| !claimed.contains(p.as_str()))
|
|
|
|
|
.collect();
|
|
|
|
|
assert!(
|
|
|
|
|
unclassified.is_empty(),
|
|
|
|
|
"unclassified path(s) in GroundView: {unclassified:?} \
|
|
|
|
|
— add each to RENDERED with a token, or to OMITTED with a reason"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let stale: Vec<&&str> = claimed.iter().filter(|p| !all.contains(**p)).collect();
|
|
|
|
|
assert!(
|
|
|
|
|
stale.is_empty(),
|
|
|
|
|
"classified path(s) no longer exist in GroundView: {stale:?}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// The half that makes it a rendering gate rather than a bookkeeping
|
|
|
|
|
// one: the token must be in the *parsed document*.
|
|
|
|
|
let text = rendered_document(&view);
|
|
|
|
|
let missing: Vec<&str> = RENDERED
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|(_, tok)| !text.contains(tok))
|
|
|
|
|
.map(|(p, _)| *p)
|
|
|
|
|
.collect();
|
|
|
|
|
assert!(
|
|
|
|
|
missing.is_empty(),
|
|
|
|
|
"claimed rendered, but absent from the emitted document: {missing:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The parse must be a parse. If `text_of` returned the raw source, a
|
|
|
|
|
/// token hiding in a comment, a style rule or the script would count
|
|
|
|
|
/// as rendered — which is the loophole control 6 exists to close.
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_parse_does_not_see_script_or_style_contents() {
|
|
|
|
|
let html = "<style>.x{content:'STYLETOKEN'}</style><p id=\"pid\">seen</p>\
|
|
|
|
|
<script>var s='SCRIPTTOKEN';</script>";
|
|
|
|
|
let text = text_of(html);
|
|
|
|
|
assert!(text.contains("seen"));
|
|
|
|
|
assert!(text.contains("pid"), "element ids are addressable surface");
|
|
|
|
|
assert!(
|
|
|
|
|
!text.contains("STYLETOKEN"),
|
|
|
|
|
"style contents leaked into the text"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!text.contains("SCRIPTTOKEN"),
|
|
|
|
|
"script contents leaked into the text"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Control 5, asserted at the document level: the emitted JavaScript
|
|
|
|
|
/// must contain no game vocabulary. If a rule ever needs to live in
|
|
|
|
|
/// the page, ADR-0007 says revisit the decision, not widen this.
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_emitted_javascript_contains_no_game_vocabulary() {
|
|
|
|
|
let doc = document(&fixture(), &[], "/command?t=x", Some(PlayerId(0)), false);
|
|
|
|
|
let script = doc
|
|
|
|
|
.split_once("<script>")
|
|
|
|
|
.and_then(|(_, r)| r.rsplit_once("</script>"))
|
|
|
|
|
.map(|(s, _)| s.to_string())
|
|
|
|
|
.expect("the document carries a script");
|
|
|
|
|
for word in [
|
|
|
|
|
"Investigate",
|
|
|
|
|
"Solve",
|
|
|
|
|
"Support",
|
|
|
|
|
"Attack",
|
|
|
|
|
"Ground",
|
|
|
|
|
"darvo",
|
|
|
|
|
"DARVO",
|
|
|
|
|
"stress",
|
|
|
|
|
"freedom",
|
|
|
|
|
"problem",
|
|
|
|
|
"coalition",
|
|
|
|
|
"reveal",
|
|
|
|
|
"resolve",
|
|
|
|
|
] {
|
|
|
|
|
assert!(
|
|
|
|
|
!script.contains(word),
|
|
|
|
|
"the emitted JavaScript mentions {word:?} — control 5 is breached"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
// And it must still be doing its one job.
|
|
|
|
|
assert!(script.contains("pointerdown") && script.contains("pointerup"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The renderer must not invent a hand it was never given.
|
|
|
|
|
///
|
|
|
|
|
/// Scoped deliberately: the *projection's* hiding rules are asserted
|
|
|
|
|
/// where they live, and re-asserting them here would be a duplicated
|
|
|
|
|
/// fact that drifts. What this checks is the renderer's own failure
|
|
|
|
|
/// mode — that `hand: None` renders as an absence, for every seat, and
|
|
|
|
|
/// that a spectator's document contains no open hand at all.
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_renderer_never_invents_a_hand_it_was_not_given() {
|
|
|
|
|
for seat in [0u8, 1, 2] {
|
|
|
|
|
let view = crate::testfix::view(Some(PlayerId(seat)));
|
|
|
|
|
let text = rendered_document(&view);
|
|
|
|
|
let shown = text.matches("cards)").count();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
shown,
|
|
|
|
|
1,
|
|
|
|
|
"P{} sees {shown} open hands in the document; it was given exactly one",
|
|
|
|
|
seat + 1
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
text.matches("card(s), hidden").count(),
|
|
|
|
|
2,
|
|
|
|
|
"the other two seats' hands must render as an absence with a count"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
let text = rendered_document(&crate::testfix::view(None));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
text.matches("cards)").count(),
|
|
|
|
|
0,
|
|
|
|
|
"a spectator document shows an open hand"
|
|
|
|
|
);
|
|
|
|
|
assert!(text.contains("a spectator (no hands)"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
CB-WP-0016: the drop target that was never there
Provenance (tier S, one paragraph in lieu of survey and ADR): the human
check that kept INTENT stage 1 open was run and the drag was broken.
Root cause, worth more than the instance: drop targets were ids, and an
id must be unique, so exactly one element could ever be seat-0. The
relationship-graph circle took it and the seat card that every action
card's own text points at -- 'drag Attack onto a seat' -- silently had
none. A seat is drawn twice and both drawings are the seat; the document
model could not express that.
Drop keys are now data-drop. Any number of elements may carry the same
key, so a seat is droppable on its card and on its graph node. Measured
on a live server: seat-0/1/2 each appear twice, id survives only on
cb-status which is the one element the script looks up, and
down=action-attack&up=seat-1 returns ok.
Second defect: a drop on nothing returned without posting and without
touching the status line, so a broken target was indistinguishable from
a working page. resolve already refuses rather than defaulting, which is
right; refusing SILENTLY is not. The page now reports the raw fact --
'took action-attack, let go over nothing droppable' -- which names
elements, not moves, so ADR-0007 control 5 holds.
And the honest part: the general check added here -- every offered
affordance names a key that exists, driven through Policy::choose over
four real bot games -- does NOT catch the reported defect. seat-0 did
exist, on the graph circle. It is kept because a wholly absent target is
a real class, and paired with a targeted regression test that does catch
it. Three mutations, each red for its stated reason, including the
reported defect reintroduced; only the targeted test fires on that one.
A cb-play assertion matched id="action-ground" as a substring while
describing itself as checking the page; rewritten through drop_keys.
make all exits 0. Stage 1 stays open: verified by tests, mutation and a
live server, not by a human dragging.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 20:48:18 +02:00
|
|
|
/// CB-WP-0016 T03: every affordance the page offers must name an element
|
|
|
|
|
/// the page actually contains.
|
|
|
|
|
///
|
|
|
|
|
/// **This is the check that closes the class the human check found.** On
|
|
|
|
|
/// 2026-08-02 the maintainer ran `cb-play --serve 0` and could not drag an
|
|
|
|
|
/// action onto a seat. Every test in the repo was green. The cause: the
|
|
|
|
|
/// visible seat cards carried no `id`, so `seat-{n}` existed only on the
|
|
|
|
|
/// 26 px circles inside the relationship graph, while every action card
|
|
|
|
|
/// read *"drag Attack onto a seat…"*.
|
|
|
|
|
///
|
|
|
|
|
/// Nothing could catch it. `jsrun::gesture` feeds element ids straight
|
|
|
|
|
/// into a synthetic `{target:{id}}` and never hit-tests, so it establishes
|
|
|
|
|
/// *"the script posts the ids it was given"* — never *"there is an element
|
|
|
|
|
/// there to give."* The 42-path coverage gate asserts each view field is
|
|
|
|
|
/// present in the parsed document, which a `<div>` with no id satisfies.
|
|
|
|
|
///
|
|
|
|
|
/// So: drive a real game, and at every decision point require that both
|
|
|
|
|
/// halves of every offered affordance appear as real `id` attributes.
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod affordances {
|
|
|
|
|
use std::cell::RefCell;
|
|
|
|
|
use std::rc::Rc;
|
|
|
|
|
|
|
|
|
|
use cb_game_runtime::{Project, ScenarioGame, Setup, Viewer};
|
|
|
|
|
use cb_kernel::PlayerId;
|
|
|
|
|
use games_ground::bot::{play, Choice, Policy, RandomPolicy};
|
|
|
|
|
use games_ground::{GroundCommand, GroundState};
|
|
|
|
|
|
|
|
|
|
use crate::{doc, input};
|
|
|
|
|
|
|
|
|
|
fn fresh(seed: u64) -> GroundState {
|
|
|
|
|
GroundState::setup(
|
|
|
|
|
&Setup {
|
|
|
|
|
players: 3,
|
|
|
|
|
preset: "standard-3p".into(),
|
|
|
|
|
patch: std::collections::BTreeMap::new(),
|
|
|
|
|
},
|
|
|
|
|
seed,
|
|
|
|
|
)
|
|
|
|
|
.expect("a standard 3p deal")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Renders the page at every real decision point and checks it, then
|
|
|
|
|
/// delegates the actual choice.
|
|
|
|
|
///
|
|
|
|
|
/// Hooking `Policy` rather than re-driving the game by hand matters:
|
|
|
|
|
/// these are the *same* decision points `cb-play --serve` renders at,
|
|
|
|
|
/// with the same `legal` list. A hand-rolled walk would be a second
|
|
|
|
|
/// implementation of the loop, and could agree with itself while
|
|
|
|
|
/// disagreeing with the thing shipped.
|
|
|
|
|
struct CheckingPolicy {
|
|
|
|
|
inner: RandomPolicy,
|
|
|
|
|
checked: Rc<RefCell<usize>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Policy for CheckingPolicy {
|
|
|
|
|
fn name(&self) -> &'static str {
|
|
|
|
|
"affordance-checking"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn choose(
|
|
|
|
|
&mut self,
|
|
|
|
|
state: &GroundState,
|
|
|
|
|
seat: PlayerId,
|
|
|
|
|
legal: &[GroundCommand],
|
|
|
|
|
may_pass: bool,
|
|
|
|
|
) -> Choice {
|
|
|
|
|
let view = state.project(Viewer::Player(seat));
|
|
|
|
|
let html = doc::document(&view, legal, "/command?t=x", Some(seat), may_pass);
|
|
|
|
|
let present = doc::drop_keys(&html);
|
|
|
|
|
|
|
|
|
|
for cmd in legal {
|
|
|
|
|
let Some((from, to)) = input::affordance(cmd, seat) else {
|
|
|
|
|
// A command with no affordance is offered through the
|
|
|
|
|
// numbered-button path instead. That is a stated
|
|
|
|
|
// shape, not a missing element.
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
assert!(
|
|
|
|
|
present.contains(&from),
|
|
|
|
|
"the page offers {cmd:?} whose GRAB id {from:?} is not an \
|
|
|
|
|
element in the document (seat {seat:?}, step {:?})",
|
|
|
|
|
state.step
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
present.contains(&to),
|
|
|
|
|
"the page offers {cmd:?} whose DROP id {to:?} is not an \
|
|
|
|
|
element in the document (seat {seat:?}, step {:?}). \
|
|
|
|
|
Present ids: {present:?}",
|
|
|
|
|
state.step
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
*self.checked.borrow_mut() += 1;
|
|
|
|
|
self.inner.choose(state, seat, legal, may_pass)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// **The check that closes the class the human check found.**
|
|
|
|
|
///
|
|
|
|
|
/// On 2026-08-02 the maintainer ran `cb-play --serve 0` and could not
|
|
|
|
|
/// drag an action onto a seat. Every test in the repo was green. The
|
|
|
|
|
/// cause: the visible seat cards carried no `id`, so `seat-{n}` existed
|
|
|
|
|
/// only on the 26 px circles inside the relationship graph, while every
|
|
|
|
|
/// action card read *"drag Attack onto a seat…"*.
|
|
|
|
|
///
|
|
|
|
|
/// Nothing could catch it. `jsrun::gesture` feeds element ids straight
|
|
|
|
|
/// into a synthetic `{target:{id}}` and never hit-tests, so it
|
|
|
|
|
/// establishes *"the script posts the ids it was given"* — never
|
|
|
|
|
/// *"there is an element there to give."* The coverage gate asserts
|
|
|
|
|
/// each view field appears in the parsed document, which a `<div>` with
|
|
|
|
|
/// no id satisfies perfectly.
|
|
|
|
|
///
|
|
|
|
|
/// An affordance naming an element that does not exist is the
|
|
|
|
|
/// harness-does-nothing shape in the presentation layer, and until now
|
|
|
|
|
/// it had no detector at all.
|
|
|
|
|
#[test]
|
|
|
|
|
fn every_offered_affordance_names_an_element_that_exists() {
|
|
|
|
|
let checked = Rc::new(RefCell::new(0usize));
|
|
|
|
|
for seed in 0..4u64 {
|
|
|
|
|
let mut policies: Vec<Box<dyn Policy>> = (0..3)
|
|
|
|
|
.map(|i| {
|
|
|
|
|
Box::new(CheckingPolicy {
|
|
|
|
|
inner: RandomPolicy::new(seed * 10 + i),
|
|
|
|
|
checked: checked.clone(),
|
|
|
|
|
}) as Box<dyn Policy>
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
play(fresh(seed), &mut policies).expect("a bot game completes");
|
|
|
|
|
}
|
|
|
|
|
// Positive control: a run that rendered nothing would assert
|
|
|
|
|
// nothing and read as a pass — the exact failure this test exists
|
|
|
|
|
// to catch, one level up.
|
|
|
|
|
let n = *checked.borrow();
|
|
|
|
|
assert!(n >= 50, "checked only {n} decision point(s)");
|
|
|
|
|
}
|
|
|
|
|
|
CB-WP-0017: legible interaction, and the chaos window's verdict
Provenance (tier M, structural S, chaos d4=4 -> OVERRIDE drawn M):
the maintainer could drag after CB-WP-0016 but could not tell what was
pickable, held, or droppable. Underneath that, the page was WRONG about
which moves exist: 9 legal commands rendered as 5 cards each claiming
all three target kinds, from a const string in the emitter. Investigate
is legal on problems 2 and 3 but not 1; Solve on 1 but not 2 or 3. The
live page now says 'Solve onto problem 1'.
ADR-0010 restates control 5, which this work would otherwise have
outgrown in silence: every game fact the page acts on must arrive from
Rust as data; the script may read, match and render it, never compute,
infer, filter or default one. The survey's real finding is that the
permitted and forbidden designs are indistinguishable from outside, so
the vocabulary grep is demoted to a cheap first line and two behavioural
properties become the controls -- the highlighted set EQUALS the set
Rust emitted, and anything the page marks legal must resolve. Both
mutation-proven; the derive-legality mutation produces a plausible
highlight (seat-0,1,2 where only seat-1 is legal) and is caught.
Visible now: .pick resting shadow, .held on the grabbed element, .dropok
on every legal target including BOTH drawings of a seat, and a ghost
following the pointer. Nothing perceptual is verified and ADR-0010 D5
says so.
The DOM stub now models classList/querySelectorAll/createElement and
builds its node set from the real emitted page. Trap recorded: QuickJS
fixes its stack limit at Context creation relative to that frame, so a
helper returning a Context makes every later eval report
'SyntaxError: stack overflow'.
CHAOS WINDOW CLOSED, 12 declarations, 2 overrides, one each way. Both
changed the outcome, so the retirement condition is not met. Verdict:
keep, and recommend d4 -> d8 with a second window of 12 -- that is a
change to the loop's own constraints and is owed to the next declaration
as tier-M work, not made here.
CB-EV-0014 corrected: it quoted CB-WP-0015 at $15.14/136 and called it
the first settled figure quoted. Now $22.70/166. The number had been
read during CB-WP-0015 itself, so there are two defects -- the boundary,
and quoting from memory instead of re-running the instrument.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 22:42:45 +02:00
|
|
|
/// **ADR-0010 Decision 2, property 2.** A target the page marks legal
|
|
|
|
|
/// must resolve.
|
|
|
|
|
///
|
|
|
|
|
/// If the page can advertise a drop that Rust then refuses, the two
|
|
|
|
|
/// have drifted and the highlighting is *worse* than none — it teaches
|
|
|
|
|
/// the player something false. This walks real games and checks the
|
|
|
|
|
/// emitted `data-targets` against `resolve` itself, so the page's
|
|
|
|
|
/// promise and the referee's answer cannot disagree.
|
|
|
|
|
#[test]
|
|
|
|
|
fn everything_the_page_advertises_actually_resolves() {
|
|
|
|
|
let checked = Rc::new(RefCell::new(0usize));
|
|
|
|
|
for seed in 0..4u64 {
|
|
|
|
|
let mut policies: Vec<Box<dyn Policy>> = (0..3)
|
|
|
|
|
.map(|i| {
|
|
|
|
|
Box::new(AdvertisedPolicy {
|
|
|
|
|
inner: RandomPolicy::new(seed * 7 + i),
|
|
|
|
|
checked: checked.clone(),
|
|
|
|
|
}) as Box<dyn Policy>
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
play(fresh(seed), &mut policies).expect("a bot game completes");
|
|
|
|
|
}
|
|
|
|
|
let n = *checked.borrow();
|
|
|
|
|
assert!(n >= 50, "checked only {n} advertised target(s)");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct AdvertisedPolicy {
|
|
|
|
|
inner: RandomPolicy,
|
|
|
|
|
checked: Rc<RefCell<usize>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Policy for AdvertisedPolicy {
|
|
|
|
|
fn name(&self) -> &'static str {
|
|
|
|
|
"advertised-target-checking"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn choose(
|
|
|
|
|
&mut self,
|
|
|
|
|
state: &GroundState,
|
|
|
|
|
seat: PlayerId,
|
|
|
|
|
legal: &[GroundCommand],
|
|
|
|
|
may_pass: bool,
|
|
|
|
|
) -> Choice {
|
|
|
|
|
let view = state.project(Viewer::Player(seat));
|
|
|
|
|
let html = doc::document(&view, legal, "/command?t=x", Some(seat), may_pass);
|
|
|
|
|
|
|
|
|
|
for (grab, targets) in crate::jsrun::droppables(&html) {
|
|
|
|
|
let Some(spec) = targets else { continue };
|
|
|
|
|
for drop in spec.split(' ').filter(|s| !s.is_empty()) {
|
|
|
|
|
let fact = crate::PointerFact::new(&grab, drop);
|
|
|
|
|
assert!(
|
|
|
|
|
crate::resolve(&fact, legal, seat).is_ok(),
|
|
|
|
|
"the page advertises {grab} -> {drop} but resolve refuses it \
|
|
|
|
|
(seat {seat:?}, step {:?})",
|
|
|
|
|
state.step
|
|
|
|
|
);
|
|
|
|
|
*self.checked.borrow_mut() += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
self.inner.choose(state, seat, legal, may_pass)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
CB-WP-0016: the drop target that was never there
Provenance (tier S, one paragraph in lieu of survey and ADR): the human
check that kept INTENT stage 1 open was run and the drag was broken.
Root cause, worth more than the instance: drop targets were ids, and an
id must be unique, so exactly one element could ever be seat-0. The
relationship-graph circle took it and the seat card that every action
card's own text points at -- 'drag Attack onto a seat' -- silently had
none. A seat is drawn twice and both drawings are the seat; the document
model could not express that.
Drop keys are now data-drop. Any number of elements may carry the same
key, so a seat is droppable on its card and on its graph node. Measured
on a live server: seat-0/1/2 each appear twice, id survives only on
cb-status which is the one element the script looks up, and
down=action-attack&up=seat-1 returns ok.
Second defect: a drop on nothing returned without posting and without
touching the status line, so a broken target was indistinguishable from
a working page. resolve already refuses rather than defaulting, which is
right; refusing SILENTLY is not. The page now reports the raw fact --
'took action-attack, let go over nothing droppable' -- which names
elements, not moves, so ADR-0007 control 5 holds.
And the honest part: the general check added here -- every offered
affordance names a key that exists, driven through Policy::choose over
four real bot games -- does NOT catch the reported defect. seat-0 did
exist, on the graph circle. It is kept because a wholly absent target is
a real class, and paired with a targeted regression test that does catch
it. Three mutations, each red for its stated reason, including the
reported defect reintroduced; only the targeted test fires on that one.
A cb-play assertion matched id="action-ground" as a substring while
describing itself as checking the page; rewritten through drop_keys.
make all exits 0. Stage 1 stays open: verified by tests, mutation and a
live server, not by a human dragging.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 20:48:18 +02:00
|
|
|
/// **The regression test for the defect actually reported**, and the
|
|
|
|
|
/// reason the check above is not sufficient on its own.
|
|
|
|
|
///
|
|
|
|
|
/// `every_offered_affordance_names_an_element_that_exists` passes on
|
|
|
|
|
/// the broken tree. `seat-0` *did* exist — on the 26 px circle in the
|
|
|
|
|
/// relationship graph — so an existence check over the whole document
|
|
|
|
|
/// cannot see that the seat *card*, which is what the instruction text
|
|
|
|
|
/// points at, was not droppable.
|
|
|
|
|
///
|
|
|
|
|
/// A seat is drawn twice and both drawings are the seat. This asserts
|
|
|
|
|
/// the card specifically, by requiring the drop key on the element
|
|
|
|
|
/// that also carries `data-viewer` — the card, and nothing else.
|
|
|
|
|
#[test]
|
|
|
|
|
fn every_seat_card_is_a_drop_target_not_only_the_graph_node() {
|
|
|
|
|
let view = crate::testfix::view(Some(PlayerId(0)));
|
|
|
|
|
let html = doc::document(&view, &[], "/command?t=x", Some(PlayerId(0)), false);
|
|
|
|
|
|
|
|
|
|
for seat in view.players.keys() {
|
|
|
|
|
let card = format!(
|
|
|
|
|
"data-viewer=\"{}\" data-drop=\"seat-{}\"",
|
|
|
|
|
view.viewer == Some(*seat),
|
|
|
|
|
seat.0
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
html.contains(&card),
|
|
|
|
|
"seat {seat:?} has a card that is not a drop target; looked for {card:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// And the graph node keeps working — someone will have learned to
|
|
|
|
|
// aim at the circle, and this fix must not take that away.
|
|
|
|
|
let keys = doc::drop_keys(&html);
|
|
|
|
|
for seat in view.players.keys() {
|
|
|
|
|
assert!(keys.contains(&format!("seat-{}", seat.0)));
|
|
|
|
|
}
|
|
|
|
|
assert_eq!(
|
|
|
|
|
html.matches("data-drop=\"seat-0\"").count(),
|
|
|
|
|
2,
|
|
|
|
|
"seat 0 should be droppable in exactly two places: card and graph node"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-03 02:15:30 +02:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod gamelog {
|
|
|
|
|
//! CB-WP-0018 T02.
|
|
|
|
|
|
|
|
|
|
use crate::doc::{document_with_log, LogLine};
|
|
|
|
|
use cb_kernel::PlayerId;
|
|
|
|
|
|
|
|
|
|
fn line(effects: &[&str]) -> LogLine {
|
|
|
|
|
LogLine {
|
|
|
|
|
who: "P1".into(),
|
|
|
|
|
what: "select_action action=SOLVE problem=1".into(),
|
|
|
|
|
effects: effects.iter().map(|s| (*s).to_string()).collect(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn page(log: &[LogLine]) -> String {
|
|
|
|
|
document_with_log(
|
|
|
|
|
&crate::testfix::view(Some(PlayerId(0))),
|
|
|
|
|
&[],
|
|
|
|
|
"/command?t=x",
|
|
|
|
|
Some(PlayerId(0)),
|
|
|
|
|
false,
|
|
|
|
|
log,
|
CB-WP-0027 T01-T04: the commentary track
The meta view beside the table, and a note channel that provably cannot
carry a move.
T01 (ADR-0014). ADR-0007 D5 is SCOPED, NOT AMENDED, and the reason it was
easy is that PointerFact::parse already refuses any unrecognised field --
a comment could not reach the command path even by accident. So /command
carries pointer facts, /note carries text, and Note has no code path to
GroundCommand. Comments live in trials/<date>-<slug>.md, not in
ScenarioFile: a scenario is executed, replayed and hashed, and prose in it
is data the runner must ignore, which is how a format rots. The state hash
binds; round and step are for reading. And the retention question, decided
before any comment was written: RAW NOTES NEVER LEAVE clay-borg. A note
reaches ground-game only by being promoted to a register finding, by a
human, with the wording chosen then -- "the DARVO sequence is infuriating"
is useful signal and a bad way to open a message to the game's designer.
T02. CSS grid, minmax(0,1fr) on both tracks -- load-bearing, because a
grid child defaults to min-content width and without it the SVG table
refuses to shrink and pushes the meta column off-screen, looking correct
on the developer's monitor and broken everywhere else. Single-column
fallback under 64rem. The running tally moved into the panel so it is
visible WHILE PLAYING; it only appeared on the ending page before, and a
score you see once the game is over informs nothing.
T03. A plain <form method="post">, so the box works with the script
disabled; the command channel needs JavaScript because a drag is not a
form submission, a comment is one. 303 See Other so a reload does not
re-post. esc()'s first hostile input: <script>alert(1)</script> renders
escaped AND STILL READABLE -- escaping that eats the player's words is its
own defect. Verified over real HTTP: note posted 303, hostile note stored
as text, empty note refused 400, game did not advance.
T04. tools/trials.py and make trials. THE REPORT'S DESIGN CHANGED BECAUSE
I RAN IT: the first version called any note without a recording an orphan,
so a live session reported every note as broken -- the recording is only
written at game end. A metric that cries wolf is one nobody reads, which
is the exact failure this pass exists to prevent. Now ok / pending /
orphan, and only orphan is a target-0 number. The self-test exercises the
REPORTING path, not just the parser, because design-baseline.py had a
green self-test and an unexercised reporting path and that is where it
rotted.
And a latent Makefile defect surfaced: make trials did nothing, because
trials is also a directory and Make saw an up-to-date file. design,
difficulty and trials -- added by CB-WP-0022, CB-WP-0025 and this pass --
were ALL missing from .PHONY; only the one that collided revealed it.
make all: exit 0. 49 render tests, 26 cb-play, loop-lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:37:46 +02:00
|
|
|
&[],
|
2026-08-03 02:15:30 +02:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// **The case the pass was reported for.** A SOLVE that cannot be
|
|
|
|
|
/// fulfilled produces no events, and a log built only from events
|
|
|
|
|
/// would render nothing for it — reproducing the silence the
|
|
|
|
|
/// maintainer hit when the same move did nothing three rounds
|
|
|
|
|
/// running.
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_command_that_produced_nothing_says_so() {
|
|
|
|
|
let html = page(&[line(&[])]);
|
|
|
|
|
assert!(
|
|
|
|
|
html.contains("no effect"),
|
|
|
|
|
"a command with no events rendered as if it had done something"
|
|
|
|
|
);
|
|
|
|
|
let text = crate::text_of(&html);
|
|
|
|
|
assert!(text.contains("select_action action=SOLVE problem=1"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn effects_are_listed_and_an_empty_log_says_it_is_empty() {
|
|
|
|
|
let text = crate::text_of(&page(&[line(&["problem 1 claimed by P1"])]));
|
|
|
|
|
assert!(text.contains("problem 1 claimed by P1"));
|
|
|
|
|
assert!(
|
|
|
|
|
!text.contains("no effect"),
|
|
|
|
|
"a command WITH effects was marked as having none"
|
|
|
|
|
);
|
|
|
|
|
assert!(crate::text_of(&page(&[])).contains("nothing has happened yet"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
CB-WP-0024 T01/T02: the ending control says what it does, and the piles are objects
T01. The maintainer asked why the button says "I need to read this" and
why nothing closes. Two defects behind one control: the label described a
reading while the control STOPS THE SERVER (hotseat.rs reads `done` and
breaks its loop), and acknowledging it changed nothing on screen -- the
tab kept a live table and a `play again` pointing at a closed port.
Label is now "end session -- stops the game server". The reply says the
session has ended and the tab can be closed. 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. CSS is how
that reads, not the mechanism. removeAttribute rather than
setAttribute(_, null) -- the latter writes the truthy string "null" in a
browser, so the control would stay live while the stub called 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 reacting to the server was
unreachable from every test in this project. That is why the defect
survived: a page ignoring the server looked identical to one acting on it.
The stub now delivers a real then-chain and gesture_with_reply reports
which controls survive. The seal is mutation-proven -- deleting the
`closed` branch turns exactly one test red -- and a negative control
asserts an `ok: dealing` reply does NOT seal, since a seal that fired on
every reply would pass the first test and break `play again`.
T02. Draw and discard drawn as offset stacks with their counts. The
shuffle question the task required answering is settled and the answer is
that it already works: games/ground/src/lib.rs:1419-1435 implements the U4
default -- deterministic reshuffle of the discard seeded from seed ^ round,
skip the draw if both are empty -- and ground-game CONFIRMED U4 on
2026-08-03. A ruled rule, not an invented one, nothing to raise. The event
already reads out in the log; what the piles add is the state before it
fires, which is derivable from the view. A claim that a reshuffle HAS
happened would not be, and is not made.
The coverage gate caught its own probe going stale when the "17 remaining"
text was replaced. The count now lives in the pile's <title> -- a stable
probe and what a screen reader announces, where the on-canvas numeral
could be any number on the page.
39 tests pass; cb-play 22 including play_again_deals_a_second_game.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:23:09 +02:00
|
|
|
/// 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-0027 T01-T04: the commentary track
The meta view beside the table, and a note channel that provably cannot
carry a move.
T01 (ADR-0014). ADR-0007 D5 is SCOPED, NOT AMENDED, and the reason it was
easy is that PointerFact::parse already refuses any unrecognised field --
a comment could not reach the command path even by accident. So /command
carries pointer facts, /note carries text, and Note has no code path to
GroundCommand. Comments live in trials/<date>-<slug>.md, not in
ScenarioFile: a scenario is executed, replayed and hashed, and prose in it
is data the runner must ignore, which is how a format rots. The state hash
binds; round and step are for reading. And the retention question, decided
before any comment was written: RAW NOTES NEVER LEAVE clay-borg. A note
reaches ground-game only by being promoted to a register finding, by a
human, with the wording chosen then -- "the DARVO sequence is infuriating"
is useful signal and a bad way to open a message to the game's designer.
T02. CSS grid, minmax(0,1fr) on both tracks -- load-bearing, because a
grid child defaults to min-content width and without it the SVG table
refuses to shrink and pushes the meta column off-screen, looking correct
on the developer's monitor and broken everywhere else. Single-column
fallback under 64rem. The running tally moved into the panel so it is
visible WHILE PLAYING; it only appeared on the ending page before, and a
score you see once the game is over informs nothing.
T03. A plain <form method="post">, so the box works with the script
disabled; the command channel needs JavaScript because a drag is not a
form submission, a comment is one. 303 See Other so a reload does not
re-post. esc()'s first hostile input: <script>alert(1)</script> renders
escaped AND STILL READABLE -- escaping that eats the player's words is its
own defect. Verified over real HTTP: note posted 303, hostile note stored
as text, empty note refused 400, game did not advance.
T04. tools/trials.py and make trials. THE REPORT'S DESIGN CHANGED BECAUSE
I RAN IT: the first version called any note without a recording an orphan,
so a live session reported every note as broken -- the recording is only
written at game end. A metric that cries wolf is one nobody reads, which
is the exact failure this pass exists to prevent. Now ok / pending /
orphan, and only orphan is a target-0 number. The self-test exercises the
REPORTING path, not just the parser, because design-baseline.py had a
green self-test and an unexercised reporting path and that is where it
rotted.
And a latent Makefile defect surfaced: make trials did nothing, because
trials is also a directory and Make saw an up-to-date file. design,
difficulty and trials -- added by CB-WP-0022, CB-WP-0025 and this pass --
were ALL missing from .PHONY; only the one that collided revealed it.
make all: exit 0. 49 render tests, 26 cb-play, loop-lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:37:46 +02:00
|
|
|
/// CB-WP-0027 T03 — the note channel, and the two things it must not do.
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod notes {
|
|
|
|
|
use cb_kernel::PlayerId;
|
|
|
|
|
|
|
|
|
|
use crate::doc::{document_with_log, text_of};
|
|
|
|
|
use crate::input::{resolve, Note, PointerFact};
|
|
|
|
|
|
|
|
|
|
/// **The load-bearing control (ADR-0014 D1).** A note whose text is a
|
|
|
|
|
/// perfectly well-formed pointer fact must not become a move.
|
|
|
|
|
///
|
|
|
|
|
/// Structural, not vigilance: `Note::parse` returns a `Note`,
|
|
|
|
|
/// `resolve` takes a `PointerFact`, and nothing converts between
|
|
|
|
|
/// them. This asserts the behaviour anyway, because "the types don't
|
|
|
|
|
/// connect" is a claim about code layout until something checks it.
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_note_that_looks_like_a_move_is_not_one() {
|
|
|
|
|
let hostile = "note=down%3Daction-solve%26up%3Dproblem-1";
|
|
|
|
|
let note = Note::parse(hostile).expect("it parses as a note");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
note.text, "down=action-solve&up=problem-1",
|
|
|
|
|
"the text is stored verbatim, uninterpreted"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// And the command parser refuses the same body outright — the two
|
|
|
|
|
// channels do not overlap even at the wire level.
|
|
|
|
|
assert!(
|
|
|
|
|
PointerFact::parse(hostile).is_err(),
|
|
|
|
|
"the command channel accepted a note body"
|
|
|
|
|
);
|
|
|
|
|
// The reverse, so this is not vacuous: a real pointer fact IS a
|
|
|
|
|
// command, and is NOT a note.
|
|
|
|
|
assert!(PointerFact::parse("down=a&up=b").is_ok());
|
|
|
|
|
assert!(
|
|
|
|
|
Note::parse("down=a&up=b").is_err(),
|
|
|
|
|
"the note channel accepted a pointer fact"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Nothing in the crate turns a Note into a command. `resolve`'s
|
|
|
|
|
// signature is the proof; this pins it against a careless change.
|
|
|
|
|
let legal: Vec<games_ground::GroundCommand> = vec![];
|
|
|
|
|
assert!(resolve(&PointerFact::new("x", "y"), &legal, PlayerId(0)).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// An empty note is refused, not stored — a blank row is noise in the
|
|
|
|
|
/// register.
|
|
|
|
|
#[test]
|
|
|
|
|
fn an_empty_note_is_refused() {
|
|
|
|
|
assert!(Note::parse("note=").is_err());
|
|
|
|
|
assert!(Note::parse("note=%20%20").is_err(), "whitespace is empty");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
Note::parse("note=%20hello%20").expect("real text").text,
|
|
|
|
|
"hello",
|
|
|
|
|
"surrounding whitespace is trimmed"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Percent and `+` decoding, since the form posts urlencoded.
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_players_words_survive_the_wire() {
|
|
|
|
|
let n = Note::parse("note=why+is+SOLVE+doing+nothing%3F").expect("parses");
|
|
|
|
|
assert_eq!(n.text, "why is SOLVE doing nothing?");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// **The first hostile input this renderer has handled.** Until now
|
|
|
|
|
/// `esc()` escaped suit names.
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_note_containing_markup_renders_as_text() {
|
|
|
|
|
let hostile = "<script>alert(1)</script> & \"quoted\"";
|
|
|
|
|
let html = document_with_log(
|
|
|
|
|
&crate::testfix::view(Some(PlayerId(0))),
|
|
|
|
|
&[],
|
|
|
|
|
"/command?t=x",
|
|
|
|
|
Some(PlayerId(0)),
|
|
|
|
|
false,
|
|
|
|
|
&[],
|
|
|
|
|
&[hostile.to_string()],
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!html.contains("<script>alert(1)</script>"),
|
|
|
|
|
"a note's markup reached the document unescaped"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
html.contains("<script>"),
|
|
|
|
|
"the note should still be visible, escaped"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
text_of(&html).contains("alert(1)"),
|
|
|
|
|
"escaping must not eat the player's words — they still read what they wrote"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The comment box is always offered, and the page works without it
|
|
|
|
|
/// being used. A control that appears only sometimes trains a player
|
|
|
|
|
/// not to look for it.
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_comment_box_is_always_there() {
|
|
|
|
|
let html = document_with_log(
|
|
|
|
|
&crate::testfix::view(Some(PlayerId(0))),
|
|
|
|
|
&[],
|
|
|
|
|
"/command?t=x",
|
|
|
|
|
Some(PlayerId(0)),
|
|
|
|
|
false,
|
|
|
|
|
&[],
|
|
|
|
|
&[],
|
|
|
|
|
);
|
|
|
|
|
assert!(html.contains("action=\"/note\""), "no comment box");
|
|
|
|
|
assert!(
|
|
|
|
|
html.contains("method=\"post\""),
|
|
|
|
|
"a plain form, so it works with the script disabled"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// CB-WP-0027 T02 — the table on the left, the meta on the right.
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod two_columns {
|
|
|
|
|
use cb_kernel::PlayerId;
|
|
|
|
|
|
|
|
|
|
use crate::doc::document_with_log;
|
|
|
|
|
|
|
|
|
|
fn page(meta: &[String]) -> String {
|
|
|
|
|
document_with_log(
|
|
|
|
|
&crate::testfix::view(Some(PlayerId(0))),
|
|
|
|
|
&[],
|
|
|
|
|
"/command?t=x",
|
|
|
|
|
Some(PlayerId(0)),
|
|
|
|
|
false,
|
|
|
|
|
&[],
|
|
|
|
|
meta,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The columns exist, and the game is in the left one.
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_table_is_in_the_game_column_and_the_log_is_not() {
|
|
|
|
|
let html = page(&[]);
|
|
|
|
|
let game = html
|
|
|
|
|
.split("class=\"cb-game\"")
|
|
|
|
|
.nth(1)
|
|
|
|
|
.and_then(|s| s.split("class=\"cb-meta\"").next())
|
|
|
|
|
.expect("a game column followed by a meta column");
|
|
|
|
|
assert!(
|
|
|
|
|
game.contains("<h2>problems</h2>"),
|
|
|
|
|
"the table must be in the game column"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!game.contains("<h2>log</h2>"),
|
|
|
|
|
"the log belongs in the meta column — it is commentary on the game, not part of it"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// **A player who writes nothing must not be worse off.** An empty
|
|
|
|
|
/// meta panel renders as nothing, not as an empty heading.
|
|
|
|
|
#[test]
|
|
|
|
|
fn an_empty_meta_panel_adds_no_furniture() {
|
|
|
|
|
assert!(
|
|
|
|
|
!page(&[]).contains("this session"),
|
|
|
|
|
"an empty session panel drew a heading with nothing under it"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
page(&["2 games this session".into()]).contains("this session"),
|
|
|
|
|
"a non-empty panel must appear — otherwise the test above passes for a panel that never renders"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The single-column fallback is deliberate, not incidental. Asserted
|
|
|
|
|
/// on the stylesheet because there is no browser here to resize —
|
|
|
|
|
/// which is a weaker test than laying it out, and is said so rather
|
|
|
|
|
/// than dressed up.
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_layout_collapses_to_one_column_on_a_narrow_viewport() {
|
|
|
|
|
let html = page(&[]);
|
|
|
|
|
assert!(
|
|
|
|
|
html.contains("@media (max-width:64rem)"),
|
|
|
|
|
"no narrow-viewport rule: the two-column layout would overlap on a laptop"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
html.contains("minmax(0,1fr)"),
|
|
|
|
|
"grid children default to min-content width; without minmax(0,…) the SVG table \
|
|
|
|
|
refuses to shrink and pushes the meta column off-screen"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
CB-WP-0024: the table you can watch
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>
2026-08-05 17:32:48 +02:00
|
|
|
/// 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/T02: the ending control says what it does, and the piles are objects
T01. The maintainer asked why the button says "I need to read this" and
why nothing closes. Two defects behind one control: the label described a
reading while the control STOPS THE SERVER (hotseat.rs reads `done` and
breaks its loop), and acknowledging it changed nothing on screen -- the
tab kept a live table and a `play again` pointing at a closed port.
Label is now "end session -- stops the game server". The reply says the
session has ended and the tab can be closed. 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. CSS is how
that reads, not the mechanism. removeAttribute rather than
setAttribute(_, null) -- the latter writes the truthy string "null" in a
browser, so the control would stay live while the stub called 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 reacting to the server was
unreachable from every test in this project. That is why the defect
survived: a page ignoring the server looked identical to one acting on it.
The stub now delivers a real then-chain and gesture_with_reply reports
which controls survive. The seal is mutation-proven -- deleting the
`closed` branch turns exactly one test red -- and a negative control
asserts an `ok: dealing` reply does NOT seal, since a seal that fired on
every reply would pass the first test and break `play again`.
T02. Draw and discard drawn as offset stacks with their counts. The
shuffle question the task required answering is settled and the answer is
that it already works: games/ground/src/lib.rs:1419-1435 implements the U4
default -- deterministic reshuffle of the discard seeded from seed ^ round,
skip the draw if both are empty -- and ground-game CONFIRMED U4 on
2026-08-03. A ruled rule, not an invented one, nothing to raise. The event
already reads out in the log; what the piles add is the state before it
fires, which is derivable from the view. A claim that a reshuffle HAS
happened would not be, and is not made.
The coverage gate caught its own probe going stale when the "17 remaining"
text was replaced. The count now lives in the pile's <title> -- a stable
probe and what a screen reader announces, where the on-canvas numeral
could be any number on the page.
39 tests pass; cb-play 22 including play_again_deals_a_second_game.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:23:09 +02:00
|
|
|
/// 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 {
|
CB-WP-0024: the table you can watch
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>
2026-08-05 17:32:48 +02:00
|
|
|
doc::ending(None, "the game ended", "/command?t=x", &[], &[])
|
CB-WP-0024 T01/T02: the ending control says what it does, and the piles are objects
T01. The maintainer asked why the button says "I need to read this" and
why nothing closes. Two defects behind one control: the label described a
reading while the control STOPS THE SERVER (hotseat.rs reads `done` and
breaks its loop), and acknowledging it changed nothing on screen -- the
tab kept a live table and a `play again` pointing at a closed port.
Label is now "end session -- stops the game server". The reply says the
session has ended and the tab can be closed. 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. CSS is how
that reads, not the mechanism. removeAttribute rather than
setAttribute(_, null) -- the latter writes the truthy string "null" in a
browser, so the control would stay live while the stub called 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 reacting to the server was
unreachable from every test in this project. That is why the defect
survived: a page ignoring the server looked identical to one acting on it.
The stub now delivers a real then-chain and gesture_with_reply reports
which controls survive. The seal is mutation-proven -- deleting the
`closed` branch turns exactly one test red -- and a negative control
asserts an `ok: dealing` reply does NOT seal, since a seal that fired on
every reply would pass the first test and break `play again`.
T02. Draw and discard drawn as offset stacks with their counts. The
shuffle question the task required answering is settled and the answer is
that it already works: games/ground/src/lib.rs:1419-1435 implements the U4
default -- deterministic reshuffle of the discard seeded from seed ^ round,
skip the draw if both are empty -- and ground-game CONFIRMED U4 on
2026-08-03. A ruled rule, not an invented one, nothing to raise. The event
already reads out in the log; what the piles add is the state before it
fires, which is derivable from the view. A claim that a reshuffle HAS
happened would not be, and is not made.
The coverage gate caught its own probe going stale when the "17 remaining"
text was replaced. The count now lives in the pile's <title> -- a stable
probe and what a screen reader announces, where the on-canvas numeral
could be any number on the page.
39 tests pass; cb-play 22 including play_again_deals_a_second_game.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:23:09 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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();
|
CB-WP-0024: the table you can watch
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>
2026-08-05 17:32:48 +02:00
|
|
|
let (live, _) = jsrun::gesture_with_reply(&html, "again", "again", "ok: dealing")
|
|
|
|
|
.expect("run the page");
|
CB-WP-0024 T01/T02: the ending control says what it does, and the piles are objects
T01. The maintainer asked why the button says "I need to read this" and
why nothing closes. Two defects behind one control: the label described a
reading while the control STOPS THE SERVER (hotseat.rs reads `done` and
breaks its loop), and acknowledging it changed nothing on screen -- the
tab kept a live table and a `play again` pointing at a closed port.
Label is now "end session -- stops the game server". The reply says the
session has ended and the tab can be closed. 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. CSS is how
that reads, not the mechanism. removeAttribute rather than
setAttribute(_, null) -- the latter writes the truthy string "null" in a
browser, so the control would stay live while the stub called 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 reacting to the server was
unreachable from every test in this project. That is why the defect
survived: a page ignoring the server looked identical to one acting on it.
The stub now delivers a real then-chain and gesture_with_reply reports
which controls survive. The seal is mutation-proven -- deleting the
`closed` branch turns exactly one test red -- and a negative control
asserts an `ok: dealing` reply does NOT seal, since a seal that fired on
every reply would pass the first test and break `play again`.
T02. Draw and discard drawn as offset stacks with their counts. The
shuffle question the task required answering is settled and the answer is
that it already works: games/ground/src/lib.rs:1419-1435 implements the U4
default -- deterministic reshuffle of the discard seeded from seed ^ round,
skip the draw if both are empty -- and ground-game CONFIRMED U4 on
2026-08-03. A ruled rule, not an invented one, nothing to raise. The event
already reads out in the log; what the piles add is the state before it
fires, which is derivable from the view. A claim that a reshuffle HAS
happened would not be, and is not made.
The coverage gate caught its own probe going stale when the "17 remaining"
text was replaced. The count now lives in the pile's <title> -- a stable
probe and what a screen reader announces, where the on-canvas numeral
could be any number on the page.
39 tests pass; cb-play 22 including play_again_deals_a_second_game.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:23:09 +02:00
|
|
|
assert!(
|
|
|
|
|
live.contains(&"again".to_string()) && live.contains(&"done".to_string()),
|
|
|
|
|
"an 'ok' reply must not seal the page: {live:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit
Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat
play, at a measured marginal AM-4a cost of zero.
games-ground shipped: 23 third-party crates
cb-render-html: 23 third-party crates
new crates introduced: 0
Measured, not asserted — the survey's own lesson. AM-4a is unmoved at
246,250; own source is 7,636 -> 9,652.
What shipped:
crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship
graph), input.rs (pointer facts -> commands),
serve.rs (Guard, Request, loopback bind)
tools/cb-play hotseat.rs + `--serve PORT`
Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null.
The renderer targets the existing Project trait; the port waits for
stage 2's wgpu implementation to be its second use.
The six controls, all live, all mutation-checked (8 mutations, each red
for its stated reason):
1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind
4 a token-less request is refused, in the unit AND over a real socket
5 JS may not construct commands — the page reports pointer facts, Rust
resolves them against the legal list the aggregate already offered,
and a test asserts the emitted script contains no game vocabulary
6 the coverage gate crosses the language boundary: it walks the
serialized view for leaf paths and requires each token to appear in
the PARSED emitted document, with a test that the parse really is a
parse (script/style contents must not count as rendered)
The gate fired on its author again, on its first run: ground_choices.*.
choice, ground_choices.*.problem and players.*.blame_from were in neither
list. The last is the one worth keeping — an EMPTY vector is a leaf path
of its own, and it now renders as an explicit absence.
Also, a mutation that did not go red: removing the Sec-Fetch-Site arm
alone left the cross-site test green, because the Origin check caught it
independently. Both had to be removed before the control bit. Recorded
because a control that passes for a reason you did not intend has not
been demonstrated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod testfix;
|