//! `cb-render-html` — stage 1's renderer (ADR-0007). //! //! Emits HTML with inline SVG and one small piece of inline JavaScript; //! the browser draws it. **Marginal AM-4a cost: zero** — this crate has no //! third-party dependencies at all, and adding one requires an argument //! against ADR-0007 §Decision 3. //! //! ## What this is not //! //! It is **not** `cb-render-api`, and there is no `cb-render-null`. //! ADR-0007 Decision 2 withdrew the port: a capability port designed //! against one implementation that emits whole documents acquires a //! document's shape, and stage 2's `wgpu` renderer would find it //! unimplementable. INTENT's rule is the one that binds — //! //! > *No concept becomes canonical merely because it looks general. It //! > becomes canonical after surviving a second concrete use.* //! //! — so this renders against the existing [`cb_game_runtime::Project`] //! trait, which is a real interface with real implementations, and the //! port is declared at stage 2 when there are two. //! //! ## The controls, and why they exist //! //! CB-WP-0011 established that a renderer's defect class is **silent //! omission**: every assertion a renderer test naturally makes is //! satisfied by a renderer showing a third of the state. This crate moves //! the interactive half of stage 1 into a language `cargo test`, //! `clippy` and `M-D1-MUT` cannot reach, so two controls carry that //! finding across the boundary: //! //! * [`input`] — **JavaScript may not construct commands.** The page //! reports raw pointer facts; Rust decides what they mean, against the //! legal list the aggregate already offered. //! * the coverage gate below — asserts over the **parsed emitted //! document**, not over the Rust that emits it. Asserting over the //! emitting code would reproduce CB-WP-0011's original defect one layer //! up. //! //! And [`serve`] carries the four that make a loopback listener safe to //! have, of which the load-bearing one is a test that a token-less request //! is refused. pub mod doc; pub mod input; #[cfg(any(test, feature = "js-harness"))] pub mod jsrun; pub mod serve; pub use doc::{document, text_of}; pub use input::{resolve, PointerFact}; pub use serve::{Guard, Refusal, Request}; #[cfg(test)] mod coverage { //! **ADR-0007 control 6.** The HTML counterpart of `cb-play`'s //! `every_view_field_is_classified`. //! //! Same shape as CB-WP-0011's gate, one layer further out: walk the //! serialized `GroundView` for every leaf *path*, and require each to //! be either rendered — with a token that must appear in the **parsed //! document** — or omitted with a stated reason. A new field on //! `GroundView` that is in neither list fails the build. //! //! Paths, not keys: `problem` occurs under a DARVO target, a GROUND //! choice and a Selection, and a key-set walk would let one vouch for //! the other two. use cb_kernel::PlayerId; use games_ground::view::GroundView; use crate::doc::{document, text_of}; /// Every leaf path in the serialized view. fn paths(v: &serde_json::Value, prefix: &str, out: &mut Vec) { match v { serde_json::Value::Object(m) if !m.is_empty() => { for (k, val) in m { let p = if prefix.is_empty() { k.clone() } else { format!("{prefix}.{k}") }; paths(val, &p, out); } } serde_json::Value::Array(a) if !a.is_empty() => { for item in a { paths(item, &format!("{prefix}.*"), out); } } _ => out.push(prefix.to_string()), } } /// Collapse map keys to `*` so the classification is over fields, not /// over whichever seats and priorities the fixture happens to hold. fn normalize(path: &str) -> String { const MAPS: &[&str] = &[ "players", "relations", "problems", "focus", "selections", "ground_modes", "ground_choices", "support_responses", "darvo_targets", "personal", ]; let mut parts: Vec = Vec::new(); let mut prev_is_map = false; for seg in path.split('.') { if prev_is_map && seg != "*" { parts.push("*".to_string()); } else { parts.push(seg.to_string()); } prev_is_map = MAPS.contains(&seg); } parts.join(".") } /// `(leaf path, a token the parsed document must contain)`. const RENDERED: &[(&str, &str)] = &[ ("round", "round 3"), ("lead", "lead P2"), ("step", "step Select"), ("mode", "scoring BondedCoalitions"), ("viewer", "viewing as P1"), ("solution_deck_len", "17 remaining"), ("solution_discard.*.suit", "discard Repair Change"), ("players.*.stress", "stress 5"), ("players.*.protection", "protect 2"), ("players.*.darvo", "darvo Reverse"), ("players.*.freedom_ready", "READY"), ("players.*.freedom_gate_lifted", "gate lifted"), ("players.*.blame_from.*", "blamed by P3"), // The empty case is a leaf path of its own, and renders as an // explicit absence rather than as nothing at all. ("players.*.blame_from", "blamed by none"), ("players.*.hand.*.suit", "hand Clarify Boundary"), ("players.*.hand_size", "cards)"), ("relations.*", "Rivalry"), ("problems.*.state", "face down"), ("problems.*.suit", "Change 6"), ("problems.*.value", "Change 6"), ("problems.*.denied", "denied"), ("problems.*.claimed_by", "claimed by P2"), ("problems.*.protected_this_round", "protected"), ("focus.*", "\u{2192}P3"), ("selections.*.state", "face down"), ("selections.*.action", "action: Attack"), ("selections.*.target", "target: Some(PlayerId(1))"), ("selections.*.problem", "problem: Some(7)"), ("ground_modes.*", "ground mode Gr"), ("ground_choices.*.choice", "ProtectProblem"), ("ground_choices.*.problem", "problem: 7 }"), ("support_responses.*", "support AcceptBond"), ("darvo_targets.*.problem", "problem: Some(1)"), ("darvo_targets.*.player", "player: Some(PlayerId(1))"), ("outcome.total", "total 9"), ("outcome.threshold", "of 12"), ("outcome.group_success", "failure"), ("outcome.personal.*", "P1 +3"), ("outcome.coalitions.*.members.*", "members: [PlayerId(1)"), ("outcome.coalitions.*.score", "score: 4"), ("outcome.mastery", "mastery +1"), ("outcome.winners.*", "winners P1"), ]; /// Deliberate omissions, each with a reason. const OMITTED: &[(&str, &str)] = &[( "players.*.hand", "null for a non-viewer seat; the absence is rendered as a count", )]; fn fixture() -> GroundView { crate::testfix::view(Some(PlayerId(0))) } fn rendered_document(view: &GroundView) -> String { text_of(&document( view, &[], "/command?t=x", Some(PlayerId(0)), false, )) } #[test] fn every_view_field_is_classified_in_the_emitted_document() { let view = fixture(); let json = serde_json::to_value(&view).expect("view serializes"); let mut raw = Vec::new(); paths(&json, "", &mut raw); let all: std::collections::BTreeSet = raw.iter().map(|p| normalize(p)).collect(); // EXPECT-VACUOUS floor. A coverage test over zero paths passes // trivially, and that is precisely how this check would rot. assert!( all.len() >= 30, "the walk found {} leaf path(s) — it is not walking the view", all.len() ); let claimed: std::collections::BTreeSet<&str> = RENDERED .iter() .map(|(p, _)| *p) .chain(OMITTED.iter().map(|(p, _)| *p)) .collect(); let unclassified: Vec<&String> = all .iter() .filter(|p| !claimed.contains(p.as_str())) .collect(); assert!( unclassified.is_empty(), "unclassified path(s) in GroundView: {unclassified:?} \ — add each to RENDERED with a token, or to OMITTED with a reason" ); let stale: Vec<&&str> = claimed.iter().filter(|p| !all.contains(**p)).collect(); assert!( stale.is_empty(), "classified path(s) no longer exist in GroundView: {stale:?}" ); // The half that makes it a rendering gate rather than a bookkeeping // one: the token must be in the *parsed document*. let text = rendered_document(&view); let missing: Vec<&str> = RENDERED .iter() .filter(|(_, tok)| !text.contains(tok)) .map(|(p, _)| *p) .collect(); assert!( missing.is_empty(), "claimed rendered, but absent from the emitted document: {missing:?}" ); } /// The parse must be a parse. If `text_of` returned the raw source, a /// token hiding in a comment, a style rule or the script would count /// as rendered — which is the loophole control 6 exists to close. #[test] fn the_parse_does_not_see_script_or_style_contents() { let html = "

seen

\ "; let text = text_of(html); assert!(text.contains("seen")); assert!(text.contains("pid"), "element ids are addressable surface"); assert!( !text.contains("STYLETOKEN"), "style contents leaked into the text" ); assert!( !text.contains("SCRIPTTOKEN"), "script contents leaked into the text" ); } /// Control 5, asserted at the document level: the emitted JavaScript /// must contain no game vocabulary. If a rule ever needs to live in /// the page, ADR-0007 says revisit the decision, not widen this. #[test] fn the_emitted_javascript_contains_no_game_vocabulary() { let doc = document(&fixture(), &[], "/command?t=x", Some(PlayerId(0)), false); let script = doc .split_once("")) .map(|(s, _)| s.to_string()) .expect("the document carries a script"); for word in [ "Investigate", "Solve", "Support", "Attack", "Ground", "darvo", "DARVO", "stress", "freedom", "problem", "coalition", "reveal", "resolve", ] { assert!( !script.contains(word), "the emitted JavaScript mentions {word:?} — control 5 is breached" ); } // And it must still be doing its one job. assert!(script.contains("pointerdown") && script.contains("pointerup")); } /// The renderer must not invent a hand it was never given. /// /// Scoped deliberately: the *projection's* hiding rules are asserted /// where they live, and re-asserting them here would be a duplicated /// fact that drifts. What this checks is the renderer's own failure /// mode — that `hand: None` renders as an absence, for every seat, and /// that a spectator's document contains no open hand at all. #[test] fn the_renderer_never_invents_a_hand_it_was_not_given() { for seat in [0u8, 1, 2] { let view = crate::testfix::view(Some(PlayerId(seat))); let text = rendered_document(&view); let shown = text.matches("cards)").count(); assert_eq!( shown, 1, "P{} sees {shown} open hands in the document; it was given exactly one", seat + 1 ); assert_eq!( text.matches("card(s), hidden").count(), 2, "the other two seats' hands must render as an absence with a count" ); } let text = rendered_document(&crate::testfix::view(None)); assert_eq!( text.matches("cards)").count(), 0, "a spectator document shows an open hand" ); assert!(text.contains("a spectator (no hands)")); } } /// CB-WP-0016 T03: every affordance the page offers must name an element /// the page actually contains. /// /// **This is the check that closes the class the human check found.** On /// 2026-08-02 the maintainer ran `cb-play --serve 0` and could not drag an /// action onto a seat. Every test in the repo was green. The cause: the /// visible seat cards carried no `id`, so `seat-{n}` existed only on the /// 26 px circles inside the relationship graph, while every action card /// read *"drag Attack onto a seat…"*. /// /// Nothing could catch it. `jsrun::gesture` feeds element ids straight /// into a synthetic `{target:{id}}` and never hit-tests, so it establishes /// *"the script posts the ids it was given"* — never *"there is an element /// there to give."* The 42-path coverage gate asserts each view field is /// present in the parsed document, which a `
` with no id satisfies. /// /// So: drive a real game, and at every decision point require that both /// halves of every offered affordance appear as real `id` attributes. #[cfg(test)] mod affordances { use std::cell::RefCell; use std::rc::Rc; use cb_game_runtime::{Project, ScenarioGame, Setup, Viewer}; use cb_kernel::PlayerId; use games_ground::bot::{play, Choice, Policy, RandomPolicy}; use games_ground::{GroundCommand, GroundState}; use crate::{doc, input}; fn fresh(seed: u64) -> GroundState { GroundState::setup( &Setup { players: 3, preset: "standard-3p".into(), patch: std::collections::BTreeMap::new(), }, seed, ) .expect("a standard 3p deal") } /// Renders the page at every real decision point and checks it, then /// delegates the actual choice. /// /// Hooking `Policy` rather than re-driving the game by hand matters: /// these are the *same* decision points `cb-play --serve` renders at, /// with the same `legal` list. A hand-rolled walk would be a second /// implementation of the loop, and could agree with itself while /// disagreeing with the thing shipped. struct CheckingPolicy { inner: RandomPolicy, checked: Rc>, } impl Policy for CheckingPolicy { fn name(&self) -> &'static str { "affordance-checking" } fn choose( &mut self, state: &GroundState, seat: PlayerId, legal: &[GroundCommand], may_pass: bool, ) -> Choice { let view = state.project(Viewer::Player(seat)); let html = doc::document(&view, legal, "/command?t=x", Some(seat), may_pass); let present = doc::drop_keys(&html); for cmd in legal { let Some((from, to)) = input::affordance(cmd, seat) else { // A command with no affordance is offered through the // numbered-button path instead. That is a stated // shape, not a missing element. continue; }; assert!( present.contains(&from), "the page offers {cmd:?} whose GRAB id {from:?} is not an \ element in the document (seat {seat:?}, step {:?})", state.step ); assert!( present.contains(&to), "the page offers {cmd:?} whose DROP id {to:?} is not an \ element in the document (seat {seat:?}, step {:?}). \ Present ids: {present:?}", state.step ); } *self.checked.borrow_mut() += 1; self.inner.choose(state, seat, legal, may_pass) } } /// **The check that closes the class the human check found.** /// /// On 2026-08-02 the maintainer ran `cb-play --serve 0` and could not /// drag an action onto a seat. Every test in the repo was green. The /// cause: the visible seat cards carried no `id`, so `seat-{n}` existed /// only on the 26 px circles inside the relationship graph, while every /// action card read *"drag Attack onto a seat…"*. /// /// Nothing could catch it. `jsrun::gesture` feeds element ids straight /// into a synthetic `{target:{id}}` and never hit-tests, so it /// establishes *"the script posts the ids it was given"* — never /// *"there is an element there to give."* The coverage gate asserts /// each view field appears in the parsed document, which a `
` with /// no id satisfies perfectly. /// /// An affordance naming an element that does not exist is the /// harness-does-nothing shape in the presentation layer, and until now /// it had no detector at all. #[test] fn every_offered_affordance_names_an_element_that_exists() { let checked = Rc::new(RefCell::new(0usize)); for seed in 0..4u64 { let mut policies: Vec> = (0..3) .map(|i| { Box::new(CheckingPolicy { inner: RandomPolicy::new(seed * 10 + i), checked: checked.clone(), }) as Box }) .collect(); play(fresh(seed), &mut policies).expect("a bot game completes"); } // Positive control: a run that rendered nothing would assert // nothing and read as a pass — the exact failure this test exists // to catch, one level up. let n = *checked.borrow(); assert!(n >= 50, "checked only {n} decision point(s)"); } /// **ADR-0010 Decision 2, property 2.** A target the page marks legal /// must resolve. /// /// If the page can advertise a drop that Rust then refuses, the two /// have drifted and the highlighting is *worse* than none — it teaches /// the player something false. This walks real games and checks the /// emitted `data-targets` against `resolve` itself, so the page's /// promise and the referee's answer cannot disagree. #[test] fn everything_the_page_advertises_actually_resolves() { let checked = Rc::new(RefCell::new(0usize)); for seed in 0..4u64 { let mut policies: Vec> = (0..3) .map(|i| { Box::new(AdvertisedPolicy { inner: RandomPolicy::new(seed * 7 + i), checked: checked.clone(), }) as Box }) .collect(); play(fresh(seed), &mut policies).expect("a bot game completes"); } let n = *checked.borrow(); assert!(n >= 50, "checked only {n} advertised target(s)"); } struct AdvertisedPolicy { inner: RandomPolicy, checked: Rc>, } impl Policy for AdvertisedPolicy { fn name(&self) -> &'static str { "advertised-target-checking" } fn choose( &mut self, state: &GroundState, seat: PlayerId, legal: &[GroundCommand], may_pass: bool, ) -> Choice { let view = state.project(Viewer::Player(seat)); let html = doc::document(&view, legal, "/command?t=x", Some(seat), may_pass); for (grab, targets) in crate::jsrun::droppables(&html) { let Some(spec) = targets else { continue }; for drop in spec.split(' ').filter(|s| !s.is_empty()) { let fact = crate::PointerFact::new(&grab, drop); assert!( crate::resolve(&fact, legal, seat).is_ok(), "the page advertises {grab} -> {drop} but resolve refuses it \ (seat {seat:?}, step {:?})", state.step ); *self.checked.borrow_mut() += 1; } } self.inner.choose(state, seat, legal, may_pass) } } /// **The regression test for the defect actually reported**, and the /// reason the check above is not sufficient on its own. /// /// `every_offered_affordance_names_an_element_that_exists` passes on /// the broken tree. `seat-0` *did* exist — on the 26 px circle in the /// relationship graph — so an existence check over the whole document /// cannot see that the seat *card*, which is what the instruction text /// points at, was not droppable. /// /// A seat is drawn twice and both drawings are the seat. This asserts /// the card specifically, by requiring the drop key on the element /// that also carries `data-viewer` — the card, and nothing else. #[test] fn every_seat_card_is_a_drop_target_not_only_the_graph_node() { let view = crate::testfix::view(Some(PlayerId(0))); let html = doc::document(&view, &[], "/command?t=x", Some(PlayerId(0)), false); for seat in view.players.keys() { let card = format!( "data-viewer=\"{}\" data-drop=\"seat-{}\"", view.viewer == Some(*seat), seat.0 ); assert!( html.contains(&card), "seat {seat:?} has a card that is not a drop target; looked for {card:?}" ); } // And the graph node keeps working — someone will have learned to // aim at the circle, and this fix must not take that away. let keys = doc::drop_keys(&html); for seat in view.players.keys() { assert!(keys.contains(&format!("seat-{}", seat.0))); } assert_eq!( html.matches("data-drop=\"seat-0\"").count(), 2, "seat 0 should be droppable in exactly two places: card and graph node" ); } } #[cfg(test)] mod testfix;