//! 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, } } /// 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::() + c.as_str(), None => id.to_string(), }; } if let Some(n) = id.strip_prefix("seat-") { return n .parse::() .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 { // 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, 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()); } }