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

271 lines
9.5 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,
}
}
/// 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
)),
(None, _) => Err(format!(
"{} -> {} is not a legal move here",
fact.down, fact.up
)),
}
}
#[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());
}
}