Some checks failed
ci / check (push) Failing after 3s
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>
349 lines
13 KiB
Rust
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());
|
|
}
|
|
}
|