Some checks failed
ci / check (push) Failing after 4s
Two reports, one cause: the script's fetch had no .catch. When the server had exited the promise rejected, the chain never ran, and not even the status line moved — so a dead server and a click that did nothing were indistinguishable. play again read as a dead button, and the linger timeout was invisible. Now a rejection says the session is gone and seals the page, and a 5-second heartbeat against a new /alive notices it without needing a click, which is what the timeout case requires. The beat carries the token, and does not extend the linger: that deadline is absolute. The harness had the same hole. jsrun's fetch stub had no .catch, so the branch that notices a dead server would have been unreachable in every test — the very defect the stub's own comment records from CB-WP-0024. Teaching it __failing, .catch and a recorded setInterval was the fix; writing the script defensively would have repeated the trap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1929 lines
76 KiB
Rust
1929 lines
76 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\"/>\
|
|
<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,
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|
|
// On the table: Problems across the middle, the two stacks under them.
|
|
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>");
|
|
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.
|
|
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\">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, 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>");
|
|
}
|
|
|
|
/// 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));
|
|
|
|
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}"),
|
|
}
|
|
}
|
|
|
|
/// 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(),
|
|
);
|
|
}
|
|
}
|
|
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("&", "&")
|
|
}
|