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>
This commit is contained in:
parent
883b608f36
commit
84d688688d
14 changed files with 2058 additions and 2 deletions
270
crates/cb-render-html/src/input.rs
Normal file
270
crates/cb-render-html/src/input.rs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
//! 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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue