Tier S (a fix inside a boundary; chaos d8=5, no override). Reported by the maintainer: "I can't save notes, I get 'refused: no session token'." The form posted to a bare `/note`. Control 1 requires the token on EVERY request, so the guard refused all of them. WHY THE TESTS MISSED IT IS THE PART WORTH RECORDING. I verified the note channel over real HTTP and got 303 -- but I appended the token to the URL by hand. I tested the ENDPOINT and not the PATH A PLAYER TAKES, so the one thing standing between the feature and the user was the one thing not exercised. Same family as timing the wrong span and counting the wrong denominator: a correct measurement of the wrong subject. Fixed with Guard::note_endpoint(), so the form's action carries the token like every other request. The assertion now pins the token's PRESENCE rather than the bare path, so reverting the fix turns it red. Verified the way it should have been done first: read the form's `action` out of the SERVED page and POST to exactly that, nothing added by hand. 303. Clippy then flagged document_with_log at 8 arguments. It was right -- the signature had grown across three passes -- so the two endpoints are now one `Endpoints` struct rather than an #[allow]. They are one concept: the guarded surface this page may talk to, one channel that becomes commands and one that provably cannot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1195 lines
46 KiB
Rust
1195 lines
46 KiB
Rust
//! 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, held = null, marked = [], ghost = null, ghostLabel = '';
|
|
|
|
// What is actually under the pointer, not what the browser decided the
|
|
// event belongs to. CB-WP-0020 T03: for touch and pen a browser
|
|
// implicitly captures the pointer to the pointerdown target, so
|
|
// `e.target` on pointerup can be the element you STARTED on wherever
|
|
// you release. That makes a drop look like a drop-on-itself, and the
|
|
// "nothing droppable" message unreachable. elementFromPoint is correct
|
|
// under both behaviours.
|
|
function under(e) {
|
|
if (document.elementFromPoint && e.clientX !== undefined) {
|
|
return document.elementFromPoint(e.clientX, e.clientY) || e.target;
|
|
}
|
|
return e.target;
|
|
}
|
|
function node(e) {
|
|
var n = under(e);
|
|
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; }
|
|
|
|
// The session has ended server-side, so nothing on this page can work
|
|
// any more -- and nothing on it may look like it can (CB-WP-0024 T01).
|
|
//
|
|
// The old page kept a full table and a live `play again` control after
|
|
// the server had stopped listening, because the script acted only on
|
|
// 'ok'. A control that cannot work must stop being a control, so the
|
|
// `data-drop` attribute is REMOVED rather than styled: that is the
|
|
// attribute the script's own walk reads, so the element becomes
|
|
// undroppable by the same rule that made it droppable.
|
|
//
|
|
// No game vocabulary here (ADR-0007 D5). 'closed' is a server
|
|
// lifecycle word, exactly like the 'ok' branch below it.
|
|
function seal() {
|
|
var all = document.querySelectorAll('[data-drop]');
|
|
for (var i = 0; i < all.length; i++) {
|
|
all[i].classList.add('sealed');
|
|
// removeAttribute, NOT setAttribute(_, null): the latter writes the
|
|
// literal string "null" in a real browser, which is truthy, so the
|
|
// control would stay droppable while the test stub said otherwise.
|
|
all[i].removeAttribute('data-drop');
|
|
}
|
|
}
|
|
|
|
// 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';
|
|
// Keep the label as markup, so the explanation can be appended
|
|
// rather than replacing it (CB-WP-0020 T02). The first version set
|
|
// textContent, which collapsed the card's line break and then got
|
|
// overwritten by the explanation -- losing the only sign of what
|
|
// was being carried, exactly when it was needed.
|
|
ghostLabel = (n.getAttribute('data-drop') || '').replace('action-', '');
|
|
ghost.innerHTML = '<b>' + ghostLabel + '</b>';
|
|
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.innerHTML = '<b>' + ghostLabel + '</b>'
|
|
+ (over ? '<span class="why">' + over + '</span>' : '');
|
|
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(); }
|
|
else if (t.indexOf('closed') === 0) { seal(); }
|
|
});
|
|
});
|
|
})();
|
|
"#;
|
|
|
|
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)}
|
|
/* 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).
|
|
|
|
CB-WP-0020 T01, at the maintainer's instruction: change the EXISTING
|
|
border, do not draw a new box. `outline` + `outline-offset` drew a
|
|
second rectangle outside the element, which an SVG viewport clips (the
|
|
reported missing top and left edges) and which made a seat's highlight
|
|
the size of a whole card. Restyling the border in place cannot move
|
|
anything, because the border is already in the layout. */
|
|
.dropok{border-style:dashed;border-color:#9cf;background:#1d2a33}
|
|
.dropok circle,.dropok rect{stroke:#9cf;stroke-dasharray:5 3}
|
|
.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}
|
|
/* The thing in your hand. Deliberately NOT card-shaped: it used to be a
|
|
textContent copy of the card, so the line break collapsed and it read
|
|
as a second card sitting next to the first (CB-WP-0020 T02). */
|
|
#cb-ghost{position:fixed;pointer-events:none;z-index:9;padding:.25rem .55rem;
|
|
border-radius:999px;background:#2b4a3a;border:1px solid #7ca;
|
|
color:#dfe;font:12px ui-monospace,monospace;
|
|
box-shadow:0 6px 16px #000b;transform:translate(-50%,-160%);
|
|
white-space:nowrap}
|
|
/* The explanation is ADDITIONAL, never a replacement: losing the label is
|
|
losing the only sign of what you are carrying. */
|
|
#cb-ghost b{color:#cfe}
|
|
#cb-ghost .why{color:#9cf;margin-left:.4rem}
|
|
/* What you picked up, left visibly behind so there are not two cards. */
|
|
.held{cursor:grabbing;opacity:.35;border-style:dashed}
|
|
/* CB-WP-0024 T01: a control the server can no longer serve. The script
|
|
removes its `data-drop` so it is genuinely inert; this is only how that
|
|
reads. `pointer-events:none` is belt and braces, not the mechanism --
|
|
styling alone would leave a dead control that still looks alive to
|
|
anything reading the DOM. */
|
|
.sealed{opacity:.3;cursor:default;pointer-events:none;filter:grayscale(1)}
|
|
/* CB-WP-0027 T02. `minmax(0,...)` on both tracks, because a grid child
|
|
defaults to min-content width and the SVG table would refuse to shrink,
|
|
pushing the meta column off-screen instead of narrowing.
|
|
The single-column fallback is deliberate rather than incidental: the
|
|
page was responsive by accident before this. */
|
|
.cb-cols{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,24rem);
|
|
gap:1.2rem;align-items:start}
|
|
.cb-game{min-width:0}
|
|
.cb-meta{min-width:0;border-left:1px solid #2a3140;padding-left:1.1rem}
|
|
#cb-note{display:flex;flex-direction:column;gap:.5rem}
|
|
#cb-note textarea{width:100%;box-sizing:border-box;font:inherit;color:#dde;
|
|
background:#12141a;border:1px solid #445;border-radius:5px;padding:.5rem}
|
|
#cb-note button{align-self:flex-start;font:inherit;cursor:pointer;color:#dde;
|
|
background:#332a3a;border:1px solid #a7d;border-radius:5px;padding:.35rem .8rem}
|
|
.note{border-left:2px solid #a7d;padding-left:.6rem;margin:.4rem 0;white-space:pre-wrap}
|
|
@media (max-width:64rem){
|
|
.cb-cols{grid-template-columns:minmax(0,1fr)}
|
|
.cb-meta{border-left:none;border-top:1px solid #2a3140;padding-left:0;padding-top:1rem}
|
|
}
|
|
/* CB-WP-0024 T03: the card a seat played, in that seat's area. */
|
|
.played{display:block;margin:.3rem 0}
|
|
.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<String> = Vec::new();
|
|
for t in targets {
|
|
out.push(match t.split_once('-') {
|
|
Some(("seat", n)) => n
|
|
.parse::<u8>()
|
|
.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,
|
|
"<g data-drop=\"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).
|
|
/// One pile, drawn as a small stack of offset cards with its count on top.
|
|
///
|
|
/// `depth` is how many card-backs to suggest, not the count — a pile of 17
|
|
/// is not seventeen rectangles. The **number** is the truth; the stack is
|
|
/// how you tell at a glance that there is a pile there at all.
|
|
fn pile_svg(out: &mut String, x: i32, label: &str, count: usize, face: &str, note: &str) {
|
|
let depth = match count {
|
|
0 => 0,
|
|
1..=3 => 1,
|
|
4..=9 => 2,
|
|
_ => 3,
|
|
};
|
|
// The title carries the count in words. It is what a screen reader
|
|
// announces, and it is the stable thing a coverage probe can match --
|
|
// the on-canvas number is a bare numeral that could be anything.
|
|
let _ = write!(
|
|
out,
|
|
"<g><title>{} pile: {count} remaining</title>",
|
|
esc(label),
|
|
);
|
|
if depth == 0 {
|
|
// An EMPTY pile is drawn, not omitted. A missing slot reads as
|
|
// "this game has no discard", which is a different statement from
|
|
// "the discard is empty" (CB-WP-0024 T02).
|
|
let _ = write!(
|
|
out,
|
|
"<rect x=\"{x}\" y=\"14\" width=\"76\" height=\"64\" rx=\"7\" fill=\"none\" \
|
|
stroke=\"#3a4350\" stroke-dasharray=\"4 3\"/>",
|
|
);
|
|
}
|
|
for d in (0..depth).rev() {
|
|
let dx = x + d * 3;
|
|
let dy = 14 - d * 3;
|
|
let _ = write!(
|
|
out,
|
|
"<rect x=\"{dx}\" y=\"{dy}\" width=\"76\" height=\"64\" rx=\"7\" fill=\"{face}\" \
|
|
stroke=\"#5a6b7a\"/>",
|
|
);
|
|
}
|
|
let _ = write!(
|
|
out,
|
|
"<text x=\"{tx}\" y=\"46\" fill=\"#dde\" font-size=\"18\" text-anchor=\"middle\">{count}</text>\
|
|
<text x=\"{tx}\" y=\"64\" fill=\"#89a\" font-size=\"10\" text-anchor=\"middle\">{label}</text>\
|
|
<text x=\"{tx}\" y=\"96\" fill=\"#fc9\" font-size=\"10\" text-anchor=\"middle\">{note}</text></g>",
|
|
tx = x + 38,
|
|
count = count,
|
|
label = esc(label),
|
|
note = esc(note),
|
|
);
|
|
}
|
|
|
|
/// The draw stack and the discard stack, as objects on the table.
|
|
///
|
|
/// Both numbers come from the projection — `solution_deck_len` and
|
|
/// `solution_discard` — and are never recomputed here.
|
|
///
|
|
/// **The reshuffle is real and is the U4 default**, confirmed by
|
|
/// ground-game on 2026-08-03: when the deck runs out, the discard is
|
|
/// reshuffled into it deterministically; if both are empty the draw is
|
|
/// skipped (`games/ground/src/lib.rs` `draw_solution`). So the pile shows
|
|
/// the state in which the *next* draw will trigger it. It does not claim a
|
|
/// reshuffle has happened — the view carries no such flag, and the event
|
|
/// already reads out in the log.
|
|
fn piles_svg(out: &mut String, view: &GroundView) {
|
|
let deck = view.solution_deck_len;
|
|
let discard = view.solution_discard.len();
|
|
let will_reshuffle = deck == 0 && discard > 0;
|
|
|
|
out.push_str("<svg viewBox=\"0 0 300 110\" width=\"300\" height=\"110\" role=\"img\">");
|
|
pile_svg(
|
|
out,
|
|
10,
|
|
"draw",
|
|
deck,
|
|
"#26303a",
|
|
if will_reshuffle { "empty" } else { "" },
|
|
);
|
|
pile_svg(
|
|
out,
|
|
120,
|
|
"discard",
|
|
discard,
|
|
"#2a2f3a",
|
|
if will_reshuffle {
|
|
"shuffles in on next draw"
|
|
} else {
|
|
""
|
|
},
|
|
);
|
|
if will_reshuffle {
|
|
out.push_str(
|
|
"<path d=\"M100 46 q10 -14 20 0\" fill=\"none\" stroke=\"#fc9\" stroke-width=\"2\"/>\
|
|
<path d=\"M118 40 l4 6 -7 1z\" fill=\"#fc9\"/>",
|
|
);
|
|
}
|
|
out.push_str("</svg>");
|
|
|
|
// The discard is public — it has been played (GR-S04 hides only the
|
|
// deck) — so its contents stay readable as text beside the picture.
|
|
let _ = write!(
|
|
out,
|
|
"<div><span class=\"k\">discard</span> {}</div>",
|
|
esc(&cards(&view.solution_discard)),
|
|
);
|
|
}
|
|
|
|
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 data-drop=\"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
|
|
}
|
|
|
|
/// A revealed selection, phrased the way the log phrases the command.
|
|
///
|
|
/// Deliberately the same shape as `event_line`'s `ActionSelected` arm —
|
|
/// a second vocabulary for the same fact drifts from the first, which is
|
|
/// exactly what `{:?}` was doing here.
|
|
fn selection_words(s: &games_ground::Selection) -> String {
|
|
// Every field that is set is named. The first draft matched on
|
|
// `(target, problem)` and showed only the target when both were
|
|
// present — the coverage gate caught it, because a field the document
|
|
// never shows is a field a player never sees. The aggregate does not
|
|
// currently produce both, but a renderer that silently drops one is
|
|
// the omission class this crate exists to guard against.
|
|
let mut out = format!("{:?}", s.action);
|
|
if let Some(t) = s.target {
|
|
let _ = write!(out, " on {}", seat_name(t));
|
|
}
|
|
if let Some(n) = s.problem {
|
|
let _ = write!(out, " for problem {n}");
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The card a seat has played, as a card.
|
|
///
|
|
/// **The face-down back is a constant.** It takes no argument, because
|
|
/// `SelectionView::Hidden` carries nothing and this function must not be
|
|
/// able to leak what it does not receive. GR-R02/R04 hide another seat's
|
|
/// choice until Reveal, and `view.rs`'s own test asserts the projection
|
|
/// obeys that — but a renderer can leak what the model did not, by
|
|
/// tinting the back with the suit or shaping it by the action. So there is
|
|
/// exactly one back, with no data path into it.
|
|
const CARD_BACK: &str = "<svg viewBox=\"0 0 84 56\" width=\"84\" height=\"56\" class=\"played\" \
|
|
role=\"img\"><title>face down</title>\
|
|
<rect x=\"2\" y=\"2\" width=\"80\" height=\"52\" rx=\"7\" fill=\"#2a2f3a\" stroke=\"#5a6b7a\"/>\
|
|
<path d=\"M14 14 h56 M14 28 h56 M14 42 h56\" stroke=\"#3d4756\" stroke-width=\"3\"/></svg>";
|
|
|
|
fn played_svg(out: &mut String, sel: &SelectionView) {
|
|
let s = match sel {
|
|
SelectionView::Hidden => {
|
|
out.push_str(CARD_BACK);
|
|
return;
|
|
}
|
|
SelectionView::Shown(s) => s,
|
|
};
|
|
let mut sub = String::new();
|
|
if let Some(t) = s.target {
|
|
let _ = write!(sub, "\u{2192}{}", seat_name(t));
|
|
}
|
|
if let Some(n) = s.problem {
|
|
if !sub.is_empty() {
|
|
sub.push(' ');
|
|
}
|
|
let _ = write!(sub, "#{n}");
|
|
}
|
|
let _ = write!(
|
|
out,
|
|
"<svg viewBox=\"0 0 84 56\" width=\"84\" height=\"56\" class=\"played\" role=\"img\">\
|
|
<title>played {label}</title>\
|
|
<rect x=\"2\" y=\"2\" width=\"80\" height=\"52\" rx=\"7\" fill=\"#243\" stroke=\"#5a7\"/>\
|
|
<text x=\"42\" y=\"27\" fill=\"#dfe\" font-size=\"12\" text-anchor=\"middle\">{label}</text>\
|
|
<text x=\"42\" y=\"43\" fill=\"#9cb\" font-size=\"10\" text-anchor=\"middle\">{sub}</text>\
|
|
</svg>",
|
|
label = esc(&format!("{:?}", s.action)),
|
|
sub = esc(&sub),
|
|
);
|
|
}
|
|
|
|
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.
|
|
"<div class=\"card\" data-viewer=\"{is_viewer}\" data-drop=\"seat-{raw}\">\
|
|
<b>{name}</b>{you}<br>",
|
|
raw = id.0,
|
|
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 data-drop=\"freedom-{raw}\" class=\"act pick\"><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) {
|
|
// The picture, then the words. CB-WP-0024 T03 adds the card; the
|
|
// sentence stays, because the log is the record and a player
|
|
// reading back through it needs the same vocabulary.
|
|
played_svg(out, sel);
|
|
let _ = write!(
|
|
out,
|
|
"<span class=\"k\">selected</span> {}<br>",
|
|
match sel {
|
|
SelectionView::Hidden => "face down".to_string(),
|
|
// CB-WP-0020 T04: in words, not `Selection { action:
|
|
// Solve, target: None, problem: Some(1) }`. After Reveal
|
|
// this is how a player follows what everyone else did,
|
|
// and it was the same Debug-on-a-player-surface defect
|
|
// the log fixed in CB-WP-0018 and this did not.
|
|
SelectionView::Shown(s) => esc(&selection_words(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 {
|
|
document_with_log(
|
|
view,
|
|
legal,
|
|
Endpoints {
|
|
command: endpoint,
|
|
note: "/note",
|
|
},
|
|
seat,
|
|
may_pass,
|
|
&[],
|
|
&[],
|
|
)
|
|
}
|
|
|
|
/// The table, plus the game log (CB-WP-0018 T02).
|
|
/// Where the page posts, both channels (ADR-0014 D1).
|
|
///
|
|
/// One struct rather than two `&str` parameters because they are one
|
|
/// concept — the guarded surface this page may talk to — and because
|
|
/// clippy was right that the signature had grown across three passes.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct Endpoints<'a> {
|
|
/// Pointer facts. `resolve` turns these into commands.
|
|
pub command: &'a str,
|
|
/// Free text. Nothing turns these into commands.
|
|
pub note: &'a str,
|
|
}
|
|
|
|
pub fn document_with_log(
|
|
view: &GroundView,
|
|
legal: &[games_ground::GroundCommand],
|
|
to: Endpoints<'_>,
|
|
seat: Option<PlayerId>,
|
|
may_pass: bool,
|
|
log: &[LogLine],
|
|
meta: &[String],
|
|
) -> String {
|
|
let (endpoint, note_to) = (to.command, to.note);
|
|
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(),
|
|
},
|
|
);
|
|
|
|
// CB-WP-0027 T02: the table on the left, everything *about* the table
|
|
// on the right. The log moves right because it is commentary on the
|
|
// game rather than part of it.
|
|
s.push_str("<div class=\"cb-cols\"><div class=\"cb-game\">");
|
|
body(&mut s, view);
|
|
move_section(&mut s, legal, seat, may_pass);
|
|
s.push_str("</div><div class=\"cb-meta\">");
|
|
meta_section(&mut s, meta, note_to);
|
|
log_section(&mut s, log);
|
|
s.push_str("</div></div>");
|
|
let _ = write!(
|
|
s,
|
|
"<div id=\"cb-status\">ready</div>\
|
|
<script>window.CB_ENDPOINT={endpoint}</script><script>{SCRIPT}</script>\
|
|
</body></html>",
|
|
endpoint = json_string(endpoint),
|
|
);
|
|
s
|
|
}
|
|
|
|
/// The meta panel's own content: whatever the caller wants a player to see
|
|
/// *about* the session rather than about the position.
|
|
///
|
|
/// Empty is a legitimate state — a first game has no tally and may have no
|
|
/// notes — and renders as nothing rather than as an empty heading.
|
|
fn meta_section(s: &mut String, meta: &[String], note_to: &str) {
|
|
// CB-WP-0027 T03: the comment box. Always present — the panel's
|
|
// purpose is that a player can say something at any moment, and a box
|
|
// that appears only sometimes trains them not to look for it.
|
|
//
|
|
// A plain form POSTing to /note, so it works with the script disabled.
|
|
// The command channel needs JavaScript because a drag is not a form
|
|
// submission; a comment is, and making it depend on the script would
|
|
// add a failure mode for no gain.
|
|
let _ = write!(
|
|
s,
|
|
"<h2>what are you thinking?</h2>\
|
|
<form class=\"card\" method=\"post\" action=\"{note_to}\" id=\"cb-note\">\
|
|
<textarea name=\"note\" rows=\"4\" placeholder=\"why this move, what is \
|
|
unclear, what is annoying \u{2014} bound to this position\"></textarea>\
|
|
<button type=\"submit\">note it</button></form>",
|
|
note_to = esc(note_to),
|
|
);
|
|
if meta.is_empty() {
|
|
return;
|
|
}
|
|
s.push_str("<h2>this session</h2><div class=\"card\">");
|
|
for (i, line) in meta.iter().enumerate() {
|
|
if i > 0 {
|
|
s.push_str("<br>");
|
|
}
|
|
s.push_str(&esc(line));
|
|
}
|
|
s.push_str("</div>");
|
|
}
|
|
|
|
/// 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(
|
|
"<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(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(s, *id, p, view);
|
|
}
|
|
s.push_str("</div>");
|
|
|
|
s.push_str("<h2>solutions</h2>");
|
|
piles_svg(s, view);
|
|
|
|
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),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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<PlayerId>,
|
|
may_pass: bool,
|
|
) {
|
|
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,
|
|
] {
|
|
// 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<String> = offered.iter().map(|(t, _)| t.clone()).collect();
|
|
let descs: Vec<String> = offered.iter().map(|(_, d)| d.clone()).collect();
|
|
if !targets.is_empty() {
|
|
let _ = write!(
|
|
s,
|
|
"<div class=\"card act pick\" data-drop=\"{id}\" \
|
|
data-targets=\"{targets}\" data-descs=\"{descs}\">{a:?}<br>\
|
|
<span class=\"k\">onto</span> {names}</div>",
|
|
id = action_id(a),
|
|
targets = esc(&targets.join(" ")),
|
|
descs = esc(&descs.join("|")),
|
|
names = esc(&target_names(&targets)),
|
|
);
|
|
}
|
|
}
|
|
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 pick\" data-drop=\"cmd-{i}\">{}</div>",
|
|
esc(&format!("{c:?}"))
|
|
);
|
|
}
|
|
}
|
|
s.push_str(
|
|
"</div><div class=\"card\" data-drop=\"table\">the table \u{2014} drop here for an \
|
|
untargeted action</div>",
|
|
);
|
|
}
|
|
if may_pass {
|
|
s.push_str(
|
|
"<div class=\"card btn pick\" data-drop=\"pass\">pass \u{2014} decline to act</div>",
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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<String>,
|
|
}
|
|
|
|
/// The game log, newest last, with the empty case spelled out.
|
|
fn log_section(s: &mut String, log: &[LogLine]) {
|
|
s.push_str("<h2>log</h2><div class=\"card\" id=\"cb-log\">");
|
|
if log.is_empty() {
|
|
s.push_str("<span class=\"k\">nothing has happened yet</span>");
|
|
}
|
|
for line in log {
|
|
let _ = write!(
|
|
s,
|
|
"<div><span class=\"k\">{who}</span> {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} <span class=\"nil\">no effect</span>");
|
|
} else {
|
|
for e in &line.effects {
|
|
let _ = write!(s, "<br><span class=\"eff\">\u{2192} {}</span>", esc(e));
|
|
}
|
|
}
|
|
s.push_str("</div>");
|
|
}
|
|
s.push_str("</div>");
|
|
}
|
|
|
|
/// 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],
|
|
series: &[String],
|
|
) -> String {
|
|
let mut s = String::with_capacity(4096);
|
|
let _ = write!(
|
|
s,
|
|
"<!doctype html><meta charset=\"utf-8\">\
|
|
<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
|
|
<title>GROUND — game over</title><style>{STYLE}</style>\
|
|
<h1>game over</h1><div class=\"card\">{msg}</div>",
|
|
msg = esc(message),
|
|
);
|
|
match view {
|
|
Some(v) => body(&mut s, v),
|
|
None => {
|
|
s.push_str(
|
|
"<div class=\"card\">The game ended without a result, so there \
|
|
is no final table to show. The reason is above.</div>",
|
|
);
|
|
}
|
|
}
|
|
// CB-WP-0024 T04. Above the log, because it is a result and the log is
|
|
// the account. Empty for a first game — one game is not a series, and
|
|
// a "cumulative" panel restating the outcome above it is noise.
|
|
if !series.is_empty() {
|
|
s.push_str("<h2>this session</h2><div class=\"card\">");
|
|
for (i, line) in series.iter().enumerate() {
|
|
if i > 0 {
|
|
s.push_str("<br>");
|
|
}
|
|
s.push_str(&esc(line));
|
|
}
|
|
s.push_str("</div>");
|
|
}
|
|
log_section(&mut s, log);
|
|
let _ = write!(
|
|
s,
|
|
"<div class=\"row\">\
|
|
<div class=\"card btn pick\" data-drop=\"again\">play again</div>\
|
|
<div class=\"card btn pick\" data-drop=\"done\">end session \u{2014} stops the game server</div>\
|
|
</div>\
|
|
<div id=\"cb-status\">the game is over</div>\
|
|
<script>window.CB_ENDPOINT={endpoint}</script><script>{SCRIPT}</script>",
|
|
endpoint = json_string(endpoint),
|
|
);
|
|
s
|
|
}
|
|
|
|
pub fn drop_keys(html: &str) -> std::collections::BTreeSet<String> {
|
|
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<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("&", "&")
|
|
}
|