CB-WP-0017: legible interaction, and the chaos window's verdict
Some checks failed
ci / check (push) Failing after 4s

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>
This commit is contained in:
tegwick 2026-08-02 22:42:45 +02:00
parent ca92db53bc
commit a55e878bf0
9 changed files with 911 additions and 77 deletions

View file

@ -56,34 +56,85 @@ fn cards(list: &[games_ground::SolutionCard]) -> String {
/// The only JavaScript in the project. See the module docs.
pub const SCRIPT: &str = r#"
(function () {
var down = null;
function key(e) {
var down = null, held = null, marked = [], ghost = null;
function node(e) {
var n = e.target;
while (n && !(n.getAttribute && n.getAttribute('data-drop'))) { n = n.parentNode; }
return n ? n.getAttribute('data-drop') : null;
return n;
}
document.addEventListener('pointerdown', function (e) { down = key(e); });
function key(n) { return n ? n.getAttribute('data-drop') : null; }
function status(t) { document.getElementById('cb-status').textContent = t; }
// ADR-0010 D1: the destinations come from `data-targets`, which Rust
// wrote. This matches on them. It does not compute, infer, filter or
// default one -- a script that pattern-matched ids to guess what is
// legal would be forbidden even though the visible result is identical.
function mark(n) {
var spec = n.getAttribute('data-targets');
if (!spec) { return; }
var want = spec.split(' ');
var all = document.querySelectorAll('[data-drop]');
for (var i = 0; i < all.length; i++) {
if (want.indexOf(all[i].getAttribute('data-drop')) >= 0) {
all[i].classList.add('dropok');
marked.push(all[i]);
}
}
}
function clear() {
for (var i = 0; i < marked.length; i++) { marked[i].classList.remove('dropok'); }
marked = [];
if (held) { held.classList.remove('held'); held = null; }
if (ghost && ghost.parentNode) { ghost.parentNode.removeChild(ghost); }
ghost = null;
down = null;
}
document.addEventListener('pointerdown', function (e) {
clear();
var n = node(e);
down = key(n);
if (!n || !down) { return; }
held = n;
n.classList.add('held');
mark(n);
if (n.getAttribute('data-targets')) {
ghost = document.createElement('div');
ghost.id = 'cb-ghost';
ghost.textContent = n.textContent;
ghost.style.left = e.clientX + 'px';
ghost.style.top = e.clientY + 'px';
document.body.appendChild(ghost);
}
});
document.addEventListener('pointermove', function (e) {
if (!ghost) { return; }
ghost.style.left = e.clientX + 'px';
ghost.style.top = e.clientY + 'px';
});
document.addEventListener('pointercancel', clear);
document.addEventListener('pointerup', function (e) {
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;
var up = key(node(e));
var grabbed = down;
clear();
if (!grabbed || !up) {
// Refusing is right; refusing SILENTLY is what let a broken drop
// target survive a human sitting in front of it (CB-WP-0016).
status(grabbed ? 'took ' + grabbed + ', let go over nothing droppable'
: 'nothing droppable under the pointer');
return;
}
var body = 'down=' + encodeURIComponent(down) + '&up=' + encodeURIComponent(up);
down = null;
var body = 'down=' + encodeURIComponent(grabbed) + '&up=' + encodeURIComponent(up);
fetch(window.CB_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body
}).then(function (r) { return r.text(); }).then(function (t) {
document.getElementById('cb-status').textContent = t;
status(t);
if (t.indexOf('ok') === 0) { window.location.reload(); }
});
});
@ -98,11 +149,51 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf}
.card[data-viewer=true]{border-color:#9cf}
.act{cursor:grab;user-select:none;background:#243;border-color:#5a7}
.btn{cursor:pointer;background:#332a3a;border-color:#a7d}
/* CB-WP-0017. Interactive and inert must not look identical: `.pick` is
the resting affordance on anything that can be picked up. ADR-0010 D5
claims no evidence that this READS as pickable -- that is a human
check, and stage 1 is open on it. */
.pick{cursor:grab;user-select:none;box-shadow:0 2px 0 #0006,0 0 0 1px #5a7a inset;
transition:transform .08s,box-shadow .08s}
.pick:hover{box-shadow:0 3px 8px #000a,0 0 0 1px #7ca inset;transform:translateY(-1px)}
/* Held: the thing in your hand is lifted and dimmed where it used to be. */
.held{cursor:grabbing;opacity:.45;transform:scale(.97)}
/* A legal destination for the thing currently held -- and ONLY for that
thing. The set is written by Rust into data-targets; the script matches
it and never derives it (ADR-0010 D1). */
.dropok{outline:2px dashed #9cf;outline-offset:3px;background:#1d2a33}
.dropok text{fill:#cfe}
/* The ghost that follows the pointer, so a drag is not invisible. */
#cb-ghost{position:fixed;pointer-events:none;z-index:9;padding:.3rem .5rem;
border-radius:6px;background:#243;border:1px solid #5a7;color:#dde;
font:13px ui-monospace,monospace;box-shadow:0 6px 16px #000b;
transform:translate(-50%,-140%)}
.k{color:#89a}
#cb-status{margin-top:1rem;color:#fc9;min-height:1.2em}
svg{background:#1b1e26;border:1px solid #445;border-radius:6px}
";
/// Render drop keys as something a player can read.
///
/// The keys are what the page posts; these are what it shows. Both come
/// from the same list, so they cannot disagree about which moves exist.
fn target_names(targets: &[String]) -> String {
let mut out: Vec<String> = Vec::new();
for t in targets {
out.push(match t.split_once('-') {
Some(("seat", n)) => n
.parse::<u8>()
.map(|n| seat_name(PlayerId(n)))
.unwrap_or_else(|_| t.clone()),
Some(("problem", n)) => format!("problem {n}"),
_ if t == "table" => "the table".to_string(),
_ => t.clone(),
});
}
out.join(", ")
}
fn problem_svg(out: &mut String, priority: u32, p: &ProblemView, x: i32) {
let (label, sub, fill) = match p {
ProblemView::FaceDown => ("face down".to_string(), String::new(), "#2a2f3a"),
@ -232,7 +323,7 @@ fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView
);
let _ = write!(
out,
"<span data-drop=\"freedom-{raw}\" class=\"act\"><span class=\"k\">freedom</span> \
"<span data-drop=\"freedom-{raw}\" class=\"act pick\"><span class=\"k\">freedom</span> \
{ready}{lifted}</span><br>",
raw = id.0,
ready = if p.freedom_ready { "READY" } else { "spent" },
@ -430,17 +521,33 @@ pub fn document(
games_ground::Action::Attack,
games_ground::Action::Ground,
] {
let offered = seat.is_some_and(|seat| {
legal.iter().any(|c| {
crate::input::affordance(c, seat).is_some_and(|(f, _)| f == action_id(a))
// ADR-0010 D1: the legal targets come from `legal` and are
// written into the page as data. The script matches on them;
// it never derives them. The old text was a CONSTANT —
// "onto a seat, a problem, or the table" — emitted whenever
// any legal command used this action, and it was wrong
// wherever the real target set was narrower, which is almost
// everywhere: Investigate is legal on problems 2 and 3 but
// not 1 (CB-WP-0017).
let targets: Vec<String> = seat
.map(|seat| {
legal
.iter()
.filter_map(|c| crate::input::affordance(c, seat))
.filter(|(f, _)| *f == action_id(a))
.map(|(_, t)| t)
.collect()
})
});
if offered {
.unwrap_or_default();
if !targets.is_empty() {
let _ = write!(
s,
"<div class=\"card act\" data-drop=\"{id}\">drag {a:?} onto a seat, a problem, \
or the table</div>",
"<div class=\"card act pick\" data-drop=\"{id}\" \
data-targets=\"{targets}\">{a:?}<br>\
<span class=\"k\">onto</span> {names}</div>",
id = action_id(a),
targets = esc(&targets.join(" ")),
names = esc(&target_names(&targets)),
);
}
}
@ -450,7 +557,7 @@ pub fn document(
if !spatial {
let _ = write!(
s,
"<div class=\"card btn\" data-drop=\"cmd-{i}\">{}</div>",
"<div class=\"card btn pick\" data-drop=\"cmd-{i}\">{}</div>",
esc(&format!("{c:?}"))
);
}
@ -461,7 +568,9 @@ pub fn document(
);
}
if may_pass {
s.push_str("<div class=\"card btn\" data-drop=\"pass\">pass \u{2014} decline to act</div>");
s.push_str(
"<div class=\"card btn pick\" data-drop=\"pass\">pass \u{2014} decline to act</div>",
);
}
let _ = write!(

View file

@ -55,32 +55,79 @@ pub fn scripts(html: &str) -> Vec<String> {
const DOM: &str = r#"
var __handlers = {};
var __status = { textContent: "" };
var __body = { children: [], appendChild: function (n) { this.children.push(n); n.parentNode = this; },
removeChild: function (n) { var i = this.children.indexOf(n);
if (i >= 0) { this.children.splice(i, 1); }
n.parentNode = null; } };
var __all = [];
// A DOM node with a real classList and real attributes. CB-WP-0016 found
// that a stub too thin to express a failure is how the failure survives;
// the previous stub could not express class-toggling at all.
function __mk(key, targets, text) {
var n = {
parentNode: null,
textContent: text || key,
style: {},
_cls: {},
_attr: { 'data-drop': key, 'data-targets': targets || null },
getAttribute: function (a) { return this._attr[a] !== undefined ? this._attr[a] : null; },
setAttribute: function (a, v) { this._attr[a] = v; },
classList: {
add: function (c) { n._cls[c] = true; },
remove: function (c) { delete n._cls[c]; },
contains: function (c) { return !!n._cls[c]; }
}
};
return n;
}
function __register(key, targets) { var n = __mk(key, targets); __all.push(n); return n; }
var document = {
body: __body,
addEventListener: function (type, fn) { __handlers[type] = fn; },
getElementById: function (id) { return __status; }
getElementById: function (id) { return __status; },
createElement: function (_tag) { return __mk(null, null, ""); },
querySelectorAll: function (_sel) { return __all; }
};
var window = { location: { reload: function () { __reloaded(); } } };
function fetch(url, opts) {
__post(url, (opts && opts.body) || "");
// The script chains .then(...).then(...); give it something to chain on
// without ever resolving, so response handling stays out of scope here.
var chainable = { then: function () { return chainable; } };
return chainable;
}
// 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
};
// Gestures are driven against REGISTERED nodes, so the script's walk, its
// classList writes and its querySelectorAll all see the same objects a
// browser would -- rather than a synthetic {target:{id}} that bypasses
// every one of them (CB-EV-0014 section 2).
function __down(k) { __handlers['pointerdown']({ target: __find(k), clientX: 1, clientY: 2 }); }
function __up(k) { __handlers['pointerup']({ target: __find(k), clientX: 3, clientY: 4 }); }
function __move() { if (__handlers['pointermove']) { __handlers['pointermove']({ clientX: 9, clientY: 9 }); } }
function __cancel() { __handlers['pointercancel']({ target: __find(null) }); }
function __find(k) {
for (var i = 0; i < __all.length; i++) {
if (__all[i].getAttribute('data-drop') === k) { return __all[i]; }
}
return __mk(null, 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) }); }
function __downNowhere() { __handlers['pointerdown']({ target: __mk(null, null, ""), clientX: 0, clientY: 0 }); }
function __upNowhere() { __handlers['pointerup']({ target: __mk(null, null, ""), clientX: 0, clientY: 0 }); }
function __marked() {
var out = [];
for (var i = 0; i < __all.length; i++) {
if (__all[i].classList.contains('dropok')) { out.push(__all[i].getAttribute('data-drop')); }
}
return out.sort().join(',');
}
function __heldKeys() {
var out = [];
for (var i = 0; i < __all.length; i++) {
if (__all[i].classList.contains('held')) { out.push(__all[i].getAttribute('data-drop')); }
}
return out.sort().join(',');
}
function __ghosts() { return __body.children.length; }
"#;
/// Run the document's scripts, then a pointer gesture, and report what
@ -105,16 +152,7 @@ pub fn gesture(html: &str, down: &str, up: &str) -> Result<Vec<Posted>, String>
ctx.add_callback("__reloaded", || 0i32)
.map_err(|e| format!("register __reloaded: {e}"))?;
ctx.eval(DOM).map_err(|e| format!("dom stub: {e}"))?;
let found = scripts(html);
if found.is_empty() {
return Err("the document carries no <script> block".to_string());
}
for (i, s) in found.iter().enumerate() {
ctx.eval(s)
.map_err(|e| format!("script {i} of {}: {e}", found.len()))?;
}
prepare(&ctx, html)?;
// If the page never registered handlers, the gesture below would be a
// no-op and the test would read as "sent nothing" rather than "the
@ -137,6 +175,73 @@ pub fn gesture(html: &str, down: &str, up: &str) -> Result<Vec<Posted>, String>
Ok(out)
}
/// Every droppable in the document, as `(data-drop, data-targets)`.
///
/// The stub's node set is built from the **real emitted page**, not from a
/// list the harness invented. CB-EV-0014 §2: the old stub synthesized
/// `{target:{id}}` from whatever id the test passed in, so it could not
/// tell whether the page had such an element at all.
pub fn droppables(html: &str) -> Vec<(String, Option<String>)> {
let mut out = Vec::new();
let mut rest = html;
while let Some(i) = rest.find("data-drop=\"") {
let after = &rest[i + 11..];
let Some(j) = after.find('"') else { break };
let key = after[..j].to_string();
// `data-targets`, when present, is written on the same element, so
// it is between here and the end of this tag.
let tail = &after[j..];
let end = tail.find('>').unwrap_or(tail.len());
let targets = tail[..end].find("data-targets=\"").and_then(|k| {
let t = &tail[k + 14..];
t.find('"').map(|e| t[..e].to_string())
});
out.push((key, targets));
rest = tail;
}
out
}
/// Install the DOM stub, register the page's droppables, and evaluate the
/// page's own scripts into a context the **caller** created.
///
/// **The caller must own the `Context`, and that is not a style
/// preference.** QuickJS records its stack limit at runtime creation,
/// relative to the frame it was created in, and later checks the current
/// frame against it. Creating the context inside a helper and returning it
/// leaves every subsequent `eval` running in a *shallower* frame, the
/// comparison underflows, and QuickJS reports `SyntaxError: stack
/// overflow` for even `__all.length`. Cost an hour; recorded so it costs
/// nobody else one.
///
/// Shared so a test cannot set up a different page than the harness runs:
/// one hand-rolled setup had already skipped registration silently.
pub fn prepare(ctx: &quick_js::Context, html: &str) -> Result<(), String> {
ctx.eval(DOM).map_err(|e| format!("dom stub: {e}"))?;
register(ctx, html)?;
let found = scripts(html);
if found.is_empty() {
return Err("the document carries no <script> block".to_string());
}
for (i, sc) in found.iter().enumerate() {
ctx.eval(sc)
.map_err(|e| format!("script {i} of {}: {e}", found.len()))?;
}
Ok(())
}
fn register(ctx: &quick_js::Context, html: &str) -> Result<(), String> {
for (key, targets) in droppables(html) {
let call = match targets {
Some(t) => format!("__register({}, {});", json_lit(&key), json_lit(&t)),
None => format!("__register({}, null);", json_lit(&key)),
};
ctx.eval(&call)
.map_err(|e| format!("register {key}: {e}"))?;
}
Ok(())
}
fn json_lit(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
@ -296,7 +401,6 @@ mod tests {
/// 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();
@ -306,10 +410,8 @@ mod tests {
})
.expect("cb");
ctx.add_callback("__reloaded", || 0i32).expect("cb");
ctx.eval(DOM).expect("dom");
for s in scripts(&html) {
ctx.eval(&s).expect("script");
}
prepare(&ctx, &page()).expect("prepare");
ctx.eval("__down('action-attack'); __upNowhere();")
.expect("gesture");
@ -325,6 +427,115 @@ mod tests {
);
}
/// Setup shared by the drag-visibility tests.
fn ctx_for(html: &str) -> quick_js::Context {
let ctx = quick_js::Context::new().expect("ctx");
ctx.add_callback("__post", |_u: String, _b: String| 0i32)
.expect("cb");
ctx.add_callback("__reloaded", || 0i32).expect("cb");
prepare(&ctx, html).expect("prepare");
ctx
}
/// **ADR-0010 Decision 2, property 1.** The highlighted set must equal
/// the set Rust emitted — not a subset, not a superset.
///
/// This is the load-bearing control, and it replaces the vocabulary
/// grep as the thing that actually holds Decision 1. A script that
/// *derived* legality by pattern-matching ids would produce a
/// plausible highlight and drift from `data-targets` the moment the
/// two disagreed; this is stated over observable behaviour rather than
/// over source text, which is the same move that fixed control 5.
#[test]
fn picking_up_marks_exactly_the_targets_rust_emitted() {
let html = page();
let ctx = ctx_for(&html);
// What Rust wrote on the card, read back out of the document.
let emitted: String = droppables(&html)
.into_iter()
.find(|(k, _)| k == "action-attack")
.and_then(|(_, t)| t)
.expect("the action card carries its legal targets");
let mut want: Vec<&str> = emitted.split(' ').collect();
want.sort_unstable();
ctx.eval("__down('action-attack');").expect("pointerdown");
let marked: String = ctx.eval_as("__marked()").expect("marked");
// Compared as SETS, because a seat is drawn twice — card and graph
// node — and CB-WP-0016 made both droppable. Marking both is
// correct and the first draft of this assertion was wrong about
// it: it read `seat-1,seat-1` and expected `seat-1`.
let mut got: Vec<&str> = marked.split(',').filter(|s| !s.is_empty()).collect();
got.sort_unstable();
got.dedup();
assert_eq!(got, want, "emitted {emitted:?}, marked {marked:?}");
// And the duplication is asserted rather than tolerated: a seat
// that highlighted on only one of its two drawings would send the
// player back to hunting for the one that works.
assert_eq!(
marked.matches("seat-1").count(),
2,
"both drawings of a seat must highlight, got {marked:?}"
);
// And it is not simply marking everything.
let all: i32 = ctx.eval_as("__all.length").expect("count");
assert!(
(want.len() as i32) < all,
"the page marked every droppable, which proves nothing"
);
}
/// The grabbed element is visibly held, and a ghost follows the
/// pointer — otherwise a drag is invisible and the maintainer cannot
/// tell it is happening (CB-WP-0017).
#[test]
fn the_grabbed_element_is_held_and_a_ghost_follows_the_pointer() {
let ctx = ctx_for(&page());
ctx.eval("__down('action-attack');").expect("pointerdown");
assert_eq!(
ctx.eval_as::<String>("__heldKeys()").expect("held"),
"action-attack"
);
assert_eq!(ctx.eval_as::<i32>("__ghosts()").expect("ghosts"), 1);
ctx.eval("__move();").expect("pointermove");
let left: String = ctx
.eval_as("__body.children[0].style.left")
.expect("ghost position");
assert_eq!(left, "9px", "the ghost did not follow the pointer");
}
/// A drag that ends leaves no residue. Without this the next drag
/// highlights a stale set, which is worse than no highlight at all.
#[test]
fn ending_a_drag_clears_every_mark_however_it_ends() {
for ending in ["__up('seat-1');", "__upNowhere();", "__cancel();"] {
let ctx = ctx_for(&page());
ctx.eval("__down('action-attack');").expect("pointerdown");
assert_ne!(ctx.eval_as::<String>("__marked()").expect("m"), "");
ctx.eval(ending).expect("ending");
assert_eq!(
ctx.eval_as::<String>("__marked()").expect("m"),
"",
"marks survived {ending}"
);
assert_eq!(
ctx.eval_as::<String>("__heldKeys()").expect("h"),
"",
"held survived {ending}"
);
assert_eq!(
ctx.eval_as::<i32>("__ghosts()").expect("g"),
0,
"the ghost survived {ending}"
);
}
}
#[test]
fn both_script_blocks_are_extracted_in_order() {
let found = scripts(&page());

View file

@ -467,6 +467,69 @@ mod affordances {
assert!(n >= 50, "checked only {n} decision point(s)");
}
/// **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)
}
}
/// **The regression test for the defect actually reported**, and the
/// reason the check above is not sufficient on its own.
///