271 lines
9.5 KiB
Rust
271 lines
9.5 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,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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());
|
||
|
|
}
|
||
|
|
}
|