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.