CB-WP-0016: the drop target that was never there
Some checks failed
ci / check (push) Failing after 4s

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>
This commit is contained in:
tegwick 2026-08-02 20:48:18 +02:00
parent 4df2d0a4ae
commit bf72a1863a
9 changed files with 496 additions and 27 deletions

View file

@ -57,15 +57,25 @@ fn cards(list: &[games_ground::SolutionCard]) -> String {
pub const SCRIPT: &str = r#"
(function () {
var down = null;
function id(e) {
function key(e) {
var n = e.target;
while (n && !n.id) { n = n.parentNode; }
return n ? n.id : null;
while (n && !(n.getAttribute && n.getAttribute('data-drop'))) { n = n.parentNode; }
return n ? n.getAttribute('data-drop') : null;
}
document.addEventListener('pointerdown', function (e) { down = id(e); });
document.addEventListener('pointerdown', function (e) { down = key(e); });
document.addEventListener('pointerup', function (e) {
var up = id(e);
if (!down || !up) { down = null; return; }
var up = key(e);
if (!down || !up) {
// CB-WP-0016 T02: refusing is right; refusing SILENTLY is what let a
// broken drop target survive a human sitting in front of it. Report
// the raw fact — which element the pointer took and where it let go.
// This decides nothing: it names elements, not moves.
document.getElementById('cb-status').textContent =
down ? 'took ' + down + ', let go over nothing droppable'
: 'nothing droppable under the pointer';
down = null;
return;
}
var body = 'down=' + encodeURIComponent(down) + '&up=' + encodeURIComponent(up);
down = null;
fetch(window.CB_ENDPOINT, {
@ -122,7 +132,7 @@ fn problem_svg(out: &mut String, priority: u32, p: &ProblemView, x: i32) {
};
let _ = write!(
out,
"<g id=\"problem-{priority}\"><rect x=\"{x}\" y=\"10\" width=\"120\" height=\"78\" rx=\"8\" \
"<g data-drop=\"problem-{priority}\"><rect x=\"{x}\" y=\"10\" width=\"120\" height=\"78\" rx=\"8\" \
fill=\"{fill}\" stroke=\"#5a6b7a\"/>\
<text x=\"{tx}\" y=\"36\" fill=\"#dde\" font-size=\"13\">{label}</text>\
<text x=\"{tx}\" y=\"56\" fill=\"#89a\" font-size=\"11\">priority {priority}</text>\
@ -182,7 +192,7 @@ fn relations_svg(view: &GroundView) -> String {
.map(|f| format!(" \u{2192}{}", seat_name(*f)));
let _ = write!(
s,
"<g id=\"seat-{raw}\"><circle cx=\"{x:.0}\" cy=\"{y:.0}\" r=\"26\" fill=\"#1b1e26\" \
"<g data-drop=\"seat-{raw}\"><circle cx=\"{x:.0}\" cy=\"{y:.0}\" r=\"26\" fill=\"#1b1e26\" \
stroke=\"{stroke}\" stroke-width=\"2\"/>\
<text x=\"{x:.0}\" y=\"{ty:.0}\" fill=\"#dde\" font-size=\"12\" \
text-anchor=\"middle\">{name}</text>\
@ -204,7 +214,13 @@ fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView
let is_viewer = view.viewer == Some(id);
let _ = write!(
out,
"<div class=\"card\" data-viewer=\"{is_viewer}\"><b>{name}</b>{you}<br>",
// CB-WP-0016 T01: the seat card is a drop target. It could not be
// while drop keys were `id`s — the graph node had already taken
// `seat-{n}` and ids must be unique, so the card the instruction
// text points at silently had none.
"<div class=\"card\" data-viewer=\"{is_viewer}\" data-drop=\"seat-{raw}\">\
<b>{name}</b>{you}<br>",
raw = id.0,
name = seat_name(id),
you = if is_viewer { " (you)" } else { "" },
);
@ -216,7 +232,7 @@ fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView
);
let _ = write!(
out,
"<span id=\"freedom-{raw}\" class=\"act\"><span class=\"k\">freedom</span> \
"<span data-drop=\"freedom-{raw}\" class=\"act\"><span class=\"k\">freedom</span> \
{ready}{lifted}</span><br>",
raw = id.0,
ready = if p.freedom_ready { "READY" } else { "spent" },
@ -422,7 +438,7 @@ pub fn document(
if offered {
let _ = write!(
s,
"<div class=\"card act\" id=\"{id}\">drag {a:?} onto a seat, a problem, \
"<div class=\"card act\" data-drop=\"{id}\">drag {a:?} onto a seat, a problem, \
or the table</div>",
id = action_id(a),
);
@ -434,18 +450,18 @@ pub fn document(
if !spatial {
let _ = write!(
s,
"<div class=\"card btn\" id=\"cmd-{i}\">{}</div>",
"<div class=\"card btn\" data-drop=\"cmd-{i}\">{}</div>",
esc(&format!("{c:?}"))
);
}
}
s.push_str(
"</div><div class=\"card\" id=\"table\">the table \u{2014} drop here for an \
"</div><div class=\"card\" data-drop=\"table\">the table \u{2014} drop here for an \
untargeted action</div>",
);
}
if may_pass {
s.push_str("<div class=\"card btn\" id=\"pass\">pass \u{2014} decline to act</div>");
s.push_str("<div class=\"card btn\" data-drop=\"pass\">pass \u{2014} decline to act</div>");
}
let _ = write!(
@ -485,6 +501,34 @@ fn json_string(s: &str) -> String {
/// that parse: it drops markup and returns what a reader would see, plus
/// the ids a pointer can address. A substring search over the raw source
/// would happily find a token inside a comment or a style rule.
/// Every `data-drop="…"` value in the document.
///
/// CB-WP-0016. A real parse of the attribute rather than a substring
/// search: `html.contains("seat-1")` would be satisfied by the *text*
/// "seat-1" and by `data-drop="seat-10"`, and the point of the check this
/// feeds is that an affordance can name a target that is not there.
///
/// **Drop keys are `data-drop`, not `id`, and that is the fix for
/// CB-WP-0016.** An `id` must be unique in a document, so exactly one
/// element could ever be `seat-0` — the relationship-graph circle took it
/// and the seat card the instruction text points at went without. A seat
/// is drawn twice and both drawings are the seat.
pub fn drop_keys(html: &str) -> std::collections::BTreeSet<String> {
let mut out = std::collections::BTreeSet::new();
let mut rest = html;
while let Some(i) = rest.find("data-drop=\"") {
let after = &rest[i + 11..];
match after.find('"') {
Some(j) => {
out.insert(after[..j].to_string());
rest = &after[j..];
}
None => break,
}
}
out
}
pub fn text_of(html: &str) -> String {
let mut out = String::with_capacity(html.len() / 2);
let bytes: Vec<char> = html.chars().collect();

View file

@ -67,8 +67,20 @@ function fetch(url, opts) {
var chainable = { then: function () { return chainable; } };
return chainable;
}
function __down(id) { __handlers['pointerdown']({ target: { id: id } }); }
function __up(id) { __handlers['pointerup']({ target: { id: id } }); }
// A node carrying one data-drop key, with a null parent — so the
// script's walk up the tree terminates the way it does in a browser.
function __node(k) {
return {
getAttribute: function (a) { return a === 'data-drop' ? k : null; },
parentNode: null
};
}
function __down(k) { __handlers['pointerdown']({ target: __node(k) }); }
function __up(k) { __handlers['pointerup']({ target: __node(k) }); }
// A node with no key at all, whose parent chain ends: what the pointer
// lands on when it is dropped somewhere that is not a target.
function __downNowhere() { __handlers['pointerdown']({ target: __node(null) }); }
function __upNowhere() { __handlers['pointerup']({ target: __node(null) }); }
"#;
/// Run the document's scripts, then a pointer gesture, and report what
@ -276,6 +288,43 @@ mod tests {
assert!(e.contains("no pointer handlers"), "{e}");
}
/// CB-WP-0016 T02. A drop on nothing must SAY so.
///
/// The old script returned without posting and without touching the
/// status line, so a broken drop target was indistinguishable from a
/// page that was working — which is exactly how the seat-card defect
/// survived until a human tried it.
#[test]
fn a_drop_on_nothing_reports_instead_of_going_quiet() {
let html = page();
let ctx = quick_js::Context::new().expect("ctx");
let posted = Arc::new(Mutex::new(0usize));
let sink = posted.clone();
ctx.add_callback("__post", move |_u: String, _b: String| {
*sink.lock().expect("posted") += 1;
0i32
})
.expect("cb");
ctx.add_callback("__reloaded", || 0i32).expect("cb");
ctx.eval(DOM).expect("dom");
for s in scripts(&html) {
ctx.eval(&s).expect("script");
}
ctx.eval("__down('action-attack'); __upNowhere();")
.expect("gesture");
// Nothing goes on the wire: there is no fact to report.
assert_eq!(*posted.lock().expect("posted"), 0);
// But the page does not go quiet about it.
let status: String = ctx
.eval_as("__status.textContent")
.expect("status readable");
assert!(
status.contains("action-attack") && status.contains("nothing droppable"),
"a drop on nothing said {status:?}"
);
}
#[test]
fn both_script_blocks_are_extracted_in_order() {
let found = scripts(&page());

View file

@ -331,5 +331,184 @@ mod coverage {
}
}
/// 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"
);
}
}
#[cfg(test)]
mod testfix;