clay-borg/crates/cb-render-html/src/input.rs
tegwick bf24affa84
Some checks failed
ci / check (push) Failing after 3s
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

349 lines
13 KiB
Rust

//! 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,
}
}
/// 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:?}"),
}
}
/// 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)
)
}
/// 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 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)),
}
}
#[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());
}
}