diff --git a/TODO.md b/TODO.md index 49d791e..0f04328 100644 --- a/TODO.md +++ b/TODO.md @@ -102,6 +102,29 @@ the finish is supposed to leave behind. `result.json` carries `victoryTeam` and `victoryPlayerId` now, because a forced victory leaves the loser's units standing and a report inferring the winner from the counts would get that case backwards. +- [x] **The report hands the player on to the match's page.** The screen at + the end of a match is the verdict, the forces, and three seconds - then + the match's own page takes over, which is headquarters' to draw. No + controls: the full report and the composer are on that page, and a + screen inside a container that is about to be deleted should not offer + a worse copy of somewhere the player is already going. It does not + wait to be dismissed, either: a container being deleted is not a + place to keep anybody. +- [x] **The result says what happened to each machine, not just how many.** + `src/arena/UnitReport.java` reads the live game at victory and writes a + `units` array per player: every location's armour, rear armour and + structure against what it started with, every critical slot with what is + in it and whether it was hit, the crew and its wounds, and what became of + the machine - destroyed, withdrawn, salvage, ejected. Meks carry their + slots; every other kind reports locations and status, because naming a + vehicle's system slots means inventing a table. + Every unit type reports its slots, not only Meks - a vehicle carries + twenty-five a location - and each also carries what it was built with: + its equipment list, a vehicle's own criticals (locked turret, motive + damage, driver and commander hits, stabilisers) and a platoon's + remaining troopers. This is the arena half of headquarters' + `plan/after-action.md`; the drawing is headquarters', on the match's + own page. - [x] **The report arrives in about a second, not about ten.** `watch/results.sh` writes the result into the staged web folder as `decided.json` before it uploads anything - 348ms from the host writing diff --git a/container/lib/collect.sh b/container/lib/collect.sh index 6b755cd..1fb8691 100644 --- a/container/lib/collect.sh +++ b/container/lib/collect.sh @@ -155,8 +155,8 @@ collect_result() { # case of the GIF, which is why the early caller settles first and why # whatever it misses is picked up on the way out. collect_images() { - local logdir="${ARENA_MM_LOGDIR:-${MM_HOME:-$ARENA_HOME/megamek}/logs}" - local count=0 kind dir img dest + local logdir count=0 kind dir img dest + logdir="$(mm_logdir)" shopt -s nullglob for kind in board minimap; do @@ -175,12 +175,17 @@ collect_images() { done shopt -u nullglob - collect_share_card "$logdir" || true + collect_share_card || true printf '%s' "$count" } -# collect_share_card +# Where MegaMek writes its logs, and under them the summaries it renders. +mm_logdir() { + printf '%s' "${ARENA_MM_LOGDIR:-${MM_HOME:-$ARENA_HOME/megamek}/logs}" +} + +# collect_share_card # # The board as it stood when the game ended, under a name headquarters can ask # for without listing the bucket. @@ -196,13 +201,32 @@ collect_images() { # a lexical "last" is the last one alphabetically and not the last one played. # # Sent by the same once-only path as everything else, so the first pass that -# finds a board wins and a later one does not re-send it. A match whose final -# render lands after the early pass keeps the picture from the phase before -# it, which is a slightly earlier board and not a wrong one. +# finds a board wins and a later one does not re-send it. +# +# Sent before the result, which is why it is called separately rather than +# left to collect_images: headquarters draws a card as soon as it can read a +# result, and Bluesky fetches that card once, when the post is made. A result +# that lands first is a window where both get a card with no board on it. collect_share_card() { - local logdir="$1" dir last dest - dir="$logdir/gameSummaries/board" + local dir last dest waited limit marker + dir="$(mm_logdir)/gameSummaries/board" [ -d "$dir" ] || return 0 + + # The victory board is rendered on the same phase change that writes the + # marker, so it is usually a fraction of a second away. Without the wait the + # card is the round before the one that decided the match. Bounded: nothing + # else sends until this returns. + marker="$ARENA_STATE/game-over" + limit="${ARENA_CARD_WAIT:-2}" + waited=0 + if [ -f "$marker" ]; then + while [ "$waited" -lt "$((limit * 4))" ]; do + [ -n "$(find "$dir" -type f -name '*.png' -newer "$marker" 2>/dev/null | head -n1)" ] && break + sleep 0.25 + waited=$((waited + 1)) + done + fi + last="$(find "$dir" -type f -name '*.png' 2>/dev/null | sort -V | tail -n1)" [ -n "$last" ] || return 0 dest="$ARENA_STATE/share-card.png" diff --git a/container/watch/results.sh b/container/watch/results.sh index 90b45cd..46ee156 100755 --- a/container/watch/results.sh +++ b/container/watch/results.sh @@ -60,9 +60,12 @@ log "the game is decided" # One file copy inside this container, before anything touches the network. publish_result || true -# Then headquarters, still ahead of the settle: the result is complete the -# moment MatchHost writes it, and nothing about the images it is waiting for -# is in this document. +# The share card next, and before the result: the result is what makes +# headquarters publish this match's page and draw its card, and the card is +# drawn on this picture. A bounded wait (ARENA_CARD_WAIT), not the settle. +collect_share_card || true + +# Then the result, which publishes the page. collect_result || true sleep "$SETTLE" diff --git a/suramadu/web/report.css b/suramadu/web/report.css index dda871e..5802655 100644 --- a/suramadu/web/report.css +++ b/suramadu/web/report.css @@ -319,46 +319,34 @@ 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-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 hand-off line, under the plate. The match's own page is where the + report actually is - every machine, its armour, its critical slots - so + this screen says its piece and then goes there. Quiet, and stoppable: the + flare button lives on this page and not on the next one, so a player + reaching for it must be able to keep the screen still. */ +/* The hand-off line, under the forces. The match's own page is where the + report actually is - every machine, its armour, its critical slots - so + this screen says its piece and goes there. One quiet line: there is + nothing to press, and nothing to decide. */ +:root .lb-report-handoff { + display: flex; + align-items: center; + justify-content: center; + margin-top: 14px; + font-size: 12px; + color: var(--lb-muted); +} + +:root .lb-report-handoff-note { + margin: 0; +} + /* 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 @@ -418,12 +406,3 @@ 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 3723ab1..bc953a4 100644 --- a/suramadu/web/report.js +++ b/suramadu/web/report.js @@ -411,6 +411,12 @@ fill.style.width = Math.round(share * 100) + "%"; track.appendChild(fill); row.appendChild(track); + + // What each machine ended up as is in this document and is not drawn + // here. This screen is the last thing a player sees inside a match + // that is over, and it is worth one look: the verdict, the forces and + // the way out. The record sheets are on the match's own page, where + // there is room for them - see the hand-off below. return row; } @@ -432,34 +438,59 @@ // --- sharing it -------------------------------------------------------- - /* What a player posts when they want to show a match off. + /* How long the summary stands before the match's own page takes over. + * + * Long enough to read one word and see who won, and no longer: this is a + * pause, not a screen, and everything worth reading about the match is on + * the page it is about to open. * - * 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. + * There is no way to stop it. A player who wants to report a problem with + * the match can do it from that page; keeping them on a screen inside a + * container that is being deleted, so that one control stays in reach, + * costs everybody else the hand-off. */ - 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; + var HANDOFF_MS = 3000; + + /* The one countdown, and the line it is currently writing into. The + report is rebuilt in place when a later poll brings more - the API's + reply landing after the container's copy, say - so the timer outlives + the element it writes to and is handed the new one. */ + var handoff = null; + var handoffNote = null; + var handoffTo = null; + /* The pause is over. Separate from leaving, because the page it opens is + published by an upload still in flight: going early is a 404 the player + cannot come back from, the container being gone. */ + var handoffDue = false; + + /* Leave, once the pause is over and the result is up. Called from both + sides; whichever arrives second navigates. */ + function goOn() { + if (handoffDue && landed && handoffTo) { + window.location.href = handoffTo; + } + if (handoffDue && !landed && handoffNote) { + handoffNote.textContent = "Waiting for the match report\u2026"; + } } - 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; + function handOff(to) { + if (!to) { + return null; + } + handoffTo = to; + var row = el("div", "lb-report-handoff"); + handoffNote = el("p", "lb-report-handoff-note", handoffDue && !landed + ? "Waiting for the match report\u2026" + : "Opening the full report\u2026"); + row.appendChild(handoffNote); + if (!handoff) { + handoff = window.setTimeout(function () { + handoffDue = true; + goOn(); + }, HANDOFF_MS); + } + return row; } var VERDICTS = { won: "Victory", lost: "Defeat", drew: "Draw" }; @@ -517,18 +548,29 @@ } } - // 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; - actions.appendChild(back); - if (result && share) { - actions.appendChild(shareLink(result, call, share)); + // Where the player goes next, and nothing to press. + // + // This screen used to carry a plate: the full report on one half, the + // Bluesky composer on the other. Both live on the match's own page + // now, which is a page on the site rather than a screen inside a + // container that is about to be deleted - so offering them here was + // offering a worse copy of somewhere the player is about to be. + // + // What is left is the verdict, the forces, and a short pause before + // the page takes over. The way home stays for a match that produced + // no result to go to: without it that case is a dead end. + if (result && share && !stranded) { + var moving = handOff(share); + if (moving) { + card.appendChild(moving); + } + } else { + var actions = el("div", "lb-report-actions"); + var back = el("a", "lb-report-home", "Return to lance.blue"); + back.href = home || HOME; + actions.appendChild(back); + card.appendChild(actions); } - card.appendChild(actions); var into = seal(); into.textContent = ""; @@ -550,6 +592,11 @@ 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; + /* Whether headquarters can serve that page yet - the API returning a + result, which is the same read the page and its card make. See goOn. */ + var landed = false; + /* No result after CHASE_TRIES asks. The screen keeps the way home. */ + var stranded = false; function stop() { stopped = true; @@ -561,6 +608,25 @@ } } + /* Once the screen is painted from the local copy, the only thing left to + learn is whether the result reached the API - so the cadence drops to a + second, and gives up after twenty rather than asking all day. */ + var CHASE_MS = 1000; + var CHASE_TRIES = 20; + var chasing = 0; + + function chase() { + chasing++; + if (chasing > CHASE_TRIES) { + stranded = true; + if (shown) { + render(shown, home, false); + } + return; + } + window.setTimeout(poll, CHASE_MS); + } + /* * decided.json, resolved beside this script - so /match///, the * one path the proxy lets this player through to. @@ -609,12 +675,23 @@ // player the only report they get. if (response.status < 500) { stop(); + // Nothing is coming: stop offering a page that refuses. + stranded = true; + if (shown) { + render(shown, home, false); + } } return null; } return response.json(); }).then(function (data) { if (!data) { + // A 5xx. again() is a no-op once the polls are stopped, so + // the chase has to reschedule itself. + if (stopped && !landed && !stranded) { + chase(); + return; + } again(); return; } @@ -624,13 +701,18 @@ // 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; + var known = home + " " + share + " " + landed; home = data.home || home; share = data.share || share; + landed = landed || !!data.result; if (stopped) { - if (shown && known !== home + " " + share) { + if (shown && known !== home + " " + share + " " + landed) { render(shown, home, false); } + goOn(); + if (!landed) { + chase(); + } return; } var over = data.status === "over" || data.status === "failed"; @@ -657,6 +739,10 @@ // A dropped request is not an ending. The container's own network // goes away at the end of a match, and this API is not in the // container. + if (stopped && !landed) { + chase(); + return; + } again(); }); } diff --git a/tests/shell/test-report-ui.sh b/tests/shell/test-report-ui.sh index 5a4ce05..576bc50 100755 --- a/tests/shell/test-report-ui.sh +++ b/tests/shell/test-report-ui.sh @@ -123,15 +123,17 @@ 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" +# --- this screen holds no controls ------------------------------------------- + +# Sharing and the full report live on the match's own page, which is a page on +# the site rather than a screen inside a container that is about to be +# deleted. Nothing here writes to an account either: this page holds no token, +# and a report that asked for one would be asking for a permission the whole +# site does not have. +# The profile links and the avatar lookup stay - those are the pilot cards, +# and they read rather than write. +check "nothing here posts, or offers to" bash -c ' + ! grep -qE "intent/compose|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. @@ -161,6 +163,37 @@ check "the winner comes from the result document" bash -c ' check "index.html loads report.js" grep -q '' "$HTML" check "index.html links report.css" grep -q '"report.css"' "$HTML" +# --- the hand-off ------------------------------------------------------------ + +# The report is the verdict and the way onward; the machines, their armour and +# their critical slots are on the match's own page. This screen takes the +# player there on its own, with nothing to press: a container that is being +# deleted is not a place to keep anybody. +check "the report hands off to the match page" bash -c ' + grep -q "lb-report-handoff" "$1" && grep -q "window.location.href = handoffTo" "$1"' _ "$JS" +# Short, because it is a pause rather than a screen. +check "the pause is seconds, not tens of them" bash -c ' + ms=$(grep -o "var HANDOFF_MS = [0-9]*" "$1" | tr -dc "0-9") + [ -n "$ms" ] && [ "$ms" -le 5000 ]' _ "$JS" +# Only where there is somewhere to go: a redirect to nothing is worse than a +# screen that waits. +check "nothing is opened without a result and an address" \ + grep -q "if (result && share && !stranded)" "$JS" + +# The address is not enough: the API hands one out for any match it knows, +# result uploaded or not, and that result is what makes the page exist. So the +# pause and the departure are separate, and leaving takes both. Otherwise the +# redirect races the upload, and losing is a 404 with no way back. +check "the pause and the departure are separate" bash -c ' + grep -q "handoffDue = true" "$1" && + grep -q "if (handoffDue && landed && handoffTo)" "$1"' _ "$JS" +check "the result is asked for, not assumed" bash -c ' + grep -q "landed = landed || !!data.result" "$1"' _ "$JS" +# A result that never arrives ends as a way home, not a line that never +# resolves. +check "a result that never lands strands the hand-off" bash -c ' + grep -q "stranded = true" "$1" && grep -q "chasing > CHASE_TRIES" "$1"' _ "$JS" + # --- the layering holds ------------------------------------------------------ # One number each, and the order between them is the behaviour: the report is diff --git a/tests/shell/test-results-watch.sh b/tests/shell/test-results-watch.sh index 5751c48..0b75058 100755 --- a/tests/shell/test-results-watch.sh +++ b/tests/shell/test-results-watch.sh @@ -49,7 +49,7 @@ run_watcher() { # -> prints the watcher's output, having touched game-over out="$TMP/out.txt" : > "$out" ARENA_HOME="$TMP/home" ARENA_RUN="$TMP/run" ARENA_MM_LOGDIR="$LOGS" \ - ARENA_RESULTS_POLL=0.2 ARENA_RESULTS_SETTLE=0 \ + ARENA_RESULTS_POLL=0.2 ARENA_RESULTS_SETTLE=0 ARENA_CARD_WAIT="${CARD_WAIT:-1}" \ bash "$ROOT/container/watch/results.sh" > "$out" 2>&1 & local pid=$! @@ -108,6 +108,32 @@ check "counts what it sent" grep -q "image(s) ahead of the exit pa check "offers the share card" grep -q "share-card.png" <<<"$out" check "the share card is the last round" grep -q "share card: round_10_" <<<"$out" +# And the card goes first: headquarters draws a card as soon as it can read a +# result, so the other order publishes a page whose card has no board on it. +check "sends the share card before the result" bash -c \ + '[ "$(grep -n "share-card.png" <<<"$1" | head -n1 | cut -d: -f1)" \ + -lt "$(grep -n "result-final.json" <<<"$1" | head -n1 | cut -d: -f1)" ]' _ "$out" + +# --- a final render that lands after the marker ------------------------------- +# The victory board can be a fraction of a second behind the marker. Waiting +# is what keeps the card off the round before the one that decided the match. +rm -f "$STATE/game-over" "$STATE/share-card.png" +rm -rf "$STATE/sent" +( sleep 1.5 + touch "$LOGS/gameSummaries/board/game-uuid/round_11_9_VICTORY.png" ) & +late=$! +out="$(run_watcher)" +wait "$late" 2>/dev/null + +check "waits for the render the marker implies" grep -q "share card: round_11_9_VICTORY" <<<"$out" + +# Bounded, though: a client that never draws one must not hold the result up. +rm -f "$STATE/game-over" "$STATE/share-card.png" \ + "$LOGS/gameSummaries/board/game-uuid/round_11_9_VICTORY.png" +rm -rf "$STATE/sent" +out="$(CARD_WAIT=1 run_watcher)" +check "gives up waiting and sends anyway" grep -q "share card: round_10_" <<<"$out" + # --- what a previous pass already got through --------------------------------- # The marker file is the whole of the contract between this and finalize: an # artifact that has been accepted is never sent twice, however many passes run.