//! 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::>()
.join(" ")
}
/// The only JavaScript in the project. See the module docs.
pub const SCRIPT: &str = r#"
(function () {
var down = null, held = null, marked = [], ghost = null, ghostLabel = '';
function node(e) {
var n = e.target;
while (n && !(n.getAttribute && n.getAttribute('data-drop'))) { n = n.parentNode; }
return n;
}
function key(n) { return n ? n.getAttribute('data-drop') : null; }
function status(t) { document.getElementById('cb-status').textContent = t; }
// ADR-0010 D1: the destinations come from `data-targets`, which Rust
// wrote. This matches on them. It does not compute, infer, filter or
// default one -- a script that pattern-matched ids to guess what is
// legal would be forbidden even though the visible result is identical.
// ADR-0010 D1 again: `data-descs` is written by Rust, in step with
// `data-targets`. The script pairs them by index and renders one. It
// does not compose a description from an id.
function describeOf(held, key) {
if (!held) { return null; }
var t = held.getAttribute('data-targets');
var d = held.getAttribute('data-descs');
if (!t || !d) { return null; }
var i = t.split(' ').indexOf(key);
var all = d.split('|');
return i >= 0 && i < all.length ? all[i] : null;
}
function mark(n) {
var spec = n.getAttribute('data-targets');
if (!spec) { return; }
var want = spec.split(' ');
var all = document.querySelectorAll('[data-drop]');
for (var i = 0; i < all.length; i++) {
if (want.indexOf(all[i].getAttribute('data-drop')) >= 0) {
all[i].classList.add('dropok');
marked.push(all[i]);
}
}
}
function clear() {
for (var i = 0; i < marked.length; i++) { marked[i].classList.remove('dropok'); }
marked = [];
if (held) { held.classList.remove('held'); held = null; }
if (ghost && ghost.parentNode) { ghost.parentNode.removeChild(ghost); }
ghost = null;
ghostLabel = '';
down = null;
}
document.addEventListener('pointerdown', function (e) {
clear();
var n = node(e);
down = key(n);
if (!n || !down) { return; }
held = n;
n.classList.add('held');
mark(n);
if (n.getAttribute('data-targets')) {
ghost = document.createElement('div');
ghost.id = 'cb-ghost';
ghostLabel = n.textContent;
ghost.textContent = ghostLabel;
ghost.style.left = e.clientX + 'px';
ghost.style.top = e.clientY + 'px';
document.body.appendChild(ghost);
}
});
document.addEventListener('pointermove', function (e) {
if (!ghost) { return; }
ghost.style.left = e.clientX + 'px';
ghost.style.top = e.clientY + 'px';
// The explanation, beside the pointer and therefore beside the
// target it is over (CB-WP-0018 T03).
var over = describeOf(held, key(node(e)));
ghost.textContent = over || ghostLabel;
ghost.className = over ? 'over' : '';
});
document.addEventListener('pointercancel', clear);
document.addEventListener('pointerup', function (e) {
var up = key(node(e));
var grabbed = down;
clear();
if (!grabbed || !up) {
// Refusing is right; refusing SILENTLY is what let a broken drop
// target survive a human sitting in front of it (CB-WP-0016).
status(grabbed ? 'took ' + grabbed + ', let go over nothing droppable'
: 'nothing droppable under the pointer');
return;
}
var body = 'down=' + encodeURIComponent(grabbed) + '&up=' + encodeURIComponent(up);
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) {
status(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}
/* CB-WP-0017. Interactive and inert must not look identical: `.pick` is
the resting affordance on anything that can be picked up. ADR-0010 D5
claims no evidence that this READS as pickable -- that is a human
check, and stage 1 is open on it. */
.pick{cursor:grab;user-select:none;box-shadow:0 2px 0 #0006,0 0 0 1px #5a7a inset;
transition:transform .08s,box-shadow .08s}
.pick:hover{box-shadow:0 3px 8px #000a,0 0 0 1px #7ca inset;transform:translateY(-1px)}
/* Held: the thing in your hand is lifted and dimmed where it used to be. */
.held{cursor:grabbing;opacity:.45;transform:scale(.97)}
/* A legal destination for the thing currently held -- and ONLY for that
thing. The set is written by Rust into data-targets; the script matches
it and never derives it (ADR-0010 D1). */
.dropok{outline:2px dashed #9cf;outline-offset:3px;background:#1d2a33}
.dropok text{fill:#cfe}
/* The ghost that follows the pointer, so a drag is not invisible. */
#cb-ghost.over{background:#1d3347;border-color:#9cf;color:#cfe}
#cb-ghost{position:fixed;pointer-events:none;z-index:9;padding:.3rem .5rem;
border-radius:6px;background:#243;border:1px solid #5a7;color:#dde;
font:13px ui-monospace,monospace;box-shadow:0 6px 16px #000b;
transform:translate(-50%,-140%)}
.k{color:#89a}
.nil{color:#c88}
.eff{color:#8c9}
#cb-log{max-height:16rem;overflow-y:auto;font-size:13px}
#cb-status{margin-top:1rem;color:#fc9;min-height:1.2em}
svg{background:#1b1e26;border:1px solid #445;border-radius:6px}
";
/// Render drop keys as something a player can read.
///
/// The keys are what the page posts; these are what it shows. Both come
/// from the same list, so they cannot disagree about which moves exist.
fn target_names(targets: &[String]) -> String {
let mut out: Vec = Vec::new();
for t in targets {
out.push(match t.split_once('-') {
Some(("seat", n)) => n
.parse::()
.map(|n| seat_name(PlayerId(n)))
.unwrap_or_else(|_| t.clone()),
Some(("problem", n)) => format!("problem {n}"),
_ if t == "table" => "the table".to_string(),
_ => t.clone(),
});
}
out.join(", ")
}
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,
"\
{label}\
priority {priority}\
{sub}",
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(
"");
s
}
fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView) {
let is_viewer = view.viewer == Some(id);
let _ = write!(
out,
// CB-WP-0016 T01: the seat card is a drop target. It could not be
// while drop keys were `id`s — the graph node had already taken
// `seat-{n}` and ids must be unique, so the card the instruction
// text points at silently had none.
"
\
{name}{you} ",
raw = id.0,
name = seat_name(id),
you = if is_viewer { " (you)" } else { "" },
);
let _ = write!(
out,
"stress {} protect {} \
darvo {:?} ",
p.stress, p.protection, p.darvo
);
let _ = write!(
out,
"freedom \
{ready}{lifted} ",
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::>()
.join(" ")
};
let _ = write!(
out,
"blamed by {} ",
esc(&blame)
);
match &p.hand {
Some(h) => {
let _ = write!(
out,
"hand {} ({} cards) ",
esc(&cards(h)),
p.hand_size
);
}
None => {
let _ = write!(
out,
"hand {} card(s), hidden ",
p.hand_size
);
}
}
if let Some(sel) = view.selections.get(&id) {
let _ = write!(
out,
"selected {} ",
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, "ground mode {m:?} ");
}
if let Some(c) = view.ground_choices.get(&id) {
let _ = write!(out, "ground choice {c:?} ");
}
if let Some(r) = view.support_responses.get(&id) {
let _ = write!(out, "support {r:?} ");
}
if let Some(t) = view.darvo_targets.get(&id) {
let _ = write!(out, "darvo target {t:?} ");
}
out.push_str("
");
}
/// 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,
may_pass: bool,
) -> String {
document_with_log(view, legal, endpoint, seat, may_pass, &[])
}
/// The table, plus the game log (CB-WP-0018 T02).
pub fn document_with_log(
view: &GroundView,
legal: &[games_ground::GroundCommand],
endpoint: &str,
seat: Option,
may_pass: bool,
log: &[LogLine],
) -> String {
let mut s = String::with_capacity(8192);
let _ = write!(
s,
"\
\
GROUND \u{2014} round {round}",
round = view.round
);
let _ = write!(
s,
"
GROUND \u{2014} round {round}, step {step:?}
\
lead {lead} \
scoring {mode:?} \
viewing as {who}
",
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(),
},
);
body(&mut s, view);
move_section(&mut s, legal, seat, may_pass);
log_section(&mut s, log);
let _ = write!(
s,
"
ready
\
\
",
endpoint = json_string(endpoint),
);
s
}
/// The table itself: problems, relationships, seats, solutions, outcome.
///
/// Factored out of [`document`] so [`ending`] shows the SAME table rather
/// than a second rendering of it — two renderings of one state is how
/// they drift.
fn body(s: &mut String, view: &GroundView) {
s.push_str(
"
");
for (id, p) in &view.players {
player_card(s, *id, p, view);
}
s.push_str("
");
let _ = write!(
s,
"
solutions
deck {} remaining \
discard {}
",
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::>()
.join(" ");
let winners = if o.winners.is_empty() {
"nobody".to_string()
} else {
o.winners
.iter()
.map(|w| seat_name(*w))
.collect::>()
.join(" ")
};
let coalitions = if o.coalitions.is_empty() {
"none".to_string()
} else {
o.coalitions
.iter()
.map(|c| format!("{c:?}"))
.collect::>()
.join(" ")
};
let _ = write!(
s,
"
outcome
\
total {total} of {threshold} \
group {group} \
personal {personal} \
coalitions {coalitions} \
mastery {mastery} \
winners {winners}
",
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),
);
}
}
/// The "your move" section: action cards, numbered fallbacks, the table,
/// and pass. Emits nothing when the seat has nothing legal to do.
fn move_section(
s: &mut String,
legal: &[games_ground::GroundCommand],
seat: Option,
may_pass: bool,
) {
if !legal.is_empty() {
s.push_str("
your move
");
for a in [
games_ground::Action::Investigate,
games_ground::Action::Solve,
games_ground::Action::Support,
games_ground::Action::Attack,
games_ground::Action::Ground,
] {
// ADR-0010 D1: the legal targets come from `legal` and are
// written into the page as data. The script matches on them;
// it never derives them. The old text was a CONSTANT —
// "onto a seat, a problem, or the table" — emitted whenever
// any legal command used this action, and it was wrong
// wherever the real target set was narrower, which is almost
// everywhere: Investigate is legal on problems 2 and 3 but
// not 1 (CB-WP-0017).
// CB-WP-0018 T03: targets and their meanings, in step, both
// written by Rust. ADR-0010 D1 forbids the page composing the
// second from the first.
let offered: Vec<(String, String)> = seat
.map(|seat| {
legal
.iter()
.filter_map(|c| {
crate::input::affordance(c, seat)
.filter(|(f, _)| *f == action_id(a))
.map(|(_, t)| (t, crate::input::describe(c, seat)))
})
.collect()
})
.unwrap_or_default();
let targets: Vec = offered.iter().map(|(t, _)| t.clone()).collect();
let descs: Vec = offered.iter().map(|(_, d)| d.clone()).collect();
if !targets.is_empty() {
let _ = write!(
s,
"
");
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,
"
{}
",
esc(&format!("{c:?}"))
);
}
}
s.push_str(
"
the table \u{2014} drop here for an \
untargeted action
",
);
}
if may_pass {
s.push_str(
"
pass \u{2014} decline to act
",
);
}
}
/// 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.
/// Every `data-drop="…"` value in the document.
///
/// CB-WP-0016. A real parse of the attribute rather than a substring
/// search: `html.contains("seat-1")` would be satisfied by the *text*
/// "seat-1" and by `data-drop="seat-10"`, and the point of the check this
/// feeds is that an affordance can name a target that is not there.
///
/// **Drop keys are `data-drop`, not `id`, and that is the fix for
/// CB-WP-0016.** An `id` must be unique in a document, so exactly one
/// element could ever be `seat-0` — the relationship-graph circle took it
/// and the seat card the instruction text points at went without. A seat
/// is drawn twice and both drawings are the seat.
/// One line of the game log: what was done, and what it produced.
///
/// Built by the caller from `bot::Applied` so this crate stays free of
/// the driver, and phrased in the **recorder's** vocabulary via
/// `record::to_step` — CB-WP-0018 T02 forbids a fourth phrasing, because
/// what the player reads should be what the scenario file will say.
pub struct LogLine {
pub who: String,
pub what: String,
/// Rendered effects. **Empty is the case that matters:** a command
/// that produced no events is the one the player cannot otherwise
/// account for.
pub effects: Vec,
}
/// The game log, newest last, with the empty case spelled out.
fn log_section(s: &mut String, log: &[LogLine]) {
s.push_str("
log
");
if log.is_empty() {
s.push_str("nothing has happened yet");
}
for line in log {
let _ = write!(
s,
"
{who} {what}",
who = esc(&line.who),
what = esc(&line.what),
);
if line.effects.is_empty() {
// The silence CB-WP-0018 was reported for, said out loud.
s.push_str(" \u{2014} no effect");
} else {
for e in &line.effects {
let _ = write!(s, " \u{2192} {}", esc(e));
}
}
s.push_str("
");
}
s.push_str("
");
}
/// The page a finished game leaves behind (CB-WP-0018 T01).
///
/// `view` is `None` when the game ended badly: then there is no result to
/// draw and saying so is the whole point. Drawing a table for a game that
/// crashed would be the same lie the empty page told, dressed up.
///
/// **This page does not auto-reload.** The old one reloaded on `ok` and
/// the reload was refused, which is how a completed game became a blank
/// tab.
pub fn ending(view: Option<&GroundView>, message: &str, endpoint: &str, log: &[LogLine]) -> String {
let mut s = String::with_capacity(4096);
let _ = write!(
s,
"\
\
GROUND — game over\
\
",
endpoint = json_string(endpoint),
);
s
}
pub fn drop_keys(html: &str) -> std::collections::BTreeSet {
let mut out = std::collections::BTreeSet::new();
let mut rest = html;
while let Some(i) = rest.find("data-drop=\"") {
let after = &rest[i + 11..];
match after.find('"') {
Some(j) => {
out.insert(after[..j].to_string());
rest = &after[j..];
}
None => break,
}
}
out
}
pub fn text_of(html: &str) -> String {
let mut out = String::with_capacity(html.len() / 2);
let bytes: Vec = 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("&", "&")
}