clay-borg/crates/cb-render-html/src/input.rs

350 lines
13 KiB
Rust
Raw Normal View History

CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat play, at a measured marginal AM-4a cost of zero. games-ground shipped: 23 third-party crates cb-render-html: 23 third-party crates new crates introduced: 0 Measured, not asserted — the survey's own lesson. AM-4a is unmoved at 246,250; own source is 7,636 -> 9,652. What shipped: crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship graph), input.rs (pointer facts -> commands), serve.rs (Guard, Request, loopback bind) tools/cb-play hotseat.rs + `--serve PORT` Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null. The renderer targets the existing Project trait; the port waits for stage 2's wgpu implementation to be its second use. The six controls, all live, all mutation-checked (8 mutations, each red for its stated reason): 1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind 4 a token-less request is refused, in the unit AND over a real socket 5 JS may not construct commands — the page reports pointer facts, Rust resolves them against the legal list the aggregate already offered, and a test asserts the emitted script contains no game vocabulary 6 the coverage gate crosses the language boundary: it walks the serialized view for leaf paths and requires each token to appear in the PARSED emitted document, with a test that the parse really is a parse (script/style contents must not count as rendered) The gate fired on its author again, on its first run: ground_choices.*. choice, ground_choices.*.problem and players.*.blame_from were in neither list. The last is the one worth keeping — an EMPTY vector is a leaf path of its own, and it now renders as an explicit absence. Also, a mutation that did not go red: removing the Sec-Fetch-Site arm alone left the cross-site test green, because the Origin check caught it independently. Both had to be removed before the control bit. Recorded because a control that passes for a reason you did not intend has not been demonstrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
//! ADR-0007 Decision 5: **JavaScript may not construct commands.**
//!
//! The emitted page reports raw pointer facts — "the pointer went down on
//! element `a` and came up on element `b`" — and nothing else. This module
//! is where Rust decides what command that means.
//!
//! The rule exists because CB-WP-0011 established that a renderer's defect
//! class is silent omission, and ADR-0007 moves the interactive half of
//! stage 1 into a language `cargo test`, `clippy` and `M-D1-MUT` cannot
//! reach. Confining JavaScript to input transport keeps the decision on
//! this side of the boundary, where it can be tested against synthetic
//! events with no browser in the loop — which is exactly what the tests
//! below do.
//!
//! K13 is unaffected: resolving a drag produces an **index into the legal
//! list the aggregate already offered**. A drag can never name a command
//! the aggregate did not enumerate, so the projection cannot feed back
//! into validation.
use games_ground::{Action, GroundCommand};
/// What the browser is permitted to tell us: two element ids.
///
/// Deliberately not "a move", "a command", or anything carrying game
/// meaning. If this struct ever grows a field with a rule in it, control 5
/// has been breached and ADR-0007 says to revisit the decision rather than
/// widen the control.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PointerFact {
pub down: String,
pub up: String,
}
impl PointerFact {
pub fn new(down: &str, up: &str) -> Self {
Self {
down: down.to_string(),
up: up.to_string(),
}
}
/// Parse the wire form `down=<id>&up=<id>`. Anything else is refused.
pub fn parse(body: &str) -> Result<Self, String> {
let (mut down, mut up) = (None, None);
for pair in body.split('&') {
match pair.split_once('=') {
Some(("down", v)) => down = Some(v.to_string()),
Some(("up", v)) => up = Some(v.to_string()),
_ => return Err(format!("unrecognised field in pointer fact: {pair:?}")),
}
}
match (down, up) {
(Some(down), Some(up)) if !down.is_empty() && !up.is_empty() => Ok(Self { down, up }),
_ => Err("a pointer fact needs a non-empty down and up".to_string()),
}
}
}
pub fn action_id(a: Action) -> &'static str {
match a {
Action::Investigate => "action-investigate",
Action::Solve => "action-solve",
Action::Support => "action-support",
Action::Attack => "action-attack",
Action::Ground => "action-ground",
}
}
/// The element pair that offers this command, if it has a spatial one.
///
/// Commands with no natural drag — choosing a GROUND mode, answering a
/// Support, naming a DARVO target — are offered as buttons instead, and
/// resolve through the `cmd-<i>` form below.
pub fn affordance(command: &GroundCommand, seat: cb_kernel::PlayerId) -> Option<(String, String)> {
match command {
GroundCommand::SelectAction {
action,
target,
problem,
} => {
let from = action_id(*action).to_string();
let to = match (target, problem) {
(Some(p), None) => format!("seat-{}", p.0),
(None, Some(n)) => format!("problem-{n}"),
(None, None) => "table".to_string(),
// Both set is not a shape the aggregate produces; refusing
// to invent an affordance is better than guessing one.
(Some(_), Some(_)) => return None,
};
Some((from, to))
}
GroundCommand::SpendFreedom => {
Some((format!("freedom-{}", seat.0), format!("freedom-{}", seat.0)))
}
_ => None,
}
}
CB-WP-0018 T03/T04: explanations, and window 1's verdict 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>
2026-08-03 02:24:13 +02:00
/// 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:?}"),
}
}
CB-WP-0020: the table you can read Six of seven perceptual defects fixed; item 1 already passed. T01, at the maintainer's instruction: a legal target restyles its EXISTING border rather than drawing a new box. outline + outline-offset drew a second rectangle, which an SVG viewport clips (the missing top and left edges) and which made a seat's highlight card-sized. A border already in the layout cannot move the layout. T02: the ghost was a textContent copy of the card, which is why the line break collapsed and it read as a second card, and why showing the explanation destroyed the label. It is now a pill, the explanation is appended beside the label, and the left-behind element is dimmed and dashed. The stub grew innerHTML so a test can assert BOTH are present -- it could previously only see that something was displayed. T03: NOT reproduced and recorded as not reproduced. The likeliest cause is which element the browser reports -- for touch and pen the pointer is captured to the pointerdown target, making every drop look like a drop-on-itself, which is the other half of the report. elementFromPoint is correct under both explanations. Separately the refusal was written in element ids on the one surface a player reads when something goes wrong; it now speaks the game's words and a test forbids id leakage. T04: seat selections rendered as Debug. The coverage gate then failed my first fix for dropping a field when target and problem were both set -- the aggregate does not produce that shape and the gate was right not to care. T05: the headline reads from group_success. 'Play again' is real, and its first version was useless: run_game bound a fresh listener per game, so a second game moved to a new port and left the tab pointing at a dead one. One listener per session now, and the test asserts the second game is a DIFFERENT deal. Chaos d8=8 fired the first override at the new rate and drew S, changing nothing -- one half of window 2's retirement condition. CB-WP-0019 settled at $38.54/117 against $34.80/107. Eight for eight, and the first under 20%. make all exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:20:38 +02:00
/// Say why a drop meant nothing, in words a player can act on.
///
/// Deliberately does **not** consult the rules to explain *why* a move is
/// illegal — that would be a second implementation of them. It names what
/// was dropped on what, and leaves the reason to the log.
fn refusal(fact: &PointerFact, seat: cb_kernel::PlayerId) -> String {
let name = |id: &str| -> String {
if let Some(a) = id.strip_prefix("action-") {
let mut c = a.chars();
return match c.next() {
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
None => id.to_string(),
};
}
if let Some(n) = id.strip_prefix("seat-") {
return n
.parse::<u8>()
.map(|n| format!("P{}", n + 1))
.unwrap_or_else(|_| id.to_string());
}
if let Some(n) = id.strip_prefix("problem-") {
return format!("problem {n}");
}
match id {
"table" => "the table".to_string(),
_ if id.starts_with("freedom-") => "your freedom token".to_string(),
_ => id.to_string(),
}
};
if fact.down == fact.up {
return format!("{} needs to be dropped on something", name(&fact.down));
}
let you = format!("P{}", seat.0 + 1);
if fact.up == format!("seat-{}", seat.0) {
return format!("{} cannot be aimed at {you} right now", name(&fact.down));
}
format!(
"{} on {} is not a move you can make right now",
name(&fact.down),
name(&fact.up)
)
}
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat play, at a measured marginal AM-4a cost of zero. games-ground shipped: 23 third-party crates cb-render-html: 23 third-party crates new crates introduced: 0 Measured, not asserted — the survey's own lesson. AM-4a is unmoved at 246,250; own source is 7,636 -> 9,652. What shipped: crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship graph), input.rs (pointer facts -> commands), serve.rs (Guard, Request, loopback bind) tools/cb-play hotseat.rs + `--serve PORT` Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null. The renderer targets the existing Project trait; the port waits for stage 2's wgpu implementation to be its second use. The six controls, all live, all mutation-checked (8 mutations, each red for its stated reason): 1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind 4 a token-less request is refused, in the unit AND over a real socket 5 JS may not construct commands — the page reports pointer facts, Rust resolves them against the legal list the aggregate already offered, and a test asserts the emitted script contains no game vocabulary 6 the coverage gate crosses the language boundary: it walks the serialized view for leaf paths and requires each token to appear in the PARSED emitted document, with a test that the parse really is a parse (script/style contents must not count as rendered) The gate fired on its author again, on its first run: ground_choices.*. choice, ground_choices.*.problem and players.*.blame_from were in neither list. The last is the one worth keeping — an EMPTY vector is a leaf path of its own, and it now renders as an explicit absence. Also, a mutation that did not go red: removing the Sec-Fetch-Site arm alone left the cross-site test green, because the Origin check caught it independently. Both had to be removed before the control bit. Recorded because a control that passes for a reason you did not intend has not been demonstrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
/// Resolve a pointer fact to an index into the legal list.
///
/// Returns `Err` rather than a default when nothing matches: a drag that
/// means nothing must mean *nothing*, not "the first legal move". A
/// renderer that silently substitutes a command is the presentation-layer
/// form of the defect this whole pass is about.
pub fn resolve(
fact: &PointerFact,
legal: &[GroundCommand],
seat: cb_kernel::PlayerId,
) -> Result<usize, String> {
// The button form: an explicit index, which still has to be in range.
if let Some(rest) = fact.down.strip_prefix("cmd-") {
if fact.down != fact.up {
return Err(format!(
"a button press must start and end on the same element ({} then {})",
fact.down, fact.up
));
}
let i: usize = rest
.parse()
.map_err(|_| format!("not a command index: {:?}", fact.down))?;
return if i < legal.len() {
Ok(i)
} else {
Err(format!("no legal command {i} (there are {})", legal.len()))
};
}
let mut hits = legal
.iter()
.enumerate()
.filter(|(_, c)| affordance(c, seat).is_some_and(|(f, t)| f == fact.down && t == fact.up));
match (hits.next(), hits.next()) {
(Some((i, _)), None) => Ok(i),
(Some(_), Some(_)) => Err(format!(
"{} -> {} is offered by more than one legal command; the page is ambiguous",
fact.down, fact.up
)),
CB-WP-0020: the table you can read Six of seven perceptual defects fixed; item 1 already passed. T01, at the maintainer's instruction: a legal target restyles its EXISTING border rather than drawing a new box. outline + outline-offset drew a second rectangle, which an SVG viewport clips (the missing top and left edges) and which made a seat's highlight card-sized. A border already in the layout cannot move the layout. T02: the ghost was a textContent copy of the card, which is why the line break collapsed and it read as a second card, and why showing the explanation destroyed the label. It is now a pill, the explanation is appended beside the label, and the left-behind element is dimmed and dashed. The stub grew innerHTML so a test can assert BOTH are present -- it could previously only see that something was displayed. T03: NOT reproduced and recorded as not reproduced. The likeliest cause is which element the browser reports -- for touch and pen the pointer is captured to the pointerdown target, making every drop look like a drop-on-itself, which is the other half of the report. elementFromPoint is correct under both explanations. Separately the refusal was written in element ids on the one surface a player reads when something goes wrong; it now speaks the game's words and a test forbids id leakage. T04: seat selections rendered as Debug. The coverage gate then failed my first fix for dropping a field when target and problem were both set -- the aggregate does not produce that shape and the gate was right not to care. T05: the headline reads from group_success. 'Play again' is real, and its first version was useless: run_game bound a fresh listener per game, so a second game moved to a new port and left the tab pointing at a dead one. One listener per session now, and the test asserts the second game is a DIFFERENT deal. Chaos d8=8 fired the first override at the new rate and drew S, changing nothing -- one half of window 2's retirement condition. CB-WP-0019 settled at $38.54/117 against $34.80/107. Eight for eight, and the first under 20%. make all exits 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:20:38 +02:00
// CB-WP-0020 T03: written in the game's words, not the DOM's.
// This read `action-attack -> action-attack is not a legal move
// here`, which is the vocabulary of element ids — the same defect
// the log had before CB-WP-0018, on the surface a player actually
// reads when something goes wrong.
(None, _) => Err(refusal(fact, seat)),
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat play, at a measured marginal AM-4a cost of zero. games-ground shipped: 23 third-party crates cb-render-html: 23 third-party crates new crates introduced: 0 Measured, not asserted — the survey's own lesson. AM-4a is unmoved at 246,250; own source is 7,636 -> 9,652. What shipped: crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship graph), input.rs (pointer facts -> commands), serve.rs (Guard, Request, loopback bind) tools/cb-play hotseat.rs + `--serve PORT` Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null. The renderer targets the existing Project trait; the port waits for stage 2's wgpu implementation to be its second use. The six controls, all live, all mutation-checked (8 mutations, each red for its stated reason): 1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind 4 a token-less request is refused, in the unit AND over a real socket 5 JS may not construct commands — the page reports pointer facts, Rust resolves them against the legal list the aggregate already offered, and a test asserts the emitted script contains no game vocabulary 6 the coverage gate crosses the language boundary: it walks the serialized view for leaf paths and requires each token to appear in the PARSED emitted document, with a test that the parse really is a parse (script/style contents must not count as rendered) The gate fired on its author again, on its first run: ground_choices.*. choice, ground_choices.*.problem and players.*.blame_from were in neither list. The last is the one worth keeping — an EMPTY vector is a leaf path of its own, and it now renders as an explicit absence. Also, a mutation that did not go red: removing the Sec-Fetch-Site arm alone left the cross-site test green, because the Origin check caught it independently. Both had to be removed before the control bit. Recorded because a control that passes for a reason you did not intend has not been demonstrated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
}
}
#[cfg(test)]
mod tests {
use super::*;
use cb_kernel::PlayerId;
use games_ground::{GroundMode, SupportResponse};
fn seat(n: u8) -> PlayerId {
PlayerId(n)
}
fn select(action: Action, target: Option<u8>, problem: Option<u32>) -> GroundCommand {
GroundCommand::SelectAction {
action,
target: target.map(seat),
problem,
}
}
#[test]
fn a_drag_resolves_to_the_command_it_offers() {
let legal = vec![
select(Action::Attack, Some(1), None),
select(Action::Solve, None, Some(7)),
select(Action::Ground, None, None),
];
assert_eq!(
resolve(
&PointerFact::new("action-solve", "problem-7"),
&legal,
seat(0)
),
Ok(1)
);
assert_eq!(
resolve(
&PointerFact::new("action-attack", "seat-1"),
&legal,
seat(0)
),
Ok(0)
);
assert_eq!(
resolve(&PointerFact::new("action-ground", "table"), &legal, seat(0)),
Ok(2)
);
}
/// The control that matters: a drag the aggregate did not offer must
/// resolve to nothing at all, not to a nearby or default command.
#[test]
fn a_drag_that_means_nothing_resolves_to_nothing() {
let legal = vec![select(Action::Solve, None, Some(7))];
// Right action, wrong problem.
assert!(resolve(
&PointerFact::new("action-solve", "problem-9"),
&legal,
seat(0)
)
.is_err());
// An action that is legal in general but not offered now.
assert!(resolve(
&PointerFact::new("action-attack", "seat-1"),
&legal,
seat(0)
)
.is_err());
// Elements that exist on the page but are not an affordance.
assert!(resolve(&PointerFact::new("seat-1", "seat-2"), &legal, seat(0)).is_err());
// Nonsense.
assert!(resolve(&PointerFact::new("", ""), &legal, seat(0)).is_err());
}
/// A command index arriving from the page is still bounds-checked
/// against the list the aggregate offered.
#[test]
fn a_button_index_is_bounds_checked() {
let legal = vec![
GroundCommand::RespondToSupport {
response: SupportResponse::AcceptBond,
},
GroundCommand::ChooseGroundMode {
mode: GroundMode::Gr,
choice: None,
},
];
assert_eq!(
resolve(&PointerFact::new("cmd-1", "cmd-1"), &legal, seat(0)),
Ok(1)
);
assert!(resolve(&PointerFact::new("cmd-2", "cmd-2"), &legal, seat(0)).is_err());
assert!(resolve(&PointerFact::new("cmd-x", "cmd-x"), &legal, seat(0)).is_err());
// A press that starts on one button and ends on another is not a
// press, and must not be read as one.
assert!(resolve(&PointerFact::new("cmd-0", "cmd-1"), &legal, seat(0)).is_err());
}
/// If two legal commands offered the same drag, picking either would
/// be a coin flip the player cannot see. Refuse instead.
#[test]
fn an_ambiguous_affordance_is_refused_rather_than_guessed() {
let legal = vec![
select(Action::Solve, None, Some(7)),
select(Action::Solve, None, Some(7)),
];
let e = resolve(
&PointerFact::new("action-solve", "problem-7"),
&legal,
seat(0),
)
.unwrap_err();
assert!(e.contains("more than one"), "{e}");
}
#[test]
fn pointer_facts_parse_and_bad_ones_are_refused() {
assert_eq!(
PointerFact::parse("down=action-solve&up=problem-7"),
Ok(PointerFact::new("action-solve", "problem-7"))
);
// A field carrying game meaning is exactly what control 5 forbids.
assert!(PointerFact::parse("down=a&up=b&command=Solve").is_err());
assert!(PointerFact::parse("down=a").is_err());
assert!(PointerFact::parse("down=&up=b").is_err());
assert!(PointerFact::parse("").is_err());
}
}