diff --git a/INTENT.md b/INTENT.md index d884b5a..2a13569 100644 --- a/INTENT.md +++ b/INTENT.md @@ -64,17 +64,14 @@ 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, 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.* + *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.* 2. **Physical 3D tabletop** — wgpu renderer, Rapier-backed physics, camera and pointer controls, snap zones, asset importer. 3. **Networked sessions** — authoritative host, private projections, diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs index c436109..4962034 100644 --- a/crates/cb-render-html/src/doc.rs +++ b/crates/cb-render-html/src/doc.rs @@ -56,85 +56,34 @@ 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; - - function node(e) { + var down = null; + function key(e) { var n = e.target; while (n && !(n.getAttribute && n.getAttribute('data-drop'))) { n = n.parentNode; } - return n; + return n ? n.getAttribute('data-drop') : null; } - 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('pointerdown', function (e) { down = key(e); }); document.addEventListener('pointerup', function (e) { - 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'); + var up = key(e); + if (!down || !up) { + // CB-WP-0016 T02: refusing is right; refusing SILENTLY is what let a + // broken drop target survive a human sitting in front of it. Report + // the raw fact — which element the pointer took and where it let go. + // This decides nothing: it names elements, not moves. + document.getElementById('cb-status').textContent = + down ? 'took ' + down + ', let go over nothing droppable' + : 'nothing droppable under the pointer'; + down = null; return; } - var body = 'down=' + encodeURIComponent(grabbed) + '&up=' + encodeURIComponent(up); + var body = 'down=' + encodeURIComponent(down) + '&up=' + encodeURIComponent(up); + down = null; 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) { - status(t); + document.getElementById('cb-status').textContent = t; if (t.indexOf('ok') === 0) { window.location.reload(); } }); }); @@ -149,51 +98,11 @@ 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 = Vec::new(); - for t in targets { - out.push(match t.split_once('-') { - Some(("seat", n)) => n - .parse::() - .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"), @@ -323,7 +232,7 @@ fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView ); let _ = write!( out, - "freedom \ + "freedom \ {ready}{lifted}
", raw = id.0, ready = if p.freedom_ready { "READY" } else { "spent" }, @@ -521,33 +430,17 @@ pub fn document( games_ground::Action::Attack, games_ground::Action::Ground, ] { - // 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 = seat - .map(|seat| { - legal - .iter() - .filter_map(|c| crate::input::affordance(c, seat)) - .filter(|(f, _)| *f == action_id(a)) - .map(|(_, t)| t) - .collect() + let offered = seat.is_some_and(|seat| { + legal.iter().any(|c| { + crate::input::affordance(c, seat).is_some_and(|(f, _)| f == action_id(a)) }) - .unwrap_or_default(); - if !targets.is_empty() { + }); + if offered { let _ = write!( s, - "
{a:?}
\ - onto {names}
", + "
drag {a:?} onto a seat, a problem, \ + or the table
", id = action_id(a), - targets = esc(&targets.join(" ")), - names = esc(&target_names(&targets)), ); } } @@ -557,7 +450,7 @@ pub fn document( if !spatial { let _ = write!( s, - "
{}
", + "
{}
", esc(&format!("{c:?}")) ); } @@ -568,9 +461,7 @@ pub fn document( ); } if may_pass { - s.push_str( - "
pass \u{2014} decline to act
", - ); + s.push_str("
pass \u{2014} decline to act
"); } let _ = write!( diff --git a/crates/cb-render-html/src/jsrun.rs b/crates/cb-render-html/src/jsrun.rs index 5713ee3..496d67c 100644 --- a/crates/cb-render-html/src/jsrun.rs +++ b/crates/cb-render-html/src/jsrun.rs @@ -55,79 +55,32 @@ pub fn scripts(html: &str) -> Vec { 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; }, - createElement: function (_tag) { return __mk(null, null, ""); }, - querySelectorAll: function (_sel) { return __all; } + getElementById: function (id) { return __status; } }; 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; } - -// 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, ""); +// A node carrying one data-drop key, with a null parent — so the +// script's walk up the tree terminates the way it does in a browser. +function __node(k) { + return { + getAttribute: function (a) { return a === 'data-drop' ? k : null; }, + parentNode: null + }; } -function __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; } +function __down(k) { __handlers['pointerdown']({ target: __node(k) }); } +function __up(k) { __handlers['pointerup']({ target: __node(k) }); } +// A node with no key at all, whose parent chain ends: what the pointer +// lands on when it is dropped somewhere that is not a target. +function __downNowhere() { __handlers['pointerdown']({ target: __node(null) }); } +function __upNowhere() { __handlers['pointerup']({ target: __node(null) }); } "#; /// Run the document's scripts, then a pointer gesture, and report what @@ -152,7 +105,16 @@ pub fn gesture(html: &str, down: &str, up: &str) -> Result, String> ctx.add_callback("__reloaded", || 0i32) .map_err(|e| format!("register __reloaded: {e}"))?; - prepare(&ctx, html)?; + ctx.eval(DOM).map_err(|e| format!("dom stub: {e}"))?; + + let found = scripts(html); + if found.is_empty() { + return Err("the document carries no