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