CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit
Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat
play, at a measured marginal AM-4a cost of zero.
games-ground shipped: 23 third-party crates
cb-render-html: 23 third-party crates
new crates introduced: 0
Measured, not asserted — the survey's own lesson. AM-4a is unmoved at
246,250; own source is 7,636 -> 9,652.
What shipped:
crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship
graph), input.rs (pointer facts -> commands),
serve.rs (Guard, Request, loopback bind)
tools/cb-play hotseat.rs + `--serve PORT`
Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null.
The renderer targets the existing Project trait; the port waits for
stage 2's wgpu implementation to be its second use.
The six controls, all live, all mutation-checked (8 mutations, each red
for its stated reason):
1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind
4 a token-less request is refused, in the unit AND over a real socket
5 JS may not construct commands — the page reports pointer facts, Rust
resolves them against the legal list the aggregate already offered,
and a test asserts the emitted script contains no game vocabulary
6 the coverage gate crosses the language boundary: it walks the
serialized view for leaf paths and requires each token to appear in
the PARSED emitted document, with a test that the parse really is a
parse (script/style contents must not count as rendered)
The gate fired on its author again, on its first run: ground_choices.*.
choice, ground_choices.*.problem and players.*.blame_from were in neither
list. The last is the one worth keeping — an EMPTY vector is a leaf path
of its own, and it now renders as an explicit absence.
Also, a mutation that did not go red: removing the Sec-Fetch-Site arm
alone left the cross-site test green, because the Origin check caught it
independently. Both had to be removed before the control bit. Recorded
because a control that passes for a reason you did not intend has not
been demonstrated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
|
|
|
//! 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,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
CB-WP-0018 T03/T04: explanations, and window 1's verdict
T03: input::describe writes a sentence per legal command; data-descs
carries them in step with data-targets; the ghost already following the
pointer shows the one for whatever legal target is under it, so the
explanation lands beside the target with no overlay layer to keep
aligned. ADR-0010 D1 binds -- the page renders it, never composes it.
Both mutations INITIALLY SURVIVED because the fixture's Attack card had
exactly one target, where an off-by-one shift and a truncation are both
no-ops. CB-EV-0014's lesson one level in: a fixture too thin to express
a failure is how the failure survives. Two attack targets now, both red.
T04: chaos rate d4 -> d8, window 2 open at 12 declarations, retiring if
an override changes nothing twice running. Window 1's condition was NOT
met -- both overrides changed the outcome -- so the mechanism is kept.
The weakest part of the decision is that it is a rate change argued from
n=2, so window 2 carries a falsifier: no override at all is evidence the
rate went too far, not that the mechanism is healthy.
InnerLoop.md hit 401 lines and the loadability gate fired; the rationale
moved to InnerLoopReference.md, structurally, per the standing precedent
that limits are not raised.
CB-WP-0017 settled at $9.48/40 against $5.19/23 reported mid-flight,
83% higher. Six for six, always low -- read by re-running the instrument
at the moment of quoting, which is CB-EV-0015's correction applied for
the first time.
make all exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 02:24:13 +02:00
|
|
|
/// 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:?}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/// 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)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit
Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat
play, at a measured marginal AM-4a cost of zero.
games-ground shipped: 23 third-party crates
cb-render-html: 23 third-party crates
new crates introduced: 0
Measured, not asserted — the survey's own lesson. AM-4a is unmoved at
246,250; own source is 7,636 -> 9,652.
What shipped:
crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship
graph), input.rs (pointer facts -> commands),
serve.rs (Guard, Request, loopback bind)
tools/cb-play hotseat.rs + `--serve PORT`
Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null.
The renderer targets the existing Project trait; the port waits for
stage 2's wgpu implementation to be its second use.
The six controls, all live, all mutation-checked (8 mutations, each red
for its stated reason):
1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind
4 a token-less request is refused, in the unit AND over a real socket
5 JS may not construct commands — the page reports pointer facts, Rust
resolves them against the legal list the aggregate already offered,
and a test asserts the emitted script contains no game vocabulary
6 the coverage gate crosses the language boundary: it walks the
serialized view for leaf paths and requires each token to appear in
the PARSED emitted document, with a test that the parse really is a
parse (script/style contents must not count as rendered)
The gate fired on its author again, on its first run: ground_choices.*.
choice, ground_choices.*.problem and players.*.blame_from were in neither
list. The last is the one worth keeping — an EMPTY vector is a leaf path
of its own, and it now renders as an explicit absence.
Also, a mutation that did not go red: removing the Sec-Fetch-Site arm
alone left the cross-site test green, because the Origin check caught it
independently. Both had to be removed before the control bit. Recorded
because a control that passes for a reason you did not intend has not
been demonstrated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
|
|
|
/// 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: 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
|
|
|
// 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)),
|
CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit
Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat
play, at a measured marginal AM-4a cost of zero.
games-ground shipped: 23 third-party crates
cb-render-html: 23 third-party crates
new crates introduced: 0
Measured, not asserted — the survey's own lesson. AM-4a is unmoved at
246,250; own source is 7,636 -> 9,652.
What shipped:
crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship
graph), input.rs (pointer facts -> commands),
serve.rs (Guard, Request, loopback bind)
tools/cb-play hotseat.rs + `--serve PORT`
Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null.
The renderer targets the existing Project trait; the port waits for
stage 2's wgpu implementation to be its second use.
The six controls, all live, all mutation-checked (8 mutations, each red
for its stated reason):
1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind
4 a token-less request is refused, in the unit AND over a real socket
5 JS may not construct commands — the page reports pointer facts, Rust
resolves them against the legal list the aggregate already offered,
and a test asserts the emitted script contains no game vocabulary
6 the coverage gate crosses the language boundary: it walks the
serialized view for leaf paths and requires each token to appear in
the PARSED emitted document, with a test that the parse really is a
parse (script/style contents must not count as rendered)
The gate fired on its author again, on its first run: ground_choices.*.
choice, ground_choices.*.problem and players.*.blame_from were in neither
list. The last is the one worth keeping — an EMPTY vector is a leaf path
of its own, and it now renders as an explicit absence.
Also, a mutation that did not go red: removing the Sec-Fetch-Site arm
alone left the cross-site test green, because the Origin check caught it
independently. Both had to be removed before the control bit. Recorded
because a control that passes for a reason you did not intend has not
been demonstrated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 04:27:25 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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());
|
|
|
|
|
}
|
|
|
|
|
}
|