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;
|
|
|
|
|
|
|
2026-08-06 15:32:09 +02:00
|
|
|
|
pub use doc::{document, text_of, Endpoints};
|
|
|
|
|
|
|
|
|
|
|
|
/// The endpoint pair every test in this crate posts to.
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
const TEST_ENDPOINTS: Endpoints<'static> = Endpoints {
|
|
|
|
|
|
command: "/command?t=x",
|
|
|
|
|
|
note: "/note?t=x",
|
|
|
|
|
|
};
|
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,
|
|
|
|
|
|
&[],
|
2026-08-06 15:32:09 +02:00
|
|
|
|
crate::TEST_ENDPOINTS.command,
|
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
|
|
|
|
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))),
|
|
|
|
|
|
&[],
|
2026-08-06 15:32:09 +02:00
|
|
|
|
crate::TEST_ENDPOINTS,
|
2026-08-03 02:15:30 +02:00
|
|
|
|
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-0028 T03/T08: one overhead table, and what the nine observations
turned out to be
T03. One table_svg: seats around an elliptical table starting at the
BOTTOM -- the viewer sits nearest the reader, as at a real table --
Problems and both stacks in the middle, each seat's played card between it
and the centre, relations drawn between seats.
Two renderers were DELETED: relations_svg and piles_svg. The task said one
table not two diagrams, and leaving the old ones would have meant drawing
the same thing twice and letting them drift.
No coverage probe cost, through a restructure that merged three diagrams
and removed two functions. Second confirmation of CB-WP-0027's finding: a
probe naming a FACT survives a reflow, one naming a PRESENTATION does not.
CB-WP-0024's "17 remaining" broke on a rendering change; this far larger
reflow broke nothing.
The new control is per seat count -- no two seat circles closer than 70px
at 2 through 6 -- asserted rather than eyeballed at three, which is the
only count anyone ever looks at.
T08 (CB-EV-0026). Seven of nine observations were engine defects, one was
a design finding, one was already true and nobody could tell.
Observations 4 and 5 both dissolved and had ONE cause: nothing on the page
said how drawing works, so a player built a mental model to fill the gap
and reported the gap as two feature requests.
The import gap was worse than "one of nineteen" -- 5 of 13 columns read
from the file we DID vendor, discarded at parse time for eight days. Rule
coverage was 59/59 throughout. The gate measures whether rules are
EXERCISED; nothing measures whether a player can READ the game, and
nothing cheaply could, which is why the person playing it is the
instrument.
TWO GATES WERE WRITTEN FOR A SMALLER WORLD, and neither was wrong when
written. edition-check compared one recorded digest against Problems.csv
regardless of which file it described -- correct with one vendored file,
comparing across files with four. And a cb-play test asserted the literal
"game over" and went red when a won game said "solved", which was T06
working; it now asserts the heading against the OUTCOME and covers the
no-outcome case the original never touched.
Chaos window 2 closes with zero overrides in eleven declarations at d8.
Third and final statement of it: d8 bought rarity by spending evidence,
and a mechanism producing no data across a full window cannot be evaluated
by that window.
make all: exit 0. 57 render tests, 26 cb-play, loop-lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:33:57 +02:00
|
|
|
|
/// CB-WP-0028 T03 — one overhead table, not three diagrams.
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod overhead_table {
|
|
|
|
|
|
use cb_game_runtime::{Project, ScenarioGame, Setup, Viewer};
|
|
|
|
|
|
use cb_kernel::PlayerId;
|
|
|
|
|
|
use games_ground::GroundState;
|
|
|
|
|
|
|
fix: the game was unplayable, and the note redirect was refused
Tier S (fixes inside a boundary; chaos d8=7, no override). Four
observations from play, three of them caused by CB-WP-0028 -- and make all
was green for all of them.
THE NOTE BUG, WHICH I GOT WRONG TWICE. The first fix put the token in the
form's action, and that worked. But the 303 afterwards pointed at bare `/`
with no token, so the note WAS SAVED and then the browser followed a
redirect control 1 refuses. The player sees "no session token" for a note
that already landed. A redirect is a request the browser makes on your
behalf and is subject to every control the others are. I had tested the
POST and stopped there -- the same mistake as the first fix, one step
further along. Guard::page_path() now carries the token, and the test
asserts the redirect target is ADMITTED rather than merely non-empty.
WHY DRAGGING BROKE, WHICH WAS NOT THE DRAG. The gesture logic was fine: the
JS harness posts correctly against the served page, and all 14 drop
targets are present. The table was 620px tall, which pushed the action
cards a full screen below the Problems -- and you cannot drag between two
things that are never on screen together. Now 440, with a test asserting
the declared height stays under 460 and saying why. That is a proxy for a
browser layout, not the property itself, and the test says so.
Seats sat ON the ellipse: they were placed at 0.83 of the table radius,
which is inside it. Now outside, asserted numerically at 2 through 6 seats
against the ellipse equation rather than eyeballed.
And the `table` drop target was a separate CARD among the move buttons,
which is exactly why a player looking at a picture of a table could not
find anywhere to drop. The drawn ellipse is the drop zone now, and a test
asserts there is EXACTLY ONE table target and that it is the drawn one --
two elements claiming to be the table is worse than none.
The gap this exposes is the one CB-EV-0026 named a day earlier: every test
asserted the DOM was correct, and it was. Nothing asserted the page was
usable, and the drag test passes on a page you cannot physically drag on.
What is added here are proxies a browser-less test can check.
make all: exit 0. 61 render tests, 26 cb-play.
Verified over real HTTP rather than by inspection: note POST 303, the
redirect carries the token, following it returns 200, the table declares
440, one drop zone, seats at (380,421)/(113,112)/(647,112) against a table
of rx=200 ry=118, and both notes reached the trial log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:51:35 +02:00
|
|
|
|
/// Seat circle centres, read out of the emitted SVG.
|
CB-WP-0029 T01-T03: components you can count, and a supply that does not bind
ADR-0016, Tokens.csv vendored, tracks and discs on the table, and a supply
audit that found nothing and says so.
T03 MEASURED BEFORE T01 DECIDED. 750 games, 2-6 seats, greedy and random:
Protection reaches 1 per seat and 2 on the table against a supply of 6;
Denied 3 of 5; relation links EXACTLY 12 OF 12 and never more; Focus/Blame
0 conflicts. The link row is the interesting one -- GR-L01's two-slot rule
IS the twelve-token supply written twice, which is the shape of a supply
needing no separate enforcement.
AND THE FIRST VERSION OF THE FOCUS/BLAME CHECK WAS WRONG. It compared a
seat's own placed Focus against its OWN blame_from -- but that list holds
OTHER players' discs, so they are different tokens. It reported 2
conflicts; corrected, it reports 0. Fifth instance of this project's
recurring defect, a number computed correctly about the wrong subject, and
the first caught before it left the repo rather than by a reviewer.
D2: a token is a VIEW, not a type. The aggregate gains no `Token` --
adding one would create a second source of truth for Stress, and the first
time they disagreed the bug would be invisible because both would look
internally consistent.
D3: quantity does NOT bind, and the reason is not the measurement. A
component limit the rules do not state is not a rule. Refusing a seventh
Protection token would enforce something nobody ruled -- CB-WP-0023's
error inverted: SOLVE was OFFERED where it could not act; this would
REFUSE where the rules allow. The check ships as a standing control, so a
future violation becomes a question for ground-game (does the box bound
the game, or do the rules?) rather than a bound the engine invented.
Registered as F22, withdrawn: a stated negative, because a survey that
finds nothing and leaves no trace cannot be told from one never run.
D4: Stress on a 0-5 track that turns red at 5 where DARVO arms, DARVO on
OFF/DENY/ATTACK/REVERSE, Freedom as the two-sided disc the edition says it
is, Protection and Blame counted, Lead and Round on the table.
Two tests broke on token discs and both were FIXTURE defects:
seat_centres matched every <circle> and track stops are circles. Seats now
carry class="seat".
The table height limit went 460 -> 500 as a CORRECTION, not a concession.
460 had no derivation; 500 does -- ~800px viewport less ~120 header and
~150 controls leaves ~530, and the version that broke dragging was 620.
CB-WP-0021 T06's rule is to fix the measurement rather than lower the
floor, and an underived number is a measurement defect.
make all: exit 0. 66 render tests, 26 cb-play.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:54:20 +02:00
|
|
|
|
///
|
|
|
|
|
|
/// Keyed on `class="seat"`, not on `<circle>`: CB-WP-0029 put token
|
|
|
|
|
|
/// discs and track stops on the table, which are also circles, and a
|
|
|
|
|
|
/// looser match reported them as overlapping seats.
|
fix: the game was unplayable, and the note redirect was refused
Tier S (fixes inside a boundary; chaos d8=7, no override). Four
observations from play, three of them caused by CB-WP-0028 -- and make all
was green for all of them.
THE NOTE BUG, WHICH I GOT WRONG TWICE. The first fix put the token in the
form's action, and that worked. But the 303 afterwards pointed at bare `/`
with no token, so the note WAS SAVED and then the browser followed a
redirect control 1 refuses. The player sees "no session token" for a note
that already landed. A redirect is a request the browser makes on your
behalf and is subject to every control the others are. I had tested the
POST and stopped there -- the same mistake as the first fix, one step
further along. Guard::page_path() now carries the token, and the test
asserts the redirect target is ADMITTED rather than merely non-empty.
WHY DRAGGING BROKE, WHICH WAS NOT THE DRAG. The gesture logic was fine: the
JS harness posts correctly against the served page, and all 14 drop
targets are present. The table was 620px tall, which pushed the action
cards a full screen below the Problems -- and you cannot drag between two
things that are never on screen together. Now 440, with a test asserting
the declared height stays under 460 and saying why. That is a proxy for a
browser layout, not the property itself, and the test says so.
Seats sat ON the ellipse: they were placed at 0.83 of the table radius,
which is inside it. Now outside, asserted numerically at 2 through 6 seats
against the ellipse equation rather than eyeballed.
And the `table` drop target was a separate CARD among the move buttons,
which is exactly why a player looking at a picture of a table could not
find anywhere to drop. The drawn ellipse is the drop zone now, and a test
asserts there is EXACTLY ONE table target and that it is the drawn one --
two elements claiming to be the table is worse than none.
The gap this exposes is the one CB-EV-0026 named a day earlier: every test
asserted the DOM was correct, and it was. Nothing asserted the page was
usable, and the drag test passes on a page you cannot physically drag on.
What is added here are proxies a browser-less test can check.
make all: exit 0. 61 render tests, 26 cb-play.
Verified over real HTTP rather than by inspection: note POST 303, the
redirect carries the token, following it returns 200, the table declares
440, one drop zone, seats at (380,421)/(113,112)/(647,112) against a table
of rx=200 ry=118, and both notes reached the trial log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:51:35 +02:00
|
|
|
|
fn seat_centres(html: &str) -> Vec<(f64, f64)> {
|
CB-WP-0029 T01-T03: components you can count, and a supply that does not bind
ADR-0016, Tokens.csv vendored, tracks and discs on the table, and a supply
audit that found nothing and says so.
T03 MEASURED BEFORE T01 DECIDED. 750 games, 2-6 seats, greedy and random:
Protection reaches 1 per seat and 2 on the table against a supply of 6;
Denied 3 of 5; relation links EXACTLY 12 OF 12 and never more; Focus/Blame
0 conflicts. The link row is the interesting one -- GR-L01's two-slot rule
IS the twelve-token supply written twice, which is the shape of a supply
needing no separate enforcement.
AND THE FIRST VERSION OF THE FOCUS/BLAME CHECK WAS WRONG. It compared a
seat's own placed Focus against its OWN blame_from -- but that list holds
OTHER players' discs, so they are different tokens. It reported 2
conflicts; corrected, it reports 0. Fifth instance of this project's
recurring defect, a number computed correctly about the wrong subject, and
the first caught before it left the repo rather than by a reviewer.
D2: a token is a VIEW, not a type. The aggregate gains no `Token` --
adding one would create a second source of truth for Stress, and the first
time they disagreed the bug would be invisible because both would look
internally consistent.
D3: quantity does NOT bind, and the reason is not the measurement. A
component limit the rules do not state is not a rule. Refusing a seventh
Protection token would enforce something nobody ruled -- CB-WP-0023's
error inverted: SOLVE was OFFERED where it could not act; this would
REFUSE where the rules allow. The check ships as a standing control, so a
future violation becomes a question for ground-game (does the box bound
the game, or do the rules?) rather than a bound the engine invented.
Registered as F22, withdrawn: a stated negative, because a survey that
finds nothing and leaves no trace cannot be told from one never run.
D4: Stress on a 0-5 track that turns red at 5 where DARVO arms, DARVO on
OFF/DENY/ATTACK/REVERSE, Freedom as the two-sided disc the edition says it
is, Protection and Blame counted, Lead and Round on the table.
Two tests broke on token discs and both were FIXTURE defects:
seat_centres matched every <circle> and track stops are circles. Seats now
carry class="seat".
The table height limit went 460 -> 500 as a CORRECTION, not a concession.
460 had no derivation; 500 does -- ~800px viewport less ~120 header and
~150 controls leaves ~530, and the version that broke dragging was 620.
CB-WP-0021 T06's rule is to fix the measurement rather than lower the
floor, and an underived number is a measurement defect.
make all: exit 0. 66 render tests, 26 cb-play.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:54:20 +02:00
|
|
|
|
html.match_indices("<circle class=\"seat\" cx=\"")
|
fix: the game was unplayable, and the note redirect was refused
Tier S (fixes inside a boundary; chaos d8=7, no override). Four
observations from play, three of them caused by CB-WP-0028 -- and make all
was green for all of them.
THE NOTE BUG, WHICH I GOT WRONG TWICE. The first fix put the token in the
form's action, and that worked. But the 303 afterwards pointed at bare `/`
with no token, so the note WAS SAVED and then the browser followed a
redirect control 1 refuses. The player sees "no session token" for a note
that already landed. A redirect is a request the browser makes on your
behalf and is subject to every control the others are. I had tested the
POST and stopped there -- the same mistake as the first fix, one step
further along. Guard::page_path() now carries the token, and the test
asserts the redirect target is ADMITTED rather than merely non-empty.
WHY DRAGGING BROKE, WHICH WAS NOT THE DRAG. The gesture logic was fine: the
JS harness posts correctly against the served page, and all 14 drop
targets are present. The table was 620px tall, which pushed the action
cards a full screen below the Problems -- and you cannot drag between two
things that are never on screen together. Now 440, with a test asserting
the declared height stays under 460 and saying why. That is a proxy for a
browser layout, not the property itself, and the test says so.
Seats sat ON the ellipse: they were placed at 0.83 of the table radius,
which is inside it. Now outside, asserted numerically at 2 through 6 seats
against the ellipse equation rather than eyeballed.
And the `table` drop target was a separate CARD among the move buttons,
which is exactly why a player looking at a picture of a table could not
find anywhere to drop. The drawn ellipse is the drop zone now, and a test
asserts there is EXACTLY ONE table target and that it is the drawn one --
two elements claiming to be the table is worse than none.
The gap this exposes is the one CB-EV-0026 named a day earlier: every test
asserted the DOM was correct, and it was. Nothing asserted the page was
usable, and the drag test passes on a page you cannot physically drag on.
What is added here are proxies a browser-less test can check.
make all: exit 0. 61 render tests, 26 cb-play.
Verified over real HTTP rather than by inspection: note POST 303, the
redirect carries the token, following it returns 200, the table declares
440, one drop zone, seats at (380,421)/(113,112)/(647,112) against a table
of rx=200 ry=118, and both notes reached the trial log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:51:35 +02:00
|
|
|
|
.filter_map(|(i, _)| {
|
CB-WP-0029 T01-T03: components you can count, and a supply that does not bind
ADR-0016, Tokens.csv vendored, tracks and discs on the table, and a supply
audit that found nothing and says so.
T03 MEASURED BEFORE T01 DECIDED. 750 games, 2-6 seats, greedy and random:
Protection reaches 1 per seat and 2 on the table against a supply of 6;
Denied 3 of 5; relation links EXACTLY 12 OF 12 and never more; Focus/Blame
0 conflicts. The link row is the interesting one -- GR-L01's two-slot rule
IS the twelve-token supply written twice, which is the shape of a supply
needing no separate enforcement.
AND THE FIRST VERSION OF THE FOCUS/BLAME CHECK WAS WRONG. It compared a
seat's own placed Focus against its OWN blame_from -- but that list holds
OTHER players' discs, so they are different tokens. It reported 2
conflicts; corrected, it reports 0. Fifth instance of this project's
recurring defect, a number computed correctly about the wrong subject, and
the first caught before it left the repo rather than by a reviewer.
D2: a token is a VIEW, not a type. The aggregate gains no `Token` --
adding one would create a second source of truth for Stress, and the first
time they disagreed the bug would be invisible because both would look
internally consistent.
D3: quantity does NOT bind, and the reason is not the measurement. A
component limit the rules do not state is not a rule. Refusing a seventh
Protection token would enforce something nobody ruled -- CB-WP-0023's
error inverted: SOLVE was OFFERED where it could not act; this would
REFUSE where the rules allow. The check ships as a standing control, so a
future violation becomes a question for ground-game (does the box bound
the game, or do the rules?) rather than a bound the engine invented.
Registered as F22, withdrawn: a stated negative, because a survey that
finds nothing and leaves no trace cannot be told from one never run.
D4: Stress on a 0-5 track that turns red at 5 where DARVO arms, DARVO on
OFF/DENY/ATTACK/REVERSE, Freedom as the two-sided disc the edition says it
is, Protection and Blame counted, Lead and Round on the table.
Two tests broke on token discs and both were FIXTURE defects:
seat_centres matched every <circle> and track stops are circles. Seats now
carry class="seat".
The table height limit went 460 -> 500 as a CORRECTION, not a concession.
460 had no derivation; 500 does -- ~800px viewport less ~120 header and
~150 controls leaves ~530, and the version that broke dragging was 620.
CB-WP-0021 T06's rule is to fix the measurement rather than lower the
floor, and an underived number is a measurement defect.
make all: exit 0. 66 render tests, 26 cb-play.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:54:20 +02:00
|
|
|
|
let rest = &html[i + 25..];
|
fix: the game was unplayable, and the note redirect was refused
Tier S (fixes inside a boundary; chaos d8=7, no override). Four
observations from play, three of them caused by CB-WP-0028 -- and make all
was green for all of them.
THE NOTE BUG, WHICH I GOT WRONG TWICE. The first fix put the token in the
form's action, and that worked. But the 303 afterwards pointed at bare `/`
with no token, so the note WAS SAVED and then the browser followed a
redirect control 1 refuses. The player sees "no session token" for a note
that already landed. A redirect is a request the browser makes on your
behalf and is subject to every control the others are. I had tested the
POST and stopped there -- the same mistake as the first fix, one step
further along. Guard::page_path() now carries the token, and the test
asserts the redirect target is ADMITTED rather than merely non-empty.
WHY DRAGGING BROKE, WHICH WAS NOT THE DRAG. The gesture logic was fine: the
JS harness posts correctly against the served page, and all 14 drop
targets are present. The table was 620px tall, which pushed the action
cards a full screen below the Problems -- and you cannot drag between two
things that are never on screen together. Now 440, with a test asserting
the declared height stays under 460 and saying why. That is a proxy for a
browser layout, not the property itself, and the test says so.
Seats sat ON the ellipse: they were placed at 0.83 of the table radius,
which is inside it. Now outside, asserted numerically at 2 through 6 seats
against the ellipse equation rather than eyeballed.
And the `table` drop target was a separate CARD among the move buttons,
which is exactly why a player looking at a picture of a table could not
find anywhere to drop. The drawn ellipse is the drop zone now, and a test
asserts there is EXACTLY ONE table target and that it is the drawn one --
two elements claiming to be the table is worse than none.
The gap this exposes is the one CB-EV-0026 named a day earlier: every test
asserted the DOM was correct, and it was. Nothing asserted the page was
usable, and the drag test passes on a page you cannot physically drag on.
What is added here are proxies a browser-less test can check.
make all: exit 0. 61 render tests, 26 cb-play.
Verified over real HTTP rather than by inspection: note POST 303, the
redirect carries the token, following it returns 200, the table declares
440, one drop zone, seats at (380,421)/(113,112)/(647,112) against a table
of rx=200 ry=118, and both notes reached the trial log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:51:35 +02:00
|
|
|
|
let (x, rest) = rest.split_once("\" cy=\"")?;
|
|
|
|
|
|
let (y, _) = rest.split_once('"')?;
|
|
|
|
|
|
Some((x.parse().ok()?, y.parse().ok()?))
|
|
|
|
|
|
})
|
|
|
|
|
|
.collect()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0028 T03/T08: one overhead table, and what the nine observations
turned out to be
T03. One table_svg: seats around an elliptical table starting at the
BOTTOM -- the viewer sits nearest the reader, as at a real table --
Problems and both stacks in the middle, each seat's played card between it
and the centre, relations drawn between seats.
Two renderers were DELETED: relations_svg and piles_svg. The task said one
table not two diagrams, and leaving the old ones would have meant drawing
the same thing twice and letting them drift.
No coverage probe cost, through a restructure that merged three diagrams
and removed two functions. Second confirmation of CB-WP-0027's finding: a
probe naming a FACT survives a reflow, one naming a PRESENTATION does not.
CB-WP-0024's "17 remaining" broke on a rendering change; this far larger
reflow broke nothing.
The new control is per seat count -- no two seat circles closer than 70px
at 2 through 6 -- asserted rather than eyeballed at three, which is the
only count anyone ever looks at.
T08 (CB-EV-0026). Seven of nine observations were engine defects, one was
a design finding, one was already true and nobody could tell.
Observations 4 and 5 both dissolved and had ONE cause: nothing on the page
said how drawing works, so a player built a mental model to fill the gap
and reported the gap as two feature requests.
The import gap was worse than "one of nineteen" -- 5 of 13 columns read
from the file we DID vendor, discarded at parse time for eight days. Rule
coverage was 59/59 throughout. The gate measures whether rules are
EXERCISED; nothing measures whether a player can READ the game, and
nothing cheaply could, which is why the person playing it is the
instrument.
TWO GATES WERE WRITTEN FOR A SMALLER WORLD, and neither was wrong when
written. edition-check compared one recorded digest against Problems.csv
regardless of which file it described -- correct with one vendored file,
comparing across files with four. And a cb-play test asserted the literal
"game over" and went red when a won game said "solved", which was T06
working; it now asserts the heading against the OUTCOME and covers the
no-outcome case the original never touched.
Chaos window 2 closes with zero overrides in eleven declarations at d8.
Third and final statement of it: d8 bought rarity by spending evidence,
and a mechanism producing no data across a full window cannot be evaluated
by that window.
make all: exit 0. 57 render tests, 26 cb-play, loop-lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:33:57 +02:00
|
|
|
|
fn view_of(players: u8) -> games_ground::view::GroundView {
|
|
|
|
|
|
GroundState::setup(
|
|
|
|
|
|
&Setup {
|
|
|
|
|
|
players,
|
|
|
|
|
|
preset: format!("standard-{players}p"),
|
|
|
|
|
|
patch: Default::default(),
|
|
|
|
|
|
},
|
|
|
|
|
|
7,
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect("preset")
|
|
|
|
|
|
.project(Viewer::Player(PlayerId(0)))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// **Every seat count lays out, and no two seats land on each other.**
|
|
|
|
|
|
/// Asserted per count rather than eyeballed at three, which is the
|
|
|
|
|
|
/// only count anyone ever looks at.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn two_through_six_seats_all_lay_out_without_overlap() {
|
|
|
|
|
|
for players in 2..=6u8 {
|
|
|
|
|
|
let v = view_of(players);
|
|
|
|
|
|
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
|
|
|
|
|
|
fix: the game was unplayable, and the note redirect was refused
Tier S (fixes inside a boundary; chaos d8=7, no override). Four
observations from play, three of them caused by CB-WP-0028 -- and make all
was green for all of them.
THE NOTE BUG, WHICH I GOT WRONG TWICE. The first fix put the token in the
form's action, and that worked. But the 303 afterwards pointed at bare `/`
with no token, so the note WAS SAVED and then the browser followed a
redirect control 1 refuses. The player sees "no session token" for a note
that already landed. A redirect is a request the browser makes on your
behalf and is subject to every control the others are. I had tested the
POST and stopped there -- the same mistake as the first fix, one step
further along. Guard::page_path() now carries the token, and the test
asserts the redirect target is ADMITTED rather than merely non-empty.
WHY DRAGGING BROKE, WHICH WAS NOT THE DRAG. The gesture logic was fine: the
JS harness posts correctly against the served page, and all 14 drop
targets are present. The table was 620px tall, which pushed the action
cards a full screen below the Problems -- and you cannot drag between two
things that are never on screen together. Now 440, with a test asserting
the declared height stays under 460 and saying why. That is a proxy for a
browser layout, not the property itself, and the test says so.
Seats sat ON the ellipse: they were placed at 0.83 of the table radius,
which is inside it. Now outside, asserted numerically at 2 through 6 seats
against the ellipse equation rather than eyeballed.
And the `table` drop target was a separate CARD among the move buttons,
which is exactly why a player looking at a picture of a table could not
find anywhere to drop. The drawn ellipse is the drop zone now, and a test
asserts there is EXACTLY ONE table target and that it is the drawn one --
two elements claiming to be the table is worse than none.
The gap this exposes is the one CB-EV-0026 named a day earlier: every test
asserted the DOM was correct, and it was. Nothing asserted the page was
usable, and the drag test passes on a page you cannot physically drag on.
What is added here are proxies a browser-less test can check.
make all: exit 0. 61 render tests, 26 cb-play.
Verified over real HTTP rather than by inspection: note POST 303, the
redirect carries the token, following it returns 200, the table declares
440, one drop zone, seats at (380,421)/(113,112)/(647,112) against a table
of rx=200 ry=118, and both notes reached the trial log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:51:35 +02:00
|
|
|
|
let seats = seat_centres(&html);
|
CB-WP-0028 T03/T08: one overhead table, and what the nine observations
turned out to be
T03. One table_svg: seats around an elliptical table starting at the
BOTTOM -- the viewer sits nearest the reader, as at a real table --
Problems and both stacks in the middle, each seat's played card between it
and the centre, relations drawn between seats.
Two renderers were DELETED: relations_svg and piles_svg. The task said one
table not two diagrams, and leaving the old ones would have meant drawing
the same thing twice and letting them drift.
No coverage probe cost, through a restructure that merged three diagrams
and removed two functions. Second confirmation of CB-WP-0027's finding: a
probe naming a FACT survives a reflow, one naming a PRESENTATION does not.
CB-WP-0024's "17 remaining" broke on a rendering change; this far larger
reflow broke nothing.
The new control is per seat count -- no two seat circles closer than 70px
at 2 through 6 -- asserted rather than eyeballed at three, which is the
only count anyone ever looks at.
T08 (CB-EV-0026). Seven of nine observations were engine defects, one was
a design finding, one was already true and nobody could tell.
Observations 4 and 5 both dissolved and had ONE cause: nothing on the page
said how drawing works, so a player built a mental model to fill the gap
and reported the gap as two feature requests.
The import gap was worse than "one of nineteen" -- 5 of 13 columns read
from the file we DID vendor, discarded at parse time for eight days. Rule
coverage was 59/59 throughout. The gate measures whether rules are
EXERCISED; nothing measures whether a player can READ the game, and
nothing cheaply could, which is why the person playing it is the
instrument.
TWO GATES WERE WRITTEN FOR A SMALLER WORLD, and neither was wrong when
written. edition-check compared one recorded digest against Problems.csv
regardless of which file it described -- correct with one vendored file,
comparing across files with four. And a cb-play test asserted the literal
"game over" and went red when a won game said "solved", which was T06
working; it now asserts the heading against the OUTCOME and covers the
no-outcome case the original never touched.
Chaos window 2 closes with zero overrides in eleven declarations at d8.
Third and final statement of it: d8 bought rarity by spending evidence,
and a mechanism producing no data across a full window cannot be evaluated
by that window.
make all: exit 0. 57 render tests, 26 cb-play, loop-lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:33:57 +02:00
|
|
|
|
assert_eq!(
|
|
|
|
|
|
seats.len(),
|
|
|
|
|
|
players as usize,
|
|
|
|
|
|
"{players}p: expected one circle per seat, got {seats:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
for (i, a) in seats.iter().enumerate() {
|
|
|
|
|
|
for b in &seats[i + 1..] {
|
|
|
|
|
|
let d = ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt();
|
|
|
|
|
|
assert!(
|
fix: the game was unplayable, and the note redirect was refused
Tier S (fixes inside a boundary; chaos d8=7, no override). Four
observations from play, three of them caused by CB-WP-0028 -- and make all
was green for all of them.
THE NOTE BUG, WHICH I GOT WRONG TWICE. The first fix put the token in the
form's action, and that worked. But the 303 afterwards pointed at bare `/`
with no token, so the note WAS SAVED and then the browser followed a
redirect control 1 refuses. The player sees "no session token" for a note
that already landed. A redirect is a request the browser makes on your
behalf and is subject to every control the others are. I had tested the
POST and stopped there -- the same mistake as the first fix, one step
further along. Guard::page_path() now carries the token, and the test
asserts the redirect target is ADMITTED rather than merely non-empty.
WHY DRAGGING BROKE, WHICH WAS NOT THE DRAG. The gesture logic was fine: the
JS harness posts correctly against the served page, and all 14 drop
targets are present. The table was 620px tall, which pushed the action
cards a full screen below the Problems -- and you cannot drag between two
things that are never on screen together. Now 440, with a test asserting
the declared height stays under 460 and saying why. That is a proxy for a
browser layout, not the property itself, and the test says so.
Seats sat ON the ellipse: they were placed at 0.83 of the table radius,
which is inside it. Now outside, asserted numerically at 2 through 6 seats
against the ellipse equation rather than eyeballed.
And the `table` drop target was a separate CARD among the move buttons,
which is exactly why a player looking at a picture of a table could not
find anywhere to drop. The drawn ellipse is the drop zone now, and a test
asserts there is EXACTLY ONE table target and that it is the drawn one --
two elements claiming to be the table is worse than none.
The gap this exposes is the one CB-EV-0026 named a day earlier: every test
asserted the DOM was correct, and it was. Nothing asserted the page was
usable, and the drag test passes on a page you cannot physically drag on.
What is added here are proxies a browser-less test can check.
make all: exit 0. 61 render tests, 26 cb-play.
Verified over real HTTP rather than by inspection: note POST 303, the
redirect carries the token, following it returns 200, the table declares
440, one drop zone, seats at (380,421)/(113,112)/(647,112) against a table
of rx=200 ry=118, and both notes reached the trial log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:51:35 +02:00
|
|
|
|
d > 64.0,
|
CB-WP-0028 T03/T08: one overhead table, and what the nine observations
turned out to be
T03. One table_svg: seats around an elliptical table starting at the
BOTTOM -- the viewer sits nearest the reader, as at a real table --
Problems and both stacks in the middle, each seat's played card between it
and the centre, relations drawn between seats.
Two renderers were DELETED: relations_svg and piles_svg. The task said one
table not two diagrams, and leaving the old ones would have meant drawing
the same thing twice and letting them drift.
No coverage probe cost, through a restructure that merged three diagrams
and removed two functions. Second confirmation of CB-WP-0027's finding: a
probe naming a FACT survives a reflow, one naming a PRESENTATION does not.
CB-WP-0024's "17 remaining" broke on a rendering change; this far larger
reflow broke nothing.
The new control is per seat count -- no two seat circles closer than 70px
at 2 through 6 -- asserted rather than eyeballed at three, which is the
only count anyone ever looks at.
T08 (CB-EV-0026). Seven of nine observations were engine defects, one was
a design finding, one was already true and nobody could tell.
Observations 4 and 5 both dissolved and had ONE cause: nothing on the page
said how drawing works, so a player built a mental model to fill the gap
and reported the gap as two feature requests.
The import gap was worse than "one of nineteen" -- 5 of 13 columns read
from the file we DID vendor, discarded at parse time for eight days. Rule
coverage was 59/59 throughout. The gate measures whether rules are
EXERCISED; nothing measures whether a player can READ the game, and
nothing cheaply could, which is why the person playing it is the
instrument.
TWO GATES WERE WRITTEN FOR A SMALLER WORLD, and neither was wrong when
written. edition-check compared one recorded digest against Problems.csv
regardless of which file it described -- correct with one vendored file,
comparing across files with four. And a cb-play test asserted the literal
"game over" and went red when a won game said "solved", which was T06
working; it now asserts the heading against the OUTCOME and covers the
no-outcome case the original never touched.
Chaos window 2 closes with zero overrides in eleven declarations at d8.
Third and final statement of it: d8 bought rarity by spending evidence,
and a mechanism producing no data across a full window cannot be evaluated
by that window.
make all: exit 0. 57 render tests, 26 cb-play, loop-lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:33:57 +02:00
|
|
|
|
"{players}p: two seats are {d:.0}px apart and the circles are r=34 — \
|
|
|
|
|
|
they overlap"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A table where you cannot find yourself is worse than a list.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn the_viewers_own_seat_is_marked() {
|
|
|
|
|
|
let v = view_of(4);
|
|
|
|
|
|
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
crate::text_of(&html).contains("P1 (you)"),
|
|
|
|
|
|
"the viewer's seat is not identifiable"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
html.contains("stroke=\"#9cf\" stroke-width=\"3\""),
|
|
|
|
|
|
"the viewer's seat should also be visually distinct, not only labelled"
|
|
|
|
|
|
);
|
|
|
|
|
|
// A spectator has no seat to mark, and must not claim one.
|
|
|
|
|
|
let spec = GroundState::setup(
|
|
|
|
|
|
&Setup {
|
|
|
|
|
|
players: 4,
|
|
|
|
|
|
preset: "standard-4p".into(),
|
|
|
|
|
|
patch: Default::default(),
|
|
|
|
|
|
},
|
|
|
|
|
|
7,
|
|
|
|
|
|
)
|
|
|
|
|
|
.expect("preset")
|
|
|
|
|
|
.project(Viewer::Spectator);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!crate::text_of(&crate::doc::document(&spec, &[], "/x", None, false)).contains("(you)"),
|
|
|
|
|
|
"a spectator was given a seat"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix: the game was unplayable, and the note redirect was refused
Tier S (fixes inside a boundary; chaos d8=7, no override). Four
observations from play, three of them caused by CB-WP-0028 -- and make all
was green for all of them.
THE NOTE BUG, WHICH I GOT WRONG TWICE. The first fix put the token in the
form's action, and that worked. But the 303 afterwards pointed at bare `/`
with no token, so the note WAS SAVED and then the browser followed a
redirect control 1 refuses. The player sees "no session token" for a note
that already landed. A redirect is a request the browser makes on your
behalf and is subject to every control the others are. I had tested the
POST and stopped there -- the same mistake as the first fix, one step
further along. Guard::page_path() now carries the token, and the test
asserts the redirect target is ADMITTED rather than merely non-empty.
WHY DRAGGING BROKE, WHICH WAS NOT THE DRAG. The gesture logic was fine: the
JS harness posts correctly against the served page, and all 14 drop
targets are present. The table was 620px tall, which pushed the action
cards a full screen below the Problems -- and you cannot drag between two
things that are never on screen together. Now 440, with a test asserting
the declared height stays under 460 and saying why. That is a proxy for a
browser layout, not the property itself, and the test says so.
Seats sat ON the ellipse: they were placed at 0.83 of the table radius,
which is inside it. Now outside, asserted numerically at 2 through 6 seats
against the ellipse equation rather than eyeballed.
And the `table` drop target was a separate CARD among the move buttons,
which is exactly why a player looking at a picture of a table could not
find anywhere to drop. The drawn ellipse is the drop zone now, and a test
asserts there is EXACTLY ONE table target and that it is the drawn one --
two elements claiming to be the table is worse than none.
The gap this exposes is the one CB-EV-0026 named a day earlier: every test
asserted the DOM was correct, and it was. Nothing asserted the page was
usable, and the drag test passes on a page you cannot physically drag on.
What is added here are proxies a browser-less test can check.
make all: exit 0. 61 render tests, 26 cb-play.
Verified over real HTTP rather than by inspection: note POST 303, the
redirect carries the token, following it returns 200, the table declares
440, one drop zone, seats at (380,421)/(113,112)/(647,112) against a table
of rx=200 ry=118, and both notes reached the trial log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:51:35 +02:00
|
|
|
|
/// **Observation 1: the seats were ON the table, not around it.**
|
|
|
|
|
|
/// Every seat circle must lie outside the ellipse.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn the_seats_sit_outside_the_table_not_on_it() {
|
|
|
|
|
|
for players in 2..=6u8 {
|
|
|
|
|
|
let v = view_of(players);
|
|
|
|
|
|
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
|
|
|
|
|
let (cx, cy, rx, ry) = (380.0f64, 215.0f64, 200.0f64, 118.0f64);
|
|
|
|
|
|
for (x, y) in seat_centres(&html) {
|
|
|
|
|
|
// Outside an ellipse: (dx/rx)^2 + (dy/ry)^2 > 1, with the
|
|
|
|
|
|
// seat's own radius kept clear of the rim.
|
|
|
|
|
|
let d = ((x - cx) / (rx + 30.0)).powi(2) + ((y - cy) / (ry + 30.0)).powi(2);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
d > 1.0,
|
|
|
|
|
|
"{players}p: a seat at ({x:.0},{y:.0}) is on or inside the table"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// **Observations 3 and 4: the table you can see is the table you drop
|
|
|
|
|
|
/// on, and the game must be playable.** A `table` drop target that is
|
|
|
|
|
|
/// not the drawn table is why a player could not find where to drop.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn the_drawn_table_is_the_drop_target_and_there_is_only_one() {
|
|
|
|
|
|
let v = view_of(3);
|
|
|
|
|
|
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
|
html.matches("data-drop=\"table\"").count(),
|
|
|
|
|
|
1,
|
|
|
|
|
|
"two elements claim to be the table; a player cannot tell which to use"
|
|
|
|
|
|
);
|
|
|
|
|
|
let table = html
|
|
|
|
|
|
.split("aria-label=\"the table, seen from above\"")
|
|
|
|
|
|
.nth(1)
|
|
|
|
|
|
.and_then(|s| s.split("</svg>").next())
|
|
|
|
|
|
.expect("one table svg");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
table.contains("data-drop=\"table\""),
|
|
|
|
|
|
"the drop target is not on the drawn table"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
table.contains("<ellipse"),
|
|
|
|
|
|
"the drop target should be the surface itself"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// **The reason the game became unplayable.** The table was 620px
|
|
|
|
|
|
/// tall, so the action cards sat a screen below the Problems — and you
|
|
|
|
|
|
/// cannot drag between two things never on screen together.
|
|
|
|
|
|
///
|
CB-WP-0029 T01-T03: components you can count, and a supply that does not bind
ADR-0016, Tokens.csv vendored, tracks and discs on the table, and a supply
audit that found nothing and says so.
T03 MEASURED BEFORE T01 DECIDED. 750 games, 2-6 seats, greedy and random:
Protection reaches 1 per seat and 2 on the table against a supply of 6;
Denied 3 of 5; relation links EXACTLY 12 OF 12 and never more; Focus/Blame
0 conflicts. The link row is the interesting one -- GR-L01's two-slot rule
IS the twelve-token supply written twice, which is the shape of a supply
needing no separate enforcement.
AND THE FIRST VERSION OF THE FOCUS/BLAME CHECK WAS WRONG. It compared a
seat's own placed Focus against its OWN blame_from -- but that list holds
OTHER players' discs, so they are different tokens. It reported 2
conflicts; corrected, it reports 0. Fifth instance of this project's
recurring defect, a number computed correctly about the wrong subject, and
the first caught before it left the repo rather than by a reviewer.
D2: a token is a VIEW, not a type. The aggregate gains no `Token` --
adding one would create a second source of truth for Stress, and the first
time they disagreed the bug would be invisible because both would look
internally consistent.
D3: quantity does NOT bind, and the reason is not the measurement. A
component limit the rules do not state is not a rule. Refusing a seventh
Protection token would enforce something nobody ruled -- CB-WP-0023's
error inverted: SOLVE was OFFERED where it could not act; this would
REFUSE where the rules allow. The check ships as a standing control, so a
future violation becomes a question for ground-game (does the box bound
the game, or do the rules?) rather than a bound the engine invented.
Registered as F22, withdrawn: a stated negative, because a survey that
finds nothing and leaves no trace cannot be told from one never run.
D4: Stress on a 0-5 track that turns red at 5 where DARVO arms, DARVO on
OFF/DENY/ATTACK/REVERSE, Freedom as the two-sided disc the edition says it
is, Protection and Blame counted, Lead and Round on the table.
Two tests broke on token discs and both were FIXTURE defects:
seat_centres matched every <circle> and track stops are circles. Seats now
carry class="seat".
The table height limit went 460 -> 500 as a CORRECTION, not a concession.
460 had no derivation; 500 does -- ~800px viewport less ~120 header and
~150 controls leaves ~530, and the version that broke dragging was 620.
CB-WP-0021 T06's rule is to fix the measurement rather than lower the
floor, and an underived number is a measurement defect.
make all: exit 0. 66 render tests, 26 cb-play.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:54:20 +02:00
|
|
|
|
/// The limit is **derived, not guessed**. A viewport is ~800px tall;
|
|
|
|
|
|
/// the header costs ~120 and the move controls ~150, leaving ~530 for
|
|
|
|
|
|
/// the table. The version that broke dragging declared 620. 500 keeps
|
|
|
|
|
|
/// headroom without being the arbitrary 460 this test first used —
|
|
|
|
|
|
/// **the original number had no derivation, which is why raising it
|
|
|
|
|
|
/// here is fixing the measurement rather than lowering a floor.**
|
fix: the game was unplayable, and the note redirect was refused
Tier S (fixes inside a boundary; chaos d8=7, no override). Four
observations from play, three of them caused by CB-WP-0028 -- and make all
was green for all of them.
THE NOTE BUG, WHICH I GOT WRONG TWICE. The first fix put the token in the
form's action, and that worked. But the 303 afterwards pointed at bare `/`
with no token, so the note WAS SAVED and then the browser followed a
redirect control 1 refuses. The player sees "no session token" for a note
that already landed. A redirect is a request the browser makes on your
behalf and is subject to every control the others are. I had tested the
POST and stopped there -- the same mistake as the first fix, one step
further along. Guard::page_path() now carries the token, and the test
asserts the redirect target is ADMITTED rather than merely non-empty.
WHY DRAGGING BROKE, WHICH WAS NOT THE DRAG. The gesture logic was fine: the
JS harness posts correctly against the served page, and all 14 drop
targets are present. The table was 620px tall, which pushed the action
cards a full screen below the Problems -- and you cannot drag between two
things that are never on screen together. Now 440, with a test asserting
the declared height stays under 460 and saying why. That is a proxy for a
browser layout, not the property itself, and the test says so.
Seats sat ON the ellipse: they were placed at 0.83 of the table radius,
which is inside it. Now outside, asserted numerically at 2 through 6 seats
against the ellipse equation rather than eyeballed.
And the `table` drop target was a separate CARD among the move buttons,
which is exactly why a player looking at a picture of a table could not
find anywhere to drop. The drawn ellipse is the drop zone now, and a test
asserts there is EXACTLY ONE table target and that it is the drawn one --
two elements claiming to be the table is worse than none.
The gap this exposes is the one CB-EV-0026 named a day earlier: every test
asserted the DOM was correct, and it was. Nothing asserted the page was
usable, and the drag test passes on a page you cannot physically drag on.
What is added here are proxies a browser-less test can check.
make all: exit 0. 61 render tests, 26 cb-play.
Verified over real HTTP rather than by inspection: note POST 303, the
redirect carries the token, following it returns 200, the table declares
440, one drop zone, seats at (380,421)/(113,112)/(647,112) against a table
of rx=200 ry=118, and both notes reached the trial log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:51:35 +02:00
|
|
|
|
#[test]
|
|
|
|
|
|
fn the_table_is_short_enough_to_drag_from() {
|
|
|
|
|
|
let v = view_of(6);
|
|
|
|
|
|
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
|
|
|
|
|
let vb = html
|
|
|
|
|
|
.split("viewBox=\"0 0 760 ")
|
|
|
|
|
|
.nth(1)
|
|
|
|
|
|
.and_then(|s| s.split('"').next())
|
|
|
|
|
|
.expect("the table declares a viewBox");
|
|
|
|
|
|
let h: f64 = vb.parse().expect("a number");
|
|
|
|
|
|
assert!(
|
CB-WP-0029 T01-T03: components you can count, and a supply that does not bind
ADR-0016, Tokens.csv vendored, tracks and discs on the table, and a supply
audit that found nothing and says so.
T03 MEASURED BEFORE T01 DECIDED. 750 games, 2-6 seats, greedy and random:
Protection reaches 1 per seat and 2 on the table against a supply of 6;
Denied 3 of 5; relation links EXACTLY 12 OF 12 and never more; Focus/Blame
0 conflicts. The link row is the interesting one -- GR-L01's two-slot rule
IS the twelve-token supply written twice, which is the shape of a supply
needing no separate enforcement.
AND THE FIRST VERSION OF THE FOCUS/BLAME CHECK WAS WRONG. It compared a
seat's own placed Focus against its OWN blame_from -- but that list holds
OTHER players' discs, so they are different tokens. It reported 2
conflicts; corrected, it reports 0. Fifth instance of this project's
recurring defect, a number computed correctly about the wrong subject, and
the first caught before it left the repo rather than by a reviewer.
D2: a token is a VIEW, not a type. The aggregate gains no `Token` --
adding one would create a second source of truth for Stress, and the first
time they disagreed the bug would be invisible because both would look
internally consistent.
D3: quantity does NOT bind, and the reason is not the measurement. A
component limit the rules do not state is not a rule. Refusing a seventh
Protection token would enforce something nobody ruled -- CB-WP-0023's
error inverted: SOLVE was OFFERED where it could not act; this would
REFUSE where the rules allow. The check ships as a standing control, so a
future violation becomes a question for ground-game (does the box bound
the game, or do the rules?) rather than a bound the engine invented.
Registered as F22, withdrawn: a stated negative, because a survey that
finds nothing and leaves no trace cannot be told from one never run.
D4: Stress on a 0-5 track that turns red at 5 where DARVO arms, DARVO on
OFF/DENY/ATTACK/REVERSE, Freedom as the two-sided disc the edition says it
is, Protection and Blame counted, Lead and Round on the table.
Two tests broke on token discs and both were FIXTURE defects:
seat_centres matched every <circle> and track stops are circles. Seats now
carry class="seat".
The table height limit went 460 -> 500 as a CORRECTION, not a concession.
460 had no derivation; 500 does -- ~800px viewport less ~120 header and
~150 controls leaves ~530, and the version that broke dragging was 620.
CB-WP-0021 T06's rule is to fix the measurement rather than lower the
floor, and an underived number is a measurement defect.
make all: exit 0. 66 render tests, 26 cb-play.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:54:20 +02:00
|
|
|
|
h <= 500.0,
|
fix: the game was unplayable, and the note redirect was refused
Tier S (fixes inside a boundary; chaos d8=7, no override). Four
observations from play, three of them caused by CB-WP-0028 -- and make all
was green for all of them.
THE NOTE BUG, WHICH I GOT WRONG TWICE. The first fix put the token in the
form's action, and that worked. But the 303 afterwards pointed at bare `/`
with no token, so the note WAS SAVED and then the browser followed a
redirect control 1 refuses. The player sees "no session token" for a note
that already landed. A redirect is a request the browser makes on your
behalf and is subject to every control the others are. I had tested the
POST and stopped there -- the same mistake as the first fix, one step
further along. Guard::page_path() now carries the token, and the test
asserts the redirect target is ADMITTED rather than merely non-empty.
WHY DRAGGING BROKE, WHICH WAS NOT THE DRAG. The gesture logic was fine: the
JS harness posts correctly against the served page, and all 14 drop
targets are present. The table was 620px tall, which pushed the action
cards a full screen below the Problems -- and you cannot drag between two
things that are never on screen together. Now 440, with a test asserting
the declared height stays under 460 and saying why. That is a proxy for a
browser layout, not the property itself, and the test says so.
Seats sat ON the ellipse: they were placed at 0.83 of the table radius,
which is inside it. Now outside, asserted numerically at 2 through 6 seats
against the ellipse equation rather than eyeballed.
And the `table` drop target was a separate CARD among the move buttons,
which is exactly why a player looking at a picture of a table could not
find anywhere to drop. The drawn ellipse is the drop zone now, and a test
asserts there is EXACTLY ONE table target and that it is the drawn one --
two elements claiming to be the table is worse than none.
The gap this exposes is the one CB-EV-0026 named a day earlier: every test
asserted the DOM was correct, and it was. Nothing asserted the page was
usable, and the drag test passes on a page you cannot physically drag on.
What is added here are proxies a browser-less test can check.
make all: exit 0. 61 render tests, 26 cb-play.
Verified over real HTTP rather than by inspection: note POST 303, the
redirect carries the token, following it returns 200, the table declares
440, one drop zone, seats at (380,421)/(113,112)/(647,112) against a table
of rx=200 ry=118, and both notes reached the trial log.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:51:35 +02:00
|
|
|
|
"the table is {h}px tall; the action cards end up off-screen and \
|
|
|
|
|
|
dragging to a Problem becomes impossible"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0029 T01-T03: components you can count, and a supply that does not bind
ADR-0016, Tokens.csv vendored, tracks and discs on the table, and a supply
audit that found nothing and says so.
T03 MEASURED BEFORE T01 DECIDED. 750 games, 2-6 seats, greedy and random:
Protection reaches 1 per seat and 2 on the table against a supply of 6;
Denied 3 of 5; relation links EXACTLY 12 OF 12 and never more; Focus/Blame
0 conflicts. The link row is the interesting one -- GR-L01's two-slot rule
IS the twelve-token supply written twice, which is the shape of a supply
needing no separate enforcement.
AND THE FIRST VERSION OF THE FOCUS/BLAME CHECK WAS WRONG. It compared a
seat's own placed Focus against its OWN blame_from -- but that list holds
OTHER players' discs, so they are different tokens. It reported 2
conflicts; corrected, it reports 0. Fifth instance of this project's
recurring defect, a number computed correctly about the wrong subject, and
the first caught before it left the repo rather than by a reviewer.
D2: a token is a VIEW, not a type. The aggregate gains no `Token` --
adding one would create a second source of truth for Stress, and the first
time they disagreed the bug would be invisible because both would look
internally consistent.
D3: quantity does NOT bind, and the reason is not the measurement. A
component limit the rules do not state is not a rule. Refusing a seventh
Protection token would enforce something nobody ruled -- CB-WP-0023's
error inverted: SOLVE was OFFERED where it could not act; this would
REFUSE where the rules allow. The check ships as a standing control, so a
future violation becomes a question for ground-game (does the box bound
the game, or do the rules?) rather than a bound the engine invented.
Registered as F22, withdrawn: a stated negative, because a survey that
finds nothing and leaves no trace cannot be told from one never run.
D4: Stress on a 0-5 track that turns red at 5 where DARVO arms, DARVO on
OFF/DENY/ATTACK/REVERSE, Freedom as the two-sided disc the edition says it
is, Protection and Blame counted, Lead and Round on the table.
Two tests broke on token discs and both were FIXTURE defects:
seat_centres matched every <circle> and track stops are circles. Seats now
carry class="seat".
The table height limit went 460 -> 500 as a CORRECTION, not a concession.
460 had no derivation; 500 does -- ~800px viewport less ~120 header and
~150 controls leaves ~530, and the version that broke dragging was 620.
CB-WP-0021 T06's rule is to fix the measurement rather than lower the
floor, and an underived number is a measurement defect.
make all: exit 0. 66 render tests, 26 cb-play.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:54:20 +02:00
|
|
|
|
/// **CB-WP-0029 T02: Stress and DARVO are tracks, not numbers.**
|
|
|
|
|
|
///
|
|
|
|
|
|
/// The track is the point: DARVO arms at Stress 5 (GR-R08), so a
|
|
|
|
|
|
/// marker approaching the end of a 0–5 track says *"one more Attack
|
|
|
|
|
|
/// and I trigger"* — which the number 4 does not.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn stress_and_darvo_are_tracks_a_player_can_read_ahead_on() {
|
|
|
|
|
|
let mut v = view_of(3);
|
|
|
|
|
|
let seats: Vec<PlayerId> = v.players.keys().copied().collect();
|
|
|
|
|
|
if let Some(p) = v.players.get_mut(&seats[0]) {
|
|
|
|
|
|
p.stress = 4;
|
|
|
|
|
|
p.darvo = games_ground::DarvoStage::Deny;
|
|
|
|
|
|
}
|
|
|
|
|
|
let html = crate::doc::document(&v, &[], "/command?t=x", Some(seats[0]), false);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
html.contains("<title>Stress 4 of 5</title>"),
|
|
|
|
|
|
"the Stress track must say where the marker is, out of what"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
html.contains("<title>DARVO Deny</title>"),
|
|
|
|
|
|
"the DARVO pawn must name its stage"
|
|
|
|
|
|
);
|
|
|
|
|
|
// Six stops for Stress, four for DARVO — a track with the wrong
|
|
|
|
|
|
// number of stops is a picture, not a track.
|
|
|
|
|
|
let track_stops = html.matches("<circle cx=").count();
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
track_stops >= 3 * (6 + 4),
|
|
|
|
|
|
"three seats need 6 Stress stops and 4 DARVO stops each: {track_stops}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A seat with nothing renders as a seat with nothing — the empty
|
|
|
|
|
|
/// case is the one that silently vanishes.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn a_seat_with_no_tokens_still_has_its_tracks() {
|
|
|
|
|
|
let mut v = view_of(2);
|
|
|
|
|
|
let seats: Vec<PlayerId> = v.players.keys().copied().collect();
|
|
|
|
|
|
if let Some(p) = v.players.get_mut(&seats[0]) {
|
|
|
|
|
|
p.stress = 0;
|
|
|
|
|
|
p.protection = 0;
|
|
|
|
|
|
p.blame_from.clear();
|
|
|
|
|
|
p.freedom_ready = false;
|
|
|
|
|
|
p.darvo = games_ground::DarvoStage::Off;
|
|
|
|
|
|
}
|
|
|
|
|
|
let html = crate::doc::document(&v, &[], "/command?t=x", Some(seats[0]), false);
|
|
|
|
|
|
assert!(html.contains("<title>Stress 0 of 5</title>"));
|
|
|
|
|
|
assert!(html.contains("<title>DARVO Off</title>"));
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
html.contains("<title>Freedom spent</title>"),
|
|
|
|
|
|
"a spent Freedom disc is still a disc — it flips, it does not vanish"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Lead and Round belong to the table, not to a seat (ADR-0016 D4).
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn the_lead_and_round_markers_are_on_the_table() {
|
|
|
|
|
|
let v = view_of(4);
|
|
|
|
|
|
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
|
|
|
|
|
let table = html
|
|
|
|
|
|
.split("aria-label=\"the table, seen from above\"")
|
|
|
|
|
|
.nth(1)
|
|
|
|
|
|
.and_then(|s| s.split("</svg>").next())
|
|
|
|
|
|
.expect("one table svg");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
table.contains("Lead marker:"),
|
|
|
|
|
|
"the Lead marker is not on the table"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
table.contains("Round marker:"),
|
|
|
|
|
|
"the Round marker is not on the table"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0028 T03/T08: one overhead table, and what the nine observations
turned out to be
T03. One table_svg: seats around an elliptical table starting at the
BOTTOM -- the viewer sits nearest the reader, as at a real table --
Problems and both stacks in the middle, each seat's played card between it
and the centre, relations drawn between seats.
Two renderers were DELETED: relations_svg and piles_svg. The task said one
table not two diagrams, and leaving the old ones would have meant drawing
the same thing twice and letting them drift.
No coverage probe cost, through a restructure that merged three diagrams
and removed two functions. Second confirmation of CB-WP-0027's finding: a
probe naming a FACT survives a reflow, one naming a PRESENTATION does not.
CB-WP-0024's "17 remaining" broke on a rendering change; this far larger
reflow broke nothing.
The new control is per seat count -- no two seat circles closer than 70px
at 2 through 6 -- asserted rather than eyeballed at three, which is the
only count anyone ever looks at.
T08 (CB-EV-0026). Seven of nine observations were engine defects, one was
a design finding, one was already true and nobody could tell.
Observations 4 and 5 both dissolved and had ONE cause: nothing on the page
said how drawing works, so a player built a mental model to fill the gap
and reported the gap as two feature requests.
The import gap was worse than "one of nineteen" -- 5 of 13 columns read
from the file we DID vendor, discarded at parse time for eight days. Rule
coverage was 59/59 throughout. The gate measures whether rules are
EXERCISED; nothing measures whether a player can READ the game, and
nothing cheaply could, which is why the person playing it is the
instrument.
TWO GATES WERE WRITTEN FOR A SMALLER WORLD, and neither was wrong when
written. edition-check compared one recorded digest against Problems.csv
regardless of which file it described -- correct with one vendored file,
comparing across files with four. And a cb-play test asserted the literal
"game over" and went red when a won game said "solved", which was T06
working; it now asserts the heading against the OUTCOME and covers the
no-outcome case the original never touched.
Chaos window 2 closes with zero overrides in eleven declarations at d8.
Third and final statement of it: d8 bought rarity by spending evidence,
and a mechanism producing no data across a full window cannot be evaluated
by that window.
make all: exit 0. 57 render tests, 26 cb-play, loop-lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:33:57 +02:00
|
|
|
|
/// The three diagrams became one: the relationship circle and the
|
|
|
|
|
|
/// piles picture are gone as separate views, and their content is on
|
|
|
|
|
|
/// the table.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn the_stacks_and_the_relations_are_on_the_table() {
|
|
|
|
|
|
let v = view_of(3);
|
|
|
|
|
|
let html = crate::doc::document(&v, &[], "/command?t=x", Some(PlayerId(0)), false);
|
|
|
|
|
|
let table = html
|
|
|
|
|
|
.split("aria-label=\"the table, seen from above\"")
|
|
|
|
|
|
.nth(1)
|
|
|
|
|
|
.and_then(|s| s.split("</svg>").next())
|
|
|
|
|
|
.expect("one table svg");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
table.contains("draw pile:"),
|
|
|
|
|
|
"the draw stack is not on the table"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
table.contains("discard pile:"),
|
|
|
|
|
|
"the discard is not on the table"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!html.contains("relationship graph"),
|
|
|
|
|
|
"the old separate relationship diagram is still being drawn"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0028 T01/T02: the cards say what they do
ADR-0015 and the import. F18's fix: "I don't understand the GROUND card"
was never a design gap -- the card explains itself in the edition and we
never imported the explanation.
THE MEASUREMENT IS THE DECISION, and the gap is bigger than "one file of
nineteen". Of the file we DID vendor, the engine reads 5 of 13 columns:
title, problem_text, front_rules, reveal_effect and unresolved_effect were
discarded at parse time. The cheapest part of this pass costs no new bytes
and was sitting in the repo for eight days. And SCN_01 is hardcoded at
lib.rs:1824 -- the edition ships FOUR scenarios and the engine has never
dealt three of them. Nobody had said so.
ADR-0011's revisit condition is measurably absent, so the dependency
argument does not get re-run: across Actions, Solutions, Modes and
Scenarios there are ZERO doubled quotes and ZERO embedded newlines. The
hand reader's only job is comma-in-quoted-field, which it already did.
Refusing csv on a measurement rather than on a preference.
Vendored Actions, Solutions and Modes -- the text a player reads. Not the
production artifacts (BOM, Print_Manifest, Back_Designs, Symbols). NOT
Extensions.csv, which names content the designer placed outside the core;
importing it would break the claim that this engine plays the edition as
printed. It is now known to exist, which was the real risk.
One Table reader with four callers, because a per-file copy is how a
parser acquires four subtly different bugs. The GROUND card now shows
"Regulate. Restore the frame. Decide." with its GR/OU/ND text on demand;
Problems show their own titles where a priority number used to be.
The load-bearing test asserts the text is a SUBSTRING OF THE VENDORED
FILE rather than equal to a Rust literal -- a test comparing against a
hardcoded expectation would pass for a hand-copied string, which is the
drift this ends.
`edition` came out from behind #[cfg(feature = "scenarios")]. It was gated
because its only consumer was; the edition is the game's own data and the
shipped runtime now reads it. Test machinery and game content are
different things and only one of them is optional.
And edition-check was written for a single-file world: it compared the
first recorded digest against Problems.csv regardless of which file that
digest described. It now checks every file both ways -- a vendored file
with no digest fails, a digest naming an absent file fails -- and asserts
ADR-0015 D3's falsifier directly rather than trusting it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:22:04 +02:00
|
|
|
|
/// CB-WP-0028 T02 — the cards say what they do, in the edition's words.
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
|
mod card_words {
|
|
|
|
|
|
use cb_kernel::PlayerId;
|
|
|
|
|
|
use games_ground::{Action, GroundCommand};
|
|
|
|
|
|
|
|
|
|
|
|
/// The GROUND card's own tagline reaches the page. This is finding
|
|
|
|
|
|
/// F18's acceptance test and it has a person attached to it: the
|
|
|
|
|
|
/// maintainer said he did not understand this card.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn the_ground_card_explains_itself_on_the_page() {
|
|
|
|
|
|
let legal = vec![GroundCommand::SelectAction {
|
|
|
|
|
|
action: Action::Ground,
|
|
|
|
|
|
target: None,
|
|
|
|
|
|
problem: None,
|
|
|
|
|
|
}];
|
|
|
|
|
|
let html = crate::doc::document(
|
|
|
|
|
|
&crate::testfix::view(Some(PlayerId(0))),
|
|
|
|
|
|
&legal,
|
|
|
|
|
|
"/command?t=x",
|
|
|
|
|
|
Some(PlayerId(0)),
|
|
|
|
|
|
false,
|
|
|
|
|
|
);
|
|
|
|
|
|
let text = crate::text_of(&html);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
text.contains("Regulate. Restore the frame. Decide."),
|
|
|
|
|
|
"the GROUND card's tagline did not reach the page: {text}"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
text.contains("Ground & Restate"),
|
|
|
|
|
|
"its rules text must be available on demand"
|
|
|
|
|
|
);
|
|
|
|
|
|
// From the dataset, not from us.
|
|
|
|
|
|
let from_edition = games_ground::edition::actions()
|
|
|
|
|
|
.expect("actions")
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.any(|c| c.title == "GROUND" && text.contains(&c.tagline));
|
|
|
|
|
|
assert!(from_edition, "the tagline on the page is not the edition's");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// A Problem shows its own name. The page said `Repair 2` for a card
|
|
|
|
|
|
/// that reads "Missed Deadline", using columns vendored eight days
|
|
|
|
|
|
/// earlier and discarded at parse time (ADR-0015 D1).
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn a_problem_shows_the_name_the_card_has() {
|
|
|
|
|
|
let titles: Vec<String> = games_ground::edition::problem_texts("SCN_01")
|
|
|
|
|
|
.expect("SCN_01")
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.map(|t| t.title)
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
assert!(!titles.is_empty());
|
|
|
|
|
|
|
|
|
|
|
|
let mut view = crate::testfix::view(Some(PlayerId(0)));
|
|
|
|
|
|
// Priorities the fixture uses must exist in the edition for the
|
|
|
|
|
|
// lookup to resolve; use the edition's own.
|
|
|
|
|
|
let real = games_ground::edition::problem_texts("SCN_01").expect("SCN_01");
|
|
|
|
|
|
let keys: Vec<u32> = view.problems.keys().copied().collect();
|
|
|
|
|
|
for (k, t) in keys.iter().zip(real.iter()) {
|
|
|
|
|
|
if let Some(p) = view.problems.remove(k) {
|
|
|
|
|
|
view.problems.insert(u32::from(t.priority), p);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
let text = crate::text_of(&crate::doc::document(
|
|
|
|
|
|
&view,
|
|
|
|
|
|
&[],
|
|
|
|
|
|
"/command?t=x",
|
|
|
|
|
|
Some(PlayerId(0)),
|
|
|
|
|
|
false,
|
|
|
|
|
|
));
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
titles.iter().any(|t| text.contains(t.as_str())),
|
|
|
|
|
|
"no Problem title from the edition appears on the page: {text}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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))),
|
|
|
|
|
|
&[],
|
2026-08-06 15:32:09 +02:00
|
|
|
|
crate::TEST_ENDPOINTS,
|
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
|
|
|
|
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))),
|
|
|
|
|
|
&[],
|
2026-08-06 15:32:09 +02:00
|
|
|
|
crate::TEST_ENDPOINTS,
|
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
|
|
|
|
Some(PlayerId(0)),
|
|
|
|
|
|
false,
|
|
|
|
|
|
&[],
|
|
|
|
|
|
&[],
|
|
|
|
|
|
);
|
2026-08-06 15:32:09 +02:00
|
|
|
|
assert!(
|
|
|
|
|
|
html.contains("action=\"/note?t=x\""),
|
|
|
|
|
|
"the form must carry the session token"
|
|
|
|
|
|
);
|
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
|
|
|
|
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))),
|
|
|
|
|
|
&[],
|
2026-08-06 15:32:09 +02:00
|
|
|
|
crate::TEST_ENDPOINTS,
|
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
|
|
|
|
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!(
|
CB-WP-0028 T03/T08: one overhead table, and what the nine observations
turned out to be
T03. One table_svg: seats around an elliptical table starting at the
BOTTOM -- the viewer sits nearest the reader, as at a real table --
Problems and both stacks in the middle, each seat's played card between it
and the centre, relations drawn between seats.
Two renderers were DELETED: relations_svg and piles_svg. The task said one
table not two diagrams, and leaving the old ones would have meant drawing
the same thing twice and letting them drift.
No coverage probe cost, through a restructure that merged three diagrams
and removed two functions. Second confirmation of CB-WP-0027's finding: a
probe naming a FACT survives a reflow, one naming a PRESENTATION does not.
CB-WP-0024's "17 remaining" broke on a rendering change; this far larger
reflow broke nothing.
The new control is per seat count -- no two seat circles closer than 70px
at 2 through 6 -- asserted rather than eyeballed at three, which is the
only count anyone ever looks at.
T08 (CB-EV-0026). Seven of nine observations were engine defects, one was
a design finding, one was already true and nobody could tell.
Observations 4 and 5 both dissolved and had ONE cause: nothing on the page
said how drawing works, so a player built a mental model to fill the gap
and reported the gap as two feature requests.
The import gap was worse than "one of nineteen" -- 5 of 13 columns read
from the file we DID vendor, discarded at parse time for eight days. Rule
coverage was 59/59 throughout. The gate measures whether rules are
EXERCISED; nothing measures whether a player can READ the game, and
nothing cheaply could, which is why the person playing it is the
instrument.
TWO GATES WERE WRITTEN FOR A SMALLER WORLD, and neither was wrong when
written. edition-check compared one recorded digest against Problems.csv
regardless of which file it described -- correct with one vendored file,
comparing across files with four. And a cb-play test asserted the literal
"game over" and went red when a won game said "solved", which was T06
working; it now asserts the heading against the OUTCOME and covers the
no-outcome case the original never touched.
Chaos window 2 closes with zero overrides in eleven declarations at d8.
Third and final statement of it: d8 bought rarity by spending evidence,
and a mechanism producing no data across a full window cannot be evaluated
by that window.
make all: exit 0. 57 render tests, 26 cb-play, loop-lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:33:57 +02:00
|
|
|
|
game.contains("<h2>the table</h2>"),
|
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
|
|
|
|
"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 {
|
fix: a click target wearing a drag affordance made the controls look dead
Tier S (a fix inside a boundary; chaos d8=7 from the previous roll stands
for this continuation). Two observations from play that are ONE defect.
`play again`, `end session`, `pass` and the move buttons carried `.pick`,
which is cursor:grab. The stylesheet has .btn{cursor:pointer} BEFORE
.pick{cursor:grab}, so grab won.
A GRAB CURSOR INVITES A DRAG. A drag released over nothing posts nothing,
so the player picked up the button, let go, and the page did nothing. It
looked dead because the affordance told them to do the one thing that does
not work. Reported as two separate things -- "the button shows a hand to
pick up that it probably shouldn't" and "I can't start another game or
stop the server" -- and the first causes the second.
The click path itself was never broken: driving again->again and
done->done through the JS harness posts correctly. The logic was fine and
the invitation was wrong.
Click targets now carry `.tap` -- pointer cursor, same press affordance.
This extends CB-WP-0017's rule (interactive and inert must not look
identical) to: click and drag must not look identical either. The test
asserts both directions, because checking only that buttons lost `.pick`
would pass for a page with no affordances at all.
Registered F20 (applied) and F21.
F21 IS THE ONE I COULD NOT REPRODUCE: dragging did not work until after
the first note was saved. Ruled out the plausible mechanisms -- the
gesture logic posts correctly against the served page, the drag ghost
carries pointer-events:none so it cannot intercept the drop, and the
markup is identical before and after since the 303 re-renders the same
page from the same state. Remaining candidates are a <details> toggle
shifting layout mid-drag, a first-load timing difference, or browser-level
pointer capture. Reproducing it needs a browser, which no test here has --
the same gap F19 named. Recorded as unreproduced rather than given a
speculative fix.
And the fourth observation is confirmation, not a bug: "drawing my cards
from the deck is not implemented, I did not need to do that" is exactly
what CB-WP-0028 T04 determined and deliberately did not build. It is the
first evidence that importing the card text closed the comprehension gap
that produced the earlier click-the-deck request.
make all: exit 0. 62 render tests, 26 cb-play.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:25:06 +02:00
|
|
|
|
use cb_kernel::PlayerId;
|
|
|
|
|
|
|
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
|
|
|
|
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:?}"
|
|
|
|
|
|
);
|
fix: the controls sit below the log, and ending a session greys the page
Tier S (fixes inside a boundary; chaos d8=3, no override). Two
observations from play, both about what the page says has happened.
The controls moved BELOW the log: you read what happened, then decide what
to do next. They were above it, which asks for the decision first.
And sealing now marks the WHOLE PAGE inert, not just the controls. A
greyed-out button beside a full-colour table still reads as a live game
with one broken control; the session has ended and everything on screen is
a record of it. The status line stays legible on purpose -- it is the one
thing still worth reading.
THE STUB NEEDED A BODY classList TO MAKE THAT TESTABLE. Without it "the
session visibly ended" would have been a claim about CSS with nothing
checking it, which is precisely CB-WP-0016's finding: a stub too thin to
express a failure is how the failure survives. The harness reports the
sealed page through the status channel with a NUL-separated marker --
ugly, deliberate, and documented, because widening the return type would
touch every caller for one boolean.
Both directions asserted: a `closed` reply seals the page, an `ok:
dealing` reply does NOT -- otherwise the seal test would pass for a page
that greys itself whenever it is touched, breaking `play again`.
63 render tests, 26 cb-play, check and loop-lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:38:32 +02:00
|
|
|
|
// The WHOLE page goes inert, not only the controls. A greyed
|
|
|
|
|
|
// button beside a full-colour table reads as a live game with one
|
|
|
|
|
|
// broken control.
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
status.contains("sealed-page"),
|
|
|
|
|
|
"the page itself was not marked ended: {status:?}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// The controls sit **below** the log: you read what happened, then
|
|
|
|
|
|
/// decide what to do next.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn the_controls_come_after_the_log() {
|
|
|
|
|
|
let html = page();
|
|
|
|
|
|
let log = html.find("<h2>log</h2>").expect("a log section");
|
|
|
|
|
|
let again = html
|
|
|
|
|
|
.find("data-drop=\"again\"")
|
|
|
|
|
|
.expect("a play-again control");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
again > log,
|
|
|
|
|
|
"the controls are above the log; the reader decides before reading"
|
|
|
|
|
|
);
|
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
|
|
|
|
}
|
|
|
|
|
|
|
fix: a click target wearing a drag affordance made the controls look dead
Tier S (a fix inside a boundary; chaos d8=7 from the previous roll stands
for this continuation). Two observations from play that are ONE defect.
`play again`, `end session`, `pass` and the move buttons carried `.pick`,
which is cursor:grab. The stylesheet has .btn{cursor:pointer} BEFORE
.pick{cursor:grab}, so grab won.
A GRAB CURSOR INVITES A DRAG. A drag released over nothing posts nothing,
so the player picked up the button, let go, and the page did nothing. It
looked dead because the affordance told them to do the one thing that does
not work. Reported as two separate things -- "the button shows a hand to
pick up that it probably shouldn't" and "I can't start another game or
stop the server" -- and the first causes the second.
The click path itself was never broken: driving again->again and
done->done through the JS harness posts correctly. The logic was fine and
the invitation was wrong.
Click targets now carry `.tap` -- pointer cursor, same press affordance.
This extends CB-WP-0017's rule (interactive and inert must not look
identical) to: click and drag must not look identical either. The test
asserts both directions, because checking only that buttons lost `.pick`
would pass for a page with no affordances at all.
Registered F20 (applied) and F21.
F21 IS THE ONE I COULD NOT REPRODUCE: dragging did not work until after
the first note was saved. Ruled out the plausible mechanisms -- the
gesture logic posts correctly against the served page, the drag ghost
carries pointer-events:none so it cannot intercept the drop, and the
markup is identical before and after since the 303 re-renders the same
page from the same state. Remaining candidates are a <details> toggle
shifting layout mid-drag, a first-load timing difference, or browser-level
pointer capture. Reproducing it needs a browser, which no test here has --
the same gap F19 named. Recorded as unreproduced rather than given a
speculative fix.
And the fourth observation is confirmation, not a bug: "drawing my cards
from the deck is not implemented, I did not need to do that" is exactly
what CB-WP-0028 T04 determined and deliberately did not build. It is the
first evidence that importing the card text closed the comprehension gap
that produced the earlier click-the-deck request.
make all: exit 0. 62 render tests, 26 cb-play.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:25:06 +02:00
|
|
|
|
/// **A thing you click must not look like a thing you drag.**
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Reported two ways at once: *"the button shows a hand to pick up
|
|
|
|
|
|
/// that it probably shouldn't"* and *"I can't start another game"*.
|
|
|
|
|
|
/// They are one defect — a grab cursor invites a drag, and a drag
|
|
|
|
|
|
/// released over nothing posts nothing, so the button looks dead.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn click_targets_do_not_wear_the_drag_affordance() {
|
|
|
|
|
|
let pages = [
|
|
|
|
|
|
doc::ending(None, "m", "/command?t=x", &[], &[]),
|
|
|
|
|
|
doc::document(
|
|
|
|
|
|
&crate::testfix::view(Some(PlayerId(0))),
|
|
|
|
|
|
&[games_ground::GroundCommand::SelectAction {
|
|
|
|
|
|
action: games_ground::Action::Investigate,
|
|
|
|
|
|
target: None,
|
|
|
|
|
|
problem: Some(2),
|
|
|
|
|
|
}],
|
|
|
|
|
|
"/command?t=x",
|
|
|
|
|
|
Some(PlayerId(0)),
|
|
|
|
|
|
true,
|
|
|
|
|
|
),
|
|
|
|
|
|
];
|
|
|
|
|
|
for html in &pages {
|
|
|
|
|
|
for key in ["again", "done", "pass"] {
|
|
|
|
|
|
let Some(i) = html.find(&format!("data-drop=\"{key}\"")) else {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
};
|
|
|
|
|
|
let tag = &html[html[..i].rfind('<').expect("an opening tag")..i];
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!tag.contains("pick"),
|
|
|
|
|
|
"`{key}` is a click target wearing `.pick`, which is cursor:grab — \
|
|
|
|
|
|
it invites a drag, and a drag onto nothing posts nothing"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
tag.contains("tap"),
|
|
|
|
|
|
"`{key}` must still look pressable: {tag}"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// The inverse, or this passes for a page with no affordances at
|
|
|
|
|
|
// all: a real draggable still carries `.pick`.
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
pages[1].contains("class=\"card act pick\""),
|
|
|
|
|
|
"action cards must still be draggable"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
CB-WP-0028 T04-T07: a game you solve, rankings that cite their source, and
a feature deliberately not built
T04 BUILT NOTHING, WHICH IS THE CORRECT OUTCOME. "Click the draw stack to
get hand cards" is not a legal move: GroundCommand has no standalone draw,
and the edition's own INVESTIGATE text settles it -- "Choose one hidden,
non-Denied Problem and reveal it. THEN DRAW ONE SOLUTION." Drawing is a
consequence, never an action. Implementing click-to-draw would have
invented a rule, which is what CB-WP-0023 exists to stop.
And observation 5 is already true: drawing is ALREADY automatic, inside
INVESTIGATE, with no player input. An auto-draw option was asked for a
thing that has never been manual.
Both observations have one root cause and T02 fixed it. The maintainer
expected to take cards from the deck because NOTHING ON THE PAGE SAID HOW
DRAWING WORKS -- the INVESTIGATE card's own text was in a file we had not
imported. No finding raised: a player's instinct differing from a legible
rule is a comprehension gap, not a rules gap. Whether the instinct recurs
now that the text is present is a testable question and was not before.
T06: "game solved" on a win, "game over" on a loss, "the game stopped"
with no outcome. Asserted all three ways, because a test checking only the
win case passes for a page that always says solved. More than tone --
GROUND is co-operative and about repairing something, and arcade
failure-state vocabulary for a win tells a player the wrong thing about
what they just did.
T05: the ending page gets the table's two-column shape. Result left;
rankings, controls and the full log right. The seal still removes every
control wherever they now live.
T07's interesting decision was NOT TO RANK. Modes.csv defines
scoring_tiebreak per mode, so ordering is the GAME's where one exists --
"Lower combined Stress, then fewer Blame tokens" for coalitions. For
SHARED GROUND it says "Not applicable", because the table succeeds or
fails together. So co-op shows contributions and refuses to order them,
and says why. Drawing a leaderboard because a leaderboard is easy would
invent scoring the rules do not have. The one derived superlative is
labelled "clay-borg's reading, not a rule" and appears only where the mode
ranks; ties are shown as ties.
52 render tests pass. check clean, loop-lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:25:48 +02:00
|
|
|
|
/// **CB-WP-0028 T06, asserted both ways.** A test that only checked
|
|
|
|
|
|
/// the win case would pass for a page that always says "solved".
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn a_won_game_is_solved_and_a_lost_one_is_over() {
|
|
|
|
|
|
let mut won = crate::testfix::view(None);
|
|
|
|
|
|
if let Some(o) = won.outcome.as_mut() {
|
|
|
|
|
|
o.group_success = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
let mut lost = crate::testfix::view(None);
|
|
|
|
|
|
if let Some(o) = lost.outcome.as_mut() {
|
|
|
|
|
|
o.group_success = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
let head = |v: &games_ground::view::GroundView| {
|
|
|
|
|
|
crate::text_of(&doc::ending(Some(v), "m", "/command?t=x", &[], &[]))
|
|
|
|
|
|
};
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
head(&won).contains("game solved"),
|
|
|
|
|
|
"a won game said otherwise"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!head(&won).contains("game over"),
|
|
|
|
|
|
"\"game over\" is arcade vocabulary for a failure state"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
head(&lost).contains("game over"),
|
|
|
|
|
|
"a lost game said otherwise"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(!head(&lost).contains("game solved"));
|
|
|
|
|
|
|
|
|
|
|
|
// And a game with no outcome claims neither.
|
|
|
|
|
|
let none = crate::text_of(&doc::ending(
|
|
|
|
|
|
None,
|
|
|
|
|
|
"P1 ran out of input",
|
|
|
|
|
|
"/command?t=x",
|
|
|
|
|
|
&[],
|
|
|
|
|
|
&[],
|
|
|
|
|
|
));
|
|
|
|
|
|
assert!(!none.contains("game solved") && !none.contains("game over"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// **CB-WP-0028 T07.** Every ranking names its source, and the
|
|
|
|
|
|
/// co-operative mode is not ranked at all — the game says its
|
|
|
|
|
|
/// tiebreak is "Not applicable", and ranking it anyway would invent
|
|
|
|
|
|
/// scoring the rules do not have.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn a_cooperative_game_shows_contributions_and_refuses_to_rank_them() {
|
|
|
|
|
|
let mut v = crate::testfix::view(None);
|
|
|
|
|
|
v.mode = games_ground::ScoringMode::SharedGround;
|
|
|
|
|
|
let text = crate::text_of(&doc::ending(Some(&v), "m", "/command?t=x", &[], &[]));
|
|
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
text.contains("problems solved"),
|
|
|
|
|
|
"the countable fact is missing"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
text.contains("not ranked"),
|
|
|
|
|
|
"SHARED GROUND must not be ranked: {text}"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!text.contains("clay-borg's reading"),
|
|
|
|
|
|
"a reading must not be offered where the game defines no ranking"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// And the inverse, or the test above passes for a page that never
|
|
|
|
|
|
/// ranks anything. A ranked mode cites the GAME's tiebreak and marks
|
|
|
|
|
|
/// anything derived as ours.
|
|
|
|
|
|
#[test]
|
|
|
|
|
|
fn a_ranked_mode_cites_the_games_own_tiebreak() {
|
|
|
|
|
|
let mut v = crate::testfix::view(None);
|
|
|
|
|
|
v.mode = games_ground::ScoringMode::BondedCoalitions;
|
|
|
|
|
|
let text = crate::text_of(&doc::ending(Some(&v), "m", "/command?t=x", &[], &[]));
|
|
|
|
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
text.contains("Lower combined Stress"),
|
|
|
|
|
|
"the tiebreak shown must be the edition's own words: {text}"
|
|
|
|
|
|
);
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
text.contains("clay-borg's reading") || text.contains("clay-borg's reading"),
|
|
|
|
|
|
"a derived superlative must be marked as ours, not as a rule"
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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 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:?}"
|
|
|
|
|
|
);
|
fix: the controls sit below the log, and ending a session greys the page
Tier S (fixes inside a boundary; chaos d8=3, no override). Two
observations from play, both about what the page says has happened.
The controls moved BELOW the log: you read what happened, then decide what
to do next. They were above it, which asks for the decision first.
And sealing now marks the WHOLE PAGE inert, not just the controls. A
greyed-out button beside a full-colour table still reads as a live game
with one broken control; the session has ended and everything on screen is
a record of it. The status line stays legible on purpose -- it is the one
thing still worth reading.
THE STUB NEEDED A BODY classList TO MAKE THAT TESTABLE. Without it "the
session visibly ended" would have been a claim about CSS with nothing
checking it, which is precisely CB-WP-0016's finding: a stub too thin to
express a failure is how the failure survives. The harness reports the
sealed page through the status channel with a NUL-separated marker --
ugly, deliberate, and documented, because widening the return type would
touch every caller for one boolean.
Both directions asserted: a `closed` reply seals the page, an `ok:
dealing` reply does NOT -- otherwise the seal test would pass for a page
that greys itself whenever it is touched, breaking `play again`.
63 render tests, 26 cb-play, check and loop-lint clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:38:32 +02:00
|
|
|
|
let (_, status) =
|
|
|
|
|
|
jsrun::gesture_with_reply(&html, "again", "again", "ok: dealing").expect("run");
|
|
|
|
|
|
assert!(
|
|
|
|
|
|
!status.contains("sealed-page"),
|
|
|
|
|
|
"a dealing reply greyed out a page that is about to be reused"
|
|
|
|
|
|
);
|
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-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;
|