diff --git a/TODO.md b/TODO.md index cbdcbfb..f2ec341 100644 --- a/TODO.md +++ b/TODO.md @@ -185,6 +185,16 @@ the finish is supposed to leave behind. uploaded - one is read and thirty are not, at 3.3MB each - and neither are the per-phase minimap frames, which are the GIF's own. headquarters offers both layouts in the manifest, so this image runs against a control plane that has not deployed them yet. +- [x] **Every match leaves a result.** `MatchHost` keeps `state/result.json` + current from the first tick of the game and rewrites it at victory, so a + match that is abandoned, times out on `watch/idle.sh`, is signalled or is + killed has one too. `ending` is what tells the two apart - `victory` or + `no-victory-at-exit` - and an undecided match carries no winner: + `victoryTeam` and `victoryPlayerId` stay at MegaMek's own "nobody" values + rather than being inferred from what is left standing. A snapshot rather + than a shutdown hook, because `exit/finalize.sh` runs from the + entrypoint's trap before the JVMs are signalled and a SIGKILL runs no hook + at all. - [ ] **The rest of the artifacts still ride the shutdown.** That window is a SIGTERM and a bounded wait on Fargate, and a diagnostic that does not finish inside it is gone. The logs could be shipped in rolling chunks and diff --git a/container/exit/finalize.sh b/container/exit/finalize.sh index 87ca4dc..66e74a6 100755 --- a/container/exit/finalize.sh +++ b/container/exit/finalize.sh @@ -43,11 +43,19 @@ shopt -u nullglob # # The collectors skip what has already been accepted, so this costs nothing # when the early pass did its job, and says what it sent when it did not. +# +# There is a result for a match that never reached victory too: MatchHost keeps +# a provisional one on disk from the first tick and replaces it at victory, so +# what is here says either "victory" or "no-victory-at-exit" rather than being +# absent. Absent now means the host died before it wrote anything - a scenario +# that would not load, a JVM that would not start - which is worth saying in a +# different sentence. LOGDIR="${ARENA_MM_LOGDIR:-$MM_HOME/logs}" if [ -f "$ARENA_STATE/result.json" ]; then + log "the match ended: $(result_ending) (round $(result_round))" collect_result || true else - log "no result.json - the match did not reach victory" + log "no result.json - the match host wrote none" fi shot_count="$(ARENA_MM_LOGDIR="$LOGDIR" collect_images)" log "uploaded $shot_count image(s)" @@ -174,14 +182,10 @@ if [ -n "${ARENA_POSTHOG_KEY:-}" ] && [ -n "${ARENA_POSTHOG_SALT:-}" ] && bots="$(jq '[.players[] | select(.control != "human")] | length' "$ARENA_STATE/identity.json")" # A match that never reached victory still played, and how far it got is the - # interesting part of why it did not. - if [ -f "$ARENA_STATE/result.json" ]; then - rounds="$(jq -r '.round // 0' "$ARENA_STATE/result.json")" - decided=true - else - rounds=0 - decided=false - fi + # interesting part of why it did not. `decided` is the result's own ending, + # not the presence of the file: every match has a document now. + rounds="$(result_round)" + if [ "$(result_ending)" = victory ]; then decided=true; else decided=false; fi events="$ARENA_STATE/posthog-batch.json" : > "$events" diff --git a/container/lib/collect.sh b/container/lib/collect.sh index 19a9305..da67ca3 100644 --- a/container/lib/collect.sh +++ b/container/lib/collect.sh @@ -53,12 +53,26 @@ upload_once_as() { : > "$marker" } +# What the result says about itself: `.ending` is "victory" or +# "no-victory-at-exit", `.round` is how far the game got. +# +# Both answer a default rather than failing. A document that is missing or +# half-written is still a match somebody played, and neither of the callers - +# a log line and an analytics property - is worth ending the exit pass over. +result_ending() { + jq -r '.ending // "unknown"' "$ARENA_STATE/result.json" 2>/dev/null || printf 'unknown' +} + +result_round() { + jq -r '.round // 0' "$ARENA_STATE/result.json" 2>/dev/null || printf '0' +} + # The result, with attribution attached. # -# Written by MatchHost the moment the game is decided, so this is complete -# well before the container stops. Absent means the match never reached -# victory, which is not a failure here - a cancelled or timed-out match still -# had turn reports. +# MatchHost keeps this current from the first tick of the game and rewrites it +# at victory, so there is one for every match that got as far as a running host +# - `.ending` is what says whether the game was actually decided. Absent means +# the host never started, and the exit pass says so. # # result_document -> the merged document's path, or non-zero when there is none result_document() { diff --git a/src/arena/MatchHost.java b/src/arena/MatchHost.java index b1ddb84..0663d6b 100644 --- a/src/arena/MatchHost.java +++ b/src/arena/MatchHost.java @@ -55,6 +55,12 @@ import megamek.server.totalWarfare.TWGameManager; * read the victory screen. The host writes a result file, touches a game-over * marker, and then lingers. * + *

A result is written whether or not the game is ever decided. The loop + * re-writes {@code result.json} once a phase with {@code ending} set to + * {@link #ENDING_NO_VICTORY}, and victory replaces it with the same document + * saying {@link #ENDING_VICTORY}. So a match that is abandoned, times out or is + * killed still leaves an account of how far it got. + * *

  * MatchHost --scenario data/scenarios/... --port 8850 \
  *           --humans TraineeA --state-dir /var/run/arena --linger 300 \
@@ -84,6 +90,24 @@ public final class MatchHost {
      */
     static final String SPECTATOR_NAME = "ArenaSpectator";
 
+    /**
+     * The result's {@code ending}: how the match ended, as arena observed it.
+     *
+     * 

{@link #ENDING_VICTORY} means MegaMek declared the game over and the + * document was written from the victory phase. {@link #ENDING_NO_VICTORY} + * means the host was still playing when it wrote, so this is the last thing + * it saw rather than a final word. + * + *

Two values, and only two on purpose. This says whether the game reached + * victory before the container stopped; it does not say why it did not, + * because the host cannot tell a forfeit from a timeout from a reaped task. + * A richer vocabulary is the published record's to define. + */ + static final String ENDING_VICTORY = "victory"; + + /** @see #ENDING_VICTORY */ + static final String ENDING_NO_VICTORY = "no-victory-at-exit"; + public static void main(String[] args) throws Exception { Args a = Args.parse(args); @@ -265,6 +289,18 @@ public final class MatchHost { // simply absent from their own match report. Map roster = new LinkedHashMap<>(); + // `name` is what a seat is called on screen, which the renames above + // made a display name. `slot` is the join key identity.json and the + // manifest speak, which is what attribution needs - a seat not in the + // map (the observer, an unrenamed slot) is its own slot. Built here + // rather than at victory because the provisional result needs it too. + Map slotsByName = new LinkedHashMap<>(); + names.forEach((slot, display) -> slotsByName.put(display, slot)); + + // Round and phase as of the last provisional result, so the write below + // costs one document a phase rather than one a second. + String lastSnapshot = ""; + while (true) { IGame live = server.getGame(); @@ -301,11 +337,35 @@ public final class MatchHost { } if (live.getPhase() == GamePhase.VICTORY) { - onVictory(a, live, names, roster); + onVictory(a, live, roster, slotsByName); server.die(); System.exit(0); } + // The same document for a match that has not been decided, and may + // never be: abandoned in the lounge, timed out by watch/idle.sh, + // signalled by Fargate, or killed outright. None of those reach the + // block above, and until this existed every one of them left the + // container with nothing at all to say how the match went. + // + // A snapshot rather than a shutdown hook, because a hook is both too + // late and not always run: exit/finalize.sh is called from the + // entrypoint's trap *before* the JVMs are signalled, so a document + // written on the way out would miss the upload it exists for, and a + // SIGKILL or an OOM kill runs no hook at all. What is on disk is + // therefore never more than one phase behind the game. + String snapshot = live.getCurrentRound() + " " + live.getPhase(); + if (!snapshot.equals(lastSnapshot)) { + if (lastSnapshot.isEmpty()) { + System.out.println("[host] keeping a provisional result on disk;" + + " it reads \"" + ENDING_NO_VICTORY + "\" until the game is decided"); + } + lastSnapshot = snapshot; + writeState(a.stateDir, "result.json", + resultJson(ENDING_NO_VICTORY, live, roster, slotsByName, + Player.TEAM_NONE, Player.PLAYER_NONE)); + } + boolean allHumansPresent = humanSlots.stream() .map(p -> live.getPlayer(p.getId())) .allMatch(p -> p != null && !p.isGhost()); @@ -623,8 +683,32 @@ public final class MatchHost { return json.toString(); } - private static void onVictory(Args a, IGame live, Map names, - Map roster) + /** + * The whole result, as one document. + * + *

Written at victory and, with {@code ending} set to + * {@link #ENDING_NO_VICTORY} and no winner, once a phase before that. The + * two are the same shape on purpose: a reader that can read a decided match + * can read an abandoned one, and only {@code ending} says which it has. + * + *

A match with no victory carries no winner. {@code victoryTeam} and + * {@code victoryPlayerId} stay at MegaMek's own "nobody" values rather than + * being filled in from the counts, which would tell a player they won a + * fight that was never decided. + */ + static String resultJson(String ending, IGame live, Map roster, + Map slotsByName, int victoryTeam, int victoryPlayerId) { + return "{\n \"ending\": " + quote(ending) + + ",\n \"round\": " + live.getCurrentRound() + + ",\n \"victoryTeam\": " + victoryTeam + + ",\n \"victoryPlayerId\": " + victoryPlayerId + + ",\n \"players\": [\n" + + playersJson(live, roster, slotsByName) + + " ]\n}\n"; + } + + private static void onVictory(Args a, IGame live, Map roster, + Map slotsByName) throws InterruptedException { System.out.println("\n================================================="); System.out.println(" GAME OVER (round " + live.getCurrentRound() + ")"); @@ -650,20 +734,8 @@ public final class MatchHost { System.out.println("[host] game is not a Game; the result carries no winner"); } - // `name` is what the seat was called on screen, which the renames - // made a display name. `slot` is the join key identity.json and the - // manifest speak, which is what attribution needs - a player not in - // the map (the observer, an unrenamed slot) is its own slot. - Map slotsByName = new LinkedHashMap<>(); - names.forEach((slot, display) -> slotsByName.put(display, slot)); - - StringBuilder json = new StringBuilder(); - json.append("{\n \"round\": ").append(live.getCurrentRound()) - .append(",\n \"victoryTeam\": ").append(victoryTeam) - .append(",\n \"victoryPlayerId\": ").append(victoryPlayerId) - .append(",\n \"players\": [\n") - .append(playersJson(live, roster, slotsByName)) - .append(" ]\n}\n"); + String json = resultJson(ENDING_VICTORY, live, roster, slotsByName, + victoryTeam, victoryPlayerId); for (Seat p : roster.values()) { System.out.printf(" %-16s units remaining: %d/%d BV: %d/%d%s%n", p.name(), unitsInPlay(live, p.id()), p.unitsStart(), @@ -680,7 +752,7 @@ public final class MatchHost { } System.out.println("================================================="); - writeState(a.stateDir, "result.json", json.toString()); + writeState(a.stateDir, "result.json", json); writeState(a.stateDir, "host.status", "game-over\n"); writeState(a.stateDir, "game-over", ""); diff --git a/tests/shell/test-finalize.sh b/tests/shell/test-finalize.sh index 88ac172..a8e36f7 100644 --- a/tests/shell/test-finalize.sh +++ b/tests/shell/test-finalize.sh @@ -75,6 +75,45 @@ out="$(run_finalize "$EMPTY")"; status=$? check "an empty log dir still finalizes" test "$status" -eq 0 check "and says it collected nothing" grep -q 'uploaded 0 client log(s)' <<<"$out" +# --- the result, however the match ended -------------------------------------- +# MatchHost keeps a provisional result on disk from the first tick of the game +# and replaces it at victory, so a match that was abandoned, timed out or +# signalled has one too. This pass must upload it either way, and must say which +# it had: "no result" used to be the only record an undecided match left, and it +# said nothing about how far the match got. +RESULT="$TMP/run/state/result.json" + +check "a match with no document says so" bash -c \ + "grep -q 'no result.json - the match host wrote none' <<<\"\$1\"" _ "$out" + +echo '{"ending": "no-victory-at-exit", "round": 4, "victoryTeam": -1, + "victoryPlayerId": -1, "players": []}' > "$RESULT" +out="$(run_finalize "$EMPTY")"; status=$? + +check "an undecided match still finalizes" test "$status" -eq 0 +check "and its result is offered for upload" grep -q 'result-final.json' <<<"$out" +check "and the log says how it ended" grep -q 'the match ended: no-victory-at-exit (round 4)' <<<"$out" + +# A decided one reads the same way and differs only in the ending, which is the +# whole point of the field: one shape, one reader. +rm -rf "$TMP/run/state/sent" "$TMP/run/state/result-final.json" +echo '{"ending": "victory", "round": 9, "victoryTeam": 1, + "victoryPlayerId": -1, "players": []}' > "$RESULT" +out="$(run_finalize "$EMPTY")"; status=$? + +check "a decided match finalizes" test "$status" -eq 0 +check "and says so" grep -q 'the match ended: victory (round 9)' <<<"$out" + +# An artifact that has already been accepted is never offered twice, whichever +# pass got it through. +rm -rf "$TMP/run/state/sent" +mkdir -p "$TMP/run/state/sent" +: > "$TMP/run/state/sent/result-final.json" +out="$(run_finalize "$EMPTY")" +check "a sent result is not offered again" bash -c \ + "! grep -q 'result-final.json' <<<\"\$1\"" _ "$out" +rm -rf "$TMP/run/state/sent" + # --- counting the match ------------------------------------------------------- # Two operational properties, neither about whether the numbers are right: that # a container nobody configured stays silent, and that a counter which cannot be diff --git a/tests/shell/test-results-watch.sh b/tests/shell/test-results-watch.sh index 185a01a..9b14c58 100755 --- a/tests/shell/test-results-watch.sh +++ b/tests/shell/test-results-watch.sh @@ -28,7 +28,7 @@ echo '{"matchId":"test-match"}' > "$TMP/run/manifest.json" 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 '{"ending": "victory", "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 @@ -101,6 +101,13 @@ 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" +# `ending` is how a reader tells a decided match from one that stopped without +# being decided, and it has to survive both the merge and the page's copy - +# they are made by different jq invocations. +check "the merge keeps the ending" bash -c \ + "test \"\$(jq -r .ending \"\$1/result-final.json\")\" = victory" _ "$STATE" +check "the page's copy keeps it too" bash -c \ + "test \"\$(jq -r .ending \"\$1/web/decided.json\")\" = victory" _ "$TMP/run" # The per-phase renders stay in the container. Thirty-one board PNGs is over a # hundred megabytes a match and exactly one of them is ever read, so only the # victory board goes up; the per-phase minimaps are the GIF's own frames.