CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit
Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat
play, at a measured marginal AM-4a cost of zero.
games-ground shipped: 23 third-party crates
cb-render-html: 23 third-party crates
new crates introduced: 0
Measured, not asserted — the survey's own lesson. AM-4a is unmoved at
246,250; own source is 7,636 -> 9,652.
What shipped:
crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship
graph), input.rs (pointer facts -> commands),
serve.rs (Guard, Request, loopback bind)
tools/cb-play hotseat.rs + `--serve PORT`
Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null.
The renderer targets the existing Project trait; the port waits for
stage 2's wgpu implementation to be its second use.
The six controls, all live, all mutation-checked (8 mutations, each red
for its stated reason):
1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind
4 a token-less request is refused, in the unit AND over a real socket
5 JS may not construct commands — the page reports pointer facts, Rust
resolves them against the legal list the aggregate already offered,
and a test asserts the emitted script contains no game vocabulary
6 the coverage gate crosses the language boundary: it walks the
serialized view for leaf paths and requires each token to appear in
the PARSED emitted document, with a test that the parse really is a
parse (script/style contents must not count as rendered)
The gate fired on its author again, on its first run: ground_choices.*.
choice, ground_choices.*.problem and players.*.blame_from were in neither
list. The last is the one worth keeping — an EMPTY vector is a leaf path
of its own, and it now renders as an explicit absence.
Also, a mutation that did not go red: removing the Sec-Fetch-Site arm
alone left the cross-site test green, because the Origin check caught it
independently. Both had to be removed before the control bit. Recorded
because a control that passes for a reason you did not intend has not
been demonstrated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
|
|
|
//! `cb-render-html` — stage 1's renderer (ADR-0007).
|
|
|
|
|
//!
|
|
|
|
|
//! Emits HTML with inline SVG and one small piece of inline JavaScript;
|
|
|
|
|
//! the browser draws it. **Marginal AM-4a cost: zero** — this crate has no
|
|
|
|
|
//! third-party dependencies at all, and adding one requires an argument
|
|
|
|
|
//! against ADR-0007 §Decision 3.
|
|
|
|
|
//!
|
|
|
|
|
//! ## What this is not
|
|
|
|
|
//!
|
|
|
|
|
//! It is **not** `cb-render-api`, and there is no `cb-render-null`.
|
|
|
|
|
//! ADR-0007 Decision 2 withdrew the port: a capability port designed
|
|
|
|
|
//! against one implementation that emits whole documents acquires a
|
|
|
|
|
//! document's shape, and stage 2's `wgpu` renderer would find it
|
|
|
|
|
//! unimplementable. INTENT's rule is the one that binds —
|
|
|
|
|
//!
|
|
|
|
|
//! > *No concept becomes canonical merely because it looks general. It
|
|
|
|
|
//! > becomes canonical after surviving a second concrete use.*
|
|
|
|
|
//!
|
|
|
|
|
//! — so this renders against the existing [`cb_game_runtime::Project`]
|
|
|
|
|
//! trait, which is a real interface with real implementations, and the
|
|
|
|
|
//! port is declared at stage 2 when there are two.
|
|
|
|
|
//!
|
|
|
|
|
//! ## The controls, and why they exist
|
|
|
|
|
//!
|
|
|
|
|
//! CB-WP-0011 established that a renderer's defect class is **silent
|
|
|
|
|
//! omission**: every assertion a renderer test naturally makes is
|
|
|
|
|
//! satisfied by a renderer showing a third of the state. This crate moves
|
|
|
|
|
//! the interactive half of stage 1 into a language `cargo test`,
|
|
|
|
|
//! `clippy` and `M-D1-MUT` cannot reach, so two controls carry that
|
|
|
|
|
//! finding across the boundary:
|
|
|
|
|
//!
|
|
|
|
|
//! * [`input`] — **JavaScript may not construct commands.** The page
|
|
|
|
|
//! reports raw pointer facts; Rust decides what they mean, against the
|
|
|
|
|
//! legal list the aggregate already offered.
|
|
|
|
|
//! * the coverage gate below — asserts over the **parsed emitted
|
|
|
|
|
//! document**, not over the Rust that emits it. Asserting over the
|
|
|
|
|
//! emitting code would reproduce CB-WP-0011's original defect one layer
|
|
|
|
|
//! up.
|
|
|
|
|
//!
|
|
|
|
|
//! And [`serve`] carries the four that make a loopback listener safe to
|
|
|
|
|
//! have, of which the load-bearing one is a test that a token-less request
|
|
|
|
|
//! is refused.
|
|
|
|
|
|
|
|
|
|
pub mod doc;
|
|
|
|
|
pub mod input;
|
CB-WP-0014-T01/T02: execute the JavaScript — and find AM-4b blind
ADR-0009: embed quick-js; node is refused. Measured marginal cost against
the dev-toolchain graph, under the positive control:
boa_engine 896,410
rquickjs 69,985
quick-js 11,434
node 0 <- and that zero is the problem
ADR-0007 D3's acquisition rule biting its author. CI runs on rust:1.97,
which has no node, so the test would make our build fetch a JS runtime of
tens of millions of unaudited lines while scoring zero on the only
instrument that governs dependencies. A browser is exempt because a
developer has one regardless of us; a CI-installed runtime is not.
The loop is now closed: the real server serves the real page, QuickJS
runs that page's own scripts, the gesture goes over a real socket, and
the seat's Choice comes back. Before this, every link was tested and the
chain was not — a page whose JavaScript sent something else entirely
would have passed everything.
Three controls, each red for its stated reason: the JS posting a command
name instead of ids, the gesture not being delivered (EXPECT-VACUOUS),
and the token stripped from the endpoint.
A wrong assertion worth keeping: the first draft required the body not to
contain "attack". It legitimately does — action-attack is the id of an
element a finger landed on. An element may name an action; that is not
the page deciding. The real test is the shape: exactly two fields, down
and up, carrying two ids and nothing derived from them.
AND the ADR's own cost argument was wrong. It claimed 35% of AM-4b's
headroom; after landing AM-4b did not move at all. It measures
games-ground --edges normal — one package, no dev edges. Measured, the
workspace including dev edges is 725,258 lines against AM-4b's 317,021:
408,237 uncounted, MORE THAN THE TARGET ITSELF (criterion, clap,
ciborium, quick-js). The decision stands on the acquisition rule; the
affordability argument is withdrawn. Third defect in the AM-4 family.
Also fixed structurally rather than by raising a limit: `make status` had
grown past its 40-line readability gate as workplans accumulated. Closed
workplans now collapse to one line, so the report is fixed-size.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 07:56:02 +02:00
|
|
|
#[cfg(any(test, feature = "js-harness"))]
|
|
|
|
|
pub mod jsrun;
|
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit
Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat
play, at a measured marginal AM-4a cost of zero.
games-ground shipped: 23 third-party crates
cb-render-html: 23 third-party crates
new crates introduced: 0
Measured, not asserted — the survey's own lesson. AM-4a is unmoved at
246,250; own source is 7,636 -> 9,652.
What shipped:
crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship
graph), input.rs (pointer facts -> commands),
serve.rs (Guard, Request, loopback bind)
tools/cb-play hotseat.rs + `--serve PORT`
Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null.
The renderer targets the existing Project trait; the port waits for
stage 2's wgpu implementation to be its second use.
The six controls, all live, all mutation-checked (8 mutations, each red
for its stated reason):
1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind
4 a token-less request is refused, in the unit AND over a real socket
5 JS may not construct commands — the page reports pointer facts, Rust
resolves them against the legal list the aggregate already offered,
and a test asserts the emitted script contains no game vocabulary
6 the coverage gate crosses the language boundary: it walks the
serialized view for leaf paths and requires each token to appear in
the PARSED emitted document, with a test that the parse really is a
parse (script/style contents must not count as rendered)
The gate fired on its author again, on its first run: ground_choices.*.
choice, ground_choices.*.problem and players.*.blame_from were in neither
list. The last is the one worth keeping — an EMPTY vector is a leaf path
of its own, and it now renders as an explicit absence.
Also, a mutation that did not go red: removing the Sec-Fetch-Site arm
alone left the cross-site test green, because the Origin check caught it
independently. Both had to be removed before the control bit. Recorded
because a control that passes for a reason you did not intend has not
been demonstrated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
|
|
|
pub mod serve;
|
|
|
|
|
|
|
|
|
|
pub use doc::{document, text_of};
|
|
|
|
|
pub use input::{resolve, PointerFact};
|
|
|
|
|
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"),
|
|
|
|
|
("solution_deck_len", "17 remaining"),
|
|
|
|
|
("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"),
|
|
|
|
|
("selections.*.action", "action: Attack"),
|
|
|
|
|
("selections.*.target", "target: Some(PlayerId(1))"),
|
|
|
|
|
("selections.*.problem", "problem: Some(7)"),
|
|
|
|
|
("ground_modes.*", "ground mode Gr"),
|
|
|
|
|
("ground_choices.*.choice", "ProtectProblem"),
|
|
|
|
|
("ground_choices.*.problem", "problem: 7 }"),
|
|
|
|
|
("support_responses.*", "support AcceptBond"),
|
|
|
|
|
("darvo_targets.*.problem", "problem: Some(1)"),
|
|
|
|
|
("darvo_targets.*.player", "player: Some(PlayerId(1))"),
|
|
|
|
|
("outcome.total", "total 9"),
|
|
|
|
|
("outcome.threshold", "of 12"),
|
|
|
|
|
("outcome.group_success", "failure"),
|
|
|
|
|
("outcome.personal.*", "P1 +3"),
|
|
|
|
|
("outcome.coalitions.*.members.*", "members: [PlayerId(1)"),
|
|
|
|
|
("outcome.coalitions.*.score", "score: 4"),
|
|
|
|
|
("outcome.mastery", "mastery +1"),
|
|
|
|
|
("outcome.winners.*", "winners P1"),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/// Deliberate omissions, each with a reason.
|
|
|
|
|
const OMITTED: &[(&str, &str)] = &[(
|
|
|
|
|
"players.*.hand",
|
|
|
|
|
"null for a non-viewer seat; the absence is rendered as a count",
|
|
|
|
|
)];
|
|
|
|
|
|
|
|
|
|
fn fixture() -> GroundView {
|
|
|
|
|
crate::testfix::view(Some(PlayerId(0)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn rendered_document(view: &GroundView) -> String {
|
|
|
|
|
text_of(&document(
|
|
|
|
|
view,
|
|
|
|
|
&[],
|
|
|
|
|
"/command?t=x",
|
|
|
|
|
Some(PlayerId(0)),
|
|
|
|
|
false,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn every_view_field_is_classified_in_the_emitted_document() {
|
|
|
|
|
let view = fixture();
|
|
|
|
|
let json = serde_json::to_value(&view).expect("view serializes");
|
|
|
|
|
let mut raw = Vec::new();
|
|
|
|
|
paths(&json, "", &mut raw);
|
|
|
|
|
let all: std::collections::BTreeSet<String> = raw.iter().map(|p| normalize(p)).collect();
|
|
|
|
|
|
|
|
|
|
// EXPECT-VACUOUS floor. A coverage test over zero paths passes
|
|
|
|
|
// trivially, and that is precisely how this check would rot.
|
|
|
|
|
assert!(
|
|
|
|
|
all.len() >= 30,
|
|
|
|
|
"the walk found {} leaf path(s) — it is not walking the view",
|
|
|
|
|
all.len()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let claimed: std::collections::BTreeSet<&str> = RENDERED
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(p, _)| *p)
|
|
|
|
|
.chain(OMITTED.iter().map(|(p, _)| *p))
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
let unclassified: Vec<&String> = all
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|p| !claimed.contains(p.as_str()))
|
|
|
|
|
.collect();
|
|
|
|
|
assert!(
|
|
|
|
|
unclassified.is_empty(),
|
|
|
|
|
"unclassified path(s) in GroundView: {unclassified:?} \
|
|
|
|
|
— add each to RENDERED with a token, or to OMITTED with a reason"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let stale: Vec<&&str> = claimed.iter().filter(|p| !all.contains(**p)).collect();
|
|
|
|
|
assert!(
|
|
|
|
|
stale.is_empty(),
|
|
|
|
|
"classified path(s) no longer exist in GroundView: {stale:?}"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// The half that makes it a rendering gate rather than a bookkeeping
|
|
|
|
|
// one: the token must be in the *parsed document*.
|
|
|
|
|
let text = rendered_document(&view);
|
|
|
|
|
let missing: Vec<&str> = RENDERED
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|(_, tok)| !text.contains(tok))
|
|
|
|
|
.map(|(p, _)| *p)
|
|
|
|
|
.collect();
|
|
|
|
|
assert!(
|
|
|
|
|
missing.is_empty(),
|
|
|
|
|
"claimed rendered, but absent from the emitted document: {missing:?}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The parse must be a parse. If `text_of` returned the raw source, a
|
|
|
|
|
/// token hiding in a comment, a style rule or the script would count
|
|
|
|
|
/// as rendered — which is the loophole control 6 exists to close.
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_parse_does_not_see_script_or_style_contents() {
|
|
|
|
|
let html = "<style>.x{content:'STYLETOKEN'}</style><p id=\"pid\">seen</p>\
|
|
|
|
|
<script>var s='SCRIPTTOKEN';</script>";
|
|
|
|
|
let text = text_of(html);
|
|
|
|
|
assert!(text.contains("seen"));
|
|
|
|
|
assert!(text.contains("pid"), "element ids are addressable surface");
|
|
|
|
|
assert!(
|
|
|
|
|
!text.contains("STYLETOKEN"),
|
|
|
|
|
"style contents leaked into the text"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!text.contains("SCRIPTTOKEN"),
|
|
|
|
|
"script contents leaked into the text"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Control 5, asserted at the document level: the emitted JavaScript
|
|
|
|
|
/// must contain no game vocabulary. If a rule ever needs to live in
|
|
|
|
|
/// the page, ADR-0007 says revisit the decision, not widen this.
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_emitted_javascript_contains_no_game_vocabulary() {
|
|
|
|
|
let doc = document(&fixture(), &[], "/command?t=x", Some(PlayerId(0)), false);
|
|
|
|
|
let script = doc
|
|
|
|
|
.split_once("<script>")
|
|
|
|
|
.and_then(|(_, r)| r.rsplit_once("</script>"))
|
|
|
|
|
.map(|(s, _)| s.to_string())
|
|
|
|
|
.expect("the document carries a script");
|
|
|
|
|
for word in [
|
|
|
|
|
"Investigate",
|
|
|
|
|
"Solve",
|
|
|
|
|
"Support",
|
|
|
|
|
"Attack",
|
|
|
|
|
"Ground",
|
|
|
|
|
"darvo",
|
|
|
|
|
"DARVO",
|
|
|
|
|
"stress",
|
|
|
|
|
"freedom",
|
|
|
|
|
"problem",
|
|
|
|
|
"coalition",
|
|
|
|
|
"reveal",
|
|
|
|
|
"resolve",
|
|
|
|
|
] {
|
|
|
|
|
assert!(
|
|
|
|
|
!script.contains(word),
|
|
|
|
|
"the emitted JavaScript mentions {word:?} — control 5 is breached"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
// And it must still be doing its one job.
|
|
|
|
|
assert!(script.contains("pointerdown") && script.contains("pointerup"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The renderer must not invent a hand it was never given.
|
|
|
|
|
///
|
|
|
|
|
/// Scoped deliberately: the *projection's* hiding rules are asserted
|
|
|
|
|
/// where they live, and re-asserting them here would be a duplicated
|
|
|
|
|
/// fact that drifts. What this checks is the renderer's own failure
|
|
|
|
|
/// mode — that `hand: None` renders as an absence, for every seat, and
|
|
|
|
|
/// that a spectator's document contains no open hand at all.
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_renderer_never_invents_a_hand_it_was_not_given() {
|
|
|
|
|
for seat in [0u8, 1, 2] {
|
|
|
|
|
let view = crate::testfix::view(Some(PlayerId(seat)));
|
|
|
|
|
let text = rendered_document(&view);
|
|
|
|
|
let shown = text.matches("cards)").count();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
shown,
|
|
|
|
|
1,
|
|
|
|
|
"P{} sees {shown} open hands in the document; it was given exactly one",
|
|
|
|
|
seat + 1
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
text.matches("card(s), hidden").count(),
|
|
|
|
|
2,
|
|
|
|
|
"the other two seats' hands must render as an absence with a count"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
let text = rendered_document(&crate::testfix::view(None));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
text.matches("cards)").count(),
|
|
|
|
|
0,
|
|
|
|
|
"a spectator document shows an open hand"
|
|
|
|
|
);
|
|
|
|
|
assert!(text.contains("a spectator (no hands)"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
CB-WP-0016: the drop target that was never there
Provenance (tier S, one paragraph in lieu of survey and ADR): the human
check that kept INTENT stage 1 open was run and the drag was broken.
Root cause, worth more than the instance: drop targets were ids, and an
id must be unique, so exactly one element could ever be seat-0. The
relationship-graph circle took it and the seat card that every action
card's own text points at -- 'drag Attack onto a seat' -- silently had
none. A seat is drawn twice and both drawings are the seat; the document
model could not express that.
Drop keys are now data-drop. Any number of elements may carry the same
key, so a seat is droppable on its card and on its graph node. Measured
on a live server: seat-0/1/2 each appear twice, id survives only on
cb-status which is the one element the script looks up, and
down=action-attack&up=seat-1 returns ok.
Second defect: a drop on nothing returned without posting and without
touching the status line, so a broken target was indistinguishable from
a working page. resolve already refuses rather than defaulting, which is
right; refusing SILENTLY is not. The page now reports the raw fact --
'took action-attack, let go over nothing droppable' -- which names
elements, not moves, so ADR-0007 control 5 holds.
And the honest part: the general check added here -- every offered
affordance names a key that exists, driven through Policy::choose over
four real bot games -- does NOT catch the reported defect. seat-0 did
exist, on the graph circle. It is kept because a wholly absent target is
a real class, and paired with a targeted regression test that does catch
it. Three mutations, each red for its stated reason, including the
reported defect reintroduced; only the targeted test fires on that one.
A cb-play assertion matched id="action-ground" as a substring while
describing itself as checking the page; rewritten through drop_keys.
make all exits 0. Stage 1 stays open: verified by tests, mutation and a
live server, not by a human dragging.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 20:48:18 +02:00
|
|
|
/// CB-WP-0016 T03: every affordance the page offers must name an element
|
|
|
|
|
/// the page actually contains.
|
|
|
|
|
///
|
|
|
|
|
/// **This is the check that closes the class the human check found.** On
|
|
|
|
|
/// 2026-08-02 the maintainer ran `cb-play --serve 0` and could not drag an
|
|
|
|
|
/// action onto a seat. Every test in the repo was green. The cause: the
|
|
|
|
|
/// visible seat cards carried no `id`, so `seat-{n}` existed only on the
|
|
|
|
|
/// 26 px circles inside the relationship graph, while every action card
|
|
|
|
|
/// read *"drag Attack onto a seat…"*.
|
|
|
|
|
///
|
|
|
|
|
/// Nothing could catch it. `jsrun::gesture` feeds element ids straight
|
|
|
|
|
/// into a synthetic `{target:{id}}` and never hit-tests, so it establishes
|
|
|
|
|
/// *"the script posts the ids it was given"* — never *"there is an element
|
|
|
|
|
/// there to give."* The 42-path coverage gate asserts each view field is
|
|
|
|
|
/// present in the parsed document, which a `<div>` with no id satisfies.
|
|
|
|
|
///
|
|
|
|
|
/// So: drive a real game, and at every decision point require that both
|
|
|
|
|
/// halves of every offered affordance appear as real `id` attributes.
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod affordances {
|
|
|
|
|
use std::cell::RefCell;
|
|
|
|
|
use std::rc::Rc;
|
|
|
|
|
|
|
|
|
|
use cb_game_runtime::{Project, ScenarioGame, Setup, Viewer};
|
|
|
|
|
use cb_kernel::PlayerId;
|
|
|
|
|
use games_ground::bot::{play, Choice, Policy, RandomPolicy};
|
|
|
|
|
use games_ground::{GroundCommand, GroundState};
|
|
|
|
|
|
|
|
|
|
use crate::{doc, input};
|
|
|
|
|
|
|
|
|
|
fn fresh(seed: u64) -> GroundState {
|
|
|
|
|
GroundState::setup(
|
|
|
|
|
&Setup {
|
|
|
|
|
players: 3,
|
|
|
|
|
preset: "standard-3p".into(),
|
|
|
|
|
patch: std::collections::BTreeMap::new(),
|
|
|
|
|
},
|
|
|
|
|
seed,
|
|
|
|
|
)
|
|
|
|
|
.expect("a standard 3p deal")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Renders the page at every real decision point and checks it, then
|
|
|
|
|
/// delegates the actual choice.
|
|
|
|
|
///
|
|
|
|
|
/// Hooking `Policy` rather than re-driving the game by hand matters:
|
|
|
|
|
/// these are the *same* decision points `cb-play --serve` renders at,
|
|
|
|
|
/// with the same `legal` list. A hand-rolled walk would be a second
|
|
|
|
|
/// implementation of the loop, and could agree with itself while
|
|
|
|
|
/// disagreeing with the thing shipped.
|
|
|
|
|
struct CheckingPolicy {
|
|
|
|
|
inner: RandomPolicy,
|
|
|
|
|
checked: Rc<RefCell<usize>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Policy for CheckingPolicy {
|
|
|
|
|
fn name(&self) -> &'static str {
|
|
|
|
|
"affordance-checking"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn choose(
|
|
|
|
|
&mut self,
|
|
|
|
|
state: &GroundState,
|
|
|
|
|
seat: PlayerId,
|
|
|
|
|
legal: &[GroundCommand],
|
|
|
|
|
may_pass: bool,
|
|
|
|
|
) -> Choice {
|
|
|
|
|
let view = state.project(Viewer::Player(seat));
|
|
|
|
|
let html = doc::document(&view, legal, "/command?t=x", Some(seat), may_pass);
|
|
|
|
|
let present = doc::drop_keys(&html);
|
|
|
|
|
|
|
|
|
|
for cmd in legal {
|
|
|
|
|
let Some((from, to)) = input::affordance(cmd, seat) else {
|
|
|
|
|
// A command with no affordance is offered through the
|
|
|
|
|
// numbered-button path instead. That is a stated
|
|
|
|
|
// shape, not a missing element.
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
assert!(
|
|
|
|
|
present.contains(&from),
|
|
|
|
|
"the page offers {cmd:?} whose GRAB id {from:?} is not an \
|
|
|
|
|
element in the document (seat {seat:?}, step {:?})",
|
|
|
|
|
state.step
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
present.contains(&to),
|
|
|
|
|
"the page offers {cmd:?} whose DROP id {to:?} is not an \
|
|
|
|
|
element in the document (seat {seat:?}, step {:?}). \
|
|
|
|
|
Present ids: {present:?}",
|
|
|
|
|
state.step
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
*self.checked.borrow_mut() += 1;
|
|
|
|
|
self.inner.choose(state, seat, legal, may_pass)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// **The check that closes the class the human check found.**
|
|
|
|
|
///
|
|
|
|
|
/// On 2026-08-02 the maintainer ran `cb-play --serve 0` and could not
|
|
|
|
|
/// drag an action onto a seat. Every test in the repo was green. The
|
|
|
|
|
/// cause: the visible seat cards carried no `id`, so `seat-{n}` existed
|
|
|
|
|
/// only on the 26 px circles inside the relationship graph, while every
|
|
|
|
|
/// action card read *"drag Attack onto a seat…"*.
|
|
|
|
|
///
|
|
|
|
|
/// Nothing could catch it. `jsrun::gesture` feeds element ids straight
|
|
|
|
|
/// into a synthetic `{target:{id}}` and never hit-tests, so it
|
|
|
|
|
/// establishes *"the script posts the ids it was given"* — never
|
|
|
|
|
/// *"there is an element there to give."* The coverage gate asserts
|
|
|
|
|
/// each view field appears in the parsed document, which a `<div>` with
|
|
|
|
|
/// no id satisfies perfectly.
|
|
|
|
|
///
|
|
|
|
|
/// An affordance naming an element that does not exist is the
|
|
|
|
|
/// harness-does-nothing shape in the presentation layer, and until now
|
|
|
|
|
/// it had no detector at all.
|
|
|
|
|
#[test]
|
|
|
|
|
fn every_offered_affordance_names_an_element_that_exists() {
|
|
|
|
|
let checked = Rc::new(RefCell::new(0usize));
|
|
|
|
|
for seed in 0..4u64 {
|
|
|
|
|
let mut policies: Vec<Box<dyn Policy>> = (0..3)
|
|
|
|
|
.map(|i| {
|
|
|
|
|
Box::new(CheckingPolicy {
|
|
|
|
|
inner: RandomPolicy::new(seed * 10 + i),
|
|
|
|
|
checked: checked.clone(),
|
|
|
|
|
}) as Box<dyn Policy>
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
play(fresh(seed), &mut policies).expect("a bot game completes");
|
|
|
|
|
}
|
|
|
|
|
// Positive control: a run that rendered nothing would assert
|
|
|
|
|
// nothing and read as a pass — the exact failure this test exists
|
|
|
|
|
// to catch, one level up.
|
|
|
|
|
let n = *checked.borrow();
|
|
|
|
|
assert!(n >= 50, "checked only {n} decision point(s)");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// **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"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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;
|