Some checks failed
ci / check (push) Failing after 4s
CB-WP-0045 left "nothing explains what a scope does" open and gave a FALSE reason: that scoping is our Variant so no printed sentence exists. The H2 package ships Rules_Text.csv -- twenty-two passages of player-facing rules -- and nothing in clay-borg had ever read the file. A wrong reason for an open item is worse than an open item; it retires the question. Filed as F26 against ground-game: a package that adds a FILE is invisible where one that adds a column is not. The scope rule now renders under the table in the edition's own words, only when a non-global scope is in play, matched by heading rather than row number, and absent (never paraphrased) if the edition drops it. The trial log stamps its variant on the begin marker -- a session property, not an eighth column -- read off state.variant rather than the --variant flag, because a bare `state.variant = v` leaves H2 inert and a flag-stamped log would put false provenance on real player words. An unstamped log reports `unrecorded`, never `ground-darvo-r0`. Six mutations, six red. The sixth is the finding: every trials.py fixture built its marker out of BEGIN, so nine checks followed BEGIN away from what hotseat.rs writes and stayed green while real logs broke. A fixture built from the constant under test cannot test the constant -- the control is now a literal, asserted from both sides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2186 lines
86 KiB
Rust
2186 lines
86 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.
|
|
var sealed = false;
|
|
function seal() {
|
|
sealed = true;
|
|
// The WHOLE page goes inert, not only the controls. A greyed-out
|
|
// button beside a full-colour table still reads as a live game with
|
|
// one broken control; the session has ended and everything on screen
|
|
// is now a record of it.
|
|
if (document.body && document.body.classList) {
|
|
document.body.classList.add('sealed-page');
|
|
}
|
|
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(); }
|
|
}).catch(gone);
|
|
});
|
|
|
|
// CB-WP-0035. The session ended without this page being told.
|
|
//
|
|
// There was no rejection handler at all, so when the server had exited
|
|
// the promise rejected and the chain simply never ran -- not even the
|
|
// status line moved. `play again` looked like a dead button, and the
|
|
// linger timeout was invisible. A request that cannot be answered is
|
|
// information, and it was being thrown away.
|
|
function gone() {
|
|
status('the session is no longer available \u2014 the game server has '
|
|
+ 'stopped. Nothing on this page can act; it is a record now.');
|
|
seal();
|
|
}
|
|
|
|
// And notice it WITHOUT being clicked, which is what the timeout needs:
|
|
// a player who walks away comes back to a sealed page rather than to a
|
|
// live-looking table that answers nothing.
|
|
if (window.CB_ALIVE) {
|
|
setInterval(function () {
|
|
if (sealed) { return; }
|
|
fetch(window.CB_ALIVE).then(function (r) {
|
|
if (!r.ok) { gone(); }
|
|
}).catch(gone);
|
|
}, 5000);
|
|
}
|
|
})();
|
|
"#;
|
|
|
|
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)}
|
|
/* CB-WP-0017's principle, one step further: a thing you CLICK and a thing
|
|
you DRAG must not look identical either. `.pick` promises carry-me-
|
|
somewhere; these promise press-me. They wore `.pick` and therefore a
|
|
grab cursor, which invited a drag -- and a drag released over nothing
|
|
posts nothing, so the button appeared dead. Reported both as the button
|
|
showing a hand it should not, AND as being unable to start a new game. */
|
|
.tap{cursor:pointer;user-select:none;box-shadow:0 2px 0 #0006,0 0 0 1px #a7d6 inset;
|
|
transition:transform .08s,box-shadow .08s}
|
|
.tap:hover{box-shadow:0 3px 8px #000a,0 0 0 1px #a7d 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)}
|
|
/* The session has ended. Everything on screen is a record of a game that
|
|
is over, so the whole page says so -- not one greyed button beside a
|
|
live-looking table. */
|
|
.sealed-page{filter:grayscale(.9);opacity:.5;pointer-events:none;transition:opacity .2s}
|
|
.sealed-page #cb-status{filter:none;opacity:1;color:#fc9}
|
|
/* 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}
|
|
/* CB-WP-0028 T02: the card's own words. The tagline reads as the card's
|
|
voice, not as engine chrome. */
|
|
.tag{display:block;color:#9cb;font-style:italic;margin:.15rem 0 .3rem}
|
|
details summary{cursor:pointer;color:#89a;font-size:.9em;margin-top:.3rem}
|
|
details p,details{margin:.2rem 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(", ")
|
|
}
|
|
|
|
/// The edition's title for a Problem, by `hidden_priority` (ADR-0015 D1).
|
|
///
|
|
/// These columns were in the vendored file from the start and thrown away
|
|
/// at parse time — the page showed `Repair 2` for a card that reads
|
|
/// *"Missed Deadline"*.
|
|
fn problem_title(priority: u32) -> Option<String> {
|
|
games_ground::edition::problem_texts("SCN_01")
|
|
.ok()?
|
|
.into_iter()
|
|
.find(|t| u32::from(t.priority) == priority)
|
|
.map(|t| t.title)
|
|
}
|
|
|
|
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\"/>\
|
|
<circle cx=\"{bx}\" cy=\"24\" r=\"11\" fill=\"#1b1e26\" stroke=\"#5a6b7a\"/>\
|
|
<text x=\"{bx}\" y=\"29\" fill=\"#9cf\" font-size=\"13\" text-anchor=\"middle\">\
|
|
{priority}</text>\
|
|
<text x=\"{tx}\" y=\"36\" fill=\"#dde\" font-size=\"13\">{label}</text>\
|
|
<text x=\"{tx}\" y=\"56\" fill=\"#89a\" font-size=\"11\">{name}</text>\
|
|
<text x=\"{tx}\" y=\"74\" fill=\"#fc9\" font-size=\"11\">{sub}</text></g>",
|
|
x = x,
|
|
// The card's own name where a priority number used to be. Falls
|
|
// back to the number rather than blanking: a missing title must
|
|
// not remove the only identifier the player had.
|
|
name = esc(&problem_title(priority).unwrap_or_else(|| format!("priority {priority}"))),
|
|
tx = x + 10,
|
|
// CB-WP-0045: the number, on the card.
|
|
//
|
|
// Every move label says "Problem 7" and nothing on the table said
|
|
// which card that was. In the old row the position was a hint;
|
|
// once Problems are placed by scope the row is gone and the hint
|
|
// with it. The number was only ever a FALLBACK for a missing
|
|
// title — visible exactly when it was least useful.
|
|
bx = x + 104,
|
|
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),
|
|
);
|
|
}
|
|
|
|
fn piles_body(out: &mut String, view: &GroundView) {
|
|
let deck = view.solution_deck_len;
|
|
let discard = view.solution_discard.len();
|
|
let will_reshuffle = deck == 0 && discard > 0;
|
|
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\"/>",
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A marker on a track, drawn at `(x, y)` with `n` stops.
|
|
///
|
|
/// **The track is the point** (ADR-0016 D4). `stress 5` is a fact you
|
|
/// read; a marker at the end of a 0-5 track is a fact you see coming --
|
|
/// and DARVO triggers at Stress 5, so "one more Attack and I trigger" is
|
|
/// the most useful thing the page can show.
|
|
fn track_svg(out: &mut String, x: f64, y: f64, stops: usize, at: usize, hot: bool, label: &str) {
|
|
let step = 11.0;
|
|
let _ = write!(out, "<g><title>{}</title>", esc(label));
|
|
for i in 0..stops {
|
|
let cx = x + (i as f64) * step;
|
|
let here = i == at;
|
|
let _ = write!(
|
|
out,
|
|
"<circle cx=\"{cx:.0}\" cy=\"{y:.0}\" r=\"{r}\" fill=\"{fill}\" \
|
|
stroke=\"{stroke}\" stroke-width=\"1\"/>",
|
|
r = if here { 4.5 } else { 2.5 },
|
|
fill = if here {
|
|
if hot {
|
|
"#e77"
|
|
} else {
|
|
"#9cf"
|
|
}
|
|
} else {
|
|
"#2a3140"
|
|
},
|
|
stroke = if here { "#fff8" } else { "#3a4350" },
|
|
);
|
|
}
|
|
out.push_str("</g>");
|
|
}
|
|
|
|
/// The tokens at one seat, as objects rather than numbers.
|
|
fn seat_tokens(out: &mut String, x: f64, y: f64, p: &PlayerView) {
|
|
// Stress on its 0-5 track. Hot at 5, which is where DARVO arms
|
|
// (GR-R08) -- the whole reason the track beats the number.
|
|
track_svg(
|
|
out,
|
|
x - 27.0,
|
|
y + 26.0,
|
|
6,
|
|
usize::from(p.stress).min(5),
|
|
p.stress >= 5,
|
|
&format!("Stress {} of 5", p.stress),
|
|
);
|
|
// The DARVO pawn on OFF/DENY/ATTACK/REVERSE.
|
|
let stage = match p.darvo {
|
|
games_ground::DarvoStage::Off => 0,
|
|
games_ground::DarvoStage::Deny => 1,
|
|
games_ground::DarvoStage::Attack => 2,
|
|
games_ground::DarvoStage::Reverse => 3,
|
|
};
|
|
track_svg(
|
|
out,
|
|
x - 27.0,
|
|
y + 38.0,
|
|
4,
|
|
stage,
|
|
stage > 0,
|
|
&format!("DARVO {:?}", p.darvo),
|
|
);
|
|
|
|
// Counted discs: Freedom (double-sided), Protection, Blame.
|
|
let mut dx = x - 28.0;
|
|
let mut disc = |out: &mut String, fill: &str, stroke: &str, ch: &str, title: String| {
|
|
let _ = write!(
|
|
out,
|
|
"<g><title>{t}</title><circle cx=\"{dx:.0}\" cy=\"{cy:.0}\" r=\"7\" \
|
|
fill=\"{fill}\" stroke=\"{stroke}\"/>\
|
|
<text x=\"{dx:.0}\" y=\"{ty:.0}\" fill=\"#dfe\" font-size=\"8\" \
|
|
text-anchor=\"middle\">{ch}</text></g>",
|
|
t = esc(&title),
|
|
cy = y + 54.0,
|
|
ty = y + 57.0,
|
|
);
|
|
dx += 17.0;
|
|
};
|
|
disc(
|
|
out,
|
|
if p.freedom_ready {
|
|
"#2b4a3a"
|
|
} else {
|
|
"#2a2f3a"
|
|
},
|
|
if p.freedom_ready { "#7ca" } else { "#4a5260" },
|
|
if p.freedom_ready { "R" } else { "\u{2013}" },
|
|
format!(
|
|
"Freedom {}",
|
|
if p.freedom_ready { "READY" } else { "spent" }
|
|
),
|
|
);
|
|
for i in 0..p.protection {
|
|
disc(out, "#2a3a4a", "#7ac", "P", format!("Protection {}", i + 1));
|
|
}
|
|
for b in &p.blame_from {
|
|
disc(
|
|
out,
|
|
"#3a2a2a",
|
|
"#c88",
|
|
"B",
|
|
format!("Blame from {}", seat_name(*b)),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The table, seen from above (CB-WP-0028 T03).
|
|
///
|
|
/// **One diagram, not three.** The seats were already placed on a circle
|
|
/// for the relationship graph, while the Problems were a row above it and
|
|
/// the piles a separate picture below — three views of one table, and the
|
|
/// player had to assemble them. Now: seats around the edge, the Problems
|
|
/// and the two stacks in the middle, each seat's played card in front of
|
|
/// it, relations drawn between seats.
|
|
///
|
|
/// The existing `problem_svg` and `pile_svg` are reused rather than
|
|
/// reimplemented — they carry the tokens the coverage gate probes for,
|
|
/// and drawing the same thing twice is how the two drift.
|
|
fn table_svg(view: &GroundView) -> String {
|
|
let n = view.players.len().max(1);
|
|
// Compact on purpose. The first version was 760x620, which pushed the
|
|
// action cards a full screen below the Problems -- and you cannot drag
|
|
// between two things that are never on screen together, which made the
|
|
// game unplayable. Height is the constraint, not width.
|
|
let (cx, cy) = (380.0f64, 215.0f64);
|
|
let (rx, ry) = (200.0f64, 118.0f64);
|
|
// Seats sit OUTSIDE the table, as people do. The first version put
|
|
// them at 0.83 of the ellipse and they sat on it.
|
|
// Seats sit outside the table with room below each for its tokens.
|
|
let (sx, sy) = (rx + 112.0, ry + 82.0);
|
|
|
|
let pos: Vec<(PlayerId, f64, f64)> = view
|
|
.players
|
|
.keys()
|
|
.enumerate()
|
|
.map(|(i, p)| {
|
|
// Start at the bottom: the viewer sits nearest the reader.
|
|
let a = std::f64::consts::TAU * (i as f64) / (n as f64) + std::f64::consts::FRAC_PI_2;
|
|
(*p, cx + sx * a.cos(), cy + sy * a.sin())
|
|
})
|
|
.collect();
|
|
let find = |p: PlayerId| {
|
|
pos.iter()
|
|
.find(|(q, _, _)| *q == p)
|
|
.map(|(_, x, y)| (*x, *y))
|
|
};
|
|
|
|
let mut s = String::from(
|
|
"<svg viewBox=\"0 0 760 470\" width=\"100%\" style=\"max-width:760px\" \
|
|
role=\"img\" aria-label=\"the table, seen from above\">",
|
|
);
|
|
|
|
// THE TABLE IS THE DROP ZONE. `table` was an abstract id with no
|
|
// picture; the player looked for somewhere to drop GROUND and there
|
|
// was nowhere. Now the surface you can see is the surface you drop on.
|
|
let _ = write!(
|
|
s,
|
|
"<g data-drop=\"table\"><ellipse cx=\"{cx}\" cy=\"{cy}\" rx=\"{rx}\" ry=\"{ry}\" \
|
|
fill=\"#171b23\" stroke=\"#2a3140\" stroke-width=\"2\"/>\
|
|
<text x=\"{cx}\" y=\"{ty}\" fill=\"#556\" font-size=\"10\" \
|
|
text-anchor=\"middle\">the table</text></g>",
|
|
ty = cy + ry - 8.0,
|
|
);
|
|
|
|
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\" stroke-opacity=\"0.7\"/>\
|
|
<text x=\"{mx:.0}\" y=\"{my:.0}\" fill=\"{colour}\" font-size=\"10\" \
|
|
text-anchor=\"middle\">{rel:?}</text>",
|
|
mx = (x1 + x2) / 2.0,
|
|
my = (y1 + y2) / 2.0 - 4.0,
|
|
);
|
|
}
|
|
}
|
|
|
|
// **Where a Problem sits says who it falls on** (CB-WP-0043).
|
|
//
|
|
// H2 scopes End-of-Round Stress: `global` hits everyone, `personal`
|
|
// its owner, `bond` the owner's Bond network. A row across the middle
|
|
// says "these are the table's" — true under the baseline, and a lie
|
|
// under H2, where three of five cards belong to particular seats.
|
|
//
|
|
// **This shows mechanism; it does not invent any.** The scope and
|
|
// owner come off the state; only the position is ours
|
|
// (`specs/Ornamentation.md`).
|
|
let scoped = view.problem_markers.values().any(|m| {
|
|
m.scope
|
|
.is_some_and(|x| x != games_ground::edition::StressScope::Global)
|
|
});
|
|
if !scoped {
|
|
// Baseline, unchanged: a row across the middle, because under
|
|
// the baseline every Problem really is the table's.
|
|
let count = view.problems.len().max(1) as f64;
|
|
let scale = (2.0 * rx * 0.92 / (count * 130.0)).min(0.82);
|
|
let _ = write!(
|
|
s,
|
|
"<g transform=\"translate({x:.0},{y:.0}) scale({scale:.3})\">",
|
|
x = cx - count * 130.0 * scale / 2.0,
|
|
y = cy - ry + 24.0,
|
|
);
|
|
for (i, (priority, p)) in view.problems.iter().enumerate() {
|
|
problem_svg(&mut s, *priority, p, (i as i32) * 130);
|
|
}
|
|
s.push_str("</g>");
|
|
} else {
|
|
scoped_problems(&mut s, view, &pos, cx, cy, ry);
|
|
}
|
|
let _ = write!(
|
|
s,
|
|
"<g transform=\"translate({x:.0},{y:.0}) scale(0.62)\">",
|
|
x = cx - 300.0 * 0.62 / 2.0,
|
|
y = cy + 8.0,
|
|
);
|
|
piles_body(&mut s, view);
|
|
s.push_str("</g>");
|
|
|
|
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)))
|
|
.unwrap_or_default();
|
|
let pv = &view.players[p];
|
|
// The played card sits between the seat and the table.
|
|
if let Some(sel) = view.selections.get(p) {
|
|
let _ = write!(
|
|
s,
|
|
"<g transform=\"translate({px:.0},{py:.0}) scale(0.55)\">",
|
|
px = x + (cx - x) * 0.34 - 23.0,
|
|
py = y + (cy - y) * 0.34 - 15.0,
|
|
);
|
|
played_svg(&mut s, sel);
|
|
s.push_str("</g>");
|
|
}
|
|
let _ = write!(
|
|
s,
|
|
"<g data-drop=\"seat-{raw}\">\
|
|
<circle class=\"seat\" cx=\"{x:.0}\" cy=\"{y:.0}\" r=\"32\" fill=\"#1b1e26\" \
|
|
stroke=\"{stroke}\" stroke-width=\"{sw}\"/>\
|
|
<text x=\"{x:.0}\" y=\"{t1:.0}\" fill=\"#dde\" font-size=\"12\" \
|
|
text-anchor=\"middle\">{name}{you}</text>\
|
|
<text x=\"{x:.0}\" y=\"{t2:.0}\" fill=\"#89a\" font-size=\"10\" \
|
|
text-anchor=\"middle\">stress {stress}{focus}</text></g>",
|
|
raw = p.0,
|
|
stroke = if is_viewer { "#9cf" } else { "#5a6b7a" },
|
|
sw = if is_viewer { 3 } else { 2 },
|
|
t1 = y - 2.0,
|
|
t2 = y + 13.0,
|
|
name = seat_name(*p),
|
|
you = if is_viewer { " (you)" } else { "" },
|
|
stress = pv.stress,
|
|
focus = esc(&focus),
|
|
);
|
|
// CB-WP-0029 T02: the components, at their seat.
|
|
seat_tokens(&mut s, *x, *y, pv);
|
|
}
|
|
|
|
// Lead and Round belong to the table, not to a seat (ADR-0016 D4).
|
|
let _ = write!(
|
|
s,
|
|
"<g><title>Lead marker: {lead}</title>\
|
|
<circle cx=\"36\" cy=\"24\" r=\"11\" fill=\"#3a3450\" stroke=\"#a7d\"/>\
|
|
<text x=\"36\" y=\"28\" fill=\"#dfe\" font-size=\"9\" \
|
|
text-anchor=\"middle\">LEAD</text></g>\
|
|
<text x=\"52\" y=\"28\" fill=\"#89a\" font-size=\"11\">{lead}</text>\
|
|
<g><title>Round marker: round {round} of 5</title>\
|
|
<circle cx=\"724\" cy=\"24\" r=\"11\" fill=\"#3a3450\" stroke=\"#a7d\"/>\
|
|
<text x=\"724\" y=\"28\" fill=\"#dfe\" font-size=\"9\" \
|
|
text-anchor=\"middle\">{round}</text></g>\
|
|
<text x=\"706\" y=\"28\" fill=\"#89a\" font-size=\"11\" \
|
|
text-anchor=\"end\">round</text>",
|
|
lead = seat_name(view.lead),
|
|
round = view.round,
|
|
);
|
|
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)),
|
|
}
|
|
);
|
|
}
|
|
// CB-WP-0034. These four were `{:?}` on a player surface, which is
|
|
// the defect `selection_words` above was written for -- fixed once,
|
|
// for one field, with its four neighbours left as they were.
|
|
//
|
|
// The `support` line is the one the maintainer reported: it read
|
|
// `support AcceptBond`, which names the answer and not the person.
|
|
// CB-WP-0045: **what this DARVO stage will do**, in the edition's own
|
|
// words. `DARVO.csv` carries `mandatory_effect` per stage; we vendored
|
|
// it for tripwires and never showed it, so the page said "DARVO
|
|
// Reverse" and left the player to guess. Reported as *"the GROUND and
|
|
// DARVO logic is far from self explanatory"*.
|
|
if p.darvo != games_ground::DarvoStage::Off {
|
|
if let Some(text) = darvo_stage_text(p.darvo) {
|
|
let _ = write!(
|
|
out,
|
|
"<details><summary>what {:?} does</summary>{}</details>",
|
|
p.darvo,
|
|
esc(&text)
|
|
);
|
|
}
|
|
}
|
|
if let Some(m) = view.ground_modes.get(&id) {
|
|
let _ = write!(
|
|
out,
|
|
"<span class=\"k\">ground mode</span> {}<br>",
|
|
esc(ground_mode_label(m))
|
|
);
|
|
}
|
|
if let Some(c) = view.ground_choices.get(&id) {
|
|
let _ = write!(
|
|
out,
|
|
"<span class=\"k\">ground choice</span> {}<br>",
|
|
esc(&ground_choice_label(c))
|
|
);
|
|
}
|
|
if let Some(r) = view.support_responses.get(&id) {
|
|
let _ = write!(
|
|
out,
|
|
"<span class=\"k\">support</span> {}<br>",
|
|
esc(&support_words(r, supporter_of(view, Some(id))))
|
|
);
|
|
}
|
|
if let Some(t) = view.darvo_targets.get(&id) {
|
|
let _ = write!(
|
|
out,
|
|
"<span class=\"k\">darvo target</span> {}<br>",
|
|
esc(&darvo_target_words(t))
|
|
);
|
|
}
|
|
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",
|
|
alive: "/alive",
|
|
},
|
|
seat,
|
|
may_pass,
|
|
Account::of(&[]),
|
|
&[],
|
|
)
|
|
}
|
|
|
|
/// 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,
|
|
/// Where the page checks the session is still there (CB-WP-0035).
|
|
pub alive: &'a str,
|
|
}
|
|
|
|
pub fn document_with_log(
|
|
view: &GroundView,
|
|
legal: &[games_ground::GroundCommand],
|
|
to: Endpoints<'_>,
|
|
seat: Option<PlayerId>,
|
|
may_pass: bool,
|
|
log: Account<'_>,
|
|
meta: &[String],
|
|
) -> String {
|
|
let (endpoint, note_to, alive) = (to.command, to.note, to.alive);
|
|
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\">rules</span> {variant} \
|
|
<span class=\"k\">viewing as</span> {who}</div>",
|
|
round = view.round,
|
|
step = view.step,
|
|
lead = seat_name(view.lead),
|
|
mode = view.mode,
|
|
// CB-WP-0044: the page must say which rules it is playing. It did
|
|
// not, so a player who ran the default and was told the layout had
|
|
// changed reported "no changes" — correctly, because the baseline
|
|
// is deliberately unchanged and nothing on screen said so.
|
|
variant = esc(view.variant.id()),
|
|
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, view, 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};window.CB_ALIVE={alive}</script>\
|
|
<script>{SCRIPT}</script></body></html>",
|
|
endpoint = json_string(endpoint),
|
|
alive = json_string(alive),
|
|
);
|
|
s
|
|
}
|
|
|
|
/// The comment box, shared by the playing page and the ending page.
|
|
///
|
|
/// **Extracted because the ending page needs the same box** (CB-WP-0031).
|
|
/// A second copy would be a second thing to forget: the playing page's
|
|
/// form already had the token-carrying action and the works-without-script
|
|
/// property, and a hand-written twin on the ending page is how one of them
|
|
/// quietly stops posting anywhere useful.
|
|
///
|
|
/// The `placeholder` differs, and only that — what a player has to say
|
|
/// mid-turn and what they have to say having seen the result are not the
|
|
/// same prompt.
|
|
fn note_form(s: &mut String, note_to: &str, placeholder: &str) {
|
|
// 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=\"{placeholder}\"></textarea>\
|
|
<button type=\"submit\">note it</button></form>",
|
|
note_to = esc(note_to),
|
|
placeholder = esc(placeholder),
|
|
);
|
|
}
|
|
|
|
/// 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.
|
|
note_form(
|
|
s,
|
|
note_to,
|
|
"why this move, what is unclear, what is annoying \u{2014} bound to this position",
|
|
);
|
|
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>");
|
|
}
|
|
|
|
/// Place each Problem where its Stress lands (CB-WP-0043).
|
|
///
|
|
/// | scope | where |
|
|
/// |---|---|
|
|
/// | `global` | the middle of the table — it is everyone's |
|
|
/// | `personal` | beside its owner, between that seat and the table |
|
|
/// | `bond` | on the owner's Bond lines: the mean midpoint of each Bond edge from the owner |
|
|
///
|
|
/// **A bond card with no Bonds is drawn as personal**, which is not a
|
|
/// rendering convenience — it is the rule. `bond_network` falls back to
|
|
/// the owner alone at degree 0, so the picture and the arithmetic agree.
|
|
fn scoped_problems(
|
|
s: &mut String,
|
|
view: &GroundView,
|
|
pos: &[(PlayerId, f64, f64)],
|
|
cx: f64,
|
|
cy: f64,
|
|
ry: f64,
|
|
) {
|
|
use games_ground::edition::StressScope;
|
|
let seat_at = |p: PlayerId| {
|
|
pos.iter()
|
|
.find(|(q, _, _)| *q == p)
|
|
.map(|(_, x, y)| (*x, *y))
|
|
};
|
|
|
|
// Bond partners of a seat, for the `bond` anchor. One hop, because
|
|
// the LINES are what the card sits on and a line is one edge.
|
|
let partners = |owner: PlayerId| -> Vec<(f64, f64)> {
|
|
view.relations
|
|
.iter()
|
|
.filter(|(_, rel)| **rel == games_ground::Relation::Bond)
|
|
.filter_map(|(pair, _)| {
|
|
let other = if pair.0 == owner {
|
|
Some(pair.1)
|
|
} else if pair.1 == owner {
|
|
Some(pair.0)
|
|
} else {
|
|
None
|
|
};
|
|
other.and_then(seat_at)
|
|
})
|
|
.collect()
|
|
};
|
|
|
|
// Stack cards that land on the same anchor, so two personal Problems
|
|
// on one seat do not draw on top of each other.
|
|
let mut used: Vec<(f64, f64)> = Vec::new();
|
|
for (priority, p) in &view.problems {
|
|
let marker = view.problem_markers.get(priority).copied();
|
|
let owner = marker.and_then(|m| m.owner);
|
|
let (ax, mut ay) = match (marker.and_then(|m| m.scope), owner.and_then(seat_at)) {
|
|
// Everyone's: the middle.
|
|
(Some(StressScope::Global), _) | (None, _) => (cx, cy - ry * 0.30),
|
|
// The owner's: between that seat and the table, so it reads
|
|
// as theirs without leaving the felt.
|
|
(Some(StressScope::Personal), Some((sx, sy))) => {
|
|
(cx + (sx - cx) * 0.52, cy + (sy - cy) * 0.52)
|
|
}
|
|
// The network's: on the Bond lines out of the owner.
|
|
(Some(StressScope::Bond), Some((sx, sy))) => {
|
|
let ps = owner.map(partners).unwrap_or_default();
|
|
if ps.is_empty() {
|
|
// Degree 0 — the rule says personal, so does the picture.
|
|
(cx + (sx - cx) * 0.52, cy + (sy - cy) * 0.52)
|
|
} else {
|
|
let n = ps.len() as f64;
|
|
let mx: f64 = ps.iter().map(|(x, _)| (sx + x) / 2.0).sum::<f64>() / n;
|
|
let my: f64 = ps.iter().map(|(_, y)| (sy + y) / 2.0).sum::<f64>() / n;
|
|
(mx, my)
|
|
}
|
|
}
|
|
// Scoped but ownerless: treat as the table's.
|
|
(Some(_), None) => (cx, cy - ry * 0.30),
|
|
};
|
|
while used
|
|
.iter()
|
|
.any(|(ux, uy)| (ux - ax).abs() < 40.0 && (uy - ay).abs() < 30.0)
|
|
{
|
|
ay += 30.0;
|
|
}
|
|
used.push((ax, ay));
|
|
|
|
// **Say it, do not only place it.** Position is not legible when
|
|
// two anchors coincide and is invisible to `text_of` and to a
|
|
// screen reader — so whose Problem this is, is written as well as
|
|
// shown. The coverage probe asks for exactly this.
|
|
let whose = match (marker.and_then(|m| m.scope), owner) {
|
|
(Some(StressScope::Global), _) | (None, _) => "everyone\u{2019}s".to_string(),
|
|
(Some(StressScope::Personal), Some(o)) => {
|
|
format!("{}\u{2019}s alone", seat_name(o))
|
|
}
|
|
(Some(StressScope::Bond), Some(o)) => {
|
|
if owner.map(partners).unwrap_or_default().is_empty() {
|
|
format!(
|
|
"{}\u{2019}s alone \u{2014} no Bond to share it",
|
|
seat_name(o)
|
|
)
|
|
} else {
|
|
format!("{}\u{2019}s Bond network", seat_name(o))
|
|
}
|
|
}
|
|
(Some(_), None) => "everyone\u{2019}s".to_string(),
|
|
};
|
|
|
|
let scale = 0.46;
|
|
let _ = write!(
|
|
s,
|
|
"<g transform=\"translate({x:.0},{y:.0}) scale({scale})\"><title>{}</title>",
|
|
esc(&whose),
|
|
x = ax - 120.0 * scale / 2.0,
|
|
y = ay - 78.0 * scale / 2.0,
|
|
);
|
|
problem_svg(s, *priority, p, 0);
|
|
let _ = write!(
|
|
s,
|
|
"<text x=\"60\" y=\"104\" fill=\"#9cf\" font-size=\"22\" \
|
|
text-anchor=\"middle\">{}</text>",
|
|
esc(&whose)
|
|
);
|
|
s.push_str("</g>");
|
|
}
|
|
}
|
|
|
|
/// 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) {
|
|
// CB-WP-0028 T03: one overhead view — seats around the edge, the
|
|
// Problems and both stacks in the middle, each seat's played card in
|
|
// front of it. This replaces three separate diagrams the player had to
|
|
// assemble: a Problems row, a relationship circle, and a piles picture.
|
|
s.push_str("<h2>the table</h2>");
|
|
if view.problems.is_empty() {
|
|
s.push_str("<div class=\"card\">no problems in play</div>");
|
|
}
|
|
s.push_str(&table_svg(view));
|
|
// CB-WP-0046: the placement is legible, the RULE it encodes was not.
|
|
//
|
|
// Shown only when a non-global scope is actually on the table — under
|
|
// the baseline every Problem is everyone's, and an explanation of a
|
|
// distinction that is not in play is noise. Same discipline as the
|
|
// GROUND modes: at the thing it explains, and closable.
|
|
if view.problem_markers.values().any(|m| {
|
|
m.scope
|
|
.is_some_and(|x| x != games_ground::edition::StressScope::Global)
|
|
}) {
|
|
if let Some(rule) = stress_scope_rule_text() {
|
|
let _ = write!(
|
|
s,
|
|
"<details class=\"card\"><summary>what a Problem\u{2019}s scope does\
|
|
</summary>{}</details>",
|
|
esc(&rule)
|
|
);
|
|
}
|
|
}
|
|
|
|
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>");
|
|
|
|
// The discard's contents stay as text: it is public and readable,
|
|
// and the stack on the table shows only how many.
|
|
let _ = write!(
|
|
s,
|
|
"<div><span class=\"k\">discard</span> {}</div>",
|
|
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!(
|
|
"{} (score {})",
|
|
c.members
|
|
.iter()
|
|
.map(|m| seat_name(*m))
|
|
.collect::<Vec<_>>()
|
|
.join(" + "),
|
|
c.score
|
|
)
|
|
})
|
|
.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.
|
|
/// The edition's own words for one Action (F18, ADR-0015 D2).
|
|
///
|
|
/// `None` if the dataset does not name it, and the page then shows what it
|
|
/// always showed — a missing tagline must not blank the card.
|
|
fn action_text(a: games_ground::Action) -> Option<games_ground::edition::CardText> {
|
|
let want = format!("ACT_{}", format!("{a:?}").to_uppercase());
|
|
games_ground::edition::actions()
|
|
.ok()?
|
|
.into_iter()
|
|
.find(|c| c.id == want)
|
|
}
|
|
|
|
/// Who offered this seat their Support (CB-WP-0034).
|
|
///
|
|
/// **Read off the view, because the command does not carry it.**
|
|
/// `RespondToSupport { response }` names the answer and not the person,
|
|
/// so no rendering of that command alone can say who — which is why the
|
|
/// page could not say it either.
|
|
fn supporter_of(view: &GroundView, seat: Option<PlayerId>) -> Option<PlayerId> {
|
|
let me = seat?;
|
|
view.selections.iter().find_map(|(p, sel)| match sel {
|
|
games_ground::view::SelectionView::Shown(s)
|
|
if s.action == games_ground::Action::Support && s.target == Some(me) =>
|
|
{
|
|
Some(*p)
|
|
}
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
/// The edition's names for the three GROUND modes (GR-A10..A12).
|
|
fn ground_mode_label(m: &games_ground::GroundMode) -> &'static str {
|
|
use games_ground::GroundMode as M;
|
|
match m {
|
|
M::Gr => "GROUND \u{2014} Ground & Restate",
|
|
M::Ou => "GROUND \u{2014} Observe & Uphold",
|
|
M::Nd => "GROUND \u{2014} Name & Decide",
|
|
}
|
|
}
|
|
|
|
/// A DARVO target, in words.
|
|
fn darvo_target_words(t: &games_ground::DarvoTarget) -> String {
|
|
match (t.player, t.problem) {
|
|
(Some(p), Some(q)) => format!("{}, Problem {q}", seat_name(p)),
|
|
(Some(p), None) => seat_name(p),
|
|
(None, Some(q)) => format!("Problem {q}"),
|
|
(None, None) => "nothing".to_string(),
|
|
}
|
|
}
|
|
|
|
/// What a seat answered a Support with, **and to whom**.
|
|
fn support_words(r: &games_ground::SupportResponse, who: Option<PlayerId>) -> String {
|
|
use games_ground::SupportResponse as R;
|
|
// Branch on whether the seat is KNOWN before phrasing, rather than
|
|
// substituting a noun phrase into a possessive: the first draft read
|
|
// "accepted a seat this view cannot show's Support".
|
|
let Some(who) = who.map(seat_name) else {
|
|
let what = match r {
|
|
R::AcceptBond => "accepted the Support offered \u{2014} formed a Bond",
|
|
R::DeclineBond => "declined the Support offered",
|
|
R::FlipToBond => "accepted the Support offered \u{2014} Rivalry became a Bond",
|
|
R::BreakRivalry => "broke the Rivalry",
|
|
};
|
|
return format!("{what} (this view does not show which seat)");
|
|
};
|
|
match r {
|
|
R::AcceptBond => format!("accepted {who}\u{2019}s Support \u{2014} Bond with {who}"),
|
|
R::DeclineBond => format!("declined {who}\u{2019}s Support"),
|
|
R::FlipToBond => format!("accepted {who}\u{2019}s Support \u{2014} Rivalry became a Bond"),
|
|
R::BreakRivalry => format!("broke the Rivalry with {who}"),
|
|
}
|
|
}
|
|
|
|
/// The edition's own words for what a DARVO stage does (CB-WP-0045).
|
|
///
|
|
/// From `DARVO.csv`'s `mandatory_effect`. **Not our paraphrase**: the game
|
|
/// says what its stages do and ADR-0015's discipline is to use that.
|
|
fn darvo_stage_text(stage: games_ground::DarvoStage) -> Option<String> {
|
|
use games_ground::DarvoStage;
|
|
let want = match stage {
|
|
DarvoStage::Off => return None,
|
|
DarvoStage::Deny => "DENY",
|
|
DarvoStage::Attack => "ATTACK",
|
|
DarvoStage::Reverse => "REVERSE",
|
|
};
|
|
games_ground::edition::darvo_stages()
|
|
.ok()?
|
|
.into_iter()
|
|
.find(|s| s.stage == want)
|
|
.map(|s| s.mandatory_effect)
|
|
}
|
|
|
|
/// The GROUND card's own account of its three modes (CB-WP-0045).
|
|
///
|
|
/// Shown where the mode is chosen. The card explains GR, OU and ND in one
|
|
/// passage; by the Reveal step the action card is no longer on screen, so
|
|
/// the explanation has to travel to the decision.
|
|
fn ground_modes_text() -> Option<String> {
|
|
games_ground::edition::actions()
|
|
.ok()?
|
|
.into_iter()
|
|
.find(|c| c.id.to_uppercase().contains("GROUND"))
|
|
.map(|c| c.rules_text)
|
|
}
|
|
|
|
/// What a scope DOES, in the edition's words (CB-WP-0046).
|
|
///
|
|
/// CB-WP-0045 left this open and gave the wrong reason: *"the scope rule
|
|
/// is ours (a Variant), so there is no vendored sentence to render."* The
|
|
/// H2 package ships `Rules_Text.csv` and its `Problems / Stress scope`
|
|
/// passage is exactly the rule. Nothing read the file.
|
|
///
|
|
/// **The placement was legible and the rule it encodes was not** — a
|
|
/// player could see a card sitting on their Bond lines and have no way to
|
|
/// learn that its Stress presses everyone on those lines.
|
|
fn stress_scope_rule_text() -> Option<String> {
|
|
games_ground::edition::stress_scope_rule()
|
|
}
|
|
|
|
/// A GROUND sub-choice, in words, naming whoever it touches.
|
|
fn ground_choice_label(c: &games_ground::GroundChoice) -> String {
|
|
use games_ground::GroundChoice as G;
|
|
match c {
|
|
G::RestoreProblem { problem } => format!("restore Problem {problem}"),
|
|
G::CancelAttack { attacker } => {
|
|
format!("cancel {}\u{2019}s Attack on you", seat_name(*attacker))
|
|
}
|
|
G::ProtectProblem { problem } => format!("protect Problem {problem} from Deny"),
|
|
G::RemoveBlame { owner } => format!("remove {}\u{2019}s Blame token", seat_name(*owner)),
|
|
G::BreakRelation { with } => format!("break your relation with {}", seat_name(*with)),
|
|
G::RejectReverse => "reject the Reverse aimed at you".to_string(),
|
|
}
|
|
}
|
|
|
|
/// A move button\u2019s words \u2014 and **who it is aimed at** (CB-WP-0034).
|
|
///
|
|
/// The label was `format!("{c:?}")`. A player deciding whether to accept a
|
|
/// Bond read `RespondToSupport { response: AcceptBond }` \u2014 Rust struct
|
|
/// syntax with no name anywhere in it \u2014 which is why *"i cant see whos
|
|
/// support i accept"* was reported three times across three sessions and
|
|
/// survived a whole UI rebuild.
|
|
///
|
|
/// **Exhaustive on purpose: there is no catch-all arm.** A new command
|
|
/// must be given words or this crate stops compiling. A `_ =>` here is
|
|
/// how `Debug` output comes back one variant at a time.
|
|
///
|
|
/// **These are clay-borg\u2019s words, not the edition\u2019s**, and the
|
|
/// distinction matters (ADR-0015). The Action cards above carry the
|
|
/// game\u2019s own `rules_text`; nothing in the vendored files names these
|
|
/// control moves, and `Glossary` is still unvendored (F18). If it lands,
|
|
/// this is a caller to revisit.
|
|
fn command_label(
|
|
c: &games_ground::GroundCommand,
|
|
view: &GroundView,
|
|
seat: Option<PlayerId>,
|
|
) -> String {
|
|
use games_ground::GroundCommand as C;
|
|
match c {
|
|
C::SelectAction {
|
|
action,
|
|
target,
|
|
problem,
|
|
} => {
|
|
let mut out = format!("{action:?}").to_uppercase();
|
|
if let Some(t) = target {
|
|
let _ = write!(out, " on {}", seat_name(*t));
|
|
}
|
|
if let Some(p) = problem {
|
|
let _ = write!(out, ", Problem {p}");
|
|
}
|
|
out
|
|
}
|
|
C::SpendFreedom => {
|
|
"spend your Freedom token \u{2014} act despite the Stress gate".to_string()
|
|
}
|
|
C::ChooseGroundMode { mode, choice } => {
|
|
let named = ground_mode_label(mode);
|
|
match choice {
|
|
Some(ch) => format!("{named}: {}", ground_choice_label(ch)),
|
|
None => named.to_string(),
|
|
}
|
|
}
|
|
// The one the report was about.
|
|
C::RespondToSupport { response } => {
|
|
use games_ground::SupportResponse as R;
|
|
// Not a guess. If the offer is not visible in this view, the
|
|
// page says so rather than inventing a name or quietly
|
|
// dropping the question -- a confidently wrong seat is worse
|
|
// than an honest gap (ADR-0018).
|
|
let Some(who) = supporter_of(view, seat).map(seat_name) else {
|
|
let what = match response {
|
|
R::AcceptBond => "accept the Support offered to you \u{2014} form a Bond",
|
|
R::DeclineBond => "decline the Support offered to you",
|
|
R::FlipToBond => {
|
|
"accept the Support offered to you \u{2014} turn your Rivalry into a Bond"
|
|
}
|
|
R::BreakRivalry => "break the Rivalry",
|
|
};
|
|
return format!("{what} (this view does not show which seat)");
|
|
};
|
|
match response {
|
|
R::AcceptBond => {
|
|
format!("accept {who}\u{2019}s Support \u{2014} form a Bond with {who}")
|
|
}
|
|
R::DeclineBond => {
|
|
format!("decline {who}\u{2019}s Support \u{2014} the Stress still applies")
|
|
}
|
|
R::FlipToBond => {
|
|
format!("accept {who}\u{2019}s Support \u{2014} turn your Rivalry into a Bond")
|
|
}
|
|
R::BreakRivalry => {
|
|
format!("break your Rivalry with {who}")
|
|
}
|
|
}
|
|
}
|
|
C::ChooseDarvoTarget { target } => {
|
|
format!("DARVO target: {}", darvo_target_words(target))
|
|
}
|
|
C::Reveal => "reveal all selections".to_string(),
|
|
C::Resolve => "resolve the revealed Actions".to_string(),
|
|
C::EndRound => "end the round".to_string(),
|
|
}
|
|
}
|
|
|
|
fn move_section(
|
|
s: &mut String,
|
|
view: &GroundView,
|
|
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=\"tag\">{tagline}</span>\
|
|
<span class=\"k\">onto</span> {names}{rules}</div>",
|
|
id = action_id(a),
|
|
targets = esc(&targets.join(" ")),
|
|
descs = esc(&descs.join("|")),
|
|
names = esc(&target_names(&targets)),
|
|
// CB-WP-0028 T02 / F18: the card's own words. The
|
|
// tagline is always shown; the full rules text is on
|
|
// demand, because five of them at once is a wall.
|
|
tagline = esc(action_text(a)
|
|
.map(|c| c.tagline.clone())
|
|
.unwrap_or_default()
|
|
.as_str()),
|
|
rules = action_text(a)
|
|
.map(|c| format!(
|
|
"<details><summary>what it does</summary>{}</details>",
|
|
esc(&c.rules_text)
|
|
))
|
|
.unwrap_or_default(),
|
|
);
|
|
}
|
|
}
|
|
// CB-WP-0045: when a GROUND mode is on offer, the card's own
|
|
// account of the three modes travels with the decision. By the
|
|
// Reveal step the action card has left the screen, so the player
|
|
// was choosing GR / OU / ND from three names alone.
|
|
if legal
|
|
.iter()
|
|
.any(|c| matches!(c, games_ground::GroundCommand::ChooseGroundMode { .. }))
|
|
{
|
|
if let Some(text) = ground_modes_text() {
|
|
let _ = write!(
|
|
s,
|
|
"<details class=\"card\" open><summary>what the GROUND modes do\
|
|
</summary>{}</details>",
|
|
esc(&text)
|
|
);
|
|
}
|
|
}
|
|
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 tap\" data-drop=\"cmd-{i}\">{}</div>",
|
|
esc(&command_label(c, view, seat))
|
|
);
|
|
}
|
|
}
|
|
// CB-WP-0028: the table's drop zone is THE TABLE, drawn above.
|
|
// This was a separate card labelled "the table" sitting among the
|
|
// move buttons, which is why a player looking at a picture of a
|
|
// table could not find anywhere to drop.
|
|
s.push_str("</div>");
|
|
}
|
|
if may_pass {
|
|
s.push_str(
|
|
"<div class=\"card btn tap\" 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>,
|
|
}
|
|
|
|
/// One thing the player said, placed in the account (CB-WP-0032).
|
|
///
|
|
/// **`after` is a count of commands, not a round.** A note anchored to
|
|
/// `round`/`step` cannot be ordered against the log, because several
|
|
/// commands share a step and the log is a sequence of commands — placing
|
|
/// by round would put a note *somewhere in* the right round, which for a
|
|
/// remark about a specific move is the wrong position stated confidently.
|
|
/// The server knows exactly how many commands had been played when the
|
|
/// note was written, so that is what it records.
|
|
#[derive(Debug, Clone)]
|
|
pub struct LogNote {
|
|
/// Commands played when this was written; `0` is before any move.
|
|
pub after: usize,
|
|
pub round: u8,
|
|
/// Where in the round, or `after the end` for a post-game note.
|
|
pub step: String,
|
|
pub text: String,
|
|
}
|
|
|
|
/// The account of the session: what happened, and what the player said
|
|
/// about it, in one sequence.
|
|
///
|
|
/// **A struct rather than two parameters** because both pages take both
|
|
/// and `document_with_log` was already at seven arguments — and because
|
|
/// the two are only meaningful together, which is the point of showing
|
|
/// them interleaved.
|
|
#[derive(Clone, Copy)]
|
|
pub struct Account<'a> {
|
|
pub lines: &'a [LogLine],
|
|
pub notes: &'a [LogNote],
|
|
}
|
|
|
|
impl<'a> Account<'a> {
|
|
/// An account with no commentary — for callers that have no notes.
|
|
pub fn of(lines: &'a [LogLine]) -> Self {
|
|
Self { lines, notes: &[] }
|
|
}
|
|
}
|
|
|
|
/// The game log, newest last, with the empty case spelled out.
|
|
///
|
|
/// **The player's own comments are interleaved** (CB-WP-0032) at the
|
|
/// point they were written, because a remark like *"why did that do
|
|
/// nothing?"* is about the move above it and loses its subject anywhere
|
|
/// else.
|
|
///
|
|
/// **They are marked as the player's, and cannot be read as game
|
|
/// events.** A note carries no `who`/`what` and renders in its own
|
|
/// `.note` block with the player's words in quotes: the log is the
|
|
/// recorder's vocabulary — what the scenario file will say — and a
|
|
/// comment that looked like a log line would be a sentence the game never
|
|
/// produced, sitting in the account of what the game did.
|
|
fn log_section(s: &mut String, log: Account<'_>) {
|
|
fn note_block(s: &mut String, n: &LogNote) {
|
|
let _ = write!(
|
|
s,
|
|
"<div class=\"note\"><span class=\"k\">you \u{2014} round {round}, \
|
|
{step}</span><br>\u{201c}{text}\u{201d}</div>",
|
|
round = n.round,
|
|
step = esc(&n.step),
|
|
text = esc(&n.text),
|
|
);
|
|
}
|
|
let notes_after = |k: usize, s: &mut String| {
|
|
for n in log.notes.iter().filter(|n| n.after == k) {
|
|
note_block(s, n);
|
|
}
|
|
};
|
|
s.push_str("<h2>log</h2><div class=\"card\" id=\"cb-log\">");
|
|
if log.lines.is_empty() && log.notes.is_empty() {
|
|
s.push_str("<span class=\"k\">nothing has happened yet</span>");
|
|
}
|
|
// Anything said before the first move still belongs in the account.
|
|
notes_after(0, s);
|
|
for (i, line) in log.lines.iter().enumerate() {
|
|
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>");
|
|
notes_after(i + 1, s);
|
|
}
|
|
// A note can outrun the log: post-game notes are written when every
|
|
// command has been played, and one written against a longer log than
|
|
// this page has must still appear rather than be silently dropped.
|
|
for n in log.notes.iter().filter(|n| n.after > log.lines.len()) {
|
|
note_block(s, n);
|
|
}
|
|
s.push_str("</div>");
|
|
}
|
|
|
|
/// Who did what, at the end (CB-WP-0028 T07).
|
|
///
|
|
/// **Two kinds of line, and they are labelled differently.** A *fact* is
|
|
/// something the game counts — Problems claimed, personal score, Stress,
|
|
/// Blame. A *reading* is ours, and says so.
|
|
///
|
|
/// **In SHARED GROUND the game defines no ranking at all**: `Modes.csv`
|
|
/// gives `scoring_tiebreak` as *"Not applicable"*, because the table
|
|
/// succeeds or fails together. So contributions are shown and **not
|
|
/// ordered**, and the page says why. Ranking a co-operative game because
|
|
/// a leaderboard is easy to draw would be inventing scoring the game does
|
|
/// not have — the same defect as canonising a provisional default.
|
|
fn rankings(s: &mut String, view: &GroundView) {
|
|
let Some(outcome) = &view.outcome else {
|
|
return;
|
|
};
|
|
let mode_id = match view.mode {
|
|
games_ground::ScoringMode::SharedGround => "MODE_COOP",
|
|
games_ground::ScoringMode::CommonProblem => "MODE_SEMI",
|
|
games_ground::ScoringMode::BondedCoalitions => "MODE_COALITION",
|
|
};
|
|
let tiebreak = games_ground::edition::modes()
|
|
.ok()
|
|
.and_then(|m| m.into_iter().find(|(c, _)| c.id == mode_id))
|
|
.map(|(_, tb)| tb)
|
|
.unwrap_or_default();
|
|
let ranked = !tiebreak.trim().is_empty() && !tiebreak.starts_with("Not applicable");
|
|
|
|
// A fact the game counts: who claimed which Problem.
|
|
let solved = |p: PlayerId| {
|
|
view.problems
|
|
.values()
|
|
.filter(|q| matches!(q, ProblemView::FaceUp { claimed_by: Some(c), .. } if *c == p))
|
|
.count()
|
|
};
|
|
|
|
let mut seats: Vec<PlayerId> = view.players.keys().copied().collect();
|
|
if ranked {
|
|
// The GAME's tiebreak, not ours: lower Stress first, which both
|
|
// ranked modes name as their first key.
|
|
seats.sort_by_key(|p| (view.players[p].stress, std::cmp::Reverse(solved(*p))));
|
|
}
|
|
|
|
let _ = write!(
|
|
s,
|
|
"<h2>who did what</h2><div class=\"card\"><span class=\"k\">mode</span> {mode}",
|
|
mode = esc(&format!("{:?}", view.mode)),
|
|
);
|
|
if ranked {
|
|
let _ = write!(
|
|
s,
|
|
"<br><span class=\"k\">ranked by the game's own tiebreak</span> {tb}",
|
|
tb = esc(&tiebreak),
|
|
);
|
|
} else {
|
|
s.push_str(
|
|
"<br><span class=\"k\">not ranked</span> \
|
|
this mode's scoring_tiebreak is \u{201c}Not applicable\u{201d} \u{2014} \
|
|
the table succeeds or fails together, so these are contributions, \
|
|
not a leaderboard",
|
|
);
|
|
}
|
|
s.push_str("</div><div class=\"row\">");
|
|
for p in &seats {
|
|
let pv = &view.players[p];
|
|
let _ = write!(
|
|
s,
|
|
"<div class=\"card\"><b>{name}</b><br>\
|
|
<span class=\"k\">problems solved</span> {n}<br>\
|
|
<span class=\"k\">personal</span> {score}<br>\
|
|
<span class=\"k\">stress</span> {stress} \
|
|
<span class=\"k\">blame</span> {blame}</div>",
|
|
name = seat_name(*p),
|
|
n = solved(*p),
|
|
score = outcome
|
|
.personal
|
|
.get(p)
|
|
.map(|v| format!("{v:+}"))
|
|
.unwrap_or_else(|| "\u{2014}".into()),
|
|
stress = pv.stress,
|
|
blame = pv.blame_from.len(),
|
|
);
|
|
}
|
|
s.push_str("</div>");
|
|
|
|
// A READING, marked as ours. Only where the game ranks at all, and
|
|
// only when there is a clear leader -- a tie is shown as a tie.
|
|
if ranked {
|
|
let best = seats.iter().map(|p| solved(*p)).max().unwrap_or(0);
|
|
let leaders: Vec<String> = seats
|
|
.iter()
|
|
.filter(|p| solved(**p) == best && best > 0)
|
|
.map(|p| seat_name(*p))
|
|
.collect();
|
|
let _ = write!(
|
|
s,
|
|
"<div class=\"card\"><span class=\"k\">clay-borg's reading, not a rule</span> {who}</div>",
|
|
who = match leaders.len() {
|
|
0 => "nobody claimed a Problem".to_string(),
|
|
1 => format!("most Problems solved: {}", leaders[0]),
|
|
_ => format!("most Problems solved, tied: {}", leaders.join(", ")),
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// **`note_to` is `Some` when a note can still be anchored** (CB-WP-0031).
|
|
/// The comment box used to stop existing the moment the game ended, which
|
|
/// removed the channel exactly when a player has most to say — they have
|
|
/// just seen the outcome, and *"so that's why that failed"* is the reading
|
|
/// the whole trial protocol wants. It is `None` only when there is no
|
|
/// final position to bind a note to, and then the page says so rather than
|
|
/// showing a box that would refuse.
|
|
pub fn ending(
|
|
view: Option<&GroundView>,
|
|
message: &str,
|
|
endpoint: &str,
|
|
note_to: Option<&str>,
|
|
alive: &str,
|
|
log: Account<'_>,
|
|
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 — {heading}</title><style>{STYLE}</style>\
|
|
<h1>{heading}</h1><div class=\"card\">{msg}</div>",
|
|
msg = esc(message),
|
|
// CB-WP-0028 T06. "Game over" is arcade vocabulary for a failure
|
|
// state. GROUND is co-operative and about repairing a situation:
|
|
// when the table clears its threshold it SOLVED something, and
|
|
// telling a player "game over" for that describes what they did
|
|
// wrongly. A game with no outcome claims neither.
|
|
heading = match view.and_then(|v| v.outcome.as_ref()) {
|
|
Some(o) if o.group_success => "game solved",
|
|
Some(_) => "game over",
|
|
None => "the game stopped",
|
|
},
|
|
);
|
|
// CB-WP-0028 T05: the ending page gets the table's shape — the
|
|
// result on the left, everything about the session on the right.
|
|
s.push_str("<div class=\"cb-cols\"><div class=\"cb-game\">");
|
|
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>",
|
|
);
|
|
}
|
|
}
|
|
s.push_str("</div><div class=\"cb-meta\">");
|
|
if let Some(v) = view {
|
|
rankings(&mut s, v);
|
|
}
|
|
// 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>");
|
|
}
|
|
// CB-WP-0031: above the log and below the result, because that is the
|
|
// order the thought arrives in — you see how it ended, you say what
|
|
// you make of it, and the account is there to check yourself against.
|
|
match note_to {
|
|
Some(to) => note_form(
|
|
&mut s,
|
|
to,
|
|
"how did that go \u{2014} what surprised you, what was unclear, \
|
|
what would you do differently",
|
|
),
|
|
None => s.push_str(
|
|
"<div class=\"card\">No comment box: the game stopped without a \
|
|
final position, so there is nothing to bind a note to.</div>",
|
|
),
|
|
}
|
|
// Observations 6 and 7: the controls and the full log belong beside
|
|
// the result, not under it. And the controls go BELOW the log — you
|
|
// read what happened, then decide what to do next.
|
|
log_section(&mut s, log);
|
|
let _ = write!(
|
|
s,
|
|
"<div class=\"row\">\
|
|
<div class=\"card btn tap\" data-drop=\"again\">play again</div>\
|
|
<div class=\"card btn tap\" data-drop=\"done\">end session \u{2014} stops the game server</div>\
|
|
</div>",
|
|
);
|
|
s.push_str("</div></div>");
|
|
let _ = write!(
|
|
s,
|
|
"<div id=\"cb-status\">the game is over</div>\
|
|
<script>window.CB_ENDPOINT={endpoint};window.CB_ALIVE={alive}</script>\
|
|
<script>{SCRIPT}</script>",
|
|
endpoint = json_string(endpoint),
|
|
alive = json_string(alive),
|
|
);
|
|
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("&", "&")
|
|
}
|