CB-WP-0012-T04: cb-render-html — stage 1 draws, and the browser is the toolkit
Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat
play, at a measured marginal AM-4a cost of zero.
games-ground shipped: 23 third-party crates
cb-render-html: 23 third-party crates
new crates introduced: 0
Measured, not asserted — the survey's own lesson. AM-4a is unmoved at
246,250; own source is 7,636 -> 9,652.
What shipped:
crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship
graph), input.rs (pointer facts -> commands),
serve.rs (Guard, Request, loopback bind)
tools/cb-play hotseat.rs + `--serve PORT`
Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null.
The renderer targets the existing Project trait; the port waits for
stage 2's wgpu implementation to be its second use.
The six controls, all live, all mutation-checked (8 mutations, each red
for its stated reason):
1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind
4 a token-less request is refused, in the unit AND over a real socket
5 JS may not construct commands — the page reports pointer facts, Rust
resolves them against the legal list the aggregate already offered,
and a test asserts the emitted script contains no game vocabulary
6 the coverage gate crosses the language boundary: it walks the
serialized view for leaf paths and requires each token to appear in
the PARSED emitted document, with a test that the parse really is a
parse (script/style contents must not count as rendered)
The gate fired on its author again, on its first run: ground_choices.*.
choice, ground_choices.*.problem and players.*.blame_from were in neither
list. The last is the one worth keeping — an EMPTY vector is a leaf path
of its own, and it now renders as an explicit absence.
Also, a mutation that did not go red: removing the Sec-Fetch-Site arm
alone left the cross-site test green, because the Origin check caught it
independently. Both had to be removed before the control bit. Recorded
because a control that passes for a reason you did not intend has not
been demonstrated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
883b608f36
commit
84d688688d
14 changed files with 2058 additions and 2 deletions
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"
|
||||
|
|
|
|||
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],
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,13 @@
|
|||
# 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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue