Compare commits
No commits in common. "c38ecd9da36903166aae42188c123516ba23b465" and "7363e87bddc3ce0e0cb09701ca637ff1943074a9" have entirely different histories.
c38ecd9da3
...
7363e87bdd
22 changed files with 6 additions and 3492 deletions
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -81,21 +81,11 @@ 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,7 +4,6 @@ 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",
|
||||||
|
|
@ -20,7 +19,6 @@ 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 | done | — | workplans/CB-WP-0011-inspectable-table.md |
|
| workplan | CB-WP-0011 | todo | — | 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,11 +86,6 @@
|
||||||
| 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 | done | — | workplans/CB-WP-0011-inspectable-table.md |
|
| task | CB-WP-0011-T01 | 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-T02 | todo | — | workplans/CB-WP-0011-inspectable-table.md |
|
||||||
| task | CB-WP-0011-T03 | done | — | workplans/CB-WP-0011-inspectable-table.md |
|
| task | CB-WP-0011-T03 | todo | — | 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 |
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
[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
|
|
||||||
|
|
@ -1,544 +0,0 @@
|
||||||
//! 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("&", "&")
|
|
||||||
}
|
|
||||||
|
|
@ -1,270 +0,0 @@
|
||||||
//! 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,333 +0,0 @@
|
||||||
//! `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;
|
|
||||||
|
|
@ -1,323 +0,0 @@
|
||||||
//! 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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
//! 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],
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
# 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,7 +122,6 @@ 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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -1,320 +0,0 @@
|
||||||
# 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,7 +7,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,373 +0,0 @@
|
||||||
//! 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,7 +761,6 @@ 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,7 +9,6 @@
|
||||||
//! 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;
|
||||||
|
|
||||||
|
|
@ -17,7 +16,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] [--serve PORT]
|
[--replay DIR] [--all-bots] [--record FILE]
|
||||||
cb-play --inspect PATH [--as SEAT|spectator]
|
cb-play --inspect PATH [--as SEAT|spectator]
|
||||||
|
|
||||||
play:
|
play:
|
||||||
|
|
@ -28,10 +27,6 @@ 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
|
||||||
|
|
@ -109,15 +104,6 @@ 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());
|
||||||
|
|
@ -259,7 +245,6 @@ 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();
|
||||||
|
|
@ -298,7 +283,6 @@ 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");
|
||||||
|
|
@ -360,7 +344,6 @@ 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");
|
||||||
|
|
@ -420,7 +403,6 @@ 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,9 +32,6 @@ 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 {
|
||||||
|
|
@ -46,7 +43,6 @@ impl Default for Config {
|
||||||
bot: "greedy".into(),
|
bot: "greedy".into(),
|
||||||
replay_dir: None,
|
replay_dir: None,
|
||||||
record: None,
|
record: None,
|
||||||
serve: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -217,28 +213,10 @@ 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) {
|
||||||
match &server {
|
policies.push(Box::new(HumanPolicy::new(input, out, failure.clone())));
|
||||||
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))?);
|
||||||
}
|
}
|
||||||
|
|
@ -270,20 +248,6 @@ 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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,251 +0,0 @@
|
||||||
---
|
|
||||||
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