Compare commits
2 commits
b87dbec55b
...
a55e878bf0
| Author | SHA1 | Date | |
|---|---|---|---|
| a55e878bf0 | |||
| ca92db53bc |
9 changed files with 1092 additions and 72 deletions
19
INTENT.md
19
INTENT.md
|
|
@ -64,14 +64,17 @@ game.
|
|||
and scenario tests, simple bots. No rendering, no physics.
|
||||
1. **Inspectable 2D table** — card/token/hand/relationship-graph
|
||||
visualization, drag-to-propose, debug inspector, hot-seat play.
|
||||
*Open on one human verification. The first run of it (2026-08-02)
|
||||
found the table legible and the drag **broken**: drop targets were
|
||||
`id`s, an `id` must be unique, so the relationship-graph circle held
|
||||
`seat-0` and the seat card the page points at had none. Fixed in
|
||||
CB-WP-0016 — drop keys are `data-drop` — and verified by tests, by
|
||||
mutation, and against a live server, but **not** by a human dragging,
|
||||
which is the standard that found it. Run `cb-play --serve 0`, open the
|
||||
printed URL, and drag an action onto a seat card.*
|
||||
*Open on one human verification, and every run of it so far has found
|
||||
something no test could. 2026-08-02, run 1: the drag was broken — drop
|
||||
targets were `id`s, which must be unique, so the graph circle held
|
||||
`seat-0` and the seat card had none (CB-WP-0016). Run 2: the drag
|
||||
worked but the page was **wrong about which moves exist**, showing 5
|
||||
cards each claiming all three target kinds where 9 specific commands
|
||||
were legal, with no way to see what was pickable, held, or droppable
|
||||
(CB-WP-0017). Both fixed. What remains is perceptual and unreachable
|
||||
from here by construction (ADR-0010 D5): run `cb-play --serve 0` and
|
||||
confirm you can see what can be picked up, what you are holding, and
|
||||
where it may go.*
|
||||
2. **Physical 3D tabletop** — wgpu renderer, Rapier-backed physics, camera
|
||||
and pointer controls, snap zones, asset importer.
|
||||
3. **Networked sessions** — authoritative host, private projections,
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
///
|
||||
|
|
|
|||
136
decisions/ADR-0010-what-the-script-may-do.md
Normal file
136
decisions/ADR-0010-what-the-script-may-do.md
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
# ADR-0010: the script may render state it was given; it may not derive any
|
||||
|
||||
status: accepted
|
||||
date: 2026-08-02
|
||||
decided by: agent, under the standing loop authorization
|
||||
tier: M (structural S — presentation work inside an existing capability;
|
||||
chaos d4=4 → OVERRIDE, drawn M. InnerLoop v1.6). Tier M merges survey and
|
||||
decision into one document, which this is.
|
||||
references: [CB-WP-0017](../workplans/CB-WP-0017-legible-interaction.md),
|
||||
[ADR-0007](ADR-0007-render-html-not-a-port.md) D5 (control 5),
|
||||
[CB-EV-0014](../evidence/CB-EV-0014-the-drop-target.md) §4
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0007 Decision 5 constrains the page:
|
||||
|
||||
> the page reports **raw pointer facts and nothing else**, and Rust decides
|
||||
> what command they mean.
|
||||
|
||||
and `doc.rs` says of `SCRIPT`: *"it does one thing"*. Every request in
|
||||
CB-WP-0017 needs it to do more than one thing — hold a drag in flight,
|
||||
follow the pointer, and add and remove classes on **other** elements.
|
||||
|
||||
So the constraint has to be restated or the work does not happen. Restating
|
||||
a control in order to permit the thing it forbade is how controls die, and
|
||||
this ADR exists so that happens out loud or not at all.
|
||||
|
||||
## The survey: three ways to make interaction legible
|
||||
|
||||
| option | where legality is decided | script size |
|
||||
|---|---|---|
|
||||
| **A. Rust emits legality as data; the script toggles classes** | Rust | ~2× today |
|
||||
| B. The script derives legality from the rendered page | **the page** | ~3× |
|
||||
| C. Server round-trip on pointerdown to ask what is legal | Rust | ~1.5×, plus a request per grab |
|
||||
|
||||
**B is the one control 5 exists to forbid**, and it is not hypothetical:
|
||||
the page already contains every action id and every target id, so a script
|
||||
*could* infer plausible pairs. It would be wrong in exactly the cases that
|
||||
matter — Investigate is legal on problems 2 and 3 but not 1, and no amount
|
||||
of looking at the DOM reveals why.
|
||||
|
||||
**C is honest and too slow.** A highlight that arrives after a network
|
||||
round trip is not a highlight; it also makes a pointerdown a state-changing
|
||||
request, which the token guard's threat model did not consider.
|
||||
|
||||
**A is the decision.** But the survey's real output is that A and B are
|
||||
*indistinguishable from the outside*: both are "the page highlights some
|
||||
elements". A control that cannot tell them apart is not a control.
|
||||
|
||||
## Decision 1 — the script may render state it was given; it may not derive any
|
||||
|
||||
The replacement for *"it does one thing"*:
|
||||
|
||||
> **Every game fact the page acts on must arrive from Rust as data.** The
|
||||
> script may read it, match it, and turn it into presentation. It may not
|
||||
> compute, infer, filter, or default one.
|
||||
|
||||
Under this rule, class-toggling is permitted: the legal target set is a
|
||||
`data-targets` attribute Rust wrote, and the script does string matching on
|
||||
it. Deriving that set by pattern-matching ids would be forbidden even
|
||||
though the visible result is identical.
|
||||
|
||||
## Decision 2 — the honest argument, and why the old test was weak
|
||||
|
||||
The tempting defence is *"the page only renders what Rust said"*. That is
|
||||
exactly what a page constructing commands would also say, so it earns
|
||||
nothing on its own.
|
||||
|
||||
The existing control is a test that greps `SCRIPT` for game vocabulary.
|
||||
CB-WP-0014 already found that shape too weak once — it was replaced for
|
||||
control 5 by an assertion on **what the script puts on the wire**. The same
|
||||
weakness applies here and is now worse, because the script legitimately
|
||||
handles more.
|
||||
|
||||
So the vocabulary grep stays as a cheap first line, and the load-bearing
|
||||
control becomes a pair of properties a test can check:
|
||||
|
||||
1. **The highlighted set equals the set Rust emitted.** Not a subset, not a
|
||||
superset. A script that derived legality would drift from the attribute
|
||||
the moment the two disagreed, and this catches that with no reference to
|
||||
how the script is written.
|
||||
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 would teach the player something
|
||||
false.
|
||||
|
||||
Property 1 is the real replacement for the grep. It is stated over
|
||||
observable behaviour rather than over source text, which is the same move
|
||||
that fixed control 5.
|
||||
|
||||
## Decision 3 — what the script may still never do
|
||||
|
||||
Unchanged from ADR-0007 D5, and restated because this ADR widens the rest:
|
||||
|
||||
- it may not name an action, a seat, a rule, or a phase;
|
||||
- it may not construct a command, in any encoding;
|
||||
- what it posts stays **exactly two fields**, `down` and `up`, carrying two
|
||||
element keys and nothing derived from them.
|
||||
|
||||
The body-shape assertion from CB-WP-0014 continues to hold this, and
|
||||
nothing in this ADR touches it.
|
||||
|
||||
## Decision 4 — the size of the script is not the control
|
||||
|
||||
`SCRIPT` roughly doubles. That is not a violation of anything: ADR-0007's
|
||||
concern was *rules leaking into the page*, not line count, and it said so —
|
||||
*"if a rule ever needs to appear in it, the decision is wrong"*. Marginal
|
||||
AM-4a cost is zero either way, so the dependency budget does not decide
|
||||
this.
|
||||
|
||||
What decides it is Decision 1's rule plus the two properties in Decision 2.
|
||||
Recording this explicitly so that a future pass does not cite the growth as
|
||||
either a licence or an objection.
|
||||
|
||||
## Decision 5 — what this ADR does not claim
|
||||
|
||||
**No visual property here is verified.** Whether a shadow reads as *"you
|
||||
can pick this up"*, whether a highlight is noticeable, whether a drag feels
|
||||
followable — none of that is reachable by any test in this repo, and
|
||||
CB-WP-0016 is the standing evidence for what happens when that gap is
|
||||
papered over.
|
||||
|
||||
The testable core is the *correspondence* between what the page marks and
|
||||
what Rust will accept. The perceptual half stays a human check, and stage 1
|
||||
stays open on it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The JS DOM stub must model `classList` and element lookup. A stub too
|
||||
thin to express a failure is how the failure survives (CB-EV-0014 §2),
|
||||
and today's stub cannot express any of this.
|
||||
- The vocabulary grep is now explicitly the *weak* control, kept for cost.
|
||||
If it ever fails while property 1 passes, prefer property 1 and say so.
|
||||
- If a later pass needs the script to make a decision Rust cannot pre-empt
|
||||
— an animation that depends on rules, say — Decision 1 forbids it and
|
||||
this ADR is what to revisit.
|
||||
|
|
@ -113,14 +113,21 @@ would have missed it.
|
|||
|
||||
| pass | kind | responses | cost | $/response |
|
||||
|---|---|---|---|---|
|
||||
| **CB-WP-0015** | product | 136 | **$15.14** | 0.111 |
|
||||
| **CB-WP-0015** | product | ~~136~~ **166** | ~~$15.14~~ **$22.70** | ~~0.111~~ **0.137** |
|
||||
| CB-WP-0016 | product | *provisional — not quoted* | | |
|
||||
|
||||
CB-EV-0013 §5 found the self-quoting rule fixes the wrong boundary: a
|
||||
pass's window runs to the *next* pass's first commit, so CB-WP-0015's
|
||||
figure was still open when quoted. It is quoted here after CB-WP-0016's
|
||||
declaration commit closed it, which is the first figure this project has
|
||||
quoted at a boundary that had actually settled.
|
||||
**CORRECTED 2026-08-02 (CB-EV-0015 §6).** This file originally reported
|
||||
CB-WP-0015 at **$15.14 / 136** and claimed that figure was *"the first
|
||||
this project has quoted at a boundary that had actually settled."* **The
|
||||
claim was false and the number was wrong.** $15.14/136 was read from
|
||||
`make status` during CB-WP-0015 itself and carried forward — it was that
|
||||
pass's own in-flight figure, which is precisely what CB-EV-0012's rule
|
||||
exists to forbid. Measured after CB-WP-0017's declaration closed the
|
||||
window: **$22.70 / 166**, higher by 50%.
|
||||
|
||||
So the defect is not only the boundary CB-EV-0013 §5 identified. It is
|
||||
that a figure gets *read once and quoted later*, and a number read earlier
|
||||
in a session is an in-flight number no matter which pass's name is on it.
|
||||
|
||||
## 6. Open
|
||||
|
||||
|
|
|
|||
221
evidence/CB-EV-0015-legible-interaction.md
Normal file
221
evidence/CB-EV-0015-legible-interaction.md
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
# CB-EV-0015 — legible interaction, and the chaos window's verdict
|
||||
|
||||
CB-WP-0017 T04. Measured 2026-08-02 at `ca92db5`+. Pass kind `product`,
|
||||
tier **M** (structural S, **chaos d4=4 → OVERRIDE, drawn M**).
|
||||
**Declaration 12 of 12 — the chaos calibration window closes here.**
|
||||
|
||||
---
|
||||
|
||||
## 1. The page was lying, and now it is not
|
||||
|
||||
The maintainer could drag after CB-WP-0016 but could not tell what was
|
||||
draggable, what was held, where it could go, or what any of it would do.
|
||||
Underneath that was something worse than a missing affordance: the page
|
||||
was **wrong about which moves exist**.
|
||||
|
||||
Seat P1, round 1, step Select — **9 legal commands**, rendered as 5 cards
|
||||
each claiming all three target kinds. Before and after, from the live
|
||||
server:
|
||||
|
||||
| card | before | after |
|
||||
|---|---|---|
|
||||
| Investigate | *"onto a seat, a problem, or the table"* | **onto problem 2, problem 3** |
|
||||
| Solve | *"onto a seat, a problem, or the table"* | **onto problem 1** |
|
||||
| Attack | *"onto a seat, a problem, or the table"* | **onto P2, P3** |
|
||||
| Support | *"onto a seat, a problem, or the table"* | **onto P2, P3** |
|
||||
| Ground | *"onto a seat, a problem, or the table"* | **onto the table** |
|
||||
|
||||
Investigate is legal on problems 2 and 3 but **not** 1; Solve on 1 but not
|
||||
2 or 3. The old text was a `const` in the emitter, printed whenever *any*
|
||||
legal command used that action. `legal` carried the exact targets and the
|
||||
renderer threw them away.
|
||||
|
||||
So drop-target highlighting is not decoration on a working table. It is
|
||||
the first time the page tells the truth about what is legal.
|
||||
|
||||
## 2. ADR-0010: the constraint that replaced "it does one thing"
|
||||
|
||||
ADR-0007 D5 said the page *"reports raw pointer facts and nothing else"*
|
||||
and `doc.rs` said `SCRIPT` *"does one thing"*. Everything asked for here
|
||||
needs it to do more — hold a drag, follow the pointer, mark other
|
||||
elements. Restating a control in order to permit what it forbade is how
|
||||
controls die, so it was restated out loud:
|
||||
|
||||
> **Every game fact the page acts on must arrive from Rust as data.** The
|
||||
> script may read it, match it, and turn it into presentation. It may not
|
||||
> compute, infer, filter, or default one.
|
||||
|
||||
The survey's real finding: the permitted design (Rust emits legality, the
|
||||
script matches it) and the forbidden one (the script derives legality from
|
||||
the DOM) are **indistinguishable from the outside** — both are "the page
|
||||
highlights some elements". A control that cannot separate them is not a
|
||||
control.
|
||||
|
||||
The old control was a grep of `SCRIPT` for game vocabulary. CB-WP-0014
|
||||
already found that shape too weak once. It is now demoted to a cheap first
|
||||
line, and the load-bearing control is stated over behaviour:
|
||||
|
||||
**Property 1 — the highlighted set equals the set Rust emitted.** The
|
||||
mutation that makes the script derive targets by pattern-matching ids
|
||||
produces a *plausible* highlight — `seat-0, seat-1, seat-2` where only
|
||||
`seat-1` is legal — and the test catches it. That is Decision 1 actually
|
||||
enforced rather than asserted.
|
||||
|
||||
**Property 2 — a target the page marks legal must `resolve`.** A page that
|
||||
advertises a drop Rust then refuses is worse than no highlighting, because
|
||||
it teaches the player something false. Mutation: adding `table` to
|
||||
Investigate's targets → *"the page advertises action-investigate -> table
|
||||
but resolve refuses it"*.
|
||||
|
||||
## 3. What is visible now, and what is merely tested
|
||||
|
||||
| request | done | verified how |
|
||||
|---|---|---|
|
||||
| what can be picked up | `.pick` — shadow, lift on hover, `grab` cursor | **not verified.** Whether it *reads* as pickable is a human check |
|
||||
| what is held | `.held` — dimmed and scaled where it sat | JS test: `__heldKeys()` is exactly the grabbed key |
|
||||
| where it can go | `.dropok` on every legal target, **both drawings of a seat** | JS test: set equality with `data-targets` |
|
||||
| where it is going | a ghost following the pointer | JS test: ghost exists, and its `left` tracks `clientX` |
|
||||
| what moves exist | the card names its real targets | live server, §1 |
|
||||
|
||||
**A seat is marked on both of its drawings** — card and graph node. That
|
||||
is asserted rather than tolerated: highlighting only one would send the
|
||||
player back to hunting for the one that works, which is the CB-WP-0016
|
||||
defect in a new form. The first draft of that assertion was *wrong* about
|
||||
it, expecting `seat-1` and reading `seat-1,seat-1`.
|
||||
|
||||
**Third mutation:** dropping the `pointercancel` handler and the `clear()`
|
||||
on pointerup leaves marks behind — *"marks survived `__up('seat-1')`"*. A
|
||||
stale highlight set is worse than none.
|
||||
|
||||
### What this pass does not claim
|
||||
|
||||
No visual property is verified. Whether the shadow reads as *"pick me
|
||||
up"*, whether the highlight is noticeable, whether the drag feels
|
||||
followable — none of it is reachable from here, and ADR-0010 D5 says so
|
||||
rather than letting the tests imply otherwise.
|
||||
|
||||
## 4. The harness had to get deeper, and cost an hour to a real trap
|
||||
|
||||
The DOM stub could not express any of this: it synthesized
|
||||
`{target:{id}}` from whatever key the test passed. It now models
|
||||
`classList`, `querySelectorAll`, `createElement`, and a body — **and its
|
||||
node set is built from the real emitted page** via `droppables(html)`,
|
||||
not from a list the harness invented.
|
||||
|
||||
**The trap, recorded because it will recur:** QuickJS fixes its stack
|
||||
limit at `Context` creation *relative to the frame it was created in*. A
|
||||
helper that creates a context and returns it leaves every later `eval`
|
||||
running in a shallower frame, the comparison underflows, and QuickJS
|
||||
reports `SyntaxError: stack overflow` — for `__all.length`. The fix is
|
||||
that the caller owns the `Context` and the helper only prepares it.
|
||||
|
||||
That is also why one test had silently skipped registration: each test
|
||||
hand-rolled its setup. `prepare()` is now shared, so a test cannot set up
|
||||
a different page than the harness runs.
|
||||
|
||||
## 5. The chaos calibration window: the verdict it has owed
|
||||
|
||||
`gates.toml` states the retirement condition in its own words:
|
||||
|
||||
> *"the window closes with no overridden tier producing a different outcome
|
||||
> than the argued one — the evaluation this window exists to make
|
||||
> possible"*
|
||||
|
||||
Twelve declarations, **two overrides**, one in each direction. That is the
|
||||
minimum that makes an evaluation possible at all, and it arrived on the
|
||||
last declaration.
|
||||
|
||||
### Override 1 — CB-WP-0011, structural L → S
|
||||
|
||||
The deleted survey would have opened on 2D toolkits. What the pass found
|
||||
instead, at tier S, was that the **existing** text renderer was showing 24
|
||||
of 41 view fields (CB-EV-0009 §1) — a defect a toolkit survey would have
|
||||
walked straight past. Priced on the same subject: **0.099 $/response at S
|
||||
against 0.123 at L** (CB-EV-0010 §5).
|
||||
|
||||
**Different outcome than the argued one: yes.** The argued tier would have
|
||||
bought a survey of alternatives; the rolled tier bought a defect in what
|
||||
already existed.
|
||||
|
||||
### Override 2 — CB-WP-0017, structural S → M
|
||||
|
||||
The question the retirement condition asks: **what did the extra ADR buy
|
||||
that a tier-S provenance paragraph would not have?**
|
||||
|
||||
It bought the restatement of control 5. At tier S this pass would have
|
||||
widened what the JavaScript does — pointermove, class-toggling on other
|
||||
elements, a ghost node — under a one-paragraph commit note, and ADR-0007
|
||||
D5's *"reports raw pointer facts and nothing else"* would have been
|
||||
silently outgrown rather than replaced. The forced document is what
|
||||
produced Decision 1's rule and, more importantly, the finding that the
|
||||
permitted and forbidden designs are indistinguishable from outside — which
|
||||
is what turned the vocabulary grep from *the* control into a cheap first
|
||||
line, and produced the two properties that are now the real controls.
|
||||
|
||||
**Different outcome than the argued one: yes**, and in the direction that
|
||||
is harder to see from inside — the extra process caught a control quietly
|
||||
expiring.
|
||||
|
||||
### Verdict: keep, at a lower rate
|
||||
|
||||
Both overrides changed the outcome, so the retirement condition is not
|
||||
met. But the honest reading of the cost is that d4 fired twice in twelve
|
||||
and both were informative *because they were rare*; a mechanism that
|
||||
overrides a quarter of all declarations stops being a calibration and
|
||||
starts being the tier system.
|
||||
|
||||
**Recommendation: keep the chaos roll, drop the rate from d4 to d8, and
|
||||
open a second window of 12** with a new retirement condition — retire if
|
||||
an override changes nothing twice running. This is a change to how the
|
||||
loop constrains its own operation and is therefore **tier M work that this
|
||||
pass does not do**: it is recorded here as the recommendation and owed to
|
||||
the next declaration, which must make it with its own tier and its own
|
||||
roll.
|
||||
|
||||
## 6. Cost
|
||||
|
||||
| pass | kind | responses | cost | $/response |
|
||||
|---|---|---|---|---|
|
||||
| **CB-WP-0016** | product | 64 | **$14.93** | 0.233 |
|
||||
| CB-WP-0017 | product | *provisional — not quoted* | | |
|
||||
|
||||
Read from `make status` **at the moment of writing**, after CB-WP-0017's
|
||||
declaration closed CB-WP-0016's window.
|
||||
|
||||
### The self-quoting rule failed a second way, and CB-EV-0014 is corrected
|
||||
|
||||
CB-EV-0014 §5 quoted CB-WP-0015 at **$15.14 / 136** and claimed it was
|
||||
*"the first figure this project has quoted at a boundary that had actually
|
||||
settled."* Both parts were wrong. The same tool now reports CB-WP-0015 at
|
||||
**$22.70 / 166** — higher by 50%. CB-EV-0014 has been corrected in place.
|
||||
|
||||
$15.14/136 was read during CB-WP-0015 itself and carried forward into the
|
||||
next pass's write-up. So there are **two** defects, not one:
|
||||
|
||||
1. the boundary is wrong — a window runs to the *next* pass's first commit
|
||||
(CB-EV-0013 §5);
|
||||
2. **a figure read earlier in a session is an in-flight figure regardless
|
||||
of whose name is on it.** Quoting from memory defeats the rule even
|
||||
when the boundary is right.
|
||||
|
||||
Five for five now, always low: every self-reported cost in this project
|
||||
has been an underestimate. The rule owed is therefore not just *"quote two
|
||||
passes back"* but *"re-run the instrument at the moment you quote it"* —
|
||||
and the second half is what this file did.
|
||||
|
||||
**Meta budget: 0% `[ok]`** over the trailing three, all product.
|
||||
|
||||
## 7. Open
|
||||
|
||||
- **INTENT stage 1: the human check, again.** Everything perceptual here
|
||||
is unverified by construction. `cb-play --serve 0`: can you see what is
|
||||
pickable, what you are holding, and where it may go?
|
||||
- **Chaos: the d4→d8 recommendation** and a second window. §5.
|
||||
- **The self-quoting rule still names the wrong boundary.** CB-EV-0013 §5.
|
||||
- **AM-4b's scope defect (408,237 uncounted lines)** and its proc-macro
|
||||
share.
|
||||
- **`python3` as a toolchain dependency was never argued.**
|
||||
- **AM-4a cannot survive stage 2** — 1,741,979 against 161,000.
|
||||
- **ADR-0007 D3's acquisition rule** unratified after deciding two
|
||||
dependency questions; **ADR-0010** now rests on D5, which is also
|
||||
unratified.
|
||||
13
gates.toml
13
gates.toml
|
|
@ -125,9 +125,22 @@ added = "2026-07-30"
|
|||
review_by = "2026-09-30"
|
||||
caught = [
|
||||
"CB-WP-0011: first fire in 6 declarations — d4=4 rolled stage 1 from structural L to S; the deleted survey would have opened on 2D toolkits while the existing text renderer was showing 24 of 41 view fields (CB-EV-0009 §1)",
|
||||
"CB-WP-0017: d4=4 — second override in twelve, and the first to roll UP (structural S → M). It bought ADR-0010: the script's widening from 'it does one thing' to holding a drag, following the pointer and marking other elements would otherwise have landed under a tier-S provenance paragraph, silently outgrowing ADR-0007 D5. The ADR's own finding is that the permitted and forbidden designs are indistinguishable from outside, which demoted the vocabulary grep to a cheap first line and produced the two behavioural controls that replaced it",
|
||||
"CB-WP-0012: d4=1, no override — and the contrast is the entry. Tier L at full weight deleted its own structural trigger: adversarial review withdrew the capability port the declaration was made to build (ADR-0007 D2), and corrected the survey's headline claim by 85x (128x -> 1.5x, CB-EV-0010 §2). Two passes on one subject at two tiers, priced: 0.123 $/response at L against 0.099 at S (CB-EV-0010 §5)",
|
||||
]
|
||||
retire_if = "the window closes with no overridden tier producing a different outcome than the argued one — the evaluation this window exists to make possible"
|
||||
# VERDICT, CB-EV-0015 §5 (window closed 2026-08-02, 12 declarations, 2 overrides).
|
||||
# Not retired: both overrides changed the outcome. CB-WP-0011 (L→S) bought a
|
||||
# defect in the existing renderer that the deleted survey would have walked
|
||||
# past, at 0.099 $/response against 0.123. CB-WP-0017 (S→M) bought the
|
||||
# restatement of control 5 — at tier S the script would have outgrown
|
||||
# ADR-0007 D5 under a one-paragraph commit note.
|
||||
# RECOMMENDED, and owed to the next declaration as tier-M work: drop the rate
|
||||
# d4 → d8 and open a second window of 12. Both overrides were informative
|
||||
# BECAUSE they were rare; a mechanism firing on a quarter of declarations
|
||||
# stops being a calibration and becomes the tier system.
|
||||
# New retirement condition proposed: retire if an override changes nothing
|
||||
# twice running.
|
||||
|
||||
[[gate]]
|
||||
id = "GATE-REVIEW"
|
||||
|
|
|
|||
257
workplans/CB-WP-0017-legible-interaction.md
Normal file
257
workplans/CB-WP-0017-legible-interaction.md
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
---
|
||||
id: CB-WP-0017
|
||||
kind: product
|
||||
title: "Legible interaction: what can be picked up, what is being dragged, where it can go"
|
||||
status: done
|
||||
---
|
||||
|
||||
# Purpose
|
||||
|
||||
```
|
||||
structural tier S (presentation work inside an existing capability —
|
||||
no new port, no canonical interface, no new
|
||||
dependency, no change to the loop's own constraints)
|
||||
chaos d4 = 4 → OVERRIDE, tier drawn: M
|
||||
declared tier M (structural S, chaos 4 → M)
|
||||
```
|
||||
|
||||
**Declaration 12 of 12 — the chaos calibration window closes here**, and
|
||||
owes its evaluation (T04).
|
||||
|
||||
This is the **second override in twelve declarations**, and it rolls the
|
||||
opposite way from the first: CB-WP-0011 was structural L rolled *down* to
|
||||
S, this is structural S rolled *up* to M. The window therefore has one of
|
||||
each to evaluate, which is the minimum that makes an evaluation possible
|
||||
at all.
|
||||
|
||||
Tier M means survey and decision merge into one document and the
|
||||
adversarial review is optional.
|
||||
|
||||
## The report this pass exists for
|
||||
|
||||
The maintainer ran stage 1's human check again after CB-WP-0016 and could
|
||||
drag — but:
|
||||
|
||||
> *"it is not possible to get what moves are possible and how they affect
|
||||
> the state of the game"* … *"Can i have better visualization of which
|
||||
> Elements in the ui can be manipulated"* … *"If the element isnt shown as
|
||||
> picked up, there is no way to understand that i am actually dragging it
|
||||
> or where i am dragging it"* … *"visual clues about where something can be
|
||||
> dropped when it is picked up"*
|
||||
|
||||
Four requests, and one prior finding that makes them one pass: **the page
|
||||
already hides which moves are legal.**
|
||||
|
||||
Measured, seat P1 at round 1 step Select — **9 legal commands**, rendered
|
||||
as 5 cards each claiming all three target kinds:
|
||||
|
||||
| the card says | actually legal |
|
||||
|---|---|
|
||||
| Investigate → *"a seat, a problem, or the table"* | **problem-2, problem-3** |
|
||||
| Solve → *"a seat, a problem, or the table"* | **problem-1** |
|
||||
| Attack → *"a seat, a problem, or the table"* | **seat-1, seat-2** |
|
||||
| Support → *"a seat, a problem, or the table"* | **seat-1, seat-2** |
|
||||
| Ground → *"a seat, a problem, or the table"* | **table** |
|
||||
|
||||
Investigate is legal on problems 2 and 3 but not 1; Solve on 1 but not 2
|
||||
or 3. The card text is a **constant** — `"drag {a:?} onto a seat, a
|
||||
problem, or the table"` — emitted whenever *any* legal command uses that
|
||||
action, then naming all three kinds regardless. `legal` carries the exact
|
||||
targets and the renderer discards them.
|
||||
|
||||
So highlighting drop targets is not decoration on top of a working table.
|
||||
It is the first time the page will tell the truth about what is legal.
|
||||
|
||||
## Task: decide how far the JavaScript may grow
|
||||
|
||||
```task
|
||||
id: CB-WP-0017-T01
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Write `decisions/ADR-0010-*.md` (tier M: survey and decision in one).
|
||||
|
||||
**This is a real decision, not a formality.** ADR-0007 Decision 5 says the
|
||||
page *"reports raw pointer facts and nothing else, and Rust decides what
|
||||
command they mean"*, and `doc.rs` says of `SCRIPT`: *"it does one thing"*.
|
||||
Everything asked for here needs the script to do **more** than one thing —
|
||||
track a drag in flight, move a ghost with the pointer, add and remove
|
||||
classes on other elements.
|
||||
|
||||
Decide, explicitly:
|
||||
|
||||
- **Does class-toggling violate control 5?** The honest argument is that
|
||||
legality still comes from Rust as data and the script only renders it —
|
||||
but that argument must be *made*, because "the page renders what Rust
|
||||
said" is exactly what a page constructing commands would also claim.
|
||||
- **What is the invariant that replaces "it does one thing"?** If the
|
||||
script may grow, say what it may never do, in a form a test can check.
|
||||
The existing no-game-vocabulary test is the candidate and it is weak.
|
||||
- **The cost.** Marginal AM-4a is zero either way, so the budget does not
|
||||
decide this. Say what does.
|
||||
|
||||
**Done 2026-08-02.**
|
||||
[ADR-0010](../decisions/ADR-0010-what-the-script-may-do.md) — *the script
|
||||
may render state it was given; it may not derive any.*
|
||||
|
||||
The survey's real output: the permitted design (Rust emits legality, the
|
||||
script matches it) and the forbidden one (the script derives legality from
|
||||
the DOM) are **indistinguishable from the outside** — both are "the page
|
||||
highlights some elements". So the vocabulary grep is demoted to a cheap
|
||||
first line and the load-bearing controls are stated over behaviour: the
|
||||
highlighted set must **equal** the set Rust emitted, and anything the page
|
||||
marks legal must **resolve**. Both are mutation-proven below.
|
||||
|
||||
## Task: tell the truth about which moves are legal
|
||||
|
||||
```task
|
||||
id: CB-WP-0017-T02
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
Emit the legal targets **derived from `legal`**, per action, and render
|
||||
them so the table is legible without dragging anything.
|
||||
|
||||
The generic sentence goes. A card that says *"onto a seat, a problem, or
|
||||
the table"* when only `problem-1` is legal is not a simplification, it is
|
||||
wrong.
|
||||
|
||||
**Controls:**
|
||||
- The emitted target set must equal the affordance targets of the legal
|
||||
list exactly — pure Rust, no browser, and it must go red when the sets
|
||||
diverge.
|
||||
- A drop that the page marked legal must **resolve**. If the page can
|
||||
advertise a target `resolve` then refuses, the two have drifted and the
|
||||
highlighting is worse than none.
|
||||
|
||||
**Done 2026-08-02.** From the live server: *"Solve onto problem 1"*,
|
||||
*"Investigate onto problem 2, problem 3"*, *"Attack onto P2, P3"*,
|
||||
*"Ground onto the table"*. The constant sentence is gone.
|
||||
|
||||
`everything_the_page_advertises_actually_resolves` drives four real bot
|
||||
games and checks every emitted target against `resolve` itself. Mutation —
|
||||
adding `table` to Investigate's targets — goes red with *"the page
|
||||
advertises action-investigate -> table but resolve refuses it"*.
|
||||
|
||||
## Task: make picking up, dragging, and dropping visible
|
||||
|
||||
```task
|
||||
id: CB-WP-0017-T03
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
The four requests, in order of how badly they are needed:
|
||||
|
||||
1. **What can be manipulated at all** — a resting affordance on every
|
||||
pickable element. The maintainer asked for a slight shadow; the point
|
||||
is that interactive and inert must not look identical.
|
||||
2. **What is picked up** — the grabbed element must visibly change while
|
||||
the pointer is down.
|
||||
3. **Where it can go** — every legal drop target for *the thing currently
|
||||
held* highlights, and nothing else does.
|
||||
4. **Where it is going** — the drag must be followable, not invisible.
|
||||
|
||||
**Be honest about what is testable.** Whether a shadow reads as "you can
|
||||
pick this up" is not something any test here can settle, and claiming
|
||||
otherwise would be this project's characteristic error in its newest
|
||||
costume. What *is* testable, and must be:
|
||||
|
||||
- pointerdown marks the grabbed element and **exactly** the legal targets
|
||||
for it, and nothing else;
|
||||
- pointerup and cancellation clear every mark — a drag that ends leaves no
|
||||
residue, or the next drag highlights a stale set;
|
||||
- the highlighted set is the same set T02 emitted.
|
||||
|
||||
The JS DOM stub cannot express any of this today. It will have to model
|
||||
`classList` and element lookup, which is a real deepening of the harness —
|
||||
and CB-WP-0016 showed that a stub too thin to express a failure is how the
|
||||
failure survives.
|
||||
|
||||
**Done 2026-08-02.** `.pick` (resting shadow, lift on hover), `.held`
|
||||
(dimmed and scaled), `.dropok` (dashed outline on every legal target), and
|
||||
a ghost following the pointer.
|
||||
|
||||
The stub now models `classList`, `querySelectorAll`, `createElement` and a
|
||||
body, **and its node set is built from the real emitted page** via
|
||||
`droppables(html)` rather than from a list the harness invented.
|
||||
|
||||
Three mutations, each red:
|
||||
|
||||
| mutation | result |
|
||||
|---|---|
|
||||
| the script *derives* targets from the DOM instead of reading `data-targets` | red — marks `seat-0, seat-1, seat-2` where only `seat-1` is legal. **A plausible-looking highlight, caught.** This is ADR-0010 D1 enforced |
|
||||
| the page advertises a target `resolve` refuses | red |
|
||||
| `pointercancel` and the `clear()` on pointerup are dropped | red — *"marks survived `__up('seat-1')`"* |
|
||||
|
||||
**A seat highlights on both of its drawings** — card and graph node — and
|
||||
that is asserted rather than tolerated. The first draft of the assertion
|
||||
was wrong about it: it expected `seat-1` and read `seat-1,seat-1`.
|
||||
|
||||
**A real trap, recorded:** QuickJS fixes its stack limit at `Context`
|
||||
creation relative to the frame it was created in, so a helper that creates
|
||||
a context and *returns* it makes every later `eval` report `SyntaxError:
|
||||
stack overflow` — for `__all.length`. The caller must own the `Context`.
|
||||
|
||||
**Nothing perceptual is verified.** Whether a shadow reads as "pick me up"
|
||||
is not reachable from here (ADR-0010 D5).
|
||||
|
||||
## Task: evidence, and close the chaos calibration window
|
||||
|
||||
```task
|
||||
id: CB-WP-0017-T04
|
||||
status: done
|
||||
priority: high
|
||||
```
|
||||
|
||||
`evidence/CB-EV-0015-*.md`, and then **the evaluation the window has owed
|
||||
since 2026-07-31**.
|
||||
|
||||
`gates.toml` states the retirement condition in its own words:
|
||||
|
||||
> *retire_if = "the window closes with no overridden tier producing a
|
||||
> different outcome than the argued one — the evaluation this window
|
||||
> exists to make possible"*
|
||||
|
||||
So answer it, on the two overrides actually observed:
|
||||
|
||||
- **CB-WP-0011**, structural L → S. What did the deleted survey cost or
|
||||
save? CB-EV-0009 §1 and CB-EV-0010 §5 already priced part of this
|
||||
(0.123 $/response at L against 0.099 at S on the same subject).
|
||||
- **CB-WP-0017**, structural S → M. What did the *extra* ADR buy that a
|
||||
tier-S provenance paragraph would not have? If the answer is nothing,
|
||||
say so — that is the finding the window was opened to produce, and a
|
||||
mechanism that survives its own evaluation by being graded generously is
|
||||
worse than one that is retired.
|
||||
|
||||
Then: keep, retire, or change the rate, with the argument.
|
||||
|
||||
Also due:
|
||||
|
||||
- **Whether stage 1 closes.** It needs a human to drag again. Say so.
|
||||
- **Quote CB-WP-0016's cost**, and apply CB-EV-0013 §5's correction —
|
||||
quote a boundary that has actually settled, or say it has not.
|
||||
|
||||
**Done 2026-08-02.**
|
||||
[CB-EV-0015](../evidence/CB-EV-0015-legible-interaction.md). `make all`
|
||||
exits 0.
|
||||
|
||||
- **The chaos window's verdict: keep, at a lower rate.** Both overrides
|
||||
changed the outcome, so the retirement condition is not met. CB-WP-0011
|
||||
(L→S) bought a defect in the existing renderer that a toolkit survey
|
||||
would have walked past, at 0.099 $/response against 0.123. CB-WP-0017
|
||||
(S→M) bought the restatement of control 5 — at tier S the script would
|
||||
have outgrown ADR-0007 D5 under a one-paragraph commit note.
|
||||
**Recommendation: d4 → d8, second window of 12, retire if an override
|
||||
changes nothing twice running.** That is a change to how the loop
|
||||
constrains its own operation, so it is tier-M work this pass does not
|
||||
do — it is owed to the next declaration.
|
||||
- **CB-EV-0014 is corrected.** It quoted CB-WP-0015 at $15.14/136 and
|
||||
claimed that was the first settled figure this project had quoted. Both
|
||||
wrong: it now reads **$22.70/166**. The number had been read during
|
||||
CB-WP-0015 itself and carried forward, so there are two defects — the
|
||||
boundary, and quoting a figure from memory rather than re-running the
|
||||
instrument. Five for five, always low.
|
||||
- **Stage 1 stays open**, and now on a purely perceptual question.
|
||||
Loading…
Add table
Add a link
Reference in a new issue