Compare commits
7 commits
7363e87bdd
...
c38ecd9da3
| Author | SHA1 | Date | |
|---|---|---|---|
| c38ecd9da3 | |||
| 84d688688d | |||
| 883b608f36 | |||
| c5fa610e59 | |||
| 53abbaf68f | |||
| 331e7e9044 | |||
| 6fb0aeacf0 |
22 changed files with 3492 additions and 6 deletions
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -81,11 +81,21 @@ dependencies = [
|
||||||
"cb-events",
|
"cb-events",
|
||||||
"cb-game-runtime",
|
"cb-game-runtime",
|
||||||
"cb-kernel",
|
"cb-kernel",
|
||||||
|
"cb-render-html",
|
||||||
"games-ground",
|
"games-ground",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cb-render-html"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"cb-kernel",
|
||||||
|
"games-ground",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cb-sim"
|
name = "cb-sim"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ members = [
|
||||||
"crates/cb-kernel",
|
"crates/cb-kernel",
|
||||||
"crates/cb-events",
|
"crates/cb-events",
|
||||||
"crates/cb-game-runtime",
|
"crates/cb-game-runtime",
|
||||||
|
"crates/cb-render-html",
|
||||||
"games/ground",
|
"games/ground",
|
||||||
"tools/cb-sim",
|
"tools/cb-sim",
|
||||||
"tools/cb-play",
|
"tools/cb-play",
|
||||||
|
|
@ -19,6 +20,7 @@ cb-kernel = { path = "crates/cb-kernel" }
|
||||||
cb-events = { path = "crates/cb-events" }
|
cb-events = { path = "crates/cb-events" }
|
||||||
cb-game-runtime = { path = "crates/cb-game-runtime", default-features = false }
|
cb-game-runtime = { path = "crates/cb-game-runtime", default-features = false }
|
||||||
games-ground = { path = "games/ground", default-features = false }
|
games-ground = { path = "games/ground", default-features = false }
|
||||||
|
cb-render-html = { path = "crates/cb-render-html" }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
| workplan | CB-WP-0008 | done | — | workplans/CB-WP-0008-ship-stage-0.md |
|
| workplan | CB-WP-0008 | done | — | workplans/CB-WP-0008-ship-stage-0.md |
|
||||||
| workplan | CB-WP-0009 | done | — | workplans/CB-WP-0009-adaptive-gates.md |
|
| workplan | CB-WP-0009 | done | — | workplans/CB-WP-0009-adaptive-gates.md |
|
||||||
| workplan | CB-WP-0010 | done | — | workplans/CB-WP-0010-consolidation.md |
|
| workplan | CB-WP-0010 | done | — | workplans/CB-WP-0010-consolidation.md |
|
||||||
| workplan | CB-WP-0011 | todo | — | workplans/CB-WP-0011-inspectable-table.md |
|
| workplan | CB-WP-0011 | done | — | workplans/CB-WP-0011-inspectable-table.md |
|
||||||
| task | CB-WP-0001-T01 | done | — | workplans/CB-WP-0001-inner-loop.md |
|
| task | CB-WP-0001-T01 | done | — | workplans/CB-WP-0001-inner-loop.md |
|
||||||
| task | CB-WP-0001-T02 | done | — | workplans/CB-WP-0001-inner-loop.md |
|
| task | CB-WP-0001-T02 | done | — | workplans/CB-WP-0001-inner-loop.md |
|
||||||
| task | CB-WP-0001-T03 | done | — | workplans/CB-WP-0001-inner-loop.md |
|
| task | CB-WP-0001-T03 | done | — | workplans/CB-WP-0001-inner-loop.md |
|
||||||
|
|
@ -86,6 +86,11 @@
|
||||||
| task | CB-WP-0010-T01 | done | — | workplans/CB-WP-0010-consolidation.md |
|
| task | CB-WP-0010-T01 | done | — | workplans/CB-WP-0010-consolidation.md |
|
||||||
| task | CB-WP-0010-T02 | done | — | workplans/CB-WP-0010-consolidation.md |
|
| task | CB-WP-0010-T02 | done | — | workplans/CB-WP-0010-consolidation.md |
|
||||||
| task | CB-WP-0010-T03 | done | — | workplans/CB-WP-0010-consolidation.md |
|
| task | CB-WP-0010-T03 | done | — | workplans/CB-WP-0010-consolidation.md |
|
||||||
| task | CB-WP-0011-T01 | todo | — | workplans/CB-WP-0011-inspectable-table.md |
|
| task | CB-WP-0011-T01 | done | — | workplans/CB-WP-0011-inspectable-table.md |
|
||||||
| task | CB-WP-0011-T02 | todo | — | workplans/CB-WP-0011-inspectable-table.md |
|
| task | CB-WP-0011-T02 | done | — | workplans/CB-WP-0011-inspectable-table.md |
|
||||||
| task | CB-WP-0011-T03 | todo | — | workplans/CB-WP-0011-inspectable-table.md |
|
| task | CB-WP-0011-T03 | done | — | workplans/CB-WP-0011-inspectable-table.md |
|
||||||
|
| task | CB-WP-0012-T01 | done | — | workplans/CB-WP-0012-render-port.md |
|
||||||
|
| task | CB-WP-0012-T02 | done | — | workplans/CB-WP-0012-render-port.md |
|
||||||
|
| task | CB-WP-0012-T03 | done | — | workplans/CB-WP-0012-render-port.md |
|
||||||
|
| task | CB-WP-0012-T04 | done | — | workplans/CB-WP-0012-render-port.md |
|
||||||
|
| task | CB-WP-0012-T05 | done | — | workplans/CB-WP-0012-render-port.md |
|
||||||
|
|
|
||||||
19
crates/cb-render-html/Cargo.toml
Normal file
19
crates/cb-render-html/Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
[package]
|
||||||
|
name = "cb-render-html"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
license-file.workspace = true
|
||||||
|
|
||||||
|
# ADR-0007 Decision 1: the browser is the renderer, so this crate has
|
||||||
|
# **no third-party dependencies at all** beyond what the game already
|
||||||
|
# carries. Adding one here needs an argument against ADR-0007 §Decision 3.
|
||||||
|
[dependencies]
|
||||||
|
cb-kernel.workspace = true
|
||||||
|
games-ground.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# The coverage gate walks the serialized view; nothing else needs it.
|
||||||
|
serde_json.workspace = true
|
||||||
544
crates/cb-render-html/src/doc.rs
Normal file
544
crates/cb-render-html/src/doc.rs
Normal file
|
|
@ -0,0 +1,544 @@
|
||||||
|
//! The emitted document: HTML shell, inline SVG table, inline JavaScript.
|
||||||
|
//!
|
||||||
|
//! ADR-0007 Decision 1. Marginal AM-4a cost zero — this is string
|
||||||
|
//! formatting, and the browser draws it.
|
||||||
|
//!
|
||||||
|
//! ## What the JavaScript is allowed to be
|
||||||
|
//!
|
||||||
|
//! ADR-0007 Decision 5 bars it from constructing commands. [`SCRIPT`] is
|
||||||
|
//! therefore the whole of it, it is a constant, and it does one thing:
|
||||||
|
//! record which element the pointer went down on, which it came up on, and
|
||||||
|
//! POST the pair. It contains no game vocabulary — no action names, no
|
||||||
|
//! seats, no rules. If a rule ever needs to appear in it, the decision is
|
||||||
|
//! wrong and ADR-0007 says to revisit it rather than widen the control.
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
use cb_kernel::PlayerId;
|
||||||
|
use games_ground::view::{GroundView, PlayerView, ProblemView, SelectionView};
|
||||||
|
|
||||||
|
use crate::input::action_id;
|
||||||
|
|
||||||
|
/// Escape for text and attribute contexts alike.
|
||||||
|
///
|
||||||
|
/// Everything interpolated into the document goes through this. Card
|
||||||
|
/// suits and seat numbers cannot currently carry a `<`, but "cannot
|
||||||
|
/// currently" is how injection bugs are written.
|
||||||
|
pub fn esc(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'&' => out.push_str("&"),
|
||||||
|
'<' => out.push_str("<"),
|
||||||
|
'>' => out.push_str(">"),
|
||||||
|
'"' => out.push_str("""),
|
||||||
|
'\'' => out.push_str("'"),
|
||||||
|
_ => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seat_name(p: PlayerId) -> String {
|
||||||
|
format!("P{}", p.0 + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cards(list: &[games_ground::SolutionCard]) -> String {
|
||||||
|
if list.is_empty() {
|
||||||
|
return "none".to_string();
|
||||||
|
}
|
||||||
|
list.iter()
|
||||||
|
.map(|c| format!("{:?}", c.suit))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The only JavaScript in the project. See the module docs.
|
||||||
|
pub const SCRIPT: &str = r#"
|
||||||
|
(function () {
|
||||||
|
var down = null;
|
||||||
|
function id(e) {
|
||||||
|
var n = e.target;
|
||||||
|
while (n && !n.id) { n = n.parentNode; }
|
||||||
|
return n ? n.id : null;
|
||||||
|
}
|
||||||
|
document.addEventListener('pointerdown', function (e) { down = id(e); });
|
||||||
|
document.addEventListener('pointerup', function (e) {
|
||||||
|
var up = id(e);
|
||||||
|
if (!down || !up) { down = null; return; }
|
||||||
|
var body = 'down=' + encodeURIComponent(down) + '&up=' + encodeURIComponent(up);
|
||||||
|
down = null;
|
||||||
|
fetch(window.CB_ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body
|
||||||
|
}).then(function (r) { return r.text(); }).then(function (t) {
|
||||||
|
document.getElementById('cb-status').textContent = t;
|
||||||
|
if (t.indexOf('ok') === 0) { window.location.reload(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const STYLE: &str = "
|
||||||
|
body{font:14px/1.5 ui-monospace,monospace;margin:1.5rem;background:#12141a;color:#dde}
|
||||||
|
h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf}
|
||||||
|
.row{display:flex;flex-wrap:wrap;gap:.5rem;align-items:flex-start}
|
||||||
|
.card{border:1px solid #445;border-radius:6px;padding:.5rem .7rem;background:#1b1e26}
|
||||||
|
.card[data-viewer=true]{border-color:#9cf}
|
||||||
|
.act{cursor:grab;user-select:none;background:#243;border-color:#5a7}
|
||||||
|
.btn{cursor:pointer;background:#332a3a;border-color:#a7d}
|
||||||
|
.k{color:#89a}
|
||||||
|
#cb-status{margin-top:1rem;color:#fc9;min-height:1.2em}
|
||||||
|
svg{background:#1b1e26;border:1px solid #445;border-radius:6px}
|
||||||
|
";
|
||||||
|
|
||||||
|
fn problem_svg(out: &mut String, priority: u32, p: &ProblemView, x: i32) {
|
||||||
|
let (label, sub, fill) = match p {
|
||||||
|
ProblemView::FaceDown => ("face down".to_string(), String::new(), "#2a2f3a"),
|
||||||
|
ProblemView::FaceUp {
|
||||||
|
suit,
|
||||||
|
value,
|
||||||
|
denied,
|
||||||
|
claimed_by,
|
||||||
|
protected_this_round,
|
||||||
|
} => {
|
||||||
|
let mut sub = String::new();
|
||||||
|
if *denied {
|
||||||
|
sub.push_str("denied ");
|
||||||
|
}
|
||||||
|
if *protected_this_round {
|
||||||
|
sub.push_str("protected ");
|
||||||
|
}
|
||||||
|
if let Some(c) = claimed_by {
|
||||||
|
let _ = write!(sub, "claimed by {}", seat_name(*c));
|
||||||
|
}
|
||||||
|
(
|
||||||
|
format!("{suit:?} {value}"),
|
||||||
|
sub.trim_end().to_string(),
|
||||||
|
"#26303a",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = write!(
|
||||||
|
out,
|
||||||
|
"<g id=\"problem-{priority}\"><rect x=\"{x}\" y=\"10\" width=\"120\" height=\"78\" rx=\"8\" \
|
||||||
|
fill=\"{fill}\" stroke=\"#5a6b7a\"/>\
|
||||||
|
<text x=\"{tx}\" y=\"36\" fill=\"#dde\" font-size=\"13\">{label}</text>\
|
||||||
|
<text x=\"{tx}\" y=\"56\" fill=\"#89a\" font-size=\"11\">priority {priority}</text>\
|
||||||
|
<text x=\"{tx}\" y=\"74\" fill=\"#fc9\" font-size=\"11\">{sub}</text></g>",
|
||||||
|
x = x,
|
||||||
|
tx = x + 10,
|
||||||
|
label = esc(&label),
|
||||||
|
sub = esc(&sub),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The relationship graph — the one element every Rust 2D toolkit would
|
||||||
|
/// have left us to hand-roll, and the reason SVG earns its place here
|
||||||
|
/// rather than merely fitting the budget (CB-RES-0006 §5).
|
||||||
|
fn relations_svg(view: &GroundView) -> String {
|
||||||
|
let n = view.players.len().max(1);
|
||||||
|
let (cx, cy, r) = (200.0f64, 150.0f64, 110.0f64);
|
||||||
|
let pos: Vec<(PlayerId, f64, f64)> = view
|
||||||
|
.players
|
||||||
|
.keys()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, p)| {
|
||||||
|
let a = std::f64::consts::TAU * (i as f64) / (n as f64) - std::f64::consts::FRAC_PI_2;
|
||||||
|
(*p, cx + r * a.cos(), cy + r * a.sin())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let find = |p: PlayerId| {
|
||||||
|
pos.iter()
|
||||||
|
.find(|(q, _, _)| *q == p)
|
||||||
|
.map(|(_, x, y)| (*x, *y))
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut s = String::from(
|
||||||
|
"<svg width=\"400\" height=\"300\" role=\"img\" aria-label=\"relationship graph\">",
|
||||||
|
);
|
||||||
|
for (pair, rel) in &view.relations {
|
||||||
|
if let (Some((x1, y1)), Some((x2, y2))) = (find(pair.0), find(pair.1)) {
|
||||||
|
let colour = match rel {
|
||||||
|
games_ground::Relation::Bond => "#5c9",
|
||||||
|
games_ground::Relation::Rivalry => "#c66",
|
||||||
|
};
|
||||||
|
let _ = write!(
|
||||||
|
s,
|
||||||
|
"<line x1=\"{x1:.0}\" y1=\"{y1:.0}\" x2=\"{x2:.0}\" y2=\"{y2:.0}\" \
|
||||||
|
stroke=\"{colour}\" stroke-width=\"2\"/>\
|
||||||
|
<text x=\"{mx:.0}\" y=\"{my:.0}\" fill=\"{colour}\" font-size=\"10\">{rel:?}</text>",
|
||||||
|
mx = (x1 + x2) / 2.0,
|
||||||
|
my = (y1 + y2) / 2.0 - 3.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (p, x, y) in &pos {
|
||||||
|
let is_viewer = view.viewer == Some(*p);
|
||||||
|
let focus = view
|
||||||
|
.focus
|
||||||
|
.get(p)
|
||||||
|
.map(|f| format!(" \u{2192}{}", seat_name(*f)));
|
||||||
|
let _ = write!(
|
||||||
|
s,
|
||||||
|
"<g id=\"seat-{raw}\"><circle cx=\"{x:.0}\" cy=\"{y:.0}\" r=\"26\" fill=\"#1b1e26\" \
|
||||||
|
stroke=\"{stroke}\" stroke-width=\"2\"/>\
|
||||||
|
<text x=\"{x:.0}\" y=\"{ty:.0}\" fill=\"#dde\" font-size=\"12\" \
|
||||||
|
text-anchor=\"middle\">{name}</text>\
|
||||||
|
<text x=\"{x:.0}\" y=\"{ty2:.0}\" fill=\"#89a\" font-size=\"10\" \
|
||||||
|
text-anchor=\"middle\">{focus}</text></g>",
|
||||||
|
raw = p.0,
|
||||||
|
stroke = if is_viewer { "#9cf" } else { "#5a6b7a" },
|
||||||
|
ty = y + 2.0,
|
||||||
|
ty2 = y + 16.0,
|
||||||
|
name = seat_name(*p),
|
||||||
|
focus = esc(focus.as_deref().unwrap_or("")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
s.push_str("</svg>");
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView) {
|
||||||
|
let is_viewer = view.viewer == Some(id);
|
||||||
|
let _ = write!(
|
||||||
|
out,
|
||||||
|
"<div class=\"card\" data-viewer=\"{is_viewer}\"><b>{name}</b>{you}<br>",
|
||||||
|
name = seat_name(id),
|
||||||
|
you = if is_viewer { " (you)" } else { "" },
|
||||||
|
);
|
||||||
|
let _ = write!(
|
||||||
|
out,
|
||||||
|
"<span class=\"k\">stress</span> {} <span class=\"k\">protect</span> {} \
|
||||||
|
<span class=\"k\">darvo</span> {:?}<br>",
|
||||||
|
p.stress, p.protection, p.darvo
|
||||||
|
);
|
||||||
|
let _ = write!(
|
||||||
|
out,
|
||||||
|
"<span id=\"freedom-{raw}\" class=\"act\"><span class=\"k\">freedom</span> \
|
||||||
|
{ready}{lifted}</span><br>",
|
||||||
|
raw = id.0,
|
||||||
|
ready = if p.freedom_ready { "READY" } else { "spent" },
|
||||||
|
lifted = if p.freedom_gate_lifted {
|
||||||
|
" gate lifted"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let blame = if p.blame_from.is_empty() {
|
||||||
|
"none".to_string()
|
||||||
|
} else {
|
||||||
|
p.blame_from
|
||||||
|
.iter()
|
||||||
|
.map(|b| seat_name(*b))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
};
|
||||||
|
let _ = write!(
|
||||||
|
out,
|
||||||
|
"<span class=\"k\">blamed by</span> {}<br>",
|
||||||
|
esc(&blame)
|
||||||
|
);
|
||||||
|
match &p.hand {
|
||||||
|
Some(h) => {
|
||||||
|
let _ = write!(
|
||||||
|
out,
|
||||||
|
"<span class=\"k\">hand</span> {} ({} cards)<br>",
|
||||||
|
esc(&cards(h)),
|
||||||
|
p.hand_size
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let _ = write!(
|
||||||
|
out,
|
||||||
|
"<span class=\"k\">hand</span> {} card(s), hidden<br>",
|
||||||
|
p.hand_size
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(sel) = view.selections.get(&id) {
|
||||||
|
let _ = write!(
|
||||||
|
out,
|
||||||
|
"<span class=\"k\">selected</span> {}<br>",
|
||||||
|
match sel {
|
||||||
|
SelectionView::Hidden => "face down".to_string(),
|
||||||
|
SelectionView::Shown(s) => esc(&format!("{s:?}")),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(m) = view.ground_modes.get(&id) {
|
||||||
|
let _ = write!(out, "<span class=\"k\">ground mode</span> {m:?}<br>");
|
||||||
|
}
|
||||||
|
if let Some(c) = view.ground_choices.get(&id) {
|
||||||
|
let _ = write!(out, "<span class=\"k\">ground choice</span> {c:?}<br>");
|
||||||
|
}
|
||||||
|
if let Some(r) = view.support_responses.get(&id) {
|
||||||
|
let _ = write!(out, "<span class=\"k\">support</span> {r:?}<br>");
|
||||||
|
}
|
||||||
|
if let Some(t) = view.darvo_targets.get(&id) {
|
||||||
|
let _ = write!(out, "<span class=\"k\">darvo target</span> {t:?}<br>");
|
||||||
|
}
|
||||||
|
out.push_str("</div>");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the whole table as a standalone document.
|
||||||
|
///
|
||||||
|
/// `may_pass` adds the one affordance that is not a command: declining to
|
||||||
|
/// act where the driver allows it. Like every other element it carries an
|
||||||
|
/// id and nothing else — the page still reports only that the pointer went
|
||||||
|
/// down and up on `pass`.
|
||||||
|
///
|
||||||
|
/// `legal` is the list the aggregate offered; the buttons carry indices
|
||||||
|
/// into it and nothing else (ADR-0007 control 5). `endpoint` carries the
|
||||||
|
/// per-process token (control 1) — the page cannot mint one.
|
||||||
|
pub fn document(
|
||||||
|
view: &GroundView,
|
||||||
|
legal: &[games_ground::GroundCommand],
|
||||||
|
endpoint: &str,
|
||||||
|
seat: Option<PlayerId>,
|
||||||
|
may_pass: bool,
|
||||||
|
) -> String {
|
||||||
|
let mut s = String::with_capacity(8192);
|
||||||
|
let _ = write!(
|
||||||
|
s,
|
||||||
|
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
|
||||||
|
<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
|
||||||
|
<title>GROUND \u{2014} round {round}</title><style>{STYLE}</style></head><body>",
|
||||||
|
round = view.round
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = write!(
|
||||||
|
s,
|
||||||
|
"<h1>GROUND \u{2014} round {round}, step {step:?}</h1>\
|
||||||
|
<div><span class=\"k\">lead</span> {lead} \
|
||||||
|
<span class=\"k\">scoring</span> {mode:?} \
|
||||||
|
<span class=\"k\">viewing as</span> {who}</div>",
|
||||||
|
round = view.round,
|
||||||
|
step = view.step,
|
||||||
|
lead = seat_name(view.lead),
|
||||||
|
mode = view.mode,
|
||||||
|
who = match view.viewer {
|
||||||
|
Some(p) => format!("{} (their hand only)", seat_name(p)),
|
||||||
|
None => "a spectator (no hands)".to_string(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
s.push_str(
|
||||||
|
"<h2>problems</h2><svg width=\"760\" height=\"100\" role=\"img\" aria-label=\"problems\">",
|
||||||
|
);
|
||||||
|
if view.problems.is_empty() {
|
||||||
|
s.push_str(
|
||||||
|
"<text x=\"12\" y=\"52\" fill=\"#89a\" font-size=\"12\">no problems in play</text>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (i, (priority, p)) in view.problems.iter().enumerate() {
|
||||||
|
problem_svg(&mut s, *priority, p, 10 + (i as i32) * 130);
|
||||||
|
}
|
||||||
|
s.push_str("</svg>");
|
||||||
|
|
||||||
|
s.push_str("<h2>relationships</h2>");
|
||||||
|
s.push_str(&relations_svg(view));
|
||||||
|
|
||||||
|
s.push_str("<h2>seats</h2><div class=\"row\">");
|
||||||
|
for (id, p) in &view.players {
|
||||||
|
player_card(&mut s, *id, p, view);
|
||||||
|
}
|
||||||
|
s.push_str("</div>");
|
||||||
|
|
||||||
|
let _ = write!(
|
||||||
|
s,
|
||||||
|
"<h2>solutions</h2><div><span class=\"k\">deck</span> {} remaining \
|
||||||
|
<span class=\"k\">discard</span> {}</div>",
|
||||||
|
view.solution_deck_len,
|
||||||
|
esc(&cards(&view.solution_discard)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(o) = &view.outcome {
|
||||||
|
let personal = o
|
||||||
|
.personal
|
||||||
|
.iter()
|
||||||
|
.map(|(p, v)| format!("{} {v:+}", seat_name(*p)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
let winners = if o.winners.is_empty() {
|
||||||
|
"nobody".to_string()
|
||||||
|
} else {
|
||||||
|
o.winners
|
||||||
|
.iter()
|
||||||
|
.map(|w| seat_name(*w))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
};
|
||||||
|
let coalitions = if o.coalitions.is_empty() {
|
||||||
|
"none".to_string()
|
||||||
|
} else {
|
||||||
|
o.coalitions
|
||||||
|
.iter()
|
||||||
|
.map(|c| format!("{c:?}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
};
|
||||||
|
let _ = write!(
|
||||||
|
s,
|
||||||
|
"<h2>outcome</h2><div class=\"card\">\
|
||||||
|
<span class=\"k\">total</span> {total} of {threshold} \
|
||||||
|
<span class=\"k\">group</span> {group}<br>\
|
||||||
|
<span class=\"k\">personal</span> {personal}<br>\
|
||||||
|
<span class=\"k\">coalitions</span> {coalitions}<br>\
|
||||||
|
<span class=\"k\">mastery</span> {mastery}<br>\
|
||||||
|
<span class=\"k\">winners</span> {winners}</div>",
|
||||||
|
total = o.total,
|
||||||
|
threshold = o.threshold,
|
||||||
|
group = if o.group_success {
|
||||||
|
"success"
|
||||||
|
} else {
|
||||||
|
"failure"
|
||||||
|
},
|
||||||
|
personal = esc(&personal),
|
||||||
|
coalitions = esc(&coalitions),
|
||||||
|
mastery = match o.mastery {
|
||||||
|
Some(m) => format!("{m:+}"),
|
||||||
|
None => "none".to_string(),
|
||||||
|
},
|
||||||
|
winners = esc(&winners),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !legal.is_empty() {
|
||||||
|
s.push_str("<h2>your move</h2><div class=\"row\">");
|
||||||
|
for a in [
|
||||||
|
games_ground::Action::Investigate,
|
||||||
|
games_ground::Action::Solve,
|
||||||
|
games_ground::Action::Support,
|
||||||
|
games_ground::Action::Attack,
|
||||||
|
games_ground::Action::Ground,
|
||||||
|
] {
|
||||||
|
let offered = seat.is_some_and(|seat| {
|
||||||
|
legal.iter().any(|c| {
|
||||||
|
crate::input::affordance(c, seat).is_some_and(|(f, _)| f == action_id(a))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if offered {
|
||||||
|
let _ = write!(
|
||||||
|
s,
|
||||||
|
"<div class=\"card act\" id=\"{id}\">drag {a:?} onto a seat, a problem, \
|
||||||
|
or the table</div>",
|
||||||
|
id = action_id(a),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.push_str("</div><div class=\"row\">");
|
||||||
|
for (i, c) in legal.iter().enumerate() {
|
||||||
|
let spatial = seat.is_some_and(|seat| crate::input::affordance(c, seat).is_some());
|
||||||
|
if !spatial {
|
||||||
|
let _ = write!(
|
||||||
|
s,
|
||||||
|
"<div class=\"card btn\" id=\"cmd-{i}\">{}</div>",
|
||||||
|
esc(&format!("{c:?}"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.push_str(
|
||||||
|
"</div><div class=\"card\" id=\"table\">the table \u{2014} drop here for an \
|
||||||
|
untargeted action</div>",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if may_pass {
|
||||||
|
s.push_str("<div class=\"card btn\" id=\"pass\">pass \u{2014} decline to act</div>");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = write!(
|
||||||
|
s,
|
||||||
|
"<div id=\"cb-status\">ready</div>\
|
||||||
|
<script>window.CB_ENDPOINT={};</script><script>{SCRIPT}</script></body></html>",
|
||||||
|
json_string(endpoint),
|
||||||
|
);
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quote a string as a JSON literal, so a token can never end the script.
|
||||||
|
fn json_string(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len() + 2);
|
||||||
|
out.push('"');
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'<' => out.push_str("\\u003c"),
|
||||||
|
'>' => out.push_str("\\u003e"),
|
||||||
|
'&' => out.push_str("\\u0026"),
|
||||||
|
c if (c as u32) < 0x20 => {
|
||||||
|
let _ = write!(out, "\\u{:04x}", c as u32);
|
||||||
|
}
|
||||||
|
c => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('"');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the document's visible text and element ids.
|
||||||
|
///
|
||||||
|
/// ADR-0007 control 6 requires the coverage gate to assert over the
|
||||||
|
/// **parsed emitted document**, not over the Rust that emits it. This is
|
||||||
|
/// that parse: it drops markup and returns what a reader would see, plus
|
||||||
|
/// the ids a pointer can address. A substring search over the raw source
|
||||||
|
/// would happily find a token inside a comment or a style rule.
|
||||||
|
pub fn text_of(html: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(html.len() / 2);
|
||||||
|
let bytes: Vec<char> = html.chars().collect();
|
||||||
|
let mut i = 0;
|
||||||
|
let mut skip_to: Option<&str> = None;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] == '<' {
|
||||||
|
// Find the tag name.
|
||||||
|
let start = i + 1;
|
||||||
|
let mut j = start;
|
||||||
|
while j < bytes.len() && bytes[j] != '>' {
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
let tag: String = bytes[start..j.min(bytes.len())].iter().collect();
|
||||||
|
let lower = tag.to_ascii_lowercase();
|
||||||
|
let name = lower
|
||||||
|
.trim_start_matches('/')
|
||||||
|
.split([' ', '\t', '\n', '>'])
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
if let Some(want) = skip_to {
|
||||||
|
if lower.starts_with('/') && name == want {
|
||||||
|
skip_to = None;
|
||||||
|
}
|
||||||
|
} else if name == "script" || name == "style" {
|
||||||
|
// Their contents are not text a reader sees.
|
||||||
|
if !lower.starts_with('/') && !lower.ends_with('/') {
|
||||||
|
skip_to = Some(if name == "script" { "script" } else { "style" });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ids are addressable surface, so they count as rendered.
|
||||||
|
if let Some(k) = lower.find("id=\"") {
|
||||||
|
let rest = &tag[k + 4..];
|
||||||
|
if let Some(end) = rest.find('"') {
|
||||||
|
out.push(' ');
|
||||||
|
out.push_str(&rest[..end]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i = j + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if skip_to.is_none() {
|
||||||
|
out.push(bytes[i]);
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
// Unescape the entities esc() introduced, so a test looks for the text
|
||||||
|
// a reader sees rather than its encoding.
|
||||||
|
out.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("&", "&")
|
||||||
|
}
|
||||||
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
333
crates/cb-render-html/src/lib.rs
Normal file
333
crates/cb-render-html/src/lib.rs
Normal file
|
|
@ -0,0 +1,333 @@
|
||||||
|
//! `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;
|
||||||
|
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<String>) {
|
||||||
|
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<String> = 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<String> = 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 = "<style>.x{content:'STYLETOKEN'}</style><p id=\"pid\">seen</p>\
|
||||||
|
<script>var s='SCRIPTTOKEN';</script>";
|
||||||
|
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("<script>")
|
||||||
|
.and_then(|(_, r)| r.rsplit_once("</script>"))
|
||||||
|
.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)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod testfix;
|
||||||
323
crates/cb-render-html/src/serve.rs
Normal file
323
crates/cb-render-html/src/serve.rs
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
//! The loopback listener, and the controls that make it safe to have one.
|
||||||
|
//!
|
||||||
|
//! ADR-0007 Decision 5, controls 1–4. The threat the review named is not
|
||||||
|
//! hypothetical:
|
||||||
|
//!
|
||||||
|
//! > A loopback listener is reachable by any process on the machine **and
|
||||||
|
//! > by any web page the user visits**. The browser running the table is
|
||||||
|
//! > the same browser reading the internet — that is the whole premise of
|
||||||
|
//! > the option. An unauthenticated endpoint accepting `POST /command` and
|
||||||
|
//! > mutating authoritative game state is a remote-controlled game from
|
||||||
|
//! > any tab the user has open.
|
||||||
|
//!
|
||||||
|
//! So: a per-process token no page can guess, an `Origin` /
|
||||||
|
//! `Sec-Fetch-Site` check that rejects by default, an explicit
|
||||||
|
//! `127.0.0.1` bind, and — the control that makes the other three
|
||||||
|
//! evidence rather than claims — a test that a token-less request is
|
||||||
|
//! refused, with a mutation that turns it red.
|
||||||
|
//!
|
||||||
|
//! [`Guard::admit`] is a pure function of a parsed request, so all of that
|
||||||
|
//! is testable with no socket, no browser, and no timing.
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
/// Why a request was refused. Each variant is a control that fired.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Refusal {
|
||||||
|
/// Control 1: no token at all.
|
||||||
|
MissingToken,
|
||||||
|
/// Control 1: a token, but not ours.
|
||||||
|
BadToken,
|
||||||
|
/// Control 2: the request came from somewhere else's page.
|
||||||
|
CrossSite(String),
|
||||||
|
/// Not a shape this endpoint serves.
|
||||||
|
BadRequest(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Refusal {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Refusal::MissingToken => write!(f, "refused: no session token"),
|
||||||
|
Refusal::BadToken => write!(f, "refused: wrong session token"),
|
||||||
|
Refusal::CrossSite(o) => write!(f, "refused: cross-site request from {o}"),
|
||||||
|
Refusal::BadRequest(m) => write!(f, "refused: {m}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parsed HTTP/1.1 request, reduced to the fields the controls need.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct Request {
|
||||||
|
pub method: String,
|
||||||
|
pub path: String,
|
||||||
|
pub token: Option<String>,
|
||||||
|
pub origin: Option<String>,
|
||||||
|
pub sec_fetch_site: Option<String>,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Request {
|
||||||
|
/// Parse a raw request. Deliberately strict: this is hand-rolled HTTP
|
||||||
|
/// on a socket a hostile page can reach, so anything unexpected is a
|
||||||
|
/// refusal rather than a best effort.
|
||||||
|
pub fn parse(raw: &str) -> Result<Self, Refusal> {
|
||||||
|
let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((raw, ""));
|
||||||
|
let mut lines = head.split("\r\n");
|
||||||
|
let start = lines
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| Refusal::BadRequest("empty request".into()))?;
|
||||||
|
let mut parts = start.split(' ');
|
||||||
|
let method = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| Refusal::BadRequest("no method".into()))?
|
||||||
|
.to_string();
|
||||||
|
let target = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| Refusal::BadRequest("no target".into()))?;
|
||||||
|
|
||||||
|
let (path, query) = target.split_once('?').unwrap_or((target, ""));
|
||||||
|
let token = query.split('&').find_map(|kv| {
|
||||||
|
kv.strip_prefix("t=")
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut req = Request {
|
||||||
|
method,
|
||||||
|
path: path.to_string(),
|
||||||
|
token,
|
||||||
|
body: body.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
for line in lines {
|
||||||
|
if let Some((k, v)) = line.split_once(':') {
|
||||||
|
let v = v.trim().to_string();
|
||||||
|
match k.to_ascii_lowercase().as_str() {
|
||||||
|
"origin" => req.origin = Some(v),
|
||||||
|
"sec-fetch-site" => req.sec_fetch_site = Some(v),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Holds the per-process token and applies controls 1 and 2.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Guard {
|
||||||
|
token: String,
|
||||||
|
origin: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Guard {
|
||||||
|
pub fn new(token: impl Into<String>, port: u16) -> Self {
|
||||||
|
Self {
|
||||||
|
token: token.into(),
|
||||||
|
origin: format!("http://127.0.0.1:{port}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint an unguessable token.
|
||||||
|
///
|
||||||
|
/// `/dev/urandom` where it exists; otherwise `RandomState`, whose
|
||||||
|
/// per-process seed the OS randomises. The fallback is weaker and is
|
||||||
|
/// named rather than hidden — a token that silently degrades to
|
||||||
|
/// something predictable is worse than no token, because the controls
|
||||||
|
/// would still report themselves as passing.
|
||||||
|
pub fn mint(port: u16) -> Self {
|
||||||
|
let mut buf = [0u8; 24];
|
||||||
|
let strong = std::fs::File::open("/dev/urandom")
|
||||||
|
.and_then(|mut f| f.read_exact(&mut buf))
|
||||||
|
.is_ok();
|
||||||
|
if !strong {
|
||||||
|
use std::hash::{BuildHasher, Hasher};
|
||||||
|
let s = std::collections::hash_map::RandomState::new();
|
||||||
|
for chunk in buf.chunks_mut(8) {
|
||||||
|
let mut h = s.build_hasher();
|
||||||
|
h.write_usize(std::process::id() as usize);
|
||||||
|
h.write_u128(
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos())
|
||||||
|
.unwrap_or(0),
|
||||||
|
);
|
||||||
|
chunk.copy_from_slice(&h.finish().to_le_bytes()[..chunk.len()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let token: String = buf.iter().map(|b| format!("{b:02x}")).collect();
|
||||||
|
Self::new(token, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn token(&self) -> &str {
|
||||||
|
&self.token
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The URL the page is told to POST to. The page cannot mint one.
|
||||||
|
pub fn endpoint(&self) -> String {
|
||||||
|
format!("/command?t={}", self.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn page_url(&self) -> String {
|
||||||
|
format!("{}/?t={}", self.origin, self.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply controls 1 and 2. Rejects by default.
|
||||||
|
pub fn admit(&self, req: &Request) -> Result<(), Refusal> {
|
||||||
|
// Control 2 first: a cross-site request should be refused before
|
||||||
|
// its token is even considered, so a leaked token is not enough on
|
||||||
|
// its own.
|
||||||
|
match req.sec_fetch_site.as_deref() {
|
||||||
|
// Sent by every modern browser. Anything but same-origin is
|
||||||
|
// not our page.
|
||||||
|
Some("same-origin") | Some("none") => {}
|
||||||
|
Some(other) => return Err(Refusal::CrossSite(other.to_string())),
|
||||||
|
None => {
|
||||||
|
// Older or non-browser clients omit it; fall back to
|
||||||
|
// Origin, and require it to be ours when present.
|
||||||
|
if let Some(o) = &req.origin {
|
||||||
|
if o != &self.origin {
|
||||||
|
return Err(Refusal::CrossSite(o.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(o) = &req.origin {
|
||||||
|
if o != &self.origin {
|
||||||
|
return Err(Refusal::CrossSite(o.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control 1.
|
||||||
|
match &req.token {
|
||||||
|
None => Err(Refusal::MissingToken),
|
||||||
|
Some(t) if constant_eq(t, &self.token) => Ok(()),
|
||||||
|
Some(_) => Err(Refusal::BadToken),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare without an early return on the first differing byte.
|
||||||
|
///
|
||||||
|
/// The endpoint is loopback and the token is 192 bits, so a timing oracle
|
||||||
|
/// is not the realistic attack here. It costs one line.
|
||||||
|
fn constant_eq(a: &str, b: &str) -> bool {
|
||||||
|
if a.len() != b.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
a.bytes()
|
||||||
|
.zip(b.bytes())
|
||||||
|
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
|
||||||
|
== 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Control 3: bind explicitly to loopback, never `0.0.0.0`.
|
||||||
|
pub fn bind(port: u16) -> std::io::Result<std::net::TcpListener> {
|
||||||
|
std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn guard() -> Guard {
|
||||||
|
Guard::new("s3cr3t", 8731)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn req(raw: &str) -> Request {
|
||||||
|
Request::parse(raw).expect("parses")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **ADR-0007 control 4.** The one that makes controls 1–3 evidence
|
||||||
|
/// rather than claims.
|
||||||
|
///
|
||||||
|
/// M-D1-MUT: delete the `None => Err(Refusal::MissingToken)` arm in
|
||||||
|
/// `admit` (return `Ok(())` instead) and this goes red with
|
||||||
|
/// `a token-less request was admitted`. Run 2026-08-02.
|
||||||
|
#[test]
|
||||||
|
fn a_token_less_request_is_refused() {
|
||||||
|
let g = guard();
|
||||||
|
let r = req("POST /command HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\r\ndown=a&up=b");
|
||||||
|
assert_eq!(
|
||||||
|
g.admit(&r),
|
||||||
|
Err(Refusal::MissingToken),
|
||||||
|
"a token-less request was admitted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_wrong_token_is_refused() {
|
||||||
|
let g = guard();
|
||||||
|
let r = req("POST /command?t=guess HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\r\n");
|
||||||
|
assert_eq!(g.admit(&r), Err(Refusal::BadToken));
|
||||||
|
// A prefix of the real token must not be admitted either.
|
||||||
|
let r = req("POST /command?t=s3c HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\r\n");
|
||||||
|
assert_eq!(g.admit(&r), Err(Refusal::BadToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_right_token_from_our_own_page_is_admitted() {
|
||||||
|
let g = guard();
|
||||||
|
let r = req(
|
||||||
|
"POST /command?t=s3cr3t HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\
|
||||||
|
Origin: http://127.0.0.1:8731\r\n\r\ndown=a&up=b",
|
||||||
|
);
|
||||||
|
assert_eq!(g.admit(&r), Ok(()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The attack the review named: a page on the open internet POSTs to
|
||||||
|
/// `127.0.0.1`. Even holding a leaked token, it must not be admitted.
|
||||||
|
#[test]
|
||||||
|
fn a_cross_site_request_is_refused_even_with_the_right_token() {
|
||||||
|
let g = guard();
|
||||||
|
let r = req(
|
||||||
|
"POST /command?t=s3cr3t HTTP/1.1\r\nSec-Fetch-Site: cross-site\r\n\
|
||||||
|
Origin: https://evil.example\r\n\r\ndown=a&up=b",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(g.admit(&r), Err(Refusal::CrossSite(_))),
|
||||||
|
"{:?}",
|
||||||
|
g.admit(&r)
|
||||||
|
);
|
||||||
|
|
||||||
|
// A client that omits Sec-Fetch-Site but sends a foreign Origin is
|
||||||
|
// the same attack with an older browser.
|
||||||
|
let r = req(
|
||||||
|
"POST /command?t=s3cr3t HTTP/1.1\r\nOrigin: https://evil.example\r\n\r\ndown=a&up=b",
|
||||||
|
);
|
||||||
|
assert!(matches!(g.admit(&r), Err(Refusal::CrossSite(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requests_parse_and_carry_what_the_controls_need() {
|
||||||
|
let r = req("POST /command?t=abc HTTP/1.1\r\nHost: 127.0.0.1:8731\r\n\
|
||||||
|
Origin: http://127.0.0.1:8731\r\nSec-Fetch-Site: same-origin\r\n\
|
||||||
|
Content-Length: 9\r\n\r\ndown=a&up=b");
|
||||||
|
assert_eq!(r.method, "POST");
|
||||||
|
assert_eq!(r.path, "/command");
|
||||||
|
assert_eq!(r.token.as_deref(), Some("abc"));
|
||||||
|
assert_eq!(r.origin.as_deref(), Some("http://127.0.0.1:8731"));
|
||||||
|
assert_eq!(r.sec_fetch_site.as_deref(), Some("same-origin"));
|
||||||
|
assert_eq!(r.body, "down=a&up=b");
|
||||||
|
// An empty t= is no token, not a token that happens to be empty.
|
||||||
|
let r = req("GET /?t= HTTP/1.1\r\n\r\n");
|
||||||
|
assert_eq!(r.token, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minted token must not be a constant, and must not be short.
|
||||||
|
#[test]
|
||||||
|
fn a_minted_token_is_long_and_not_reused() {
|
||||||
|
let a = Guard::mint(1).token().to_string();
|
||||||
|
let b = Guard::mint(1).token().to_string();
|
||||||
|
assert_eq!(a.len(), 48, "token is {} hex chars", a.len());
|
||||||
|
assert_ne!(a, b, "two mints produced the same token");
|
||||||
|
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Control 3, asserted rather than commented.
|
||||||
|
#[test]
|
||||||
|
fn the_listener_binds_loopback_only() {
|
||||||
|
let l = bind(0).expect("bind");
|
||||||
|
assert_eq!(l.local_addr().unwrap().ip().to_string(), "127.0.0.1");
|
||||||
|
}
|
||||||
|
}
|
||||||
118
crates/cb-render-html/src/testfix.rs
Normal file
118
crates/cb-render-html/src/testfix.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
//! A populated `GroundView`, built for the coverage gate.
|
||||||
|
//!
|
||||||
|
//! Mid-DARVO, mid-GROUND, scored — **not** a fresh deal. Most of the
|
||||||
|
//! fields this gate exists to cover are empty at deal time, and a coverage
|
||||||
|
//! test run against a state where the fields are absent is the same lie in
|
||||||
|
//! a different costume (CB-WP-0011 T01).
|
||||||
|
//!
|
||||||
|
//! Built directly rather than projected from a `GroundState`. The
|
||||||
|
//! projection's own hiding rules are already asserted by
|
||||||
|
//! `games_ground::view` and by `cb-play`'s
|
||||||
|
//! `the_inspector_never_widens_the_projection`; re-asserting them here
|
||||||
|
//! would be a duplicated fact that drifts. What *this* crate must not do
|
||||||
|
//! is invent a hand it was never given, and that is what the renderer test
|
||||||
|
//! checks — with `hand: None` supplied deliberately.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use cb_kernel::PlayerId;
|
||||||
|
use games_ground::view::{GroundView, OutcomeView, PlayerView, ProblemView, SelectionView};
|
||||||
|
use games_ground::{
|
||||||
|
Action, Coalition, DarvoStage, DarvoTarget, GroundChoice, GroundMode, Pair, Relation,
|
||||||
|
RoundStep, ScoringMode, Selection, SolutionCard, Suit, SupportResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn seats() -> (PlayerId, PlayerId, PlayerId) {
|
||||||
|
(PlayerId(0), PlayerId(1), PlayerId(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixture, seen by `viewer` (`None` for a spectator).
|
||||||
|
pub fn view(viewer: Option<PlayerId>) -> GroundView {
|
||||||
|
let (p1, p2, p3) = seats();
|
||||||
|
let card = |suit| SolutionCard { suit };
|
||||||
|
|
||||||
|
let mut players = BTreeMap::new();
|
||||||
|
for (id, stress, protection, darvo, ready, lifted, blame) in [
|
||||||
|
(p1, 5u8, 2u8, DarvoStage::Reverse, true, true, vec![p3]),
|
||||||
|
(p2, 1, 0, DarvoStage::Off, false, false, vec![]),
|
||||||
|
(p3, 0, 0, DarvoStage::Deny, true, false, vec![]),
|
||||||
|
] {
|
||||||
|
let own = viewer == Some(id);
|
||||||
|
players.insert(
|
||||||
|
id,
|
||||||
|
PlayerView {
|
||||||
|
stress,
|
||||||
|
freedom_ready: ready,
|
||||||
|
freedom_gate_lifted: lifted,
|
||||||
|
darvo,
|
||||||
|
protection,
|
||||||
|
blame_from: blame,
|
||||||
|
hand: own.then(|| vec![card(Suit::Clarify), card(Suit::Boundary)]),
|
||||||
|
hand_size: 2,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut problems = BTreeMap::new();
|
||||||
|
problems.insert(1, ProblemView::FaceDown);
|
||||||
|
problems.insert(
|
||||||
|
7,
|
||||||
|
ProblemView::FaceUp {
|
||||||
|
suit: Suit::Change,
|
||||||
|
value: 6,
|
||||||
|
denied: true,
|
||||||
|
claimed_by: Some(p2),
|
||||||
|
protected_this_round: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
GroundView {
|
||||||
|
viewer,
|
||||||
|
round: 3,
|
||||||
|
lead: p2,
|
||||||
|
step: RoundStep::Select,
|
||||||
|
mode: ScoringMode::BondedCoalitions,
|
||||||
|
players,
|
||||||
|
relations: BTreeMap::from([
|
||||||
|
(Pair::new(p1, p2), Relation::Rivalry),
|
||||||
|
(Pair::new(p2, p3), Relation::Bond),
|
||||||
|
]),
|
||||||
|
problems,
|
||||||
|
focus: BTreeMap::from([(p1, p3)]),
|
||||||
|
selections: BTreeMap::from([
|
||||||
|
(
|
||||||
|
p1,
|
||||||
|
SelectionView::Shown(Selection {
|
||||||
|
action: Action::Attack,
|
||||||
|
target: Some(p2),
|
||||||
|
problem: Some(7),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
(p2, SelectionView::Hidden),
|
||||||
|
]),
|
||||||
|
ground_modes: BTreeMap::from([(p3, GroundMode::Gr)]),
|
||||||
|
ground_choices: BTreeMap::from([(p3, GroundChoice::ProtectProblem { problem: 7 })]),
|
||||||
|
support_responses: BTreeMap::from([(p2, SupportResponse::AcceptBond)]),
|
||||||
|
darvo_targets: BTreeMap::from([(
|
||||||
|
p3,
|
||||||
|
DarvoTarget {
|
||||||
|
problem: Some(1),
|
||||||
|
player: Some(p2),
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
solution_deck_len: 17,
|
||||||
|
solution_discard: vec![card(Suit::Repair), card(Suit::Change)],
|
||||||
|
outcome: Some(OutcomeView {
|
||||||
|
total: 9,
|
||||||
|
threshold: 12,
|
||||||
|
group_success: false,
|
||||||
|
personal: BTreeMap::from([(p1, 3), (p2, -1), (p3, 0)]),
|
||||||
|
coalitions: vec![Coalition {
|
||||||
|
members: vec![p2, p3],
|
||||||
|
score: 4,
|
||||||
|
}],
|
||||||
|
mastery: Some(1),
|
||||||
|
winners: vec![p1],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
173
decisions/ADR-0007-render-html-not-a-port.md
Normal file
173
decisions/ADR-0007-render-html-not-a-port.md
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
# ADR-0007: render to HTML, and do not declare the port yet
|
||||||
|
|
||||||
|
status: accepted
|
||||||
|
date: 2026-08-02
|
||||||
|
decided by: agent, under the standing loop authorization. **Two items are
|
||||||
|
explicitly reserved for the maintainer and are not decided here** — see
|
||||||
|
§Reserved.
|
||||||
|
tier: L (structural L — the declaration was made when this pass would
|
||||||
|
create a capability port; chaos d4=1 → no override. Review removed the
|
||||||
|
port, and the tier was **not** re-derived — see §Consequences)
|
||||||
|
references: [CB-RES-0006](../research/CB-RES-0006-render-port.md),
|
||||||
|
`history/260802-render-port-{research,challenge,response}.md`,
|
||||||
|
[INTENT.md](../INTENT.md) §Implementation order,
|
||||||
|
[ArchitectureBlueprint.md](../specs/ArchitectureBlueprint.md) §Port pattern,
|
||||||
|
[ADR-0004](ADR-0004-am4-ratification.md) (AM-4 budgets),
|
||||||
|
[CB-EV-0009](../evidence/CB-EV-0009-inspectable-table.md) (the inspector)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
INTENT stage 1 is *"Inspectable 2D table — card/token/hand/relationship-
|
||||||
|
graph visualization, drag-to-propose, debug inspector, hot-seat play."*
|
||||||
|
CB-WP-0011 shipped the debug inspector. This decides how the other three
|
||||||
|
get drawn.
|
||||||
|
|
||||||
|
The obvious shape — `cb-render-api` / `cb-render-null` / `cb-render-wgpu`,
|
||||||
|
straight from ArchitectureBlueprint §Port pattern — did not survive review.
|
||||||
|
Two things went wrong with the survey that produced it, and both are
|
||||||
|
recorded because the decision below is only trustworthy given what was
|
||||||
|
wrong with the reasoning that first reached it.
|
||||||
|
|
||||||
|
## Decision 1 — render by emitting HTML, SVG and JavaScript
|
||||||
|
|
||||||
|
`cb-render-html` emits a document; the browser draws it. SVG covers cards,
|
||||||
|
tokens and the relationship graph natively — the graph being the one
|
||||||
|
element every Rust 2D toolkit would have left us to hand-roll. Pointer
|
||||||
|
events cover drag-to-propose. One tab covers hot-seat.
|
||||||
|
|
||||||
|
**Rejected alternatives, with measured marginal cost against the
|
||||||
|
23-crate shipped-runtime base:**
|
||||||
|
|
||||||
|
| rejected | marginal lines | why not |
|
||||||
|
|---|---:|---|
|
||||||
|
| `egui` + `eframe` | 2,782,849 | 30× the corrected headroom |
|
||||||
|
| `wgpu` + `winit` | 1,741,979 | stage 2's, and stage 2 should buy it |
|
||||||
|
| `softbuffer` + `tiny-skia` + `winit` | 1,147,081 | windowing dominates the rasterizer |
|
||||||
|
| `ratatui` + `crossterm` | 1,067,013 | costs more than a GPU game framework; `rustix` → `linux-raw-sys` at 479,901 |
|
||||||
|
| `macroquad` | 480,501 | cheapest GPU-era stack, still 5.2× |
|
||||||
|
| `sdl2` | 191,973 + a C library | see Decision 3 |
|
||||||
|
| **`fltk`** | **140,079** + a C library | **the real floor. Affordable if the target moved — rejected on allocation, not cost** |
|
||||||
|
| `tiny-skia` alone | 83,956 | fits, but rasterizes without windowing or input |
|
||||||
|
|
||||||
|
**The reason is allocation, not affordability.** `fltk` is 1.5× the
|
||||||
|
corrected headroom — a negotiation, not an impossibility. But `wgpu` at
|
||||||
|
1,741,979 is named by INTENT for stage 2, is unavoidable, and is **twelve
|
||||||
|
times** the cost of the stage-1 toolkit it would replace. Spending 1.5× the
|
||||||
|
remaining budget on windowing that stage 2 discards is the wrong
|
||||||
|
allocation whether or not the budget can be stretched. This argument does
|
||||||
|
not depend on AM-4a's target being 250,000; it depends only on stage 2's
|
||||||
|
bill exceeding stage 1's benefit, which is measured and true by 12×.
|
||||||
|
|
||||||
|
The first draft of the survey argued affordability — "128×, two orders of
|
||||||
|
magnitude, not a near miss." That was false, drawn from a candidate list
|
||||||
|
containing no cheap windowed toolkits. It is recorded here because a
|
||||||
|
decision whose stated reason was wrong once should carry the correction
|
||||||
|
where the decision lives, not only where the survey does.
|
||||||
|
|
||||||
|
## Decision 2 — do **not** declare `cb-render-api` or `cb-render-null`
|
||||||
|
|
||||||
|
INTENT: *"No concept becomes canonical merely because it looks general. It
|
||||||
|
becomes canonical after surviving a second concrete use."*
|
||||||
|
|
||||||
|
A port designed against one implementation that emits whole documents
|
||||||
|
acquires a document's shape — retained mode, full state per update, string
|
||||||
|
identity, no frame timing — and stage 2 finds it unimplementable and
|
||||||
|
rewrites it. `CommitWindow` is already in this repo with a delete-by date
|
||||||
|
and two declined second-use opportunities, for exactly this error.
|
||||||
|
|
||||||
|
`cb-render-html` therefore ships against the **existing `Project` trait**,
|
||||||
|
which is a real interface with real implementations. The port is declared
|
||||||
|
at stage 2, derived from two implementations rather than imagined from one.
|
||||||
|
`cb-render-null` goes with it: a null implementation of an interface that
|
||||||
|
does not exist is a null implementation of nothing.
|
||||||
|
|
||||||
|
**This overrides ArchitectureBlueprint §Port pattern for this capability,
|
||||||
|
and only for its timing.** The blueprint says every important capability
|
||||||
|
ships null, reference and optimized implementations; it does not say when
|
||||||
|
the interface is fixed. Where the two rules met, INTENT's second-use rule
|
||||||
|
won, because it is the one INTENT states without exception.
|
||||||
|
|
||||||
|
## Decision 3 — AM-4 counts what the project causes to be acquired
|
||||||
|
|
||||||
|
Scoring the browser at zero is true under AM-4a and suspect in substance.
|
||||||
|
If a target can be satisfied by relocating a dependency somewhere the
|
||||||
|
target does not look, it constrains nothing — and the same move was already
|
||||||
|
flattering `sdl2` and `fltk`, whose `.rs` counts exclude the C libraries
|
||||||
|
that do the work.
|
||||||
|
|
||||||
|
> **AM-4 counts third-party code the project causes to be acquired.** It
|
||||||
|
> does not count runtimes the user already has independently of us — the
|
||||||
|
> kernel, the system C library, the shell, the terminal, a web browser. It
|
||||||
|
> **does** count a library our build or install instructions cause to be
|
||||||
|
> fetched, pinned, or linked, whether or not its source is Rust.
|
||||||
|
|
||||||
|
Consequences, applied consistently: a browser is not counted; **`sdl2` and
|
||||||
|
`fltk` are counted at more than their Rust bindings**, since they oblige a
|
||||||
|
user to install a `-dev` package. The rule makes the target harder to
|
||||||
|
satisfy, not easier, which is the only direction a rule proposed by its
|
||||||
|
beneficiary should be trusted to run.
|
||||||
|
|
||||||
|
## Decision 4 — AM-4a's proc-macro defect is a separate change
|
||||||
|
|
||||||
|
AM-4a counts `--edges normal`, which includes proc-macro crates that run in
|
||||||
|
the compiler and never reach a binary: `syn` (66,916), `proc-macro2`,
|
||||||
|
`quote`, `unicode-ident`, `serde_derive` — **89,048 lines, 36.2%** of the
|
||||||
|
"shipped-runtime" figure. Real headroom is **92,798**, not the 3,750 this
|
||||||
|
repo has cited in every pass that mentioned it, including CB-WP-0011's
|
||||||
|
reason for deferring this declaration.
|
||||||
|
|
||||||
|
The metric should count `--edges normal,no-proc-macro`. **Filed as its own
|
||||||
|
change, not bundled here.** A budget correction that arrives attached to the
|
||||||
|
request it unblocks is indistinguishable from motivated reasoning even when
|
||||||
|
it is right — and it is right, which is why it must arrive separately.
|
||||||
|
|
||||||
|
## Decision 5 — six controls bind the implementation
|
||||||
|
|
||||||
|
| # | control | why |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | unguessable token minted per process, required on every request | a loopback listener is reachable by any process **and any web page the user visits** |
|
||||||
|
| 2 | `Origin` / `Sec-Fetch-Site` checked, rejected by default | cross-origin POST to `127.0.0.1` is the attack, not a hypothetical |
|
||||||
|
| 3 | listener bound explicitly to `127.0.0.1` | not `0.0.0.0` by default |
|
||||||
|
| 4 | **a test that a token-less request is refused**, plus an M-D1-MUT mutation removing the check and turning it red | 1–3 without 4 are three claims and no evidence |
|
||||||
|
| 5 | **JavaScript may not construct commands** — the page reports raw pointer facts (`down on id`, `up on id`); Rust decides what command they mean | confines JS to input transport, making the decision testable in Rust against synthetic events |
|
||||||
|
| 6 | **the coverage gate crosses the language boundary** — the HTML counterpart of `every_view_field_is_classified` asserts over the **parsed emitted document** | asserting over the Rust that emits the document reproduces CB-WP-0011's defect one layer up |
|
||||||
|
|
||||||
|
Controls 5 and 6 exist because CB-WP-0011 established that a renderer's
|
||||||
|
defect class is silent omission, and this decision moves the interactive
|
||||||
|
half of stage 1 into a language `cargo test`, `clippy` and `M-D1-MUT`
|
||||||
|
cannot reach. Without them, this ADR would spend a pass's finding one pass
|
||||||
|
after paying for it.
|
||||||
|
|
||||||
|
**K13 binds unchanged.** The renderer consumes a projection and can never
|
||||||
|
feed back into validation; a drag that proposes a move goes through the
|
||||||
|
same command path a CLI move takes.
|
||||||
|
|
||||||
|
## Reserved for the maintainer — not decided here
|
||||||
|
|
||||||
|
1. **AM-4a is incompatible with INTENT stage 2.** `wgpu` + `winit` is
|
||||||
|
1,741,979 marginal lines against a **250,000** total target — 7× the
|
||||||
|
whole budget, 19× the corrected headroom. No sequencing, feature-gating
|
||||||
|
or metric correction closes that. Either the target moves, the render
|
||||||
|
port sits outside the AM-4a configuration by an argued rule, or stage 2
|
||||||
|
changes. **A pass that discovers a budget conflict and also settles it
|
||||||
|
has reviewed nothing**, so this pass does not settle it.
|
||||||
|
2. **Whether Decision 3's acquisition rule is the right rule**, given it is
|
||||||
|
proposed by the pass that benefits from it. It is written to cost more
|
||||||
|
than it saves, but that is an argument, not a ratification.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Stage 1 ships without a capability port. That is a deliberate deferral
|
||||||
|
with a named trigger (stage 2's `wgpu` implementation), not an omission.
|
||||||
|
- **The tier is now wrong and was not re-derived.** Tier L's structural
|
||||||
|
trigger was "creates a new capability port"; review removed the port.
|
||||||
|
Re-rolling would make the tier a function of the outcome, which is the
|
||||||
|
one thing a tier declaration must not be. The pass runs at L. This is the
|
||||||
|
CHAOS calibration window's second entry, and unlike the first it comes
|
||||||
|
from a *non*-override: full-weight review deleted its own trigger.
|
||||||
|
- A second language enters the build, gated only by control 6. If the
|
||||||
|
emitted JavaScript grows beyond input transport, control 5 has failed and
|
||||||
|
the decision should be revisited rather than the control widened.
|
||||||
|
- `cb-play` acquires a third mode. CB-EV-0009 §5 flagged that a third mode
|
||||||
|
is the second use at which its single-binary shape should be
|
||||||
|
reconsidered; that reconsideration is now due.
|
||||||
216
evidence/CB-EV-0010-render-port.md
Normal file
216
evidence/CB-EV-0010-render-port.md
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
# CB-EV-0010 — tier L at full weight, and what it deleted
|
||||||
|
|
||||||
|
CB-WP-0012 T05. Measured 2026-08-02 at `84d6886`. Pass kind `product`.
|
||||||
|
|
||||||
|
The previous pass was the CHAOS gate's first fire, rolling this same
|
||||||
|
declaration from L down to S. This one rolled a 1 and ran at L. So for the
|
||||||
|
first time there are two passes on the same subject at two tiers, and the
|
||||||
|
comparison is the point.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What full-weight review actually did
|
||||||
|
|
||||||
|
**It deleted the thing the pass was declared to build.**
|
||||||
|
|
||||||
|
The declaration's structural trigger was *"creates a new capability port."*
|
||||||
|
The survey recommended `cb-render-api` + `cb-render-null` +
|
||||||
|
`cb-render-html`. The adversarial review (C3) found that recommendation
|
||||||
|
contradicted the survey's own citation of INTENT's second-use rule, and
|
||||||
|
the response conceded entirely. **What shipped has no port and no null
|
||||||
|
implementation** — a renderer against the existing `Project` trait, and a
|
||||||
|
deferral with a named trigger.
|
||||||
|
|
||||||
|
That is the strongest evidence yet that step 2 is not ceremony. A tier-S
|
||||||
|
pass has no step 2, would have shipped `cb-render-api`, and stage 2 would
|
||||||
|
have found it unimplementable and rewritten it — which is exactly what
|
||||||
|
`CommitWindow` is already on record in this repo for doing.
|
||||||
|
|
||||||
|
**Four of six challenges were conceded, not answered.** Two of them
|
||||||
|
invalidated the survey's main arguments:
|
||||||
|
|
||||||
|
| challenge | outcome |
|
||||||
|
|---|---|
|
||||||
|
| C1 the sub-100k region was never measured | conceded; **it is not empty** |
|
||||||
|
| C2 "cost zero" scored on an axis chosen to produce zero | conceded; one rule now covers browser, `sdl2` and `fltk` alike |
|
||||||
|
| C3 the port contradicts the second-use rule | conceded; **port withdrawn** |
|
||||||
|
| C6 the C1 measurements had no positive control | conceded; re-measured under it |
|
||||||
|
| C4 loopback server undersold | controls adopted into ADR-0007 |
|
||||||
|
| C5 the gate stops at the language boundary | controls adopted into ADR-0007 |
|
||||||
|
|
||||||
|
**Fidelity note, which caps all of the above.** The review ran in the same
|
||||||
|
session as the survey, not a separate one, because this environment's
|
||||||
|
standing instruction is not to spawn agents unasked. It therefore inherits
|
||||||
|
the author's sampling — the exact failure mode that produced CB-WP-0002's
|
||||||
|
dedup blind spot and CB-WP-0005's AM-7 defect. Treat the six challenges as
|
||||||
|
a lower bound.
|
||||||
|
|
||||||
|
## 2. The numbers the survey got wrong, and by how much
|
||||||
|
|
||||||
|
The survey's first draft argued the render port was unaffordable:
|
||||||
|
|
||||||
|
> *"The cheapest candidate that opens a window costs 128 times the entire
|
||||||
|
> remaining budget. This is not a near miss to be negotiated; it is two
|
||||||
|
> orders of magnitude."*
|
||||||
|
|
||||||
|
Both halves were wrong, in the same direction, for two independent
|
||||||
|
reasons — and they compound:
|
||||||
|
|
||||||
|
| | claimed | measured |
|
||||||
|
|---|---:|---:|
|
||||||
|
| AM-4a headroom | 3,750 | **92,798** (36.2% of the figure is proc-macro code that never ships) |
|
||||||
|
| cheapest windowed toolkit | 480,501 (`macroquad`) | **140,079** (`fltk`) |
|
||||||
|
| ratio | 128× | **1.5×** |
|
||||||
|
|
||||||
|
The instrument was wrong by 25×, the candidate sample by 3.4×, and the
|
||||||
|
headline claim by 85×. The 3,750 figure has been cited in every pass that
|
||||||
|
mentioned AM-4a, CB-WP-0011's reason for deferring this declaration
|
||||||
|
included.
|
||||||
|
|
||||||
|
**The recommendation survived both corrections**, and only because it was
|
||||||
|
re-derived rather than defended: the argument moved from *affordability*
|
||||||
|
to *allocation* — stage 2's `wgpu` bill is **1,741,979** marginal lines,
|
||||||
|
twelve times the stage-1 toolkit it would replace, so buying `fltk` means
|
||||||
|
spending 1.5× the remaining budget on something stage 2 discards. That
|
||||||
|
argument needs no particular value for AM-4a's target, which is why it is
|
||||||
|
worth more than the one it replaced.
|
||||||
|
|
||||||
|
## 3. AM-4a cannot survive stage 2 — raised, not decided
|
||||||
|
|
||||||
|
| | marginal lines |
|
||||||
|
|---|---:|
|
||||||
|
| AM-4a target, **total** | 250,000 |
|
||||||
|
| `wgpu` + `winit`, named by INTENT stage 2 | **1,741,979** |
|
||||||
|
|
||||||
|
**7× the entire target, 19× the corrected headroom.** No sequencing,
|
||||||
|
feature-gating or metric correction closes that. AM-4a as targeted is
|
||||||
|
incompatible with INTENT as written, and has been since both were written;
|
||||||
|
nothing in this pass caused it.
|
||||||
|
|
||||||
|
Reserved for the maintainer along with the acquisition rule of ADR-0007
|
||||||
|
Decision 3 — which is proposed by the pass that benefits from it, is
|
||||||
|
written to cost more than it saves, and is still an argument rather than a
|
||||||
|
ratification.
|
||||||
|
|
||||||
|
## 4. What shipped, and what has never been run
|
||||||
|
|
||||||
|
`cb-render-html`: HTML/SVG/JS emission, pointer-fact resolution, and a
|
||||||
|
loopback listener. `cb-play --serve PORT`. **Measured** marginal cost:
|
||||||
|
|
||||||
|
```
|
||||||
|
games-ground shipped: 23 third-party crates
|
||||||
|
cb-render-html: 23 third-party crates
|
||||||
|
new crates introduced: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
AM-4a unmoved at 246,250. Own source 7,636 → 9,652.
|
||||||
|
|
||||||
|
Eight mutations run, each red for its stated reason. Two results worth
|
||||||
|
more than the six that behaved:
|
||||||
|
|
||||||
|
- **The coverage gate fired on its author again**, first run, before
|
||||||
|
commit: `ground_choices.*.choice`, `ground_choices.*.problem` and
|
||||||
|
`players.*.blame_from` were in neither list. The third is the one to
|
||||||
|
keep — an **empty vector is a leaf path of its own**, and a fixture
|
||||||
|
where every collection is populated would never have produced it. It
|
||||||
|
now renders as an explicit absence (`blamed by none`).
|
||||||
|
- **One mutation did not go red.** Removing the `Sec-Fetch-Site` arm alone
|
||||||
|
left the cross-site test green: the `Origin` check caught it
|
||||||
|
independently. Both had to go before the control bit. Defence in depth
|
||||||
|
is fine; a control that passes for a reason you did not intend has not
|
||||||
|
been demonstrated, and would have been reported as a clean result by
|
||||||
|
anyone who ran one mutation and stopped.
|
||||||
|
|
||||||
|
### What has never been executed
|
||||||
|
|
||||||
|
**The emitted JavaScript has never run.** Every test is on the Rust side:
|
||||||
|
the socket loop is driven by synthetic HTTP, and the page is asserted
|
||||||
|
against as a parsed document. No browser engine has executed `SCRIPT`.
|
||||||
|
|
||||||
|
So "hot-seat play" is delivered in the sense that the Rust half is tested
|
||||||
|
end-to-end over a real socket and the page is emitted correctly — and
|
||||||
|
**not** in the sense that anyone has played a game in a browser. Control 5
|
||||||
|
bounds the exposure (the JS is 20 lines and holds no game logic, by
|
||||||
|
assertion) but does not remove it.
|
||||||
|
|
||||||
|
**INTENT stage 1 is therefore left open.** Its four named deliverables now
|
||||||
|
all exist, which is the first time that has been true — but a stage marked
|
||||||
|
complete on the strength of code that has never been run once is the
|
||||||
|
failure mode stage 0 avoided by leaving the CLI player open for three
|
||||||
|
passes. Closing it is the maintainer's call, and it needs one person to
|
||||||
|
open the URL.
|
||||||
|
|
||||||
|
## 5. Cost, shape, and a prediction that held
|
||||||
|
|
||||||
|
| pass | kind | responses | cost | $/response |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| CB-WP-0008 | product | 134 | $17.38 | 0.123 |
|
||||||
|
| CB-WP-0009 | meta | 46 | $11.31 | 0.246 |
|
||||||
|
| CB-WP-0010 | product | 26 | $4.08 | 0.157 |
|
||||||
|
| CB-WP-0011 | product | 71 | $7.02 | **0.099** |
|
||||||
|
| **CB-WP-0012** | **product** | **72** | **$8.82** | **0.123** |
|
||||||
|
|
||||||
|
**Correcting CB-EV-0009 §4.** It reported CB-WP-0011 as `45 responses,
|
||||||
|
$4.23, 0.094 $/response` and called it the cheapest pass on record. The
|
||||||
|
final figures are **71 responses, $7.02, 0.099**. Still the cheapest, so
|
||||||
|
the conclusion holds — but the number does not, and this is the *second*
|
||||||
|
consecutive pass to report its own cost low mid-flight (CB-EV-0008 did the
|
||||||
|
same for CB-WP-0009). Twice is a pattern: **a pass cannot measure its own
|
||||||
|
cost, and every evidence file that quotes its own is quoting a floor.**
|
||||||
|
Future evidence files should quote the prior pass's final figure and mark
|
||||||
|
their own as provisional.
|
||||||
|
|
||||||
|
This tier-L pass cost 0.123 $/response against the rolled-down pass's
|
||||||
|
0.099 — **24% more per response**, for a pass that also deleted its own
|
||||||
|
deliverable and found two errors of a factor of 25 and 85. That is the
|
||||||
|
first priced comparison of the two tiers on the same subject, and on this
|
||||||
|
one data point step 2 is cheap.
|
||||||
|
|
||||||
|
**Meta budget: 0%**, exactly as CB-EV-0009 §4 predicted:
|
||||||
|
|
||||||
|
> *"if the next pass is `product`, the trailing-3 meta share drops to 0%,
|
||||||
|
> because CB-WP-0009 will be the pass that rolled off. If it does not, the
|
||||||
|
> windowing is wrong in a way neither CB-EV-0007 §3 nor CB-EV-0008 §1
|
||||||
|
> found."*
|
||||||
|
|
||||||
|
Falsifiable, published in advance, and it held. The windowing is doing
|
||||||
|
what ADR-0006 D1 says it does.
|
||||||
|
|
||||||
|
### Session shape
|
||||||
|
|
||||||
|
| metric | this pass | target |
|
||||||
|
|---|---|---|
|
||||||
|
| SH-1 mean context | 209,486 `[SOFT]` | ≤ 200,000 soft / 300,000 hard |
|
||||||
|
| SH-2 p90 context | 210,760 `[ok]` | ≤ 300,000 / 450,000 |
|
||||||
|
| SH-3 batching | 0.0% `[SOFT]` | ≥ 20% |
|
||||||
|
|
||||||
|
SH-1 drifted back over the soft line — a tier-L pass reads more than a
|
||||||
|
tier-S one, which is the mechanism, not an excuse. The standing prediction
|
||||||
|
from CB-EV-0009 (*"the next pass opened above the SH-1 hard line will cost
|
||||||
|
more per response than 0.123"*) is **untested**: this pass opened after a
|
||||||
|
compaction, below the line.
|
||||||
|
|
||||||
|
**SH-3 has now read 0.0% for six consecutive passes** against a 20% floor.
|
||||||
|
It is the oldest unargued number in the project: either the floor is wrong
|
||||||
|
or the behaviour is, and no pass has argued either. It is past time this
|
||||||
|
was a declaration of its own rather than a line in an evidence file.
|
||||||
|
|
||||||
|
## 6. Open
|
||||||
|
|
||||||
|
- **INTENT stage 1 stays open** — all four deliverables exist; none of the
|
||||||
|
browser half has been run. §4.
|
||||||
|
- **Two items reserved for the maintainer**: AM-4a vs stage 2, and
|
||||||
|
ADR-0007 Decision 3's acquisition rule. §3.
|
||||||
|
- **CHAOS gets its second entry**, and it is the more interesting one: the
|
||||||
|
first came from an override, this one from a *non*-override that deleted
|
||||||
|
its own structural trigger. Window open to 2026-09-30; 7 of 12
|
||||||
|
declarations used, 1 override.
|
||||||
|
- **GATE-REVIEW still has zero `caught`**, two passes older.
|
||||||
|
- **SH-3 at 0.0% for six passes.** §5.
|
||||||
|
- **`cb-play` is now three modes in one binary** — play, inspect, serve.
|
||||||
|
CB-EV-0009 §5 named the third mode as the second use at which the
|
||||||
|
single-binary shape should be reconsidered. That is now due, and this
|
||||||
|
pass did not do it.
|
||||||
|
- **The proc-macro correction to AM-4a is filed and unimplemented.**
|
||||||
|
ADR-0007 Decision 4. Until it lands, every AM-4a figure in this repo
|
||||||
|
overstates the load by 36%.
|
||||||
|
|
@ -122,6 +122,7 @@ added = "2026-07-30"
|
||||||
review_by = "2026-09-30"
|
review_by = "2026-09-30"
|
||||||
caught = [
|
caught = [
|
||||||
"CB-WP-0011: first fire in 6 declarations — d4=4 rolled stage 1 from structural L to S; the deleted survey would have opened on 2D toolkits while the existing text renderer was showing 24 of 41 view fields (CB-EV-0009 §1)",
|
"CB-WP-0011: first fire in 6 declarations — d4=4 rolled stage 1 from structural L to S; the deleted survey would have opened on 2D toolkits while the existing text renderer was showing 24 of 41 view fields (CB-EV-0009 §1)",
|
||||||
|
"CB-WP-0012: d4=1, no override — and the contrast is the entry. Tier L at full weight deleted its own structural trigger: adversarial review withdrew the capability port the declaration was made to build (ADR-0007 D2), and corrected the survey's headline claim by 85x (128x -> 1.5x, CB-EV-0010 §2). Two passes on one subject at two tiers, priced: 0.123 $/response at L against 0.099 at S (CB-EV-0010 §5)",
|
||||||
]
|
]
|
||||||
retire_if = "the window closes with no overridden tier producing a different outcome than the argued one — the evaluation this window exists to make possible"
|
retire_if = "the window closes with no overridden tier producing a different outcome than the argued one — the evaluation this window exists to make possible"
|
||||||
|
|
||||||
|
|
|
||||||
204
history/260802-render-port-challenge.md
Normal file
204
history/260802-render-port-challenge.md
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
# 260802 — challenge to CB-RES-0006
|
||||||
|
|
||||||
|
Adversarial review, one round, per InnerLoop §Step 2.
|
||||||
|
|
||||||
|
**Fidelity note, first, because it caps everything below.** The spec asks
|
||||||
|
for a *separate session or agent*. This review was run in the same session
|
||||||
|
as the survey, because this environment's standing instruction is not to
|
||||||
|
spawn agents unless asked. So it does not have the property the step exists
|
||||||
|
to provide: the reviewer inherits the author's sampling, the author's
|
||||||
|
framing, and the author's blind spots. Every prior instance of this class in
|
||||||
|
the repo — CB-WP-0002's dedup sample, CB-WP-0005's AM-7 assertion — was a
|
||||||
|
shared blind spot between author and reviewer who sampled the same way.
|
||||||
|
Treat the challenges below as a lower bound on what a genuinely separate
|
||||||
|
reviewer would find.
|
||||||
|
|
||||||
|
The claim rests on numbers, so per the table in §Step 2 the reviewer's job
|
||||||
|
is to reproduce them independently and to mutate the assertions behind
|
||||||
|
them — not to argue with the prose.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C1 — The survey concluded the sub-100k region was empty without looking
|
||||||
|
|
||||||
|
**This is the challenge that lands.**
|
||||||
|
|
||||||
|
The survey measures seven candidates. The cheapest windowed one costs
|
||||||
|
480,501. It then reports the corrected headroom as 92,798 and concludes
|
||||||
|
that no windowed toolkit fits — a conclusion about the interval
|
||||||
|
`0 < x < 92,798` drawn from a sample whose smallest windowed member is
|
||||||
|
480,501. Nothing was measured in the interval the conclusion is about.
|
||||||
|
|
||||||
|
The candidate list is not random, either. It is the list a 2026 Rust
|
||||||
|
developer reaches for, which is a list of GPU-era stacks: everything on it
|
||||||
|
pulls `wgpu`, `winit`, or `rustix`. Cheap, old, thin bindings to system
|
||||||
|
libraries were structurally absent from the sample.
|
||||||
|
|
||||||
|
Measured, same method, marginal against the same base:
|
||||||
|
|
||||||
|
| candidate | marginal lines | vs 92,798 corrected headroom |
|
||||||
|
|---|---:|---:|
|
||||||
|
| `tiny-skia` alone | **83,956** | **fits**, at 90% of it |
|
||||||
|
| `fltk` | **140,079** | 1.5× |
|
||||||
|
| `sdl2` | **191,973** | 2.1× |
|
||||||
|
| `sdl3` | 276,445 | 3.0× |
|
||||||
|
| `raqote` | 286,852 | 3.1× |
|
||||||
|
| `minifb` | 774,389 | 8.3× |
|
||||||
|
| `pixels` | 863,528 | 9.3× |
|
||||||
|
| `speedy2d` | 1,328,008 | 14.3× |
|
||||||
|
|
||||||
|
**The survey's central rhetorical move — "this is not a near miss to be
|
||||||
|
negotiated; it is two orders of magnitude" — is false.** It is two orders
|
||||||
|
of magnitude for the candidates the survey chose. The real floor for a
|
||||||
|
windowed toolkit is `fltk` at 140,079, which is **1.5×** the corrected
|
||||||
|
headroom. That is exactly a near miss to be negotiated.
|
||||||
|
|
||||||
|
`tiny-skia` at 83,956 actually fits — it is a pure-Rust CPU rasterizer with
|
||||||
|
no windowing, so it is not by itself a table, but it disproves the survey's
|
||||||
|
stated interval claim on its own.
|
||||||
|
|
||||||
|
**Required:** the survey must either withdraw the "two orders of magnitude"
|
||||||
|
framing and re-argue against a 1.5× floor, or concede that its
|
||||||
|
recommendation rests on something other than dependency cost.
|
||||||
|
|
||||||
|
## C2 — "Marginal cost zero" is scored on an axis chosen to produce zero
|
||||||
|
|
||||||
|
AM-4a measures vendored third-party **Rust**. The HTML option scores zero
|
||||||
|
because it relocates the renderer into a runtime AM-4a cannot see. The
|
||||||
|
browser is tens of millions of lines of unaudited third-party code. Calling
|
||||||
|
that zero is true under the metric and false in substance.
|
||||||
|
|
||||||
|
If a proposal may satisfy a dependency target by moving the dependency
|
||||||
|
somewhere the target does not look, then the target constrains nothing, and
|
||||||
|
this pass is the first to demonstrate it. That is a worse outcome for the
|
||||||
|
project than buying `macroquad` honestly.
|
||||||
|
|
||||||
|
**And the same critique convicts C1's cheap candidates.** `sdl2` at 191,973
|
||||||
|
and `fltk` at 140,079 are thin Rust bindings to large C libraries; their
|
||||||
|
`.rs` line counts exclude the actual implementation for exactly the same
|
||||||
|
reason the browser's is excluded. So the metric already permits this move,
|
||||||
|
and the survey's own table already rewards it — it simply did not notice,
|
||||||
|
because it never measured a binding.
|
||||||
|
|
||||||
|
**Required:** one rule, applied to all three. Either relocating an
|
||||||
|
implementation into an unmeasured runtime is permissible (and then `sdl2`
|
||||||
|
and `fltk` are legitimately cheap and must be compared on their merits, not
|
||||||
|
excluded on cost), or it is not (and then HTML is not free either). The
|
||||||
|
survey cannot have it one way for the browser and another for `sdl2`
|
||||||
|
without stating the distinction. It states none.
|
||||||
|
|
||||||
|
## C3 — The survey contradicts itself on the second-use rule
|
||||||
|
|
||||||
|
Recommendation 1 declares `cb-render-api` a canonical port now.
|
||||||
|
Recommendation 4 says `cb-render-wgpu` at stage 2 is "where the port
|
||||||
|
interface gets its second use and only then becomes canonical."
|
||||||
|
|
||||||
|
Both cannot hold. INTENT: *"No concept becomes canonical merely because it
|
||||||
|
looks general. It becomes canonical after surviving a second concrete
|
||||||
|
use."* A port interface written against exactly one implementation, which
|
||||||
|
emits whole documents, will acquire a document's shape — retained mode,
|
||||||
|
full state per update, string identity, no frame timing — and stage 2 will
|
||||||
|
find it unimplementable and rewrite it. The survey names this risk in §6
|
||||||
|
and mitigates it with "write it to be implemented twice," which is a wish.
|
||||||
|
`CommitWindow` is already on record in this repo as the concept that looked
|
||||||
|
general, has a delete-by date, and has declined two second-use
|
||||||
|
opportunities.
|
||||||
|
|
||||||
|
**Required:** either defer `cb-render-api` until stage 2 supplies a second
|
||||||
|
implementation, and ship `cb-render-html` directly against the existing
|
||||||
|
`Project` trait — or state why this port is exempt from a rule INTENT
|
||||||
|
states without exception.
|
||||||
|
|
||||||
|
## C4 — The 250-line HTTP server is undersold, and "loopback only" is not a boundary
|
||||||
|
|
||||||
|
The survey bounds the risk with "listens on loopback only and speaks to a
|
||||||
|
page it emitted itself." Neither clause holds:
|
||||||
|
|
||||||
|
- Any process on the machine can reach a loopback listener. Hot-seat play
|
||||||
|
is explicitly *several people at one machine*.
|
||||||
|
- **Any web page the user visits can reach it too.** A page on the open
|
||||||
|
internet can issue requests to `127.0.0.1` — that is what DNS rebinding
|
||||||
|
and cross-origin POST are. The browser running the table is the same
|
||||||
|
browser reading the internet; that is the whole premise of the option.
|
||||||
|
- "A page it emitted itself" is an assumption the server cannot check
|
||||||
|
unless it is written to check it, and the survey specifies no such check.
|
||||||
|
|
||||||
|
An unauthenticated loopback endpoint that accepts `POST /command` and
|
||||||
|
mutates authoritative game state is a remote-controlled game from any tab
|
||||||
|
the user has open.
|
||||||
|
|
||||||
|
**Required, as concrete controls the survey lacks:** an unguessable token
|
||||||
|
minted per process and required on every request; `Origin` and
|
||||||
|
`Sec-Fetch-Site` checked and rejected by default; the listener bound to
|
||||||
|
`127.0.0.1` explicitly rather than `0.0.0.0`; and a test that a request
|
||||||
|
without the token is refused. The last one is the one that matters — the
|
||||||
|
other three are claims until something asserts them.
|
||||||
|
|
||||||
|
## C5 — The proposal regresses the finding the previous pass paid for
|
||||||
|
|
||||||
|
CB-WP-0011's finding, in its own words: a renderer's defect class is
|
||||||
|
**silent omission**, no natural renderer assertion catches it, and the fix
|
||||||
|
is to walk the shape of the input and make silence cost a build.
|
||||||
|
|
||||||
|
This proposal moves drag hit-testing and command construction into
|
||||||
|
JavaScript embedded in emitted strings — a language `cargo test` cannot
|
||||||
|
run, `clippy` cannot lint, and `M-D1-MUT` cannot mutate. The gate that
|
||||||
|
CB-WP-0011 built stops at the language boundary, and the survey proposes
|
||||||
|
putting the interactive half of stage 1 on the far side of it, one pass
|
||||||
|
after paying to learn why that is dangerous.
|
||||||
|
|
||||||
|
§6 lists this as "the strongest argument against, and the one T02 should
|
||||||
|
press." Naming a challenge in advance is not answering it.
|
||||||
|
|
||||||
|
**Required, as controls:**
|
||||||
|
- **JS may not construct commands.** The emitted page reports raw pointer
|
||||||
|
facts — "pointer down on element `id`, up on element `id`" — and Rust
|
||||||
|
decides what command that is. That confines JS to input transport and
|
||||||
|
makes the decision testable by feeding the Rust side synthetic events.
|
||||||
|
- **The coverage gate must cross the boundary.** `every_view_field_is_
|
||||||
|
classified` must have an HTML counterpart asserting over the *parsed
|
||||||
|
emitted document*, not over the Rust that emits it. Emitting a `<div>`
|
||||||
|
per field is not evidence the field is visible; the assertion must be
|
||||||
|
that the token is in the document.
|
||||||
|
|
||||||
|
## C6 — One number in the survey is reproduced; one is not independently checkable
|
||||||
|
|
||||||
|
Reproduced: the proc-macro delta. `--edges normal,no-proc-macro` gives 18
|
||||||
|
crates / 157,202 lines against 23 / 246,250. The 89,048 / 36.2% figure is
|
||||||
|
correct, and the five crates named are the five that differ.
|
||||||
|
|
||||||
|
**Not independently checkable:** every candidate figure, because the
|
||||||
|
reviewer used the author's script. Per §Step 2 this is precisely the
|
||||||
|
failure mode — "the reviewer re-derives on a different sample than the
|
||||||
|
author used" — and no different sample was used. The C1 table above is
|
||||||
|
*new* data from the same instrument, which tests the survey's coverage but
|
||||||
|
not its instrument.
|
||||||
|
|
||||||
|
**What the harness would report if it silently stopped:** `source_lines()`
|
||||||
|
returns 0 for a crate it cannot locate, and the probe scripts sum without
|
||||||
|
a positive control. A registry path change would report every candidate as
|
||||||
|
0 marginal lines — i.e. as *fitting comfortably* — which is the direction
|
||||||
|
that flatters the recommendation. `tools/dep-weight.py` has exactly this
|
||||||
|
control (`unlocatable crate measures zero (so the guard fires)`); the probe
|
||||||
|
scripts copied the measurement function and not the guard.
|
||||||
|
|
||||||
|
The `svg-emit` row in the survey's table reads `0` marginal for reasons
|
||||||
|
that are correct, but a reader cannot distinguish that 0 from a harness
|
||||||
|
that stopped. **A zero that means "costs nothing" and a zero that means
|
||||||
|
"measured nothing" are printed identically.** That is HDN, in the survey's
|
||||||
|
own instrument, in the row carrying the recommendation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
**Not approvable as written.** C1, C2, C3 and C6 each require a change to
|
||||||
|
the survey, not a clarification. C4 and C5 require controls that must land
|
||||||
|
in ADR-0007 as conditions on the implementation.
|
||||||
|
|
||||||
|
The recommendation may well survive — none of these establishes that a
|
||||||
|
windowed toolkit is affordable, only that the survey's argument for why it
|
||||||
|
is not was overstated and partly unmeasured. But the survey as written
|
||||||
|
argues from a false interval claim, an inconsistently applied metric rule,
|
||||||
|
an internal contradiction, and a zero that cannot be distinguished from a
|
||||||
|
dead harness.
|
||||||
75
history/260802-render-port-research.md
Normal file
75
history/260802-render-port-research.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# 260802 — how CB-RES-0006 was conducted
|
||||||
|
|
||||||
|
Unpolished trail for `research/CB-RES-0006-render-port.md`, per
|
||||||
|
InnerLoop §Step 2 documentation requirement.
|
||||||
|
|
||||||
|
## What was measured vs cited
|
||||||
|
|
||||||
|
**Everything quoted is measured.** No line count in the survey comes from
|
||||||
|
a README, a crates.io page, or memory. Method: a scratch cargo crate per
|
||||||
|
candidate, `cargo add`, then `cargo tree --edges normal --prefix none`
|
||||||
|
over the resolved graph, then `\n` counted in every `.rs` file of the
|
||||||
|
vendored source under `~/.cargo/registry/src/*/`.
|
||||||
|
|
||||||
|
That method is copied out of `tools/dep-weight.py` rather than
|
||||||
|
re-invented, because the survey's whole purpose is to subtract candidate
|
||||||
|
sizes from a budget produced by that tool, and two methods produce two
|
||||||
|
numbers that cannot be subtracted.
|
||||||
|
|
||||||
|
Scripts: `$CLAUDE_JOB_DIR/tmp/survey/{measure,breakdown,gap}.py`.
|
||||||
|
Deliberately not committed — a throwaway probe promoted to a repo tool is
|
||||||
|
how the second-use rule gets broken by accident. If a later pass needs
|
||||||
|
these numbers refreshed, it should re-derive them, and the fact that it
|
||||||
|
costs ten minutes is a feature.
|
||||||
|
|
||||||
|
## Dead ends and corrections during the survey
|
||||||
|
|
||||||
|
**First reading was on the wrong axis.** The initial run reported headline
|
||||||
|
totals: `egui+eframe` 2,946,121, `ratatui+crossterm` 1,146,363,
|
||||||
|
`macroquad` 480,729. Those are the numbers a survey normally quotes and
|
||||||
|
they are the wrong ones — AM-4a charges what a candidate *adds* to a graph
|
||||||
|
that already holds 23 crates. Recomputed as marginal against the real
|
||||||
|
base. For `macroquad` the difference is negligible (480,729 → 480,501);
|
||||||
|
for `egui+eframe` it is 163,272 lines of overlap.
|
||||||
|
|
||||||
|
**A network 403 that was not a network failure.** `curl` to crates.io
|
||||||
|
returned 403; the sandbox looked like it was blocking egress. It was a
|
||||||
|
missing user-agent — crates.io rejects requests without one. Two further
|
||||||
|
probes (sparse index, static.crates.io) returned 200. Worth recording
|
||||||
|
because "the environment is blocking me" is a conclusion that ends
|
||||||
|
surveys, and it was wrong here by one header.
|
||||||
|
|
||||||
|
**Checked whether `cargo tree` was including non-host targets.** The
|
||||||
|
`ratatui` figure was dominated by `linux-raw-sys` (479,901) and looked
|
||||||
|
like it might be an all-platform artifact — a Windows-bindings inflation
|
||||||
|
of a Linux measurement. Re-ran with `--target x86_64-unknown-linux-gnu`:
|
||||||
|
identical, 1,146,363 both ways. The number is real, and `ratatui` really
|
||||||
|
does cost more than a 2D game framework.
|
||||||
|
|
||||||
|
**The proc-macro finding was not what the survey set out to look for.** It
|
||||||
|
came from asking why `syn` was the largest single entry in a
|
||||||
|
"shipped-runtime" figure. `--edges normal,no-proc-macro` answered it:
|
||||||
|
89,048 lines, 36.2%, none of which reaches a binary.
|
||||||
|
|
||||||
|
Order matters here, and it went the right way by luck rather than design:
|
||||||
|
the candidate measurements were complete *before* the instrument defect
|
||||||
|
was found, so the recommendation could be checked against the corrected
|
||||||
|
number instead of being derived from it. Had it gone the other way the
|
||||||
|
survey would have been unusable — a budget correction discovered while
|
||||||
|
looking for headroom is not evidence, whatever it says.
|
||||||
|
|
||||||
|
## What the survey did not do
|
||||||
|
|
||||||
|
- **It did not sample the region between 0 and 480,501 lines.** The
|
||||||
|
candidate list was assembled from what one reaches for — modern GPU-era
|
||||||
|
Rust GUI stacks — and every one of them landed two orders of magnitude
|
||||||
|
over. The conclusion "no windowed toolkit fits" was therefore drawn from
|
||||||
|
a sample that contained no small windowed toolkits. This is the gap the
|
||||||
|
challenge (T02) attacks, and it is a real one.
|
||||||
|
- **It did not run any candidate.** No candidate was built, drawn with, or
|
||||||
|
benchmarked. Per InnerLoop §Step 1 the comparisons here are dependency
|
||||||
|
cost only; nothing in the survey claims one toolkit draws better than
|
||||||
|
another, and no `better` verdict is available on that axis.
|
||||||
|
- **It did not measure the browser.** The HTML option is scored zero on
|
||||||
|
AM-4a because AM-4a measures vendored Rust. What the browser costs on
|
||||||
|
any other axis is unmeasured and the survey says so in §5.
|
||||||
193
history/260802-render-port-response.md
Normal file
193
history/260802-render-port-response.md
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
# 260802 — response to the challenge on CB-RES-0006
|
||||||
|
|
||||||
|
One round, per InnerLoop §Step 2. Four challenges conceded, two answered
|
||||||
|
with evidence, and one of the concessions turned up something larger than
|
||||||
|
this pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C1 — the unmeasured interval — **conceded, and the framing withdrawn**
|
||||||
|
|
||||||
|
The challenge is right and the survey's rhetoric was wrong. "Two orders of
|
||||||
|
magnitude" described the candidates I picked, and I picked them from what a
|
||||||
|
2026 Rust developer reaches for, which is a list of GPU-era stacks. The
|
||||||
|
real floor for a windowed toolkit is `fltk` at **140,079** marginal lines —
|
||||||
|
**1.5×** the corrected headroom, not 128×.
|
||||||
|
|
||||||
|
The C1 table is folded into the survey as measured data, including
|
||||||
|
`tiny-skia` at 83,956, which fits inside 92,798 and disproves the interval
|
||||||
|
claim by itself.
|
||||||
|
|
||||||
|
**So dependency cost alone does not decide this.** The survey's argument as
|
||||||
|
written is withdrawn. What replaces it is an allocation argument, and it is
|
||||||
|
stronger:
|
||||||
|
|
||||||
|
INTENT stage 2 names `wgpu` explicitly. That is a **1,741,979-line**
|
||||||
|
marginal cost, already measured, already unavoidable, and it arrives one
|
||||||
|
stage from now. Against that, spending 1.5× the entire remaining budget on
|
||||||
|
a stage-1 windowing toolkit that stage 2 immediately discards is the wrong
|
||||||
|
allocation — not because it does not fit, but because it buys a thing with
|
||||||
|
a known expiry using money already committed elsewhere.
|
||||||
|
|
||||||
|
That argument does not need AM-4a's target to be exactly 250,000. It only
|
||||||
|
needs stage 2's bill to be larger than stage 1's benefit, which is
|
||||||
|
measured, and true by a factor of twelve.
|
||||||
|
|
||||||
|
## C1b — what the concession exposed: **AM-4a cannot survive stage 2**
|
||||||
|
|
||||||
|
Following C1 honestly produces a finding neither the survey nor the
|
||||||
|
challenge was looking for.
|
||||||
|
|
||||||
|
| | marginal lines |
|
||||||
|
|---|---:|
|
||||||
|
| AM-4a target, total | 250,000 |
|
||||||
|
| current shipped-runtime (corrected, proc-macro excluded) | 157,202 |
|
||||||
|
| headroom | 92,798 |
|
||||||
|
| `wgpu` + `winit`, which INTENT stage 2 names by name | **1,741,979** |
|
||||||
|
|
||||||
|
Stage 2 exceeds the entire AM-4a target by **7×**, and exceeds the
|
||||||
|
remaining headroom by **19×**. No sequencing, no feature-gating and no
|
||||||
|
metric correction closes a gap that size. **AM-4a as targeted is
|
||||||
|
incompatible with INTENT as written**, and has been since both were
|
||||||
|
written; nothing in this pass caused it.
|
||||||
|
|
||||||
|
That is a maintainer's decision, not mine — either the target moves, or the
|
||||||
|
port stays out of the AM-4a configuration by an argued rule, or stage 2
|
||||||
|
changes. It is recorded here and carried to the evidence file, and it is
|
||||||
|
deliberately **not** resolved by this pass, because a pass that discovers a
|
||||||
|
budget conflict and also decides it has reviewed nothing.
|
||||||
|
|
||||||
|
It does, however, retire one bad reason for the recommendation. "HTML,
|
||||||
|
because we cannot afford anything else" is false. "HTML, because stage 1
|
||||||
|
should not spend stage 2's money on something stage 2 throws away" is true.
|
||||||
|
|
||||||
|
## C2 — the metric is gameable by relocation — **conceded; one rule, stated**
|
||||||
|
|
||||||
|
The challenge is right that the survey scored the browser at zero on an
|
||||||
|
axis chosen to produce zero, and right that the same move already makes
|
||||||
|
`sdl2` and `fltk` look cheap in the survey's own table. I had not noticed,
|
||||||
|
because I had measured no bindings.
|
||||||
|
|
||||||
|
The rule, which belongs in AM-4's definition and not in this pass's
|
||||||
|
reasoning, applied consistently to all three:
|
||||||
|
|
||||||
|
> **AM-4 counts third-party code the project causes to be acquired.** It
|
||||||
|
> does not count runtimes the user already has independently of us — the
|
||||||
|
> kernel, the system C library, the shell, the terminal, a web browser. It
|
||||||
|
> *does* count a library our build or install instructions cause to be
|
||||||
|
> fetched, pinned, or linked, whether or not its source is Rust.
|
||||||
|
|
||||||
|
Under that rule, consistently:
|
||||||
|
|
||||||
|
- a **browser** is not counted — nothing we ship or instruct causes it to
|
||||||
|
be acquired;
|
||||||
|
- **`sdl2`/`fltk`** are counted at more than their Rust binding, not less —
|
||||||
|
they oblige a user to install a `-dev` package they would not otherwise
|
||||||
|
have, so the survey's 191,973 and 140,079 **understate** them;
|
||||||
|
- and the rule makes the metric *harder* to satisfy by relocation, not
|
||||||
|
easier, which is the direction a rule proposed by the party it benefits
|
||||||
|
has to go before it is worth anything.
|
||||||
|
|
||||||
|
This makes the HTML row honest rather than merely favourable. It also
|
||||||
|
means the survey's table understates two candidates, and that correction is
|
||||||
|
folded in.
|
||||||
|
|
||||||
|
## C3 — the port contradicts the second-use rule — **conceded entirely**
|
||||||
|
|
||||||
|
Recommendations 1 and 4 cannot both stand, and 4 is the one INTENT
|
||||||
|
supports. `CommitWindow` is already in this repo with a delete-by date for
|
||||||
|
exactly this mistake.
|
||||||
|
|
||||||
|
**`cb-render-api` is withdrawn from this pass.** `cb-render-html` ships
|
||||||
|
against the existing `Project` trait, which is a real interface with real
|
||||||
|
implementations rather than a speculative one. The port gets declared at
|
||||||
|
stage 2, when `wgpu` supplies the second concrete use and the interface can
|
||||||
|
be derived from two implementations instead of imagined from one.
|
||||||
|
|
||||||
|
This also removes `cb-render-null`: a null implementation of an interface
|
||||||
|
that does not exist yet is a null implementation of nothing.
|
||||||
|
|
||||||
|
**And it changes the pass's tier.** The structural trigger for tier L was
|
||||||
|
"creates a new capability port." This pass no longer creates one. That is
|
||||||
|
recorded rather than acted on — the declaration and its roll stand, and the
|
||||||
|
pass runs at the weight it declared. Re-rolling a tier because review made
|
||||||
|
the work smaller would make the tier a function of the outcome, which is
|
||||||
|
the one thing a tier declaration must not be. **The CHAOS calibration
|
||||||
|
window gets a second entry from this**, and it is a more interesting one
|
||||||
|
than the first: tier L at full weight deleted its own structural trigger.
|
||||||
|
|
||||||
|
## C4 — the loopback server — **conceded; controls carried to ADR-0007**
|
||||||
|
|
||||||
|
The challenge is right that "loopback only" is not a boundary against a
|
||||||
|
browser that also reads the internet, and right that "a page it emitted
|
||||||
|
itself" is an assumption no code checks. All four controls are adopted as
|
||||||
|
conditions on the implementation, and the fourth is the load-bearing one:
|
||||||
|
|
||||||
|
1. an unguessable token minted per process, required on every request;
|
||||||
|
2. `Origin` / `Sec-Fetch-Site` checked, rejected by default;
|
||||||
|
3. bound explicitly to `127.0.0.1`;
|
||||||
|
4. **a test that a request without the token is refused** — and, per
|
||||||
|
M-D1-MUT, a mutation that removes the check and turns that test red.
|
||||||
|
|
||||||
|
Controls 1–3 without 4 are three claims and no evidence, which is the
|
||||||
|
class this project keeps finding in its own work.
|
||||||
|
|
||||||
|
## C5 — the gate stops at the language boundary — **conceded; both controls adopted**
|
||||||
|
|
||||||
|
Naming a challenge in the survey and calling it pressed was not an answer,
|
||||||
|
and the challenge says so correctly.
|
||||||
|
|
||||||
|
1. **JavaScript may not construct commands.** The page reports raw pointer
|
||||||
|
facts; Rust decides what command they mean. JS becomes input transport,
|
||||||
|
and the decision becomes testable in Rust against synthetic events.
|
||||||
|
2. **The coverage gate crosses the boundary.** The HTML counterpart to
|
||||||
|
`every_view_field_is_classified` asserts over the *parsed emitted
|
||||||
|
document* — the token is in the document, or the build fails. Asserting
|
||||||
|
over the Rust that emits the document would reproduce CB-WP-0011's
|
||||||
|
original defect one layer up.
|
||||||
|
|
||||||
|
## C6 — the zeros — **partly answered, partly conceded**
|
||||||
|
|
||||||
|
**Answered:** the candidate measurements did carry the positive control the
|
||||||
|
challenge says they lacked. `measure.py` tracks an `unlocated` list per
|
||||||
|
candidate and it is empty for all seven — verified in `results.json`, not
|
||||||
|
asserted. So no candidate figure is a silently-stopped harness.
|
||||||
|
|
||||||
|
**Conceded, twice over:**
|
||||||
|
|
||||||
|
- `gap.py`, which produced the C1 table, copied the measurement function
|
||||||
|
**without** the guard. The C1 numbers therefore have no positive control.
|
||||||
|
They are load-bearing now, since they are what withdrew the survey's main
|
||||||
|
argument, so they must be re-measured under the guard before ADR-0007
|
||||||
|
cites them.
|
||||||
|
- The survey's HTML row is **0 by construction, not by measurement** —
|
||||||
|
there is no crate to fail to locate. The challenge is right that a
|
||||||
|
reader cannot tell that zero from a dead one, and the survey printed them
|
||||||
|
identically in a table whose other rows are measurements. It is relabelled
|
||||||
|
rather than left to be read as a measured figure.
|
||||||
|
|
||||||
|
The general observation is worth keeping: a probe script that copies a
|
||||||
|
tool's measurement function and not its controls is how a positive control
|
||||||
|
gets lost, and it happened here inside one pass, in a repo whose whole
|
||||||
|
discipline is positive controls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed in the survey
|
||||||
|
|
||||||
|
- The "two orders of magnitude" framing is **withdrawn**; the real floor is
|
||||||
|
`fltk` at 1.5× corrected headroom.
|
||||||
|
- The C1 candidates are folded in as measured rows, re-measured under the
|
||||||
|
positive control.
|
||||||
|
- The recommendation's justification changes from *affordability* to
|
||||||
|
*allocation against stage 2's known 1,741,979-line bill*.
|
||||||
|
- The AM-4 acquisition rule is stated, and it **raises** the cost of two
|
||||||
|
candidates rather than lowering the cost of the recommended one.
|
||||||
|
- **`cb-render-api` and `cb-render-null` are withdrawn from this pass.**
|
||||||
|
- The HTML row is relabelled *0 by construction*.
|
||||||
|
- C4 and C5's six controls become conditions in ADR-0007.
|
||||||
|
- **AM-4a's incompatibility with stage 2 is raised as a maintainer
|
||||||
|
decision** and explicitly not decided here.
|
||||||
|
|
||||||
|
**Approvable after these changes.** The recommendation stands; the argument
|
||||||
|
that reached it does not, and has been replaced.
|
||||||
320
research/CB-RES-0006-render-port.md
Normal file
320
research/CB-RES-0006-render-port.md
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
# CB-RES-0006 — the render port, and what a 2D table costs
|
||||||
|
|
||||||
|
capability: render.table.2d
|
||||||
|
status: approved (v2, after adversarial review — v1 was **not approvable**,
|
||||||
|
with four conceded findings; see §2, §4, §5, §7)
|
||||||
|
tier: L (structural L — the declaration was made when this pass would
|
||||||
|
create a capability port; chaos d4=1 → no override. Review then removed
|
||||||
|
the port, and the tier was deliberately **not** re-derived: a tier that
|
||||||
|
changes because review shrank the work is a function of the outcome)
|
||||||
|
|
||||||
|
CB-WP-0012 T01. Step 1 of the inner loop. Measured 2026-08-02 at
|
||||||
|
`331e7e9`.
|
||||||
|
|
||||||
|
The question: INTENT stage 1 needs `cb-render-api` and a first real
|
||||||
|
implementation. AM-4a reports **3,750 lines of headroom** (246,250 of a
|
||||||
|
250,000 target). Is that a real obstacle, or an artifact of the
|
||||||
|
instrument?
|
||||||
|
|
||||||
|
Both, and not in the proportions I expected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Method
|
||||||
|
|
||||||
|
Every figure below comes from the same method `tools/dep-weight.py` uses
|
||||||
|
for AM-4a: resolve a real dependency graph with
|
||||||
|
`cargo tree --edges normal --prefix none`, drop path dependencies, and
|
||||||
|
count `\n` in every `.rs` file of the vendored source. Reusing the method
|
||||||
|
verbatim is the point — a survey that measures candidates one way and the
|
||||||
|
budget another produces two numbers that cannot be subtracted.
|
||||||
|
|
||||||
|
Probe crates were built in a scratch directory, one per candidate, and
|
||||||
|
resolved against the live registry. The scripts are not committed, because
|
||||||
|
a throwaway probe promoted to a tool is how the second-use rule gets broken
|
||||||
|
by accident.
|
||||||
|
|
||||||
|
**Positive control.** `source_lines()` returns 0 for a crate it cannot
|
||||||
|
locate, so a registry path change would report every candidate as costing
|
||||||
|
nothing — the direction that flatters the recommendation. Every figure
|
||||||
|
below was produced under two guards copied from `dep-weight.py`: an
|
||||||
|
unlocatable crate must measure zero, a real one must measure non-zero, and
|
||||||
|
any candidate containing an unlocated crate is reported rather than summed.
|
||||||
|
No candidate contained one. The review (T02) found that the second batch of
|
||||||
|
measurements had copied the measurement function *without* the guards; they
|
||||||
|
were re-run under them before being cited here, and reproduced unchanged.
|
||||||
|
|
||||||
|
**Marginal, not total.** A candidate's headline size overstates its cost
|
||||||
|
by whatever it already shares with the base graph. What AM-4a charges is
|
||||||
|
the delta.
|
||||||
|
|
||||||
|
## 2. What a 2D table costs
|
||||||
|
|
||||||
|
Base: `games-ground --no-default-features`, 23 third-party crates,
|
||||||
|
246,250 lines.
|
||||||
|
|
||||||
|
| candidate | total | shared with base | **marginal** | vs 3,750 headroom |
|
||||||
|
|---|---:|---:|---:|---:|
|
||||||
|
| `egui` + `eframe` | 2,946,121 | 163,272 | **2,782,849** | 742× |
|
||||||
|
| `wgpu` + `winit` | 1,866,820 | 124,841 | **1,741,979** | 465× |
|
||||||
|
| `softbuffer` + `tiny-skia` + `winit` | 1,271,922 | 124,841 | **1,147,081** | 306× |
|
||||||
|
| `ratatui` + `crossterm` | 1,146,363 | 79,350 | **1,067,013** | 285× |
|
||||||
|
| `egui` alone (no windowing) | 778,067 | 110,542 | **667,525** | 178× |
|
||||||
|
| `macroquad` | 480,729 | 228 | **480,501** | 128× |
|
||||||
|
| HTML/SVG emission, no Rust toolkit | — | — | **0 by construction** | — |
|
||||||
|
|
||||||
|
**Revised after review (T02).** The first draft of this section concluded
|
||||||
|
from the table above that no windowed toolkit could fit, and called it
|
||||||
|
"two orders of magnitude, not a near miss." **That was drawn from a sample
|
||||||
|
containing no small windowed toolkits.** Every candidate above is a
|
||||||
|
GPU-era stack, because that is what one reaches for; cheap bindings to
|
||||||
|
system libraries were structurally absent. Measured, same method, same
|
||||||
|
base, under the positive control:
|
||||||
|
|
||||||
|
| candidate | marginal | vs 92,798 corrected headroom (§3) |
|
||||||
|
|---|---:|---:|
|
||||||
|
| `tiny-skia` alone (rasterizer, no window) | **83,956** | **fits**, at 90% |
|
||||||
|
| `termion` | 133,130 | 1.4× |
|
||||||
|
| `fltk` | **140,079** | **1.5×** |
|
||||||
|
| `sdl2` | 191,973 | 2.1× |
|
||||||
|
| `sdl3` | 276,445 | 3.0× |
|
||||||
|
| `raqote` | 286,852 | 3.1× |
|
||||||
|
| `minifb` | 774,389 | 8.3× |
|
||||||
|
| `crossterm` alone | 802,677 | 8.6× |
|
||||||
|
| `softbuffer` alone | 909,416 | 9.8× |
|
||||||
|
| `pixels` | 863,528 | 9.3× |
|
||||||
|
| `speedy2d` | 1,328,008 | 14.3× |
|
||||||
|
|
||||||
|
The real floor for a windowed toolkit is `fltk` at **1.5×** the corrected
|
||||||
|
headroom, and `tiny-skia` fits outright. **The "two orders of magnitude"
|
||||||
|
claim is withdrawn**, and with it the survey's original argument:
|
||||||
|
dependency cost alone does not decide this. What replaces it is §4.
|
||||||
|
|
||||||
|
**The `0` in the first table is by construction, not by measurement** —
|
||||||
|
there is no crate to fail to locate. It is relabelled because a zero
|
||||||
|
meaning "costs nothing" and a zero meaning "measured nothing" print
|
||||||
|
identically, and every other row is a measurement.
|
||||||
|
|
||||||
|
**A surprise worth recording:** `ratatui` — a *terminal* library, the
|
||||||
|
option one reaches for expecting it to be the cheap one — costs more than
|
||||||
|
`macroquad`, a 2D game framework with a GPU backend. The reason is
|
||||||
|
`linux-raw-sys` at 479,901 lines and `libc` at 129,990, pulled through
|
||||||
|
`rustix`. Neither is code anyone audits by hand; both are largely
|
||||||
|
generated constant and type definitions. Which raises the next question.
|
||||||
|
|
||||||
|
## 3. The instrument is wrong, and has been all along
|
||||||
|
|
||||||
|
AM-4a counts the `--edges normal` graph. That graph includes **proc-macro
|
||||||
|
crates**, which run in the compiler and never reach a shipped binary.
|
||||||
|
|
||||||
|
| `games-ground --no-default-features` | crates | lines |
|
||||||
|
|---|---:|---:|
|
||||||
|
| as AM-4a measures it | 23 | **246,250** |
|
||||||
|
| excluding proc-macro crates (`--edges normal,no-proc-macro`) | 18 | **157,202** |
|
||||||
|
| the difference | 5 | **89,048** (36.2%) |
|
||||||
|
|
||||||
|
The five are `syn`, `quote`, `proc-macro2`, `unicode-ident` and
|
||||||
|
`serde_derive`. `syn` alone is 66,916 lines — the single largest entry in
|
||||||
|
the shipped-runtime figure, and it is a parser for Rust source that exists
|
||||||
|
only at build time.
|
||||||
|
|
||||||
|
**So 36.2% of what AM-4a calls "what a game ships" is not shipped.** Under
|
||||||
|
a corrected metric the headroom is not 3,750 but **92,798**. Every pass
|
||||||
|
that has cited the 3,750 figure — including CB-WP-0011's own reasoning for
|
||||||
|
deferring this declaration — cited a number that was wrong in the
|
||||||
|
conservative direction.
|
||||||
|
|
||||||
|
**This does not change the recommendation, and that is the point of
|
||||||
|
checking it before recommending.** Under the corrected metric `macroquad`
|
||||||
|
still costs 5.2× the available headroom, and every other windowed
|
||||||
|
candidate is worse. The dependency argument survives the correction of the
|
||||||
|
number it rests on, which is the only condition under which a survey may
|
||||||
|
propose correcting a number in its own favour.
|
||||||
|
|
||||||
|
The correction is proposed as a separate, narrow change — the metric
|
||||||
|
should measure what ships — and it must not be bundled with the render
|
||||||
|
decision, because a budget correction that arrives attached to the request
|
||||||
|
it unblocks is indistinguishable from motivated reasoning even when it is
|
||||||
|
right.
|
||||||
|
|
||||||
|
## 4. What stage 1 actually asks for
|
||||||
|
|
||||||
|
> *card/token/hand/relationship-graph visualization, drag-to-propose,
|
||||||
|
> debug inspector, hot-seat play*
|
||||||
|
|
||||||
|
Read plainly: draw a table, let a human point at it, and let several
|
||||||
|
humans take turns at one machine. It does **not** ask for a GPU, a frame
|
||||||
|
loop, physics, or 60fps. Those are stage 2 (*"Physical 3D tabletop —
|
||||||
|
wgpu renderer, Rapier-backed physics"*), and stage 2 is where the 1.7M
|
||||||
|
lines of `wgpu` + `winit` get argued for on their merits.
|
||||||
|
|
||||||
|
### The allocation argument, which replaces the affordability one
|
||||||
|
|
||||||
|
`fltk` at 140,079 is affordable if AM-4a's target moves a little. The
|
||||||
|
reason not to buy it is not cost but **allocation**:
|
||||||
|
|
||||||
|
| | marginal lines |
|
||||||
|
|---|---:|
|
||||||
|
| corrected headroom (§3) | 92,798 |
|
||||||
|
| cheapest windowed stage-1 toolkit (`fltk`) | 140,079 |
|
||||||
|
| **`wgpu` + `winit`, which INTENT stage 2 names by name** | **1,741,979** |
|
||||||
|
|
||||||
|
Stage 2's renderer is measured, unavoidable, named in INTENT, and **twelve
|
||||||
|
times** the cost of the stage-1 toolkit it would replace. Spending 1.5× the
|
||||||
|
entire remaining budget on a windowing stack that stage 2 discards is the
|
||||||
|
wrong allocation regardless of whether the budget can be stretched to
|
||||||
|
cover it. That argument does not depend on AM-4a's target being exactly
|
||||||
|
250,000 — only on stage 2's bill exceeding stage 1's benefit, which is
|
||||||
|
measured and true by a factor of twelve.
|
||||||
|
|
||||||
|
### AM-4a cannot survive stage 2, and that is not this pass's to decide
|
||||||
|
|
||||||
|
Following the above honestly produces a finding this survey did not set out
|
||||||
|
to look for. Stage 2 exceeds AM-4a's **entire target** by 7× and the
|
||||||
|
remaining headroom by 19×. No sequencing, feature-gating, or metric
|
||||||
|
correction closes a gap that size: **AM-4a as targeted is incompatible with
|
||||||
|
INTENT as written**, and has been since both were written.
|
||||||
|
|
||||||
|
Either the target moves, or the render port stays outside the AM-4a
|
||||||
|
configuration by an argued rule, or stage 2 changes. That is a maintainer's
|
||||||
|
decision. It is raised here and **deliberately not resolved** — a pass that
|
||||||
|
discovers a budget conflict and also settles it has reviewed nothing.
|
||||||
|
|
||||||
|
## 5. The option that is not a Rust toolkit
|
||||||
|
|
||||||
|
Emit HTML with inline SVG and inline JavaScript; open it in the browser
|
||||||
|
the machine already has.
|
||||||
|
|
||||||
|
- **visualization** — SVG draws cards, tokens and a relationship graph
|
||||||
|
natively, and the relationship graph is the one element every Rust 2D
|
||||||
|
toolkit would have made us hand-roll anyway.
|
||||||
|
- **drag-to-propose** — pointer events in the emitted page.
|
||||||
|
- **debug inspector** — already shipped (CB-WP-0011), and its renderer is
|
||||||
|
already a total function from a projection to text; HTML is a second
|
||||||
|
output format for the same walk.
|
||||||
|
- **hot-seat play** — one browser tab, seats taking turns, the same
|
||||||
|
commit/reveal the CLI player uses.
|
||||||
|
|
||||||
|
### The rule that makes "zero" honest (added by review)
|
||||||
|
|
||||||
|
Scoring the browser at zero is true under AM-4a and suspect in substance: a
|
||||||
|
browser is tens of millions of lines of unaudited third-party code. If a
|
||||||
|
proposal can satisfy a dependency target by relocating the dependency
|
||||||
|
somewhere the target does not look, the target constrains nothing.
|
||||||
|
|
||||||
|
The same critique convicts `sdl2` and `fltk` above — thin Rust bindings
|
||||||
|
whose `.rs` counts exclude the C library that does the work. The metric
|
||||||
|
already permits the move; this survey simply never measured a binding
|
||||||
|
before, so it never noticed.
|
||||||
|
|
||||||
|
One rule, applied to all three, belonging in AM-4's definition rather than
|
||||||
|
in this pass's reasoning:
|
||||||
|
|
||||||
|
> **AM-4 counts third-party code the project causes to be acquired.** It
|
||||||
|
> does not count runtimes the user already has independently of us — the
|
||||||
|
> kernel, the system C library, the shell, the terminal, a web browser. It
|
||||||
|
> **does** count a library our build or install instructions cause to be
|
||||||
|
> fetched, pinned, or linked, whether or not its source is Rust.
|
||||||
|
|
||||||
|
Under it: a browser is not counted; **`sdl2` and `fltk` are counted at more
|
||||||
|
than their Rust binding, not less**, because they oblige a user to install
|
||||||
|
a `-dev` package they would not otherwise have. The rule makes the metric
|
||||||
|
*harder* to satisfy by relocation, which is the direction a rule proposed
|
||||||
|
by the party it benefits has to run before it is worth anything.
|
||||||
|
|
||||||
|
**Marginal AM-4a cost: zero.** Not "small" — zero. Emitting HTML is string
|
||||||
|
formatting; the 2,897-line `svg` crate measured above is not needed and is
|
||||||
|
not proposed. The input path costs no dependency either: a loopback
|
||||||
|
HTTP/1.1 listener sufficient for `GET /` and `POST /command` is
|
||||||
|
`std::net::TcpListener` plus roughly 250 lines of our own code, and our
|
||||||
|
own code is not what AM-4a governs.
|
||||||
|
|
||||||
|
This is the central rule applied literally — *own the semantics; assimilate
|
||||||
|
the implementation*. The browser is the most thoroughly assimilated 2D
|
||||||
|
renderer available, and it is the one implementation we are guaranteed not
|
||||||
|
to have to vendor, audit, or keep on a target.
|
||||||
|
|
||||||
|
**Own-code cost is a real cost.** ~250 lines of hand-rolled HTTP is code
|
||||||
|
we own, test and carry, and hand-rolled HTTP has a bad security history.
|
||||||
|
It listens on loopback only and speaks to a page it emitted itself, which
|
||||||
|
bounds the exposure but does not erase it. That trade — 250 own lines
|
||||||
|
against 480,501 third-party ones — is the decision ADR-0007 has to make
|
||||||
|
explicitly rather than inherit from this survey.
|
||||||
|
|
||||||
|
## 6. What this cannot do
|
||||||
|
|
||||||
|
Stated concretely, because the adversarial review (T02) should not have to
|
||||||
|
discover it:
|
||||||
|
|
||||||
|
- **No frame loop.** Animation, drag *feedback* at pointer-event rates,
|
||||||
|
and anything requiring sustained redraw are the browser's problem, not
|
||||||
|
the port's — the Rust side emits state and receives commands. If stage 1
|
||||||
|
later wants tweened card motion, it is written in the emitted JS, in a
|
||||||
|
language this repo otherwise does not use and has no gates for.
|
||||||
|
- **No offline binary.** A game that "ships" ships a process that wants a
|
||||||
|
browser. For a headless-first rules engine that is nearly free; for a
|
||||||
|
consumer product at stage 4 it is not.
|
||||||
|
- **A second language enters the build.** JavaScript in emitted strings is
|
||||||
|
untested by `cargo test` and invisible to every gate this repo has.
|
||||||
|
This is the strongest argument against, and the one T02 should press.
|
||||||
|
- **It is not stage 2's renderer.** Nothing here carries forward to wgpu
|
||||||
|
except the port interface — which is the argument *for* doing it, under
|
||||||
|
the second-use rule, but only if the interface is written to be
|
||||||
|
implemented twice rather than written around HTML.
|
||||||
|
|
||||||
|
## 7. Recommendation (revised after review)
|
||||||
|
|
||||||
|
The first draft recommended declaring `cb-render-api` and `cb-render-null`
|
||||||
|
now, while also saying the interface becomes canonical only at stage 2's
|
||||||
|
second use. **Those two cannot both hold**, and INTENT's rule is the one
|
||||||
|
that survives: *"No concept becomes canonical merely because it looks
|
||||||
|
general. It becomes canonical after surviving a second concrete use."* A
|
||||||
|
port written against exactly one implementation that emits whole documents
|
||||||
|
will acquire a document's shape — retained mode, full state per update,
|
||||||
|
string identity, no frame timing — and stage 2 will find it
|
||||||
|
unimplementable. `CommitWindow` is already in this repo with a delete-by
|
||||||
|
date for precisely this mistake.
|
||||||
|
|
||||||
|
1. **No port this pass.** `cb-render-api` and `cb-render-null` are
|
||||||
|
withdrawn. A null implementation of an interface that does not exist is
|
||||||
|
a null implementation of nothing.
|
||||||
|
2. **`cb-render-html`** ships against the **existing `Project` trait** — a
|
||||||
|
real interface with real implementations, not a speculative one.
|
||||||
|
Marginal AM-4a cost zero, honestly zero under §5's rule.
|
||||||
|
3. **The port is declared at stage 2**, derived from two implementations
|
||||||
|
instead of imagined from one.
|
||||||
|
4. **AM-4a's proc-macro defect is filed separately**, and is not a
|
||||||
|
precondition for any of the above.
|
||||||
|
5. **AM-4a's incompatibility with stage 2 (§4) goes to the maintainer.**
|
||||||
|
|
||||||
|
### Six controls the implementation must carry
|
||||||
|
|
||||||
|
From the review, and binding on ADR-0007:
|
||||||
|
|
||||||
|
| # | control |
|
||||||
|
|---|---|
|
||||||
|
| 1 | an unguessable token minted per process, required on every request |
|
||||||
|
| 2 | `Origin` / `Sec-Fetch-Site` checked, rejected by default |
|
||||||
|
| 3 | listener bound explicitly to `127.0.0.1` |
|
||||||
|
| 4 | **a test that a token-less request is refused**, with an M-D1-MUT mutation removing the check and turning it red |
|
||||||
|
| 5 | **JavaScript may not construct commands** — the page reports raw pointer facts, Rust decides what command they mean |
|
||||||
|
| 6 | **the coverage gate crosses the language boundary** — the HTML counterpart of `every_view_field_is_classified` asserts over the *parsed emitted document*, not over the Rust that emits it |
|
||||||
|
|
||||||
|
Controls 1–3 without 4 are three claims and no evidence. Control 6 exists
|
||||||
|
because asserting over the emitting code would reproduce CB-WP-0011's
|
||||||
|
original defect one layer up.
|
||||||
|
|
||||||
|
### The honest summary
|
||||||
|
|
||||||
|
Two of this survey's three arguments did not survive review. The budget
|
||||||
|
constraint that appeared to force the decision was overstated by 36% by a
|
||||||
|
mis-instrumented metric, and *then* overstated again by a candidate list
|
||||||
|
that contained no cheap windowed toolkits — the real floor is 1.5× the
|
||||||
|
corrected headroom, not 128×. The port the survey proposed to build
|
||||||
|
contradicted the rule the same survey cited.
|
||||||
|
|
||||||
|
What survives is narrower and better founded: stage 1 should not spend
|
||||||
|
stage 2's money on a toolkit stage 2 discards, and it should not canonize
|
||||||
|
an interface it can only implement once. The recommendation is unchanged.
|
||||||
|
The reasoning that reached it has been replaced, which is what the review
|
||||||
|
step is for.
|
||||||
|
|
@ -7,6 +7,7 @@ license-file.workspace = true
|
||||||
[dependencies]
|
[dependencies]
|
||||||
cb-kernel.workspace = true
|
cb-kernel.workspace = true
|
||||||
cb-events.workspace = true
|
cb-events.workspace = true
|
||||||
|
cb-render-html.workspace = true
|
||||||
cb-game-runtime = { workspace = true, features = ["scenarios"] }
|
cb-game-runtime = { workspace = true, features = ["scenarios"] }
|
||||||
games-ground = { workspace = true, features = ["scenarios"] }
|
games-ground = { workspace = true, features = ["scenarios"] }
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|
|
||||||
373
tools/cb-play/src/hotseat.rs
Normal file
373
tools/cb-play/src/hotseat.rs
Normal file
|
|
@ -0,0 +1,373 @@
|
||||||
|
//! Hot-seat play in a browser (ADR-0007, INTENT stage 1).
|
||||||
|
//!
|
||||||
|
//! One loopback listener, one tab, seats taking turns. The server blocks
|
||||||
|
//! inside [`Policy::choose`] until the page reports a pointer fact that
|
||||||
|
//! resolves to a legal command — so the game loop is the same
|
||||||
|
//! `games_ground::bot::play` loop the CLI and the bots run through, and a
|
||||||
|
//! browser seat is indistinguishable from a CLI seat to the driver.
|
||||||
|
//!
|
||||||
|
//! Every control in ADR-0007 §Decision 5 lives in `cb-render-html`; this
|
||||||
|
//! module is the socket and the turn-taking. It deliberately holds no
|
||||||
|
//! game logic beyond "which seat is being asked" — [`cb_render_html`]
|
||||||
|
//! decides what a drag means, and the aggregate decides whether the
|
||||||
|
//! result is legal.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::{TcpListener, TcpStream};
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use cb_game_runtime::{Project, Viewer};
|
||||||
|
use cb_kernel::PlayerId;
|
||||||
|
use cb_render_html::{document, resolve, Guard, PointerFact, Request};
|
||||||
|
use games_ground::bot::{Choice, Policy};
|
||||||
|
use games_ground::{GroundCommand, GroundState};
|
||||||
|
|
||||||
|
/// The shared listener. One per process; every human seat borrows it.
|
||||||
|
pub struct Server {
|
||||||
|
listener: TcpListener,
|
||||||
|
guard: Guard,
|
||||||
|
log: RefCell<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
pub fn bind(port: u16) -> Result<Self, String> {
|
||||||
|
let listener = cb_render_html::serve::bind(port).map_err(|e| format!("bind: {e}"))?;
|
||||||
|
let port = listener
|
||||||
|
.local_addr()
|
||||||
|
.map_err(|e| format!("local_addr: {e}"))?
|
||||||
|
.port();
|
||||||
|
Ok(Self {
|
||||||
|
listener,
|
||||||
|
guard: Guard::mint(port),
|
||||||
|
log: RefCell::new(Vec::new()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The URL to open, token and all. Printed once at start; the page
|
||||||
|
/// cannot mint one for itself.
|
||||||
|
pub fn url(&self) -> String {
|
||||||
|
self.guard.page_url()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What was refused, for the evidence a session leaves behind.
|
||||||
|
pub fn refusals(&self) -> Vec<String> {
|
||||||
|
self.log.borrow().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve until the page reports something that resolves to a move.
|
||||||
|
///
|
||||||
|
/// Requests that fail a control are answered and the loop continues —
|
||||||
|
/// a refused request must not end someone's game, and must not be
|
||||||
|
/// silently indistinguishable from one that did nothing.
|
||||||
|
pub fn next_choice(
|
||||||
|
&self,
|
||||||
|
state: &GroundState,
|
||||||
|
seat: PlayerId,
|
||||||
|
legal: &[GroundCommand],
|
||||||
|
may_pass: bool,
|
||||||
|
) -> Result<Choice, String> {
|
||||||
|
let view = state.project(Viewer::Player(seat));
|
||||||
|
loop {
|
||||||
|
let (mut stream, _) = self.listener.accept().map_err(|e| format!("accept: {e}"))?;
|
||||||
|
let raw = match read_request(&mut stream) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
respond(&mut stream, 400, "text/plain", &e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let req = match Request::parse(&raw) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(refusal) => {
|
||||||
|
self.log.borrow_mut().push(refusal.to_string());
|
||||||
|
respond(&mut stream, 400, "text/plain", &refusal.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(refusal) = self.guard.admit(&req) {
|
||||||
|
self.log.borrow_mut().push(refusal.to_string());
|
||||||
|
respond(&mut stream, 403, "text/plain", &refusal.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match (req.method.as_str(), req.path.as_str()) {
|
||||||
|
("GET", "/") => {
|
||||||
|
let page = document(&view, legal, &self.guard.endpoint(), Some(seat), may_pass);
|
||||||
|
respond(&mut stream, 200, "text/html; charset=utf-8", &page);
|
||||||
|
}
|
||||||
|
("POST", "/command") => {
|
||||||
|
let fact = match PointerFact::parse(&req.body) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) => {
|
||||||
|
respond(&mut stream, 400, "text/plain", &e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if may_pass && fact.down == "pass" && fact.up == "pass" {
|
||||||
|
respond(&mut stream, 200, "text/plain", "ok: passed");
|
||||||
|
return Ok(Choice::Pass);
|
||||||
|
}
|
||||||
|
match resolve(&fact, legal, seat) {
|
||||||
|
Ok(i) => {
|
||||||
|
respond(&mut stream, 200, "text/plain", "ok");
|
||||||
|
return Ok(Choice::Command(i));
|
||||||
|
}
|
||||||
|
// Not an error: the player dragged somewhere that
|
||||||
|
// means nothing. Say so and keep the turn.
|
||||||
|
Err(why) => respond(&mut stream, 200, "text/plain", &why),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => respond(&mut stream, 404, "text/plain", "no such thing here"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One seat's view of the shared server.
|
||||||
|
pub struct SeatPolicy {
|
||||||
|
server: Rc<Server>,
|
||||||
|
failure: Rc<RefCell<Option<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SeatPolicy {
|
||||||
|
pub fn new(server: Rc<Server>, failure: Rc<RefCell<Option<String>>>) -> Self {
|
||||||
|
Self { server, failure }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Policy for SeatPolicy {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"browser"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn choose(
|
||||||
|
&mut self,
|
||||||
|
state: &GroundState,
|
||||||
|
seat: PlayerId,
|
||||||
|
legal: &[GroundCommand],
|
||||||
|
may_pass: bool,
|
||||||
|
) -> Choice {
|
||||||
|
match self.server.next_choice(state, seat, legal, may_pass) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
// Same shape as the CLI's out-of-input path: record the
|
||||||
|
// real reason and return something the driver will
|
||||||
|
// reject, rather than inventing a move.
|
||||||
|
*self.failure.borrow_mut() = Some(e);
|
||||||
|
Choice::Command(usize::MAX)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_request(stream: &mut TcpStream) -> Result<String, String> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut chunk = [0u8; 1024];
|
||||||
|
loop {
|
||||||
|
let n = stream.read(&mut chunk).map_err(|e| format!("read: {e}"))?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buf.extend_from_slice(&chunk[..n]);
|
||||||
|
// Once the head is in, read exactly the declared body and stop —
|
||||||
|
// otherwise a keep-alive connection blocks here forever.
|
||||||
|
if let Some(head_end) = find(&buf, b"\r\n\r\n") {
|
||||||
|
let head = String::from_utf8_lossy(&buf[..head_end]).to_string();
|
||||||
|
let want: usize = head
|
||||||
|
.split("\r\n")
|
||||||
|
.find_map(|l| {
|
||||||
|
let (k, v) = l.split_once(':')?;
|
||||||
|
k.eq_ignore_ascii_case("content-length")
|
||||||
|
.then(|| v.trim().parse().ok())?
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
if buf.len() >= head_end + 4 + want {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if buf.len() > 64 * 1024 {
|
||||||
|
return Err("request too large".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String::from_utf8(buf).map_err(|_| "request is not UTF-8".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||||
|
haystack.windows(needle.len()).position(|w| w == needle)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &str) {
|
||||||
|
let reason = match status {
|
||||||
|
200 => "OK",
|
||||||
|
400 => "Bad Request",
|
||||||
|
403 => "Forbidden",
|
||||||
|
_ => "Not Found",
|
||||||
|
};
|
||||||
|
let head = format!(
|
||||||
|
"HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\n\
|
||||||
|
Content-Length: {}\r\nConnection: close\r\n\
|
||||||
|
X-Content-Type-Options: nosniff\r\n\r\n",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
let _ = stream.write_all(head.as_bytes());
|
||||||
|
let _ = stream.write_all(body.as_bytes());
|
||||||
|
let _ = stream.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use games_ground::Action;
|
||||||
|
|
||||||
|
/// One client thread issuing requests **in order**, each on its own
|
||||||
|
/// `Connection: close` socket and each fully read before the next is
|
||||||
|
/// sent. Two concurrent clients would race the server's `accept`, and
|
||||||
|
/// a test whose outcome depends on which socket wins is worse than no
|
||||||
|
/// test.
|
||||||
|
fn converse(port: u16, requests: Vec<String>) -> std::thread::JoinHandle<Vec<String>> {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
requests
|
||||||
|
.into_iter()
|
||||||
|
.map(|raw| {
|
||||||
|
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
|
||||||
|
s.write_all(raw.as_bytes()).expect("write");
|
||||||
|
let mut out = String::new();
|
||||||
|
let _ = s.read_to_string(&mut out);
|
||||||
|
out
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(token: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1\r\n\
|
||||||
|
Sec-Fetch-Site: same-origin\r\n\r\n"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post(token: &str, body: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1\r\n\
|
||||||
|
Sec-Fetch-Site: same-origin\r\nContent-Length: {}\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ground_only() -> Vec<GroundCommand> {
|
||||||
|
vec![GroundCommand::SelectAction {
|
||||||
|
action: Action::Ground,
|
||||||
|
target: None,
|
||||||
|
problem: None,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state() -> GroundState {
|
||||||
|
<GroundState as cb_game_runtime::ScenarioGame>::setup(
|
||||||
|
&cb_game_runtime::Setup {
|
||||||
|
players: 3,
|
||||||
|
preset: "standard-3p".to_string(),
|
||||||
|
patch: Default::default(),
|
||||||
|
},
|
||||||
|
7,
|
||||||
|
)
|
||||||
|
.expect("setup")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole loop, with a real socket and no browser: the page is
|
||||||
|
/// served, a pointer fact is posted, and the seat's choice comes back.
|
||||||
|
#[test]
|
||||||
|
fn a_drag_posted_over_the_socket_becomes_a_choice() {
|
||||||
|
let server = Server::bind(0).expect("bind");
|
||||||
|
let port = server.listener.local_addr().unwrap().port();
|
||||||
|
let token = server.url().rsplit("t=").next().unwrap().to_string();
|
||||||
|
let client = converse(
|
||||||
|
port,
|
||||||
|
vec![get(&token), post(&token, "down=action-ground&up=table")],
|
||||||
|
);
|
||||||
|
|
||||||
|
let choice = server
|
||||||
|
.next_choice(&state(), PlayerId(0), &ground_only(), false)
|
||||||
|
.expect("a choice");
|
||||||
|
assert_eq!(choice, Choice::Command(0));
|
||||||
|
|
||||||
|
let replies = client.join().expect("client thread");
|
||||||
|
assert!(replies[0].contains("200 OK"), "{}", replies[0]);
|
||||||
|
assert!(replies[0].contains("GROUND"), "the page was not the table");
|
||||||
|
assert!(
|
||||||
|
replies[0].contains("id=\"action-ground\""),
|
||||||
|
"the offered action was not on the page"
|
||||||
|
);
|
||||||
|
assert!(replies[1].contains("200 OK"));
|
||||||
|
assert!(server.refusals().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ADR-0007 control 1, end to end rather than in the unit: a page
|
||||||
|
/// without the token gets nothing, and the seat keeps its turn.
|
||||||
|
///
|
||||||
|
/// M-D1-MUT: make `admit` return `Ok(())` unconditionally and this
|
||||||
|
/// goes red — the token-less request is served the table. Run
|
||||||
|
/// 2026-08-02.
|
||||||
|
#[test]
|
||||||
|
fn a_token_less_request_over_the_socket_is_refused_and_the_turn_continues() {
|
||||||
|
let server = Server::bind(0).expect("bind");
|
||||||
|
let port = server.listener.local_addr().unwrap().port();
|
||||||
|
let token = server.url().rsplit("t=").next().unwrap().to_string();
|
||||||
|
let client = converse(
|
||||||
|
port,
|
||||||
|
vec![
|
||||||
|
"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nSec-Fetch-Site: same-origin\r\n\r\n"
|
||||||
|
.to_string(),
|
||||||
|
post(&token, "down=action-ground&up=table"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The turn survives the refusal and is decided by the good request.
|
||||||
|
let choice = server
|
||||||
|
.next_choice(&state(), PlayerId(0), &ground_only(), false)
|
||||||
|
.expect("a choice");
|
||||||
|
assert_eq!(choice, Choice::Command(0));
|
||||||
|
|
||||||
|
let replies = client.join().expect("client thread");
|
||||||
|
assert!(replies[0].contains("403 Forbidden"), "{}", replies[0]);
|
||||||
|
assert!(
|
||||||
|
!replies[0].contains("GROUND"),
|
||||||
|
"the table leaked to a token-less request"
|
||||||
|
);
|
||||||
|
assert!(replies[1].contains("200 OK"));
|
||||||
|
assert_eq!(
|
||||||
|
server.refusals(),
|
||||||
|
vec!["refused: no session token".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A drag that means nothing must not end the turn, and must say so.
|
||||||
|
#[test]
|
||||||
|
fn a_meaningless_drag_keeps_the_turn() {
|
||||||
|
let server = Server::bind(0).expect("bind");
|
||||||
|
let port = server.listener.local_addr().unwrap().port();
|
||||||
|
let token = server.url().rsplit("t=").next().unwrap().to_string();
|
||||||
|
let client = converse(
|
||||||
|
port,
|
||||||
|
vec![
|
||||||
|
post(&token, "down=seat-1&up=seat-2"),
|
||||||
|
post(&token, "down=action-ground&up=table"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let choice = server
|
||||||
|
.next_choice(&state(), PlayerId(0), &ground_only(), false)
|
||||||
|
.expect("a choice");
|
||||||
|
assert_eq!(choice, Choice::Command(0));
|
||||||
|
|
||||||
|
let replies = client.join().expect("client thread");
|
||||||
|
assert!(replies[0].contains("200 OK"));
|
||||||
|
assert!(
|
||||||
|
replies[0].contains("not a legal move"),
|
||||||
|
"a meaningless drag must be told it meant nothing: {}",
|
||||||
|
replies[0]
|
||||||
|
);
|
||||||
|
assert!(replies[1].contains("200 OK"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -761,6 +761,7 @@ mod tests {
|
||||||
bot: "greedy".into(),
|
bot: "greedy".into(),
|
||||||
replay_dir: Some(dir.clone()),
|
replay_dir: Some(dir.clone()),
|
||||||
record: Some(dir.join("session.yaml")),
|
record: Some(dir.join("session.yaml")),
|
||||||
|
serve: None,
|
||||||
};
|
};
|
||||||
let mut sink: Vec<u8> = Vec::new();
|
let mut sink: Vec<u8> = Vec::new();
|
||||||
let summary =
|
let summary =
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
//! success — a session that ends without an outcome is a failure, and
|
//! success — a session that ends without an outcome is a failure, and
|
||||||
//! saying otherwise is how a stalled game passes for a played one.
|
//! saying otherwise is how a stalled game passes for a played one.
|
||||||
|
|
||||||
|
mod hotseat;
|
||||||
mod inspect;
|
mod inspect;
|
||||||
mod table;
|
mod table;
|
||||||
|
|
||||||
|
|
@ -16,7 +17,7 @@ use table::Config;
|
||||||
|
|
||||||
const USAGE: &str = "\
|
const USAGE: &str = "\
|
||||||
usage: cb-play [--seed N] [--players N] [--seat N]... [--bot greedy|random]
|
usage: cb-play [--seed N] [--players N] [--seat N]... [--bot greedy|random]
|
||||||
[--replay DIR] [--all-bots] [--record FILE]
|
[--replay DIR] [--all-bots] [--record FILE] [--serve PORT]
|
||||||
cb-play --inspect PATH [--as SEAT|spectator]
|
cb-play --inspect PATH [--as SEAT|spectator]
|
||||||
|
|
||||||
play:
|
play:
|
||||||
|
|
@ -27,6 +28,10 @@ play:
|
||||||
--bot KIND policy for every other seat: greedy (default) or random
|
--bot KIND policy for every other seat: greedy (default) or random
|
||||||
--replay DIR write a .cbreplay bundle of the finished game to DIR
|
--replay DIR write a .cbreplay bundle of the finished game to DIR
|
||||||
--record FILE write the finished game as a scenario YAML
|
--record FILE write the finished game as a scenario YAML
|
||||||
|
--serve PORT play human seats in a browser on 127.0.0.1:PORT instead
|
||||||
|
of on the terminal; 0 lets the OS pick. Prints a URL
|
||||||
|
carrying a per-process token — without it the page is
|
||||||
|
refused (ADR-0007).
|
||||||
|
|
||||||
inspect a recorded game — a .cbreplay bundle directory or a scenario YAML:
|
inspect a recorded game — a .cbreplay bundle directory or a scenario YAML:
|
||||||
--inspect PATH render the table after every recorded step
|
--inspect PATH render the table after every recorded step
|
||||||
|
|
@ -104,6 +109,15 @@ fn parse_args(argv: &[String]) -> Result<Mode, String> {
|
||||||
config.replay_dir = Some(value(i, argv, flag)?.into());
|
config.replay_dir = Some(value(i, argv, flag)?.into());
|
||||||
i += 2;
|
i += 2;
|
||||||
}
|
}
|
||||||
|
"--serve" => {
|
||||||
|
play_flags.push(flag.into());
|
||||||
|
config.serve = Some(
|
||||||
|
value(i, argv, flag)?
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| format!("--serve: {e}"))?,
|
||||||
|
);
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
"--all-bots" => {
|
"--all-bots" => {
|
||||||
all_bots = true;
|
all_bots = true;
|
||||||
play_flags.push(flag.into());
|
play_flags.push(flag.into());
|
||||||
|
|
@ -245,6 +259,7 @@ mod tests {
|
||||||
bot: "greedy".into(),
|
bot: "greedy".into(),
|
||||||
replay_dir: None,
|
replay_dir: None,
|
||||||
record: None,
|
record: None,
|
||||||
|
serve: None,
|
||||||
};
|
};
|
||||||
let script = "0\n".repeat(400);
|
let script = "0\n".repeat(400);
|
||||||
let mut out: Vec<u8> = Vec::new();
|
let mut out: Vec<u8> = Vec::new();
|
||||||
|
|
@ -283,6 +298,7 @@ mod tests {
|
||||||
bot: "greedy".into(),
|
bot: "greedy".into(),
|
||||||
replay_dir: None,
|
replay_dir: None,
|
||||||
record: None,
|
record: None,
|
||||||
|
serve: None,
|
||||||
};
|
};
|
||||||
let mut out: Vec<u8> = Vec::new();
|
let mut out: Vec<u8> = Vec::new();
|
||||||
table::play(&config, "0\n".repeat(200).as_bytes(), &mut out).expect("game");
|
table::play(&config, "0\n".repeat(200).as_bytes(), &mut out).expect("game");
|
||||||
|
|
@ -344,6 +360,7 @@ mod tests {
|
||||||
bot: "random".into(),
|
bot: "random".into(),
|
||||||
replay_dir: None,
|
replay_dir: None,
|
||||||
record: None,
|
record: None,
|
||||||
|
serve: None,
|
||||||
};
|
};
|
||||||
let mut out: Vec<u8> = Vec::new();
|
let mut out: Vec<u8> = Vec::new();
|
||||||
let summary = table::play(&config, "".as_bytes(), &mut out).expect("bot game");
|
let summary = table::play(&config, "".as_bytes(), &mut out).expect("bot game");
|
||||||
|
|
@ -403,6 +420,7 @@ mod tests {
|
||||||
bot: "greedy".into(),
|
bot: "greedy".into(),
|
||||||
replay_dir: Some(dir.clone()),
|
replay_dir: Some(dir.clone()),
|
||||||
record: Some(dir.join("session.yaml")),
|
record: Some(dir.join("session.yaml")),
|
||||||
|
serve: None,
|
||||||
};
|
};
|
||||||
let mut out: Vec<u8> = Vec::new();
|
let mut out: Vec<u8> = Vec::new();
|
||||||
let summary = table::play(&config, "".as_bytes(), &mut out).expect("game");
|
let summary = table::play(&config, "".as_bytes(), &mut out).expect("game");
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ pub struct Config {
|
||||||
/// Where to write the finished game as a scenario file. A session
|
/// Where to write the finished game as a scenario file. A session
|
||||||
/// somebody played becomes a regression test.
|
/// somebody played becomes a regression test.
|
||||||
pub record: Option<std::path::PathBuf>,
|
pub record: Option<std::path::PathBuf>,
|
||||||
|
/// Serve human seats in a browser instead of on the terminal
|
||||||
|
/// (ADR-0007). `Some(0)` lets the OS pick the port.
|
||||||
|
pub serve: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
|
|
@ -43,6 +46,7 @@ impl Default for Config {
|
||||||
bot: "greedy".into(),
|
bot: "greedy".into(),
|
||||||
replay_dir: None,
|
replay_dir: None,
|
||||||
record: None,
|
record: None,
|
||||||
|
serve: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -213,10 +217,28 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
|
||||||
// Human seats and bot seats fill one policy vector; the driver does
|
// Human seats and bot seats fill one policy vector; the driver does
|
||||||
// not know which is which, which is the point.
|
// not know which is which, which is the point.
|
||||||
let failure: Failure = Default::default();
|
let failure: Failure = Default::default();
|
||||||
|
// ADR-0007: a browser seat and a CLI seat are both just a Policy, so
|
||||||
|
// the driver cannot tell them apart — which is the property that lets
|
||||||
|
// a browser game replay as a scenario like any other.
|
||||||
|
let server = match config.serve {
|
||||||
|
Some(port) => {
|
||||||
|
let s = std::rc::Rc::new(crate::hotseat::Server::bind(port)?);
|
||||||
|
let _ = writeln!(out.borrow_mut(), " open {}", s.url());
|
||||||
|
let _ = out.borrow_mut().flush();
|
||||||
|
Some(s)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
let mut policies: Vec<Box<dyn Policy + 'a>> = Vec::new();
|
let mut policies: Vec<Box<dyn Policy + 'a>> = Vec::new();
|
||||||
for seat in 0..config.players {
|
for seat in 0..config.players {
|
||||||
if config.human_seats.contains(&seat) {
|
if config.human_seats.contains(&seat) {
|
||||||
policies.push(Box::new(HumanPolicy::new(input, out, failure.clone())));
|
match &server {
|
||||||
|
Some(s) => policies.push(Box::new(crate::hotseat::SeatPolicy::new(
|
||||||
|
s.clone(),
|
||||||
|
failure.clone(),
|
||||||
|
))),
|
||||||
|
None => policies.push(Box::new(HumanPolicy::new(input, out, failure.clone()))),
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
policies.push(bot_policy(&config.bot, config.seed + u64::from(seat))?);
|
policies.push(bot_policy(&config.bot, config.seed + u64::from(seat))?);
|
||||||
}
|
}
|
||||||
|
|
@ -248,6 +270,20 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>(
|
||||||
game.commands,
|
game.commands,
|
||||||
&end_hash[..12]
|
&end_hash[..12]
|
||||||
);
|
);
|
||||||
|
// ADR-0007 control 1 leaves evidence rather than only a 403: a
|
||||||
|
// session that was probed says so, so a player finds out from the
|
||||||
|
// transcript rather than from nothing at all.
|
||||||
|
if let Some(s) = &server {
|
||||||
|
let refusals = s.refusals();
|
||||||
|
if refusals.is_empty() {
|
||||||
|
let _ = writeln!(w, " no requests were refused this session");
|
||||||
|
} else {
|
||||||
|
let _ = writeln!(w, " {} request(s) refused:", refusals.len());
|
||||||
|
for r in &refusals {
|
||||||
|
let _ = writeln!(w, " {r}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let _ = w.flush();
|
let _ = w.flush();
|
||||||
drop(w);
|
drop(w);
|
||||||
|
|
||||||
|
|
|
||||||
251
workplans/CB-WP-0012-render-port.md
Normal file
251
workplans/CB-WP-0012-render-port.md
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
---
|
||||||
|
id: CB-WP-0012
|
||||||
|
kind: product
|
||||||
|
title: "Stage 1, second slice: the rendering port"
|
||||||
|
status: done
|
||||||
|
---
|
||||||
|
|
||||||
|
# Purpose
|
||||||
|
|
||||||
|
Tier **L**, the declaration CB-WP-0011 deferred and owed.
|
||||||
|
|
||||||
|
```
|
||||||
|
structural tier L (INTENT stage 1 creates a new capability port)
|
||||||
|
chaos d4 = 1 → no override
|
||||||
|
declared tier L
|
||||||
|
```
|
||||||
|
|
||||||
|
Declaration 7 of 12 in the calibration window. The CHAOS gate earned its
|
||||||
|
first `caught` entry last pass by rolling this same declaration down to S;
|
||||||
|
this time it rolls nothing, so the pass runs at full weight — survey,
|
||||||
|
adversarial review, ADR, then code. The hard gate applies as stated: **no
|
||||||
|
implementation code for the render capability exists before ADR-0007 is
|
||||||
|
committed.**
|
||||||
|
|
||||||
|
## What this pass owes
|
||||||
|
|
||||||
|
INTENT stage 1 is *"Inspectable 2D table — card/token/hand/relationship-
|
||||||
|
graph visualization, drag-to-propose, debug inspector, hot-seat play."*
|
||||||
|
CB-WP-0011 shipped the debug inspector. This pass owes the other three,
|
||||||
|
and the port they hang from:
|
||||||
|
|
||||||
|
```
|
||||||
|
cb-render-api
|
||||||
|
├── cb-render-null
|
||||||
|
└── cb-render-??? <- the survey's question
|
||||||
|
```
|
||||||
|
|
||||||
|
**The leading constraint is AM-4a**: 246,250 lines of 250,000, measured —
|
||||||
|
**3,750 lines of headroom**. Every candidate implementation of a 2D table
|
||||||
|
is a five- or six-figure line count. This is the first capability in the
|
||||||
|
project whose obvious implementation costs more than the entire remaining
|
||||||
|
budget, so the survey's first duty is to establish whether that is a real
|
||||||
|
obstacle or an artifact of how AM-4a is instrumented.
|
||||||
|
|
||||||
|
## Task: survey the render port and its dependency cost
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CB-WP-0012-T01
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Write `research/CB-RES-0006-render-port.md`.
|
||||||
|
|
||||||
|
**Measure, do not estimate.** The candidate line counts must come from
|
||||||
|
the same method `tools/dep-weight.py` uses — `cargo tree --edges normal`
|
||||||
|
over a real resolved graph, then counted `.rs` lines in vendored source —
|
||||||
|
so the numbers are comparable to the AM-4a figure rather than merely
|
||||||
|
adjacent. Last pass's correction was a hand-counted number; this pass has
|
||||||
|
no excuse for one.
|
||||||
|
|
||||||
|
**Measure the marginal cost, not the total.** What AM-4a charges is what a
|
||||||
|
candidate adds to a graph that already holds 23 crates. A candidate's
|
||||||
|
headline size overstates its cost by whatever it shares with the base.
|
||||||
|
|
||||||
|
Cover at minimum: an immediate-mode GUI (`egui`/`eframe`), a 2D game
|
||||||
|
framework (`macroquad`), the raw stage-2 stack (`wgpu` + `winit`), a CPU
|
||||||
|
rasterizer (`softbuffer` + `tiny-skia`), a terminal UI (`ratatui`), and at
|
||||||
|
least one option that is not a Rust toolkit at all.
|
||||||
|
|
||||||
|
**Ask whether the instrument is right.** If AM-4a's figure counts code
|
||||||
|
that never reaches a shipped binary, the headroom it reports is wrong, and
|
||||||
|
that has been true for every pass that has cited it. Check this before
|
||||||
|
recommending any budget change — a survey that argues for raising a target
|
||||||
|
it has not first audited is arguing in its own favour.
|
||||||
|
|
||||||
|
**Done 2026-08-02.** [CB-RES-0006](../research/CB-RES-0006-render-port.md).
|
||||||
|
Two findings, and they point opposite ways.
|
||||||
|
|
||||||
|
The cheapest candidate that opens a window (`macroquad`) costs a marginal
|
||||||
|
**480,501** lines — **128×** the headroom. `egui` + `eframe` costs
|
||||||
|
2,782,849. `ratatui`, the option one expects to be cheap, costs *more than
|
||||||
|
macroquad* (1,067,013) because `rustix` pulls `linux-raw-sys` at 479,901
|
||||||
|
lines.
|
||||||
|
|
||||||
|
And AM-4a is mis-instrumented: **36.2% of the shipped-runtime figure
|
||||||
|
(89,048 lines) is proc-macro crates** — `syn` alone is 66,916 — which run
|
||||||
|
in the compiler and never reach a binary. Real headroom is **92,798**, not
|
||||||
|
3,750. Every pass that cited 3,750, this workplan's own Purpose included,
|
||||||
|
cited a number wrong in the conservative direction.
|
||||||
|
|
||||||
|
The recommendation survives the correction: `macroquad` is still 5.2× over
|
||||||
|
at 92,798. That was the condition for proposing the correction at all, and
|
||||||
|
the correction is filed separately rather than bundled with the decision
|
||||||
|
it would unblock.
|
||||||
|
|
||||||
|
Recommended: `cb-render-api` + `cb-render-null` + **`cb-render-html`** —
|
||||||
|
emitted HTML/SVG/JS, marginal AM-4a cost **zero**, with `cb-render-wgpu`
|
||||||
|
left to stage 2 where it becomes the interface's second use.
|
||||||
|
|
||||||
|
## Task: adversarial review of the survey
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CB-WP-0012-T02
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Step 2 of the inner loop, run against T01's recommendation rather than for
|
||||||
|
it. The review must name, at minimum:
|
||||||
|
|
||||||
|
- what the recommended option **cannot** do that a windowed toolkit can,
|
||||||
|
stated concretely rather than as a caveat;
|
||||||
|
- the failure mode of building the port around a single implementation
|
||||||
|
when the second-use rule exists precisely to stop that;
|
||||||
|
- whether the survey's dependency argument would survive if AM-4a's target
|
||||||
|
were simply wrong, i.e. whether the recommendation is load-bearing on a
|
||||||
|
number the same pass proposes to correct.
|
||||||
|
|
||||||
|
A review that ratifies the survey without producing a control the survey
|
||||||
|
lacked has not run.
|
||||||
|
|
||||||
|
**Done 2026-08-02.** [challenge](../history/260802-render-port-challenge.md),
|
||||||
|
[response](../history/260802-render-port-response.md). **Not approvable as
|
||||||
|
written** — four of six challenges conceded, and the survey's two main
|
||||||
|
arguments replaced.
|
||||||
|
|
||||||
|
- **C1**: the survey concluded the sub-100k region was empty without
|
||||||
|
measuring it. It is not empty. `tiny-skia` fits at 83,956; the real
|
||||||
|
windowed floor is `fltk` at **140,079 — 1.5×** corrected headroom, not
|
||||||
|
128×. "Two orders of magnitude" withdrawn.
|
||||||
|
- **C1b**, which the concession exposed: `wgpu` + `winit` is **1,741,979**
|
||||||
|
marginal lines against a 250,000 target. **AM-4a is incompatible with
|
||||||
|
INTENT stage 2** — 7× the whole target — and has been since both were
|
||||||
|
written. Raised for the maintainer, deliberately not decided here.
|
||||||
|
- **C2**: "marginal cost zero" was scored on an axis chosen to produce
|
||||||
|
zero, and the same move already flattered `sdl2`/`fltk`. One acquisition
|
||||||
|
rule now covers all three, and it *raises* two candidates' cost.
|
||||||
|
- **C3**: the survey contradicted itself on the second-use rule.
|
||||||
|
**`cb-render-api` and `cb-render-null` are withdrawn from this pass.**
|
||||||
|
- **C4/C5**: six controls adopted as binding on ADR-0007 — a loopback
|
||||||
|
token with a mutation-backed refusal test, JS barred from constructing
|
||||||
|
commands, and the coverage gate crossing the language boundary.
|
||||||
|
- **C6**: the candidate measurements did carry a positive control (empty
|
||||||
|
`unlocated` for all seven, verified). The C1 batch did **not** — it
|
||||||
|
copied the measurement function without the guards — and was re-measured
|
||||||
|
under them before being cited. The HTML row is relabelled *0 by
|
||||||
|
construction, not by measurement*.
|
||||||
|
|
||||||
|
**Fidelity note:** the review ran in the same session as the survey rather
|
||||||
|
than a separate one, per this environment's standing instruction not to
|
||||||
|
spawn agents unasked. It therefore inherits the author's sampling and is a
|
||||||
|
lower bound on what a separate reviewer would find.
|
||||||
|
|
||||||
|
**Note on tier:** this pass no longer creates a capability port, which was
|
||||||
|
its structural trigger for tier L. The declaration and its roll stand — a
|
||||||
|
tier that changes because review shrank the work would be a function of the
|
||||||
|
outcome. Recorded as the CHAOS window's second entry.
|
||||||
|
|
||||||
|
## Task: ADR-0007 — the render port
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CB-WP-0012-T03
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Record the decision, the rejected alternatives with their measured costs,
|
||||||
|
and the controls the implementation must carry. No implementation code
|
||||||
|
lands before this commits.
|
||||||
|
|
||||||
|
**Done 2026-08-02.**
|
||||||
|
[ADR-0007](../decisions/ADR-0007-render-html-not-a-port.md) — *render to
|
||||||
|
HTML, and do not declare the port yet.* Five decisions, eight rejected
|
||||||
|
alternatives with measured costs, six binding controls, and **two items
|
||||||
|
reserved for the maintainer**: AM-4a's 7× incompatibility with stage 2, and
|
||||||
|
whether the acquisition rule proposed by the pass that benefits from it is
|
||||||
|
the right rule.
|
||||||
|
|
||||||
|
The hard gate held: no render implementation code existed before this
|
||||||
|
commit.
|
||||||
|
|
||||||
|
## Task: the port and its first implementation
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CB-WP-0012-T04
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
`cb-render-api` plus `cb-render-null` and the implementation ADR-0007
|
||||||
|
chooses, delivering visualization, drag-to-propose and hot-seat play.
|
||||||
|
|
||||||
|
**K13 binds here.** The renderer consumes a `Project`ion and can never
|
||||||
|
feed back into validation; a drag that proposes a move must go through the
|
||||||
|
same command path a CLI move takes. The inspector's coverage gate is the
|
||||||
|
precedent for the control this needs.
|
||||||
|
|
||||||
|
**Done 2026-08-02.** `crates/cb-render-html` (doc/input/serve) and
|
||||||
|
`tools/cb-play/src/hotseat.rs`, behind `cb-play --serve PORT`. Per
|
||||||
|
ADR-0007 D2 there is **no `cb-render-api` and no `cb-render-null`**.
|
||||||
|
|
||||||
|
Marginal AM-4a cost, measured rather than claimed: **0 new third-party
|
||||||
|
crates** (23 before, 23 after). AM-4a unmoved at 246,250.
|
||||||
|
|
||||||
|
Eight mutations, each red for its stated reason. Two results worth more
|
||||||
|
than the six that behaved:
|
||||||
|
|
||||||
|
| control | result |
|
||||||
|
|---|---|
|
||||||
|
| a field present in neither list | fired **for real on the first run** — `ground_choices.*.choice`, `ground_choices.*.problem`, and `players.*.blame_from`, the last being an **empty vector**, a leaf path a fully-populated fixture would never produce |
|
||||||
|
| `Sec-Fetch-Site` arm removed | **stayed green** — the `Origin` check caught it independently. Both had to go before the control bit; a control that passes for an unintended reason has not been demonstrated |
|
||||||
|
|
||||||
|
**Never executed: the emitted JavaScript.** The socket loop is tested
|
||||||
|
end to end with synthetic HTTP and the page is asserted against as a
|
||||||
|
parsed document, but no browser engine has run `SCRIPT`.
|
||||||
|
|
||||||
|
## Task: evidence
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CB-WP-0012-T05
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
```
|
||||||
|
|
||||||
|
`evidence/CB-EV-0010-render-port.md`. Carry the standing items: the
|
||||||
|
falsifiable prediction from CB-EV-0009 §4 that the trailing-3 meta share
|
||||||
|
drops to 0% this pass, SH-3 at 0.0% for a sixth pass, and whether tier L
|
||||||
|
at full weight produced anything the rolled-down pass would have missed —
|
||||||
|
the CHAOS window's second data point, and the first from a non-override.
|
||||||
|
|
||||||
|
**Done 2026-08-02.**
|
||||||
|
[CB-EV-0010](../evidence/CB-EV-0010-render-port.md).
|
||||||
|
|
||||||
|
- **Tier L deleted its own deliverable.** Review withdrew the port. A
|
||||||
|
tier-S pass has no step 2 and would have shipped it.
|
||||||
|
- **The prediction held: meta budget reads 0%**, published in advance and
|
||||||
|
unfalsified.
|
||||||
|
- **A correction, and a pattern.** CB-EV-0009 reported CB-WP-0011 at
|
||||||
|
`45 responses / $4.23 / 0.094`; final is `71 / $7.02 / 0.099`. Still the
|
||||||
|
cheapest pass, so its conclusion stands. But this is the **second**
|
||||||
|
consecutive evidence file to report its own pass's cost low — a pass
|
||||||
|
cannot measure its own cost, and every evidence file quoting its own is
|
||||||
|
quoting a floor.
|
||||||
|
- **Priced tier comparison, first ever on one subject**: 0.123 $/response
|
||||||
|
at L against 0.099 at S — 24% more, for a pass that found errors of 25×
|
||||||
|
and 85×.
|
||||||
|
- **SH-3 at 0.0% for a sixth pass.** Oldest unargued number in the
|
||||||
|
project; owed a declaration of its own.
|
||||||
|
- **INTENT stage 1 stays open** — all four deliverables now exist, and
|
||||||
|
none of the browser half has ever been run.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue