CB-WP-0018 T03/T04: explanations, and window 1's verdict
Some checks failed
ci / check (push) Failing after 3s

T03: input::describe writes a sentence per legal command; data-descs
carries them in step with data-targets; the ghost already following the
pointer shows the one for whatever legal target is under it, so the
explanation lands beside the target with no overlay layer to keep
aligned. ADR-0010 D1 binds -- the page renders it, never composes it.

Both mutations INITIALLY SURVIVED because the fixture's Attack card had
exactly one target, where an off-by-one shift and a truncation are both
no-ops. CB-EV-0014's lesson one level in: a fixture too thin to express
a failure is how the failure survives. Two attack targets now, both red.

T04: chaos rate d4 -> d8, window 2 open at 12 declarations, retiring if
an override changes nothing twice running. Window 1's condition was NOT
met -- both overrides changed the outcome -- so the mechanism is kept.
The weakest part of the decision is that it is a rate change argued from
n=2, so window 2 carries a falsifier: no override at all is evidence the
rate went too far, not that the mechanism is healthy.

InnerLoop.md hit 401 lines and the loadability gate fired; the rationale
moved to InnerLoopReference.md, structurally, per the standing precedent
that limits are not raised.

CB-WP-0017 settled at $9.48/40 against $5.19/23 reported mid-flight,
83% higher. Six for six, always low -- read by re-running the instrument
at the moment of quoting, which is CB-EV-0015's correction applied for
the first time.

make all exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-03 02:24:13 +02:00
parent 7a78c58404
commit a733826d95
9 changed files with 424 additions and 30 deletions

View file

@ -56,7 +56,7 @@ 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, held = null, marked = [], ghost = null;
var down = null, held = null, marked = [], ghost = null, ghostLabel = '';
function node(e) {
var n = e.target;
@ -70,6 +70,19 @@ pub const SCRIPT: &str = r#"
// 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.
// ADR-0010 D1 again: `data-descs` is written by Rust, in step with
// `data-targets`. The script pairs them by index and renders one. It
// does not compose a description from an id.
function describeOf(held, key) {
if (!held) { return null; }
var t = held.getAttribute('data-targets');
var d = held.getAttribute('data-descs');
if (!t || !d) { return null; }
var i = t.split(' ').indexOf(key);
var all = d.split('|');
return i >= 0 && i < all.length ? all[i] : null;
}
function mark(n) {
var spec = n.getAttribute('data-targets');
if (!spec) { return; }
@ -88,6 +101,7 @@ pub const SCRIPT: &str = r#"
if (held) { held.classList.remove('held'); held = null; }
if (ghost && ghost.parentNode) { ghost.parentNode.removeChild(ghost); }
ghost = null;
ghostLabel = '';
down = null;
}
@ -102,7 +116,8 @@ pub const SCRIPT: &str = r#"
if (n.getAttribute('data-targets')) {
ghost = document.createElement('div');
ghost.id = 'cb-ghost';
ghost.textContent = n.textContent;
ghostLabel = n.textContent;
ghost.textContent = ghostLabel;
ghost.style.left = e.clientX + 'px';
ghost.style.top = e.clientY + 'px';
document.body.appendChild(ghost);
@ -113,6 +128,11 @@ pub const SCRIPT: &str = r#"
if (!ghost) { return; }
ghost.style.left = e.clientX + 'px';
ghost.style.top = e.clientY + 'px';
// The explanation, beside the pointer and therefore beside the
// target it is over (CB-WP-0018 T03).
var over = describeOf(held, key(node(e)));
ghost.textContent = over || ghostLabel;
ghost.className = over ? 'over' : '';
});
document.addEventListener('pointercancel', clear);
@ -165,6 +185,7 @@ h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf}
.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.over{background:#1d3347;border-color:#9cf;color:#cfe}
#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;
@ -572,24 +593,32 @@ fn move_section(
// 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
// CB-WP-0018 T03: targets and their meanings, in step, both
// written by Rust. ADR-0010 D1 forbids the page composing the
// second from the first.
let offered: Vec<(String, String)> = seat
.map(|seat| {
legal
.iter()
.filter_map(|c| crate::input::affordance(c, seat))
.filter(|(f, _)| *f == action_id(a))
.map(|(_, t)| t)
.filter_map(|c| {
crate::input::affordance(c, seat)
.filter(|(f, _)| *f == action_id(a))
.map(|(_, t)| (t, crate::input::describe(c, seat)))
})
.collect()
})
.unwrap_or_default();
let targets: Vec<String> = offered.iter().map(|(t, _)| t.clone()).collect();
let descs: Vec<String> = offered.iter().map(|(_, d)| d.clone()).collect();
if !targets.is_empty() {
let _ = write!(
s,
"<div class=\"card act pick\" data-drop=\"{id}\" \
data-targets=\"{targets}\">{a:?}<br>\
data-targets=\"{targets}\" data-descs=\"{descs}\">{a:?}<br>\
<span class=\"k\">onto</span> {names}</div>",
id = action_id(a),
targets = esc(&targets.join(" ")),
descs = esc(&descs.join("|")),
names = esc(&target_names(&targets)),
);
}

View file

@ -96,6 +96,40 @@ pub fn affordance(command: &GroundCommand, seat: cb_kernel::PlayerId) -> Option<
}
}
/// What a drop would mean, in a sentence.
///
/// **ADR-0010 Decision 1 puts this in Rust.** The page may render it; it
/// may not compose it. A script that assembled "Attack P2" from an id and
/// a seat name would be deriving a game fact, and would be wrong the
/// moment a command meant something the ids do not say.
pub fn describe(command: &GroundCommand, seat: cb_kernel::PlayerId) -> String {
let who = |p: cb_kernel::PlayerId| format!("P{}", p.0 + 1);
match command {
GroundCommand::SelectAction {
action,
target,
problem,
} => {
let head = match action {
Action::Attack => "Attack",
Action::Support => "Support",
Action::Solve => "Solve",
Action::Investigate => "Investigate",
Action::Ground => "Ground",
};
match (target, problem) {
(Some(t), _) => format!("commit {head} against {}", who(*t)),
(_, Some(n)) => format!("commit {head} on problem {n}"),
_ => format!("commit {head}, untargeted"),
}
}
GroundCommand::SpendFreedom => {
format!("{} spends freedom to act again", who(seat))
}
other => format!("{other:?}"),
}
}
/// Resolve a pointer fact to an index into the legal list.
///
/// Returns `Err` rather than a default when nothing matches: a drag that

View file

@ -64,13 +64,14 @@ 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) {
function __mk(key, targets, text, descs) {
var n = {
parentNode: null,
textContent: text || key,
style: {},
_cls: {},
_attr: { 'data-drop': key, 'data-targets': targets || null },
_attr: { 'data-drop': key, 'data-targets': targets || null,
'data-descs': descs || null },
getAttribute: function (a) { return this._attr[a] !== undefined ? this._attr[a] : null; },
setAttribute: function (a, v) { this._attr[a] = v; },
classList: {
@ -81,7 +82,7 @@ function __mk(key, targets, text) {
};
return n;
}
function __register(key, targets) { var n = __mk(key, targets); __all.push(n); return n; }
function __register(key, targets, descs) { var n = __mk(key, targets, key, descs); __all.push(n); return n; }
var document = {
body: __body,
@ -103,7 +104,9 @@ function fetch(url, opts) {
// 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 __move() { if (__handlers['pointermove']) { __handlers['pointermove']({ clientX: 9, clientY: 9, target: __mk(null, null, "") }); } }
function __moveOver(k) { __handlers['pointermove']({ clientX: 5, clientY: 5, target: __find(k) }); }
function __ghostText() { return __body.children.length ? __body.children[0].textContent : ""; }
function __cancel() { __handlers['pointercancel']({ target: __find(null) }); }
function __find(k) {
for (var i = 0; i < __all.length; i++) {
@ -230,12 +233,29 @@ pub fn prepare(ctx: &quick_js::Context, html: &str) -> Result<(), String> {
Ok(())
}
/// The `data-descs` of the element carrying `key`, if any.
pub fn descriptions(html: &str, key: &str) -> Option<String> {
let needle = format!("data-drop=\"{key}\"");
let i = html.find(&needle)?;
let tail = &html[i..];
let end = tail.find('>')?;
let k = tail[..end].find("data-descs=\"")?;
let t = &tail[k + 12..];
t.find('"').map(|e| t[..e].to_string())
}
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)),
};
// Descriptions are registered too: the stub could not express
// `data-descs` at all, so the first version of the description
// test failed against a DOM that simply did not carry them.
let descs = descriptions(html, &key);
let call = format!(
"__register({}, {}, {});",
json_lit(&key),
targets.map(|t| json_lit(&t)).unwrap_or("null".into()),
descs.map(|d| json_lit(&d)).unwrap_or("null".into()),
);
ctx.eval(&call)
.map_err(|e| format!("register {key}: {e}"))?;
}
@ -274,6 +294,15 @@ mod tests {
target: Some(PlayerId(1)),
problem: None,
},
// TWO attack targets on purpose. With one, an off-by-one in
// the target/description pairing is a no-op and the mutations
// that should catch it survive — a fixture too thin to express
// the failure, which is how the failure survives (CB-EV-0014).
GroundCommand::SelectAction {
action: Action::Attack,
target: Some(PlayerId(2)),
problem: None,
},
];
crate::doc::document(
&crate::testfix::view(Some(PlayerId(0))),
@ -536,6 +565,59 @@ mod tests {
}
}
/// **ADR-0010 D1 for descriptions.** Dragging over a legal target
/// shows the sentence Rust wrote **for that pair** — not one the page
/// assembled, and not a neighbouring pair's.
#[test]
fn dragging_over_a_target_shows_the_description_rust_wrote_for_it() {
let html = page();
let ctx = ctx_for(&html);
// What Rust wrote on the card, read back out of the document.
let card = droppables(&html)
.into_iter()
.find(|(k, _)| k == "action-attack")
.expect("the attack card");
let targets = card.1.expect("targets");
let descs = descriptions(&html, "action-attack").expect("descs");
let i = targets
.split(' ')
.position(|t| t == "seat-1")
.expect("seat-1 is a target");
let want = descs.split('|').nth(i).expect("a description").to_string();
assert!(!want.is_empty(), "the description is blank");
ctx.eval("__down('action-attack'); __moveOver('seat-1');")
.expect("drag over");
let shown: String = ctx.eval_as("__ghostText()").expect("ghost text");
assert_eq!(shown, want);
// Over nothing droppable it falls back to the label, rather than
// keeping a stale explanation pointing at the wrong element.
ctx.eval("__move();").expect("move away");
let away: String = ctx.eval_as("__ghostText()").expect("ghost text");
assert_ne!(away, want, "the explanation stuck after leaving the target");
}
/// Every advertised target has a description; a card offering three
/// drops and two sentences would silently mispair them.
#[test]
fn every_advertised_target_carries_its_own_description() {
let html = page();
for (key, targets) in droppables(&html) {
let Some(targets) = targets else { continue };
let descs = descriptions(&html, &key)
.unwrap_or_else(|| panic!("{key} advertises targets but no descriptions"));
assert_eq!(
targets.split(' ').count(),
descs.split('|').count(),
"{key}: {} target(s) but {} description(s)",
targets.split(' ').count(),
descs.split('|').count()
);
}
}
#[test]
fn both_script_blocks_are_extracted_in_order() {
let found = scripts(&page());