//! 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=&up=`. Anything else is refused. pub fn parse(body: &str) -> Result { 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-` 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 { // 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, problem: Option) -> 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()); } }