//! The emitted document: HTML shell, inline SVG table, inline JavaScript. //! //! ADR-0007 Decision 1. Marginal AM-4a cost zero — this is string //! formatting, and the browser draws it. //! //! ## What the JavaScript is allowed to be //! //! ADR-0007 Decision 5 bars it from constructing commands. [`SCRIPT`] is //! therefore the whole of it, it is a constant, and it does one thing: //! record which element the pointer went down on, which it came up on, and //! POST the pair. It contains no game vocabulary — no action names, no //! seats, no rules. If a rule ever needs to appear in it, the decision is //! wrong and ADR-0007 says to revisit it rather than widen the control. use std::fmt::Write as _; use cb_kernel::PlayerId; use games_ground::view::{GroundView, PlayerView, ProblemView, SelectionView}; use crate::input::action_id; /// Escape for text and attribute contexts alike. /// /// Everything interpolated into the document goes through this. Card /// suits and seat numbers cannot currently carry a `<`, but "cannot /// currently" is how injection bugs are written. pub fn esc(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), _ => out.push(c), } } out } fn seat_name(p: PlayerId) -> String { format!("P{}", p.0 + 1) } fn cards(list: &[games_ground::SolutionCard]) -> String { if list.is_empty() { return "none".to_string(); } list.iter() .map(|c| format!("{:?}", c.suit)) .collect::>() .join(" ") } /// The only JavaScript in the project. See the module docs. pub const SCRIPT: &str = r#" (function () { var down = null, held = null, marked = [], ghost = null, ghostLabel = ''; // 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 = '' + ghostLabel + ''; ghost.style.left = e.clientX + 'px'; ghost.style.top = e.clientY + 'px'; document.body.appendChild(ghost); } }); document.addEventListener('pointermove', function (e) { if (!ghost) { return; } ghost.style.left = e.clientX + 'px'; ghost.style.top = e.clientY + 'px'; // The explanation, beside the pointer and therefore beside the // target it is over (CB-WP-0018 T03). var over = describeOf(held, key(node(e))); ghost.innerHTML = '' + ghostLabel + '' + (over ? '' + over + '' : ''); 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 = Vec::new(); for t in targets { out.push(match t.split_once('-') { Some(("seat", n)) => n .parse::() .map(|n| seat_name(PlayerId(n))) .unwrap_or_else(|_| t.clone()), Some(("problem", n)) => format!("problem {n}"), _ if t == "table" => "the table".to_string(), _ => t.clone(), }); } out.join(", ") } /// 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 { 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, "\ \ \ {priority}\ {label}\ {name}\ {sub}", 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, "{} pile: {count} remaining", 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, "", ); } for d in (0..depth).rev() { let dx = x + d * 3; let dy = 14 - d * 3; let _ = write!( out, "", ); } let _ = write!( out, "{count}\ {label}\ {note}", 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( "\ ", ); } } /// 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, "{}", esc(label)); for i in 0..stops { let cx = x + (i as f64) * step; let here = i == at; let _ = write!( out, "", 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(""); } /// 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, "{t}\ {ch}", 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( "", ); // 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, "\ the table", 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, "\ {rel:?}", 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, "", 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(""); } else { scoped_problems(&mut s, view, &pos, cx, cy, ry); } let _ = write!( s, "", x = cx - 300.0 * 0.62 / 2.0, y = cy + 8.0, ); piles_body(&mut s, view); s.push_str(""); 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, "", px = x + (cx - x) * 0.34 - 23.0, py = y + (cy - y) * 0.34 - 15.0, ); played_svg(&mut s, sel); s.push_str(""); } let _ = write!( s, "\ \ {name}{you}\ stress {stress}{focus}", 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, "Lead marker: {lead}\ \ LEAD\ {lead}\ Round marker: round {round} of 5\ \ {round}\ round", lead = seat_name(view.lead), round = view.round, ); s.push_str(""); 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 = "face down\ \ "; 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, "\ played {label}\ \ {label}\ {sub}\ ", 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. "
\ {name}{you}
", raw = id.0, name = seat_name(id), you = if is_viewer { " (you)" } else { "" }, ); let _ = write!( out, "stress {} protect {} \ darvo {:?}
", p.stress, p.protection, p.darvo ); let _ = write!( out, "freedom \ {ready}{lifted}
", raw = id.0, ready = if p.freedom_ready { "READY" } else { "spent" }, lifted = if p.freedom_gate_lifted { " gate lifted" } else { "" }, ); let blame = if p.blame_from.is_empty() { "none".to_string() } else { p.blame_from .iter() .map(|b| seat_name(*b)) .collect::>() .join(" ") }; let _ = write!( out, "blamed by {}
", esc(&blame) ); match &p.hand { Some(h) => { let _ = write!( out, "hand {} ({} cards)
", esc(&cards(h)), p.hand_size ); } None => { let _ = write!( out, "hand {} card(s), hidden
", p.hand_size ); } } if let Some(sel) = view.selections.get(&id) { // 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, "selected {}
", 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, "
what {:?} does{}
", p.darvo, esc(&text) ); } } if let Some(m) = view.ground_modes.get(&id) { let _ = write!( out, "ground mode {}
", esc(ground_mode_label(m)) ); } if let Some(c) = view.ground_choices.get(&id) { let _ = write!( out, "ground choice {}
", esc(&ground_choice_label(c)) ); } if let Some(r) = view.support_responses.get(&id) { let _ = write!( out, "support {}
", esc(&support_words(r, supporter_of(view, Some(id)))) ); } if let Some(t) = view.darvo_targets.get(&id) { let _ = write!( out, "darvo target {}
", esc(&darvo_target_words(t)) ); } out.push_str("
"); } /// Render the whole table as a standalone document. /// /// `may_pass` adds the one affordance that is not a command: declining to /// act where the driver allows it. Like every other element it carries an /// id and nothing else — the page still reports only that the pointer went /// down and up on `pass`. /// /// `legal` is the list the aggregate offered; the buttons carry indices /// into it and nothing else (ADR-0007 control 5). `endpoint` carries the /// per-process token (control 1) — the page cannot mint one. pub fn document( view: &GroundView, legal: &[games_ground::GroundCommand], endpoint: &str, seat: Option, may_pass: bool, ) -> String { document_with_log( view, legal, 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, 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, "\ \ GROUND \u{2014} round {round}", round = view.round ); let _ = write!( s, "

GROUND \u{2014} round {round}, step {step:?}

\
lead {lead} \ scoring {mode:?} \ rules {variant} \ viewing as {who}
", 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("
"); body(&mut s, view); move_section(&mut s, view, legal, seat, may_pass); s.push_str("
"); meta_section(&mut s, meta, note_to); log_section(&mut s, log); s.push_str("
"); let _ = write!( s, "
ready
\ \ ", 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, "

what are you thinking?

\
\ \
", 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("

this session

"); for (i, line) in meta.iter().enumerate() { if i > 0 { s.push_str("
"); } s.push_str(&esc(line)); } s.push_str("
"); } /// 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::() / n; let my: f64 = ps.iter().map(|(_, y)| (sy + y) / 2.0).sum::() / 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, "{}", 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, "{}", esc(&whose) ); s.push_str(""); } } /// 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("

the table

"); if view.problems.is_empty() { s.push_str("
no problems in play
"); } 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, "
what a Problem\u{2019}s scope does\ {}
", esc(&rule) ); } } s.push_str("

seats

"); for (id, p) in &view.players { player_card(s, *id, p, view); } s.push_str("
"); // 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, "
discard {}
", esc(&cards(&view.solution_discard)), ); if let Some(o) = &view.outcome { let personal = o .personal .iter() .map(|(p, v)| format!("{} {v:+}", seat_name(*p))) .collect::>() .join(" "); let winners = if o.winners.is_empty() { "nobody".to_string() } else { o.winners .iter() .map(|w| seat_name(*w)) .collect::>() .join(" ") }; let coalitions = if o.coalitions.is_empty() { "none".to_string() } else { o.coalitions .iter() .map(|c| { format!( "{} (score {})", c.members .iter() .map(|m| seat_name(*m)) .collect::>() .join(" + "), c.score ) }) .collect::>() .join(", ") }; let _ = write!( s, "

outcome

\ total {total} of {threshold} \ group {group}
\ personal {personal}
\ coalitions {coalitions}
\ mastery {mastery}
\ winners {winners}
", total = o.total, threshold = o.threshold, group = if o.group_success { "success" } else { "failure" }, personal = esc(&personal), coalitions = esc(&coalitions), mastery = match o.mastery { Some(m) => format!("{m:+}"), None => "none".to_string(), }, winners = esc(&winners), ); } } /// The "your move" section: action cards, numbered fallbacks, the table, /// and pass. Emits nothing when the seat has nothing legal to do. /// 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 { 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) -> Option { 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) -> 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 { 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 { 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 { 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, ) -> 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, may_pass: bool, ) { if !legal.is_empty() { s.push_str("

your move

"); for a in [ games_ground::Action::Investigate, games_ground::Action::Solve, games_ground::Action::Support, games_ground::Action::Attack, games_ground::Action::Ground, ] { // ADR-0010 D1: the legal targets come from `legal` and are // written into the page as data. The script matches on them; // it never derives them. The old text was a CONSTANT — // "onto a seat, a problem, or the table" — emitted whenever // any legal command used this action, and it was wrong // wherever the real target set was narrower, which is almost // everywhere: Investigate is legal on problems 2 and 3 but // not 1 (CB-WP-0017). // CB-WP-0018 T03: targets and their meanings, in step, both // written by Rust. ADR-0010 D1 forbids the page composing the // second from the first. let offered: Vec<(String, String)> = seat .map(|seat| { legal .iter() .filter_map(|c| { crate::input::affordance(c, seat) .filter(|(f, _)| *f == action_id(a)) .map(|(_, t)| (t, crate::input::describe(c, seat))) }) .collect() }) .unwrap_or_default(); let targets: Vec = offered.iter().map(|(t, _)| t.clone()).collect(); let descs: Vec = offered.iter().map(|(_, d)| d.clone()).collect(); if !targets.is_empty() { let _ = write!( s, "
{a:?}
\ {tagline}\ onto {names}{rules}
", 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!( "
what it does{}
", 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, "
what the GROUND modes do\ {}
", esc(&text) ); } } s.push_str("
"); for (i, c) in legal.iter().enumerate() { let spatial = seat.is_some_and(|seat| crate::input::affordance(c, seat).is_some()); if !spatial { let _ = write!( s, "
{}
", esc(&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("
"); } if may_pass { s.push_str( "
pass \u{2014} decline to act
", ); } } /// Quote a string as a JSON literal, so a token can never end the script. fn json_string(s: &str) -> String { let mut out = String::with_capacity(s.len() + 2); out.push('"'); for c in s.chars() { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), '<' => out.push_str("\\u003c"), '>' => out.push_str("\\u003e"), '&' => out.push_str("\\u0026"), c if (c as u32) < 0x20 => { let _ = write!(out, "\\u{:04x}", c as u32); } c => out.push(c), } } out.push('"'); out } /// Extract the document's visible text and element ids. /// /// ADR-0007 control 6 requires the coverage gate to assert over the /// **parsed emitted document**, not over the Rust that emits it. This is /// that parse: it drops markup and returns what a reader would see, plus /// the ids a pointer can address. A substring search over the raw source /// would happily find a token inside a comment or a style rule. /// Every `data-drop="…"` value in the document. /// /// CB-WP-0016. A real parse of the attribute rather than a substring /// search: `html.contains("seat-1")` would be satisfied by the *text* /// "seat-1" and by `data-drop="seat-10"`, and the point of the check this /// feeds is that an affordance can name a target that is not there. /// /// **Drop keys are `data-drop`, not `id`, and that is the fix for /// CB-WP-0016.** An `id` must be unique in a document, so exactly one /// element could ever be `seat-0` — the relationship-graph circle took it /// and the seat card the instruction text points at went without. A seat /// is drawn twice and both drawings are the seat. /// One line of the game log: what was done, and what it produced. /// /// Built by the caller from `bot::Applied` so this crate stays free of /// the driver, and phrased in the **recorder's** vocabulary via /// `record::to_step` — CB-WP-0018 T02 forbids a fourth phrasing, because /// what the player reads should be what the scenario file will say. pub struct LogLine { pub who: String, pub what: String, /// Rendered effects. **Empty is the case that matters:** a command /// that produced no events is the one the player cannot otherwise /// account for. pub effects: Vec, } /// 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, "
you \u{2014} round {round}, \ {step}
\u{201c}{text}\u{201d}
", 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("

log

"); if log.lines.is_empty() && log.notes.is_empty() { s.push_str("nothing has happened yet"); } // 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, "
{who} {what}", who = esc(&line.who), what = esc(&line.what), ); if line.effects.is_empty() { // The silence CB-WP-0018 was reported for, said out loud. s.push_str(" \u{2014} no effect"); } else { for e in &line.effects { let _ = write!(s, "
\u{2192} {}", esc(e)); } } s.push_str("
"); 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("
"); } /// 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 = 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, "

who did what

mode {mode}", mode = esc(&format!("{:?}", view.mode)), ); if ranked { let _ = write!( s, "
ranked by the game's own tiebreak {tb}", tb = esc(&tiebreak), ); } else { s.push_str( "
not ranked \ 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("
"); for p in &seats { let pv = &view.players[p]; let _ = write!( s, "
{name}
\ problems solved {n}
\ personal {score}
\ stress {stress} \ blame {blame}
", 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("
"); // 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 = seats .iter() .filter(|p| solved(**p) == best && best > 0) .map(|p| seat_name(*p)) .collect(); let _ = write!( s, "
clay-borg's reading, not a rule {who}
", 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, "\ \ GROUND — {heading}\

{heading}

{msg}
", 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("
"); match view { Some(v) => body(&mut s, v), None => { s.push_str( "
The game ended without a result, so there \ is no final table to show. The reason is above.
", ); } } s.push_str("
"); 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("

this session

"); for (i, line) in series.iter().enumerate() { if i > 0 { s.push_str("
"); } s.push_str(&esc(line)); } s.push_str("
"); } // 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( "
No comment box: the game stopped without a \ final position, so there is nothing to bind a note to.
", ), } // 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, "
\
play again
\
end session \u{2014} stops the game server
\
", ); s.push_str("
"); let _ = write!( s, "
the game is over
\ \ ", endpoint = json_string(endpoint), alive = json_string(alive), ); s } pub fn drop_keys(html: &str) -> std::collections::BTreeSet { let mut out = std::collections::BTreeSet::new(); let mut rest = html; while let Some(i) = rest.find("data-drop=\"") { let after = &rest[i + 11..]; match after.find('"') { Some(j) => { out.insert(after[..j].to_string()); rest = &after[j..]; } None => break, } } out } pub fn text_of(html: &str) -> String { let mut out = String::with_capacity(html.len() / 2); let bytes: Vec = html.chars().collect(); let mut i = 0; let mut skip_to: Option<&str> = None; while i < bytes.len() { if bytes[i] == '<' { // Find the tag name. let start = i + 1; let mut j = start; while j < bytes.len() && bytes[j] != '>' { j += 1; } let tag: String = bytes[start..j.min(bytes.len())].iter().collect(); let lower = tag.to_ascii_lowercase(); let name = lower .trim_start_matches('/') .split([' ', '\t', '\n', '>']) .next() .unwrap_or("") .to_string(); if let Some(want) = skip_to { if lower.starts_with('/') && name == want { skip_to = None; } } else if name == "script" || name == "style" { // Their contents are not text a reader sees. if !lower.starts_with('/') && !lower.ends_with('/') { skip_to = Some(if name == "script" { "script" } else { "style" }); } } else { // Ids are addressable surface, so they count as rendered. if let Some(k) = lower.find("id=\"") { let rest = &tag[k + 4..]; if let Some(end) = rest.find('"') { out.push(' '); out.push_str(&rest[..end]); } } } i = j + 1; continue; } if skip_to.is_none() { out.push(bytes[i]); } i += 1; } // Unescape the entities esc() introduced, so a test looks for the text // a reader sees rather than its encoding. out.replace("<", "<") .replace(">", ">") .replace(""", "\"") .replace("'", "'") .replace("&", "&") }