diff --git a/container/lib/collect.sh b/container/lib/collect.sh index 21d2698..a872082 100644 --- a/container/lib/collect.sh +++ b/container/lib/collect.sh @@ -83,6 +83,7 @@ publish_result() { doc="$(result_document)" || return 0 web="$ARENA_WEB" [ -d "$web" ] || { warn "no web folder at $web; the page will wait for S3"; return 1; } + doc="$(publish_camo "$doc" "$web")" cp "$doc" "$web/.decided.json.tmp" && mv "$web/.decided.json.tmp" "$web/decided.json" || { warn "could not publish the result to the page" return 1 @@ -90,6 +91,50 @@ publish_result() { log "published the result to the page" } +# publish_camo -> the document to publish +# +# Put each force's colours where the page can draw them, and name them in the +# copy it reads. +# +# The camo is already in this container - init/30-assets.sh staged it for +# MegaMek and indexed it by slot - so this is a file copy into the directory +# Suramadu already serves, and the report ends up drawing the same picture the +# units wore. Same-origin, which is what keeps it out of the page's CSP. +# +# Only the page's copy is touched. The document that goes to S3 carries no +# camo names: those paths exist only while this container does, and a report +# opened afterwards would be asking a dead host for them. A page with no camo +# shows a card without colours, which is the same thing a match played in +# default ones shows. +# +# Never fatal, and never allowed to cost the result: every failure here falls +# back to publishing the document untouched. +publish_camo() { + local doc="$1" web="$2" index="$ARENA_RUN/camo/index.tsv" slot file + [ -s "$index" ] || { printf '%s' "$doc"; return 0; } + command -v jq >/dev/null || { printf '%s' "$doc"; return 0; } + + mkdir -p "$web/camo" || { printf '%s' "$doc"; return 0; } + : > "$ARENA_STATE/web-camo.tsv" + while IFS=$'\t' read -r slot file; do + [ -n "$slot" ] && [ -n "$file" ] || continue + [ -f "$ARENA_RUN/camo/$file" ] || continue + cp "$ARENA_RUN/camo/$file" "$web/camo/$file" || continue + printf '%s\t%s\n' "$slot" "camo/$file" >> "$ARENA_STATE/web-camo.tsv" + done < "$index" + + [ -s "$ARENA_STATE/web-camo.tsv" ] || { printf '%s' "$doc"; return 0; } + # slot -> path, merged in as one object beside the players. + jq -R -s 'split("\n") | map(select(length > 0) | split("\t")) + | map({(.[0]): .[1]}) | add' "$ARENA_STATE/web-camo.tsv" \ + > "$ARENA_STATE/web-camo.json" 2>/dev/null || { printf '%s' "$doc"; return 0; } + jq -s '.[0] * {camo: .[1]}' "$doc" "$ARENA_STATE/web-camo.json" \ + > "$ARENA_STATE/result-page.json" 2>/dev/null || { printf '%s' "$doc"; return 0; } + + log "published camo for $(wc -l < "$ARENA_STATE/web-camo.tsv") force(s) to the page" + printf '%s' "$ARENA_STATE/result-page.json" +} + # collect_result -> non-zero only when there was a result and it did not land collect_result() { local doc diff --git a/suramadu/verify-web.sh b/suramadu/verify-web.sh index f48a984..43d6cb7 100755 --- a/suramadu/verify-web.sh +++ b/suramadu/verify-web.sh @@ -42,14 +42,16 @@ refs() { | sort -u } -# Our page carries two deliberate additions on top of upstream's CSP -# (media-src for match sounds, manifest-src for the PWA manifest); they are -# stripped before comparing, so upstream drift still fails loudly. +# Our page carries four deliberate additions on top of upstream's CSP: +# media-src for match sounds, manifest-src for the PWA manifest, and the two +# Bluesky hosts the end-of-match report draws faces from. They are stripped +# before comparing, so upstream drift still fails loudly. csp() { tr '\n' ' ' < "$1" \ | grep -o -E 'http-equiv="Content-Security-Policy"[^>]*content="[^"]*"' \ | sed -E 's/.*content="([^"]*)".*/\1/' \ | sed -E "s/ ?media-src blob:;//; s/ ?manifest-src 'self';//" \ + | sed -E "s, https://public.api.bsky.app,,; s, https://cdn.bsky.app,," \ | tr -s ' ' } diff --git a/suramadu/web/index.html b/suramadu/web/index.html index 735c301..3c782a1 100644 --- a/suramadu/web/index.html +++ b/suramadu/web/index.html @@ -17,10 +17,14 @@ bump that renames a script fails the build instead of producing a page that loads nothing. - CSP: upstream's directives plus two of ours - media-src blob: for - match sounds (Suramadu plays audio frames through blob: URLs) and - manifest-src 'self' for the PWA manifest. verify-web.sh knows these - two are deliberate additions and compares the rest against the war. + CSP: upstream's directives plus four additions of ours - media-src + blob: for match sounds (Suramadu plays audio frames through blob: + URLs), manifest-src 'self' for the PWA manifest, and the two Bluesky + hosts the end-of-match report needs: public.api.bsky.app, which + report.js asks for the picture on an account, and cdn.bsky.app, which + serves it. Both are the public appview - no token, no cookie, and + nothing of ours travels to either. verify-web.sh knows these four are + deliberate and compares the rest against the war. script-src 'self' blocks injected inline/external scripts. Our own perf.js is same-origin, so it needs no change here; anything inline @@ -33,7 +37,7 @@ browser never receives the download. --> + content="default-src 'none'; connect-src 'self' ws: wss: data: https://public.api.bsky.app; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://cdn.bsky.app; font-src 'self' data:; worker-src blob:; frame-src 'self'; media-src blob:; manifest-src 'self';"> diff --git a/suramadu/web/report.css b/suramadu/web/report.css index bee6012..dda871e 100644 --- a/suramadu/web/report.css +++ b/suramadu/web/report.css @@ -101,13 +101,116 @@ :root .lb-report-force-head { display: flex; - align-items: baseline; - gap: 8px; + align-items: center; + gap: 10px; +} + +/* The face on a force's card. A fixed square holding whichever of the three + things arrived: the monogram, which is always there and always first; the + account's picture over it; and the force's own camo pinned to the corner. + Sized once here so a card whose picture never lands is exactly as tall as + one whose did - a report that reflows a second after it appears is a + report a player is still reading when it moves. */ +:root .lb-report-face { + position: relative; + flex: none; + width: 40px; + height: 40px; +} + +:root .lb-report-monogram, +:root .lb-report-avatar { + position: absolute; + inset: 0; + width: 40px; + height: 40px; + border-radius: 50%; + border: 1px solid var(--lb-line); + background: var(--lb-panel); + box-sizing: border-box; +} + +:root .lb-report-monogram { + display: flex; + align-items: center; + justify-content: center; + font-family: var(--lb-display); + font-weight: 700; + font-size: 16px; + color: var(--lb-muted); +} + +:root .lb-report-avatar { + object-fit: cover; +} + +/* The colours the force fought in, cut square against the round face so the + two read as two things rather than one badly drawn one. */ +:root .lb-report-camo { + position: absolute; + right: -4px; + bottom: -4px; + width: 18px; + height: 18px; + border: 1px solid var(--lb-line); + border-radius: 3px; + object-fit: cover; + background: var(--lb-panel); +} + +/* Name over the row of small things that qualify it. Takes the width the + team label does not, so a long handle ellipses rather than pushing the + team off the card. */ +:root .lb-report-who { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + flex: 1; } :root .lb-report-name { + display: block; + overflow: hidden; font-weight: 600; font-size: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* The name is the account, so it is a link where there is an account behind + it. Underlined only on hover: a card of four links with four rules under + them stops looking like a report. */ +:root .lb-report-card a.lb-report-name, +:root .lb-report-card a.lb-report-name:link, +:root .lb-report-card a.lb-report-name:visited { + color: var(--lb-ink); + text-decoration: none; +} + +:root .lb-report-card a.lb-report-name:hover, +:root .lb-report-card a.lb-report-name:focus { + color: var(--lb-accent); + text-decoration: underline; +} + +:root .lb-report-under { + display: flex; + align-items: center; + gap: 6px; +} + +/* Said on the winning force as well as at the top of the card. The word at + the top is about the player reading it; this is about each side, which is + the only way a match with more than two of them reads at all. */ +:root .lb-report-victor { + padding: 1px 6px; + border: 1px solid var(--lb-brand); + border-radius: 999px; + color: var(--lb-brand); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.1em; } :root .lb-report-you { @@ -120,8 +223,9 @@ letter-spacing: 0.1em; } -/* Pushed to the end of the row, so the team reads as the column it is when - there are more than two forces. */ +/* Which side, under the name with the rest of what is true about the seat. + Pushed to the end of that row, so it reads as the column it is when there + are more than two forces. */ :root .lb-report-team { margin-left: auto; font-size: 12px; @@ -163,24 +267,98 @@ background: var(--lb-accent); } -:root .lb-report-home { +/* The way out and the way to show it off, cut as one plate the way the + site cuts its Play control: outer corners notched on the diagonal - the + lit half at its top left, the grey half at its bottom right - with a 2px + seam of card between them, so the pair reads as one stamped object with + two targets in it. The site's own is .daily-launch in headquarters' + web/src/styles.css; these are the same shapes in this file's variables, + because nothing builds this page and the values cannot be imported. */ +:root .lb-report-actions { + --lb-cut: 10px; + display: inline-flex; + align-items: stretch; + gap: 2px; margin-top: 24px; - padding: 9px 20px; - border: 1px solid var(--lb-accent); - border-radius: var(--lb-radius); +} + +/* clip-path clips the element's outline along with its background, so the + focus ring comes back below as an inset one that takes the cut shape. */ +:root .lb-report-home, +:root .lb-report-card a.lb-report-home:link, +:root .lb-report-card a.lb-report-home:visited { + display: inline-flex; + align-items: center; + padding: 10px 22px 10px 26px; + border: 0; + border-radius: 0; background: var(--lb-accent); color: #ffffff; + font-family: var(--lb-display); font-size: 14px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.18em; + text-decoration: none; + white-space: nowrap; + clip-path: polygon(var(--lb-cut) 0, 100% 0, 100% 100%, 0 100%, 0 var(--lb-cut)); + transition: filter 150ms ease; +} + +/* drop-shadow rather than box-shadow: a box shadow is cut away with the rest + of the box, and a filter follows the notched edge. */ +:root .lb-report-card a.lb-report-home:hover, +:root .lb-report-card a.lb-report-home:focus { + background: var(--lb-accent); + color: #ffffff; + filter: drop-shadow(0 0 10px rgba(76, 141, 255, 0.6)); +} + +:root .lb-report-card a.lb-report-home:focus-visible { + outline: 0; + box-shadow: inset 0 0 0 2px #ffffff; +} + +/* The grey half. Present, clearly secondary, and clearly attached to the lit + half rather than floating beside it. */ +:root .lb-report-share-out, +:root .lb-report-card a.lb-report-share-out:link, +:root .lb-report-card a.lb-report-share-out:visited { + display: inline-flex; + align-items: center; + padding: 10px 18px 10px 16px; + border: 0; + background: #2b3646; + color: var(--lb-ink); + font-family: var(--lb-display); + font-size: 12px; font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; text-decoration: none; + white-space: nowrap; + /* One line, like --lb-notch in theme.css: test-report-ui.sh reads any line + ending in a comma as a selector, and a wrapped polygon() trips it. */ + clip-path: polygon(0 0, 100% 0, 100% calc(100% - var(--lb-cut)), calc(100% - var(--lb-cut)) 100%, 0 100%); } -:root .lb-report-home:hover, -:root .lb-report-home:focus { - background: var(--lb-panel); +:root .lb-report-card a.lb-report-share-out:hover, +:root .lb-report-card a.lb-report-share-out:focus { + background: #3a4759; color: var(--lb-ink); } +:root .lb-report-card a.lb-report-share-out:focus-visible { + outline: 0; + box-shadow: inset 0 0 0 2px var(--lb-accent); +} + +@media (prefers-reduced-motion: reduce) { + :root .lb-report-home { + transition: none; + } +} + /* The spinner while the result is still climbing to S3. Upstream declares .ws-spinner's geometry only inside .suramadu-element-content, and this card is outside it, so an unstyled dot here is a 0x0 box - the same trap the boot @@ -223,4 +401,29 @@ :root .lb-report-verdict { font-size: 22px; } + + /* The plate is two tracked-capitals labels side by side, which is wider + than a narrow phone. Tighter rather than stacked: stacking it loses the + diagonal that makes it one object, and the halves are still a comfortable + target at this size. */ + :root .lb-report-actions { + --lb-cut: 8px; + max-width: 100%; + } + + :root .lb-report-home, + :root .lb-report-card a.lb-report-home:link, + :root .lb-report-card a.lb-report-home:visited { + padding: 9px 14px 9px 18px; + font-size: 12px; + letter-spacing: 0.12em; + } + + :root .lb-report-share-out, + :root .lb-report-card a.lb-report-share-out:link, + :root .lb-report-card a.lb-report-share-out:visited { + padding: 9px 12px; + font-size: 11px; + letter-spacing: 0.06em; + } } diff --git a/suramadu/web/report.js b/suramadu/web/report.js index fef3585..3723ab1 100644 --- a/suramadu/web/report.js +++ b/suramadu/web/report.js @@ -25,8 +25,12 @@ * same document, read back out of S3, for a page that arrives after the * container has stopped or a match that ended without one being published. * The match page is served on the API's own origin (the proxy is the auth - * boundary), so both fetches are same-origin, carry the session cookie, and - * need nothing from the CSP beyond connect-src 'self'. + * boundary), so both fetches are same-origin and carry the session cookie. + * + * One thing here is not: the face on each force's card is looked up against + * Bluesky's public appview and drawn from its CDN, which is why index.html + * names those two hosts in its CSP and why nothing on this screen waits on + * the answer. A picture that does not arrive is a monogram. * * There is no event to wait on instead. Suramadu forwards a window title when * it creates the window and not when it changes, so MegaMek relabelling its @@ -68,12 +72,27 @@ // nothing below runs. var matchId = null; var playerDid = null; + /* decodeURIComponent throws on a stray percent, which is not a reason to + lose the screen: an undecodable segment is used as it stands. */ + function decodeDid(text) { + try { + return decodeURIComponent(text); + } catch (e) { + return text; + } + } (function () { var parts = window.location.pathname.split("/"); // ["", "match", id, did, ...] if (parts.length >= 4 && parts[1] === "match" && parts[2] && parts[3]) { matchId = parts[2]; - playerDid = parts[3]; + // Decoded, because the DID in the path is compared against the one + // in the result document. A DID is path-safe and the container + // renders it raw, but a browser or a link that percent-encoded the + // colons would still arrive here - and a did%3Aplc%3A... that + // matches no seat costs the player the "you" marker and the + // verdict, silently, on the one screen that exists to say both. + playerDid = decodeDid(parts[3]); } })(); if (!matchId) { @@ -110,17 +129,29 @@ return null; } - /* Everyone who fought, in the order the host recorded them. Observers are - dropped: the watcher and the spectator seat are in the players list and - have never had a unit between them. */ + // The two seats MegaMek holds for us rather than for a player: the + // watcher that spools turn reports, and the seat a browser takes in a + // match nobody is playing. Named here as well as flagged because the flag + // is not reliable - the host sets it once every human seat is filled, and + // a result written for a match that never got there records it false. + var WATCHERS = ["ArenaObserver", "ArenaSpectator"]; + + /* Everyone who fought, in the order the host recorded them. + * + * Three ways of saying the same thing, because one of them is enough to + * put a row on this screen that is not a player: the server's observer + * flag, the two reserved names, and a seat that never owned a unit. A + * force with nothing on the board did not fight, whatever it is called. */ function combatants(result) { var players = result.players || []; var out = []; - var i; + var i, p; for (i = 0; i < players.length; i++) { - if (!players[i].observer) { - out.push(players[i]); - } + p = players[i]; + if (p.observer) { continue; } + if (WATCHERS.indexOf(p.name) >= 0 || WATCHERS.indexOf(p.slot) >= 0) { continue; } + if (number(p.unitsStart, 0) <= 0) { continue; } + out.push(p); } return out; } @@ -160,6 +191,9 @@ var screen = null; + /* The result currently on screen, or null while there is none. */ + var shown = null; + /* * Take the page and keep it. * @@ -185,22 +219,180 @@ return screen; } + // --- who each force belongs to ----------------------------------------- + + /* The identity behind a slot: the DID the seat was launched under and the + handle it was verified as, both from the map arena merges into the + result. A slot with no entry - a bot, or a result collected without an + identity - answers an empty object, and everything below treats that as + "a force with a name and nothing else". */ + function identityOf(result, slot) { + var players = result.identity && result.identity.players; + var i; + if (!players || !slot) { + return {}; + } + for (i = 0; i < players.length; i++) { + if (players[i] && players[i].slot === slot) { + return players[i]; + } + } + return {}; + } + + /* Where a force's colours are, when the page has them. + * + * container/lib/collect.sh copies each staged camo beside decided.json + * and names it here, so this is a same-origin path under the match's own + * directory - no CSP allowance, no S3, nothing to fetch from an account. + * Only the container's copy of the result carries it: a page that opened + * after the container stopped reads the S3 copy, which has no files + * behind it, and gets a card without colours rather than a broken image. + */ + function camoOf(result, slot) { + var camo = result.camo; + if (!camo || !slot || typeof camo[slot] !== "string") { + return null; + } + return camo[slot]; + } + + /* The account a name points at. Handle first, because a handle is what a + player recognises; the DID behind it when verification produced no + handle, which bsky.app resolves just as well. Null for a seat with + neither, which is every bot. */ + function profileUrl(identity) { + var actor = identity.handle || identity.did; + if (!actor || identity.control === "bot") { + return null; + } + return "https://bsky.app/profile/" + encodeURIComponent(actor); + } + + // --- faces ------------------------------------------------------------- + // + // The picture on an account, from the public appview - the same lookup + // and the same CDN the site's own avatars.ts uses, and the reason + // index.html names both hosts in its CSP. It is the one thing on this + // screen that leaves the origin, it is asked for once per account, and + // every failure is "no picture", never an error: a report that waited on + // Bluesky to tell a player how their match went would be a worse screen + // than one with a monogram in it. + + var APPVIEW = "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile"; + + /* One answer per actor for the life of the page, misses included. */ + var faces = {}; + + function avatarFor(actor) { + if (Object.prototype.hasOwnProperty.call(faces, actor)) { + return faces[actor]; + } + var kept = fetch(APPVIEW + "?actor=" + encodeURIComponent(actor)) + .then(function (response) { + return response.ok ? response.json() : null; + }) + .then(function (body) { + return body && typeof body.avatar === "string" && body.avatar + ? body.avatar + : null; + }) + .catch(function () { + return null; + }); + faces[actor] = kept; + return kept; + } + + /* The face on a force's card: the account's picture when it has one, and + the first letter of the name under it until then and instead of it. */ + function faceFor(identity, name, camo) { + var box = el("span", "lb-report-face"); + var letter = String(name || "?").replace("@", "").charAt(0).toUpperCase(); + var mono = el("span", "lb-report-monogram", letter || "?"); + box.appendChild(mono); + + var actor = identity.handle || identity.did; + if (actor && identity.control !== "bot") { + var img = el("img", "lb-report-avatar"); + img.alt = ""; + img.hidden = true; + box.appendChild(img); + avatarFor(actor).then(function (url) { + if (!url) { + return; + } + img.src = url; + img.hidden = false; + mono.hidden = true; + }); + } + + // The force's own colours, pinned to the face: the camo a player + // chose is the thing they recognise their lance by on the board, and + // this is the same picture their units wore in the match. + if (camo) { + var swatch = el("img", "lb-report-camo"); + swatch.src = camo; + swatch.alt = ""; + // A camo that will not decode is a missing file, not a failure + // worth a broken-image icon in the middle of the report. + swatch.onerror = function () { + swatch.hidden = true; + }; + box.appendChild(swatch); + } + return box; + } + + // --- forces ------------------------------------------------------------ + /* One force: who it was, what is left of it, and how much of its battle value is still standing. The bar is the BV fraction - a force that lost nothing and one that limped out with a crippled lance both have units remaining, and only this separates them. */ - function forceRow(player, me) { + function forceRow(result, player, me, winner) { var row = el("div", "lb-report-force"); - if (me && player.slot === me.slot) { + var mine = me && player.slot === me.slot; + if (mine) { row.className = "lb-report-force lb-report-force-mine"; } + var identity = identityOf(result, player.slot); + var name = player.name || player.slot || "?"; + var head = el("div", "lb-report-force-head"); - head.appendChild(el("span", "lb-report-name", player.name || player.slot || "?")); - if (me && player.slot === me.slot) { - head.appendChild(el("span", "lb-report-you", "you")); + head.appendChild(faceFor(identity, name, camoOf(result, player.slot))); + + var who = el("div", "lb-report-who"); + var href = profileUrl(identity); + // A link when there is an account behind the name and plain text when + // there is not: a bot fights as the site, and a dead link on the seat + // that beat you is worse than no link at all. + var label = el(href ? "a" : "span", "lb-report-name", name); + if (href) { + label.href = href; + label.target = "_blank"; + label.rel = "noopener noreferrer"; } - head.appendChild(el("span", "lb-report-team", "Team " + number(player.team, "?"))); + who.appendChild(label); + + var under = el("div", "lb-report-under"); + if (mine) { + under.appendChild(el("span", "lb-report-you", "you")); + } + // Said on the force rather than only in the word at the top, because + // the top says what happened to the player reading and this says what + // happened to each side - which is the whole of it in a match with + // more than two. + if (winner && (winner.team !== null + ? player.team === winner.team + : player.id === winner.id)) { + under.appendChild(el("span", "lb-report-victor", "victor")); + } + under.appendChild(el("span", "lb-report-team", "Team " + number(player.team, "?"))); + who.appendChild(under); + head.appendChild(who); row.appendChild(head); var bvStart = number(player.bvStart, 0); @@ -222,11 +414,62 @@ return row; } + /* The winning side, as the document recorded it, or null when it recorded + none. Returned as a team-and-id pair because MegaMek keeps the win in + whichever of the two applies and the row above has to match on the + same one. */ + function victorOf(result) { + var team = number(result.victoryTeam, null); + var player = number(result.victoryPlayerId, null); + if (team !== null && team !== TEAM_NONE) { + return { team: team, id: null }; + } + if (player !== null && player !== PLAYER_NONE) { + return { team: null, id: player }; + } + return null; + } + + // --- sharing it -------------------------------------------------------- + + /* What a player posts when they want to show a match off. + * + * An intent link and not a post: it opens Bluesky's composer with the + * words already in it and the player sends it themselves, which needs no + * permission on their account and writes nothing anywhere. The URL is the + * share page the API named, and Bluesky's card service is what turns it + * into a picture in the feed - so the text stays short and says the one + * thing the card cannot. + */ + function shareText(result, call, url) { + var line = call === "won" ? "Victory on lance.blue." + : call === "lost" ? "Defeat on lance.blue." + : call === "drew" ? "A draw on lance.blue." + : "A match on lance.blue."; + line = line + " Round " + number(result.round, "?") + "."; + return url ? line + "\n\n" + url : line; + } + + function shareLink(result, call, url) { + // Named for where it goes, not for what it does: a control that posts + // to an account has to say which account service before it is pressed, + // even when pressing it only opens a composer. + var link = el("a", "lb-report-share-out", "Post to Bluesky"); + link.href = "https://bsky.app/intent/compose?text=" + + encodeURIComponent(shareText(result, call, url)); + link.target = "_blank"; + link.rel = "noopener noreferrer"; + return link; + } + var VERDICTS = { won: "Victory", lost: "Defeat", drew: "Draw" }; /* The report, or the sentence that stands in for it. Built from scratch each time so the tallying screen can become the real one in place. */ function render(result, home, waiting) { + // Kept so a late reply from the API can repaint the same report with + // what it brought - see the poll below. + shown = result; var card = el("div", "lb-report-card"); var mark = el("img", "lb-report-mark"); @@ -236,6 +479,7 @@ mark.height = 105; card.appendChild(mark); + var call = null; if (result) { var forces = combatants(result); var slot = mySlot(result); @@ -246,18 +490,22 @@ me = forces[i]; } } - var call = verdict(result, me); - card.appendChild(el("p", "lb-report-verdict lb-report-" + (call || "none"), + call = verdict(result, me); + card.appendChild(el("h1", "lb-report-verdict lb-report-" + (call || "none"), call ? VERDICTS[call] : "Match over")); card.appendChild(el("p", "lb-report-round", "Round " + number(result.round, "?"))); + var winner = victorOf(result); var list = el("div", "lb-report-forces"); for (i = 0; i < forces.length; i++) { - list.appendChild(forceRow(forces[i], me)); + list.appendChild(forceRow(result, forces[i], me, winner)); } card.appendChild(list); + // The tab, for a player who left the match in a background one + // and is deciding which to come back to. + document.title = (call ? VERDICTS[call] : "Match over") + " - lance.blue"; } else { - card.appendChild(el("p", "lb-report-verdict lb-report-none", "Match over")); + card.appendChild(el("h1", "lb-report-verdict lb-report-none", "Match over")); card.appendChild(el("p", "lb-report-round", waiting ? "Tallying the result" : "This match ended without a result")); @@ -269,9 +517,18 @@ } } + // One plate, cut the way the site cuts its Play control: the way out + // is the lit half and sharing is the grey one beside it. A result + // nobody can read is nothing to post, so the second half is there + // only when there is a result to point at. + var actions = el("div", "lb-report-actions"); var back = el("a", "lb-report-home", "Return to lance.blue"); back.href = home || HOME; - card.appendChild(back); + actions.appendChild(back); + if (result && share) { + actions.appendChild(shareLink(result, call, share)); + } + card.appendChild(actions); var into = seal(); into.textContent = ""; @@ -288,6 +545,11 @@ var tallying = 0; var stopped = false; var home = null; // learned from the API; the local copy cannot know it + /* Where a shared result lives, also from the API. A page in a container + has no way to construct it - it is a route on the API's own origin, + and which origin that is is exactly what this page cannot see - so the + share button exists only once the API has said. */ + var share = null; function stop() { stopped = true; @@ -352,11 +614,25 @@ } return response.json(); }).then(function (data) { - if (!data || stopped) { + if (!data) { again(); return; } + // Learned before the stopped check, and repainted if the screen + // is already up: the container's copy of the result usually wins + // this race, and it is this reply - not that one - that knows + // where the site is and where a result can be shared from. A + // report that beat the API to the screen would otherwise keep a + // fallback link and no share button for good. + var known = home + " " + share; home = data.home || home; + share = data.share || share; + if (stopped) { + if (shown && known !== home + " " + share) { + render(shown, home, false); + } + return; + } var over = data.status === "over" || data.status === "failed"; if (data.result) { render(data.result, data.home, false); diff --git a/tests/shell/test-report-ui.sh b/tests/shell/test-report-ui.sh index 58d37ce..5a4ce05 100755 --- a/tests/shell/test-report-ui.sh +++ b/tests/shell/test-report-ui.sh @@ -97,6 +97,42 @@ check "the only fetches are the local copy and the result route" bash -c ' calls="$(grep -o "fetch(\"[^\"]*\"" "$1" | sort -u | tr "\n" " ")" [ "$calls" = "fetch(\"/api/matches/\" fetch(\"decided.json\" " ]' _ "$JS" +# The one exception, and it is a constant rather than a literal so the check +# above cannot see it: the face on a force's card, from Bluesky's public +# appview. Nothing of ours goes with it - no cookie, no match id, no DID +# beyond the account being asked about, which is public - and the CSP has to +# name the host or the request never leaves. A third host appearing here is a +# new thing to think about, which is what this fails for. +check "the only cross-origin lookup is the public appview" bash -c ' + hosts="$(grep -o "https://[a-z.]*" "$1" | sort -u \ + | grep -v -E "^https://(bsky.app|lance.blue)$" | tr "\n" " ")" + [ "$hosts" = "https://public.api.bsky.app " ]' _ "$JS" + +check "the CSP allows the appview and its CDN" bash -c ' + grep -q "connect-src[^;]*https://public.api.bsky.app" "$1" && + grep -q "img-src[^;]*https://cdn.bsky.app" "$1"' _ "$HTML" + +# --- the watchers are never a force ------------------------------------------ + +# ArenaObserver spools turn reports and ArenaSpectator is the seat a browser +# takes in a match nobody is playing. Neither is a player, and the server's +# own observer flag is not enough to keep them off this screen: the host sets +# it once every human seat is filled, so a match decided before that records +# it false and the report grew a third force with no units in it. +check "the reserved seats are dropped by name as well as by flag" bash -c ' + grep -q "ArenaObserver" "$1" && grep -q "ArenaSpectator" "$1" && + grep -q "unitsStart" "$1"' _ "$JS" + +# --- posting it is a link, never a write ------------------------------------- + +# The share control opens Bluesky's composer with the words in it and the +# player sends it themselves. Nothing here may write to an account: this page +# holds no token, and a report that asked for one would be asking for a +# permission the whole site does not have. +check "sharing is an intent link and nothing else" bash -c ' + grep -q "bsky.app/intent/compose" "$1" && + ! grep -qE "com\.atproto\.repo|createRecord" "$1"' _ "$JS" + # The fast path is the point of it: a file in the match's own container, a # second away, against S3 and a control plane fifteen seconds away. check "the local copy is polled faster than the API" bash -c ' diff --git a/tests/shell/test-results-watch.sh b/tests/shell/test-results-watch.sh index 1302158..5b28512 100755 --- a/tests/shell/test-results-watch.sh +++ b/tests/shell/test-results-watch.sh @@ -30,6 +30,13 @@ STATE="$TMP/run/state" # What the host writes at victory, and what the client leaves in its log dir. echo '{"round": 7, "players": []}' > "$STATE/result.json" echo '{"matchId": "test-match", "players": []}' > "$STATE/identity.json" + +# What init/30-assets.sh staged: one camo per slot, indexed by slot. The page +# draws these, so they have to end up in the web folder under the names the +# published document gives them. +mkdir -p "$TMP/run/camo" +printf 'fake-png' > "$TMP/run/camo/1-TraineeA.png" +printf 'TraineeA\t1-TraineeA.png\n' > "$TMP/run/camo/index.tsv" LOGS="$TMP/logs" mkdir -p "$LOGS/gameSummaries/board/game-uuid" "$LOGS/gameSummaries/minimap/game-uuid" touch "$LOGS/gameSummaries/board/game-uuid/round_1_9_TARGETING.png" \ @@ -78,6 +85,14 @@ check "the published copy carries identity" bash -c \ "grep -q '\"identity\"' \"\$1/web/decided.json\"" _ "$TMP/run" check "no half-written copy is left" bash -c \ "! ls \"\$1\"/web/.decided.json.tmp >/dev/null 2>&1" _ "$TMP/run" +# The colours, beside the result and only in the page's copy: the files live +# and die with this container, so a name in the document that goes to S3 would +# point at a host that is gone by the time anyone reads it. +check "stages the camo where the page can read it" test -f "$TMP/run/web/camo/1-TraineeA.png" +check "the published copy names the camo" bash -c \ + "grep -q '\"camo/1-TraineeA.png\"' \"\$1/web/decided.json\"" _ "$TMP/run" +check "the uploaded copy names none" bash -c \ + "! grep -q 'camo/' \"\$1/result-final.json\"" _ "$STATE" check "attaches identity to the result" bash -c \ "grep -q '\"identity\"' \"\$1/result-final.json\"" _ "$STATE" check "offers the board summary" grep -q "board-round_1_9_TARGETING.png" <<<"$out"