From 615c4e4625ff4f55f86d575f3e9b685ed25a3d77 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 20 Aug 2026 17:32:24 -0400 Subject: [PATCH] feat(result): put the start and end timestamps and the scenario in the result Both timestamps are clock readings taken in the container: startedAt at the top of MatchHost.main, endedAt as the document is built. The scenario object carries the name the file gives itself, which of the manifest's two forms it arrived in, and the library path when it was named rather than carried whole. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I4026a0c2d3c548bb515de93cd69720755880d26b --- TODO.md | 9 ++++ container/entrypoint.sh | 2 + container/init/30-assets.sh | 14 +++++- src/arena/MatchHost.java | 80 +++++++++++++++++++++++++++---- tests/shell/test-assets.sh | 13 +++++ tests/shell/test-finalize.sh | 2 +- tests/shell/test-results-watch.sh | 14 +++++- tests/shell/test-start-order.sh | 15 +++++- 8 files changed, 136 insertions(+), 13 deletions(-) diff --git a/TODO.md b/TODO.md index f2ec341..036bb56 100644 --- a/TODO.md +++ b/TODO.md @@ -195,6 +195,15 @@ the finish is supposed to leave behind. 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. +- [x] **The result says when the match ran and what was played.** `startedAt` + is the container's own clock read at the top of `MatchHost.main`, + `endedAt` is read as the document is built - the victory on a decided + match, the last thing the host saw on one that stopped without being + decided. `scenario` carries the name the scenario file gives itself, + which of the manifest's two forms it arrived in, and the library path it + was picked from when it had one. headquarters could derive some of this + from the launch row; it is written here so the document a player is handed + is complete on its own. - [ ] **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/entrypoint.sh b/container/entrypoint.sh index c3a0c2b..d0edc59 100755 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -274,6 +274,8 @@ log "starting match host on :$PORT_MM (humans: $ARENA_HUMAN_SLOTS)" -cp "$CP" \ arena.MatchHost \ --scenario "$ARENA_SCENARIO" \ + --scenario-source "${ARENA_SCENARIO_SOURCE:-}" \ + --scenario-library "${ARENA_SCENARIO_LIBRARY:-}" \ --port "$PORT_MM" \ --humans "$ARENA_HUMAN_SLOTS" \ --names "$ARENA_RUN/names.tsv" \ diff --git a/container/init/30-assets.sh b/container/init/30-assets.sh index c2cb033..0fdc0b4 100755 --- a/container/init/30-assets.sh +++ b/container/init/30-assets.sh @@ -67,6 +67,7 @@ elif [ -n "$content" ]; then # printf, not echo: a scenario is arbitrary text and the first line of a # generated one can legitimately begin with a dash. printf '%s\n' "$content" > "$SCENARIO_DIR/match.mms" + scenario_source=manifest log "scenario carried by the manifest ($(wc -c < "$SCENARIO_DIR/match.mms") bytes)" else case "$name" in @@ -75,9 +76,20 @@ else src="$MM_HOME/data/scenarios/$name" [ -f "$src" ] || die "no such scenario in this image: $name" cp "$src" "$SCENARIO_DIR/match.mms" + scenario_source=library log "scenario $name from the image library" fi -echo "ARENA_SCENARIO=$SCENARIO_DIR/match.mms" > "$ARENA_RUN/scenario.env" +# The host puts the last two in the result, so a reader of that document knows +# which fight it was without going back to the launch manifest. The staged path +# names no fight - both forms resolve to match.mms - and the library path names +# one only for the form that has one, which is why the source travels with it. +# +# %q for the library path: it comes from the manifest and this file is sourced. +{ + echo "ARENA_SCENARIO=$SCENARIO_DIR/match.mms" + echo "ARENA_SCENARIO_SOURCE=$scenario_source" + printf 'ARENA_SCENARIO_LIBRARY=%q\n' "$name" +} > "$ARENA_RUN/scenario.env" # The staged file, sent as the match's own input. # diff --git a/src/arena/MatchHost.java b/src/arena/MatchHost.java index 0663d6b..335c607 100644 --- a/src/arena/MatchHost.java +++ b/src/arena/MatchHost.java @@ -65,7 +65,8 @@ import megamek.server.totalWarfare.TWGameManager; * MatchHost --scenario data/scenarios/... --port 8850 \ * --humans TraineeA --state-dir /var/run/arena --linger 300 \ * --camo-index /run/arena/camo/index.tsv \ - * --names /run/arena/names.tsv + * --names /run/arena/names.tsv \ + * --scenario-source library --scenario-library Training/1-FirstRun.mms * * *

{@code --names} maps slot names to the names the seats fight under - @@ -109,6 +110,10 @@ public final class MatchHost { static final String ENDING_NO_VICTORY = "no-victory-at-exit"; public static void main(String[] args) throws Exception { + // The first thing this process does, so it is the container's own clock + // saying when the match host started rather than anything derived from + // a launch row or a task's timestamps. + Instant startedAt = Instant.now(); Args a = Args.parse(args); Compute.d6(); @@ -121,6 +126,17 @@ public final class MatchHost { Scenario scenario = loader.load(); IGame game = scenario.createGame(); + // Everything the result says about the match that the game itself does + // not. The path in --scenario is the staged copy and names no fight; + // the scenario's own name does, and init/30-assets.sh passes along + // which of the manifest's two forms it came from. + Launch launch = new Launch(startedAt, scenario.getName(), + a.scenarioSource, a.scenarioLibrary); + System.out.println("[host] scenario \"" + launch.scenarioName() + "\" (" + + launch.scenarioSource() + + (launch.scenarioLibrary().isEmpty() ? "" : " " + launch.scenarioLibrary()) + + "), started " + startedAt); + // Rename the scenario's players to the names the manifest wants shown // - "@handle" for a claimed account, the site's own handle for a bot. // Before the Server exists, for the same reason the camo is: the name @@ -337,7 +353,7 @@ public final class MatchHost { } if (live.getPhase() == GamePhase.VICTORY) { - onVictory(a, live, roster, slotsByName); + onVictory(a, launch, live, roster, slotsByName); server.die(); System.exit(0); } @@ -362,7 +378,7 @@ public final class MatchHost { } lastSnapshot = snapshot; writeState(a.stateDir, "result.json", - resultJson(ENDING_NO_VICTORY, live, roster, slotsByName, + resultJson(ENDING_NO_VICTORY, launch, live, roster, slotsByName, Player.TEAM_NONE, Player.PLAYER_NONE)); } @@ -617,6 +633,36 @@ public final class MatchHost { } } + /** + * What the container knows about this match that the game does not: when it + * started, and which fight it is. + * + *

{@code scenarioName} is what the scenario file calls itself, which is + * the only name both manifest forms have - one carried whole by the + * manifest was never in any library. {@code scenarioSource} is which form + * it arrived in, {@code library} or {@code manifest}; {@code scenarioLibrary} + * is the path it was picked out of, and is empty for the other form. + * + *

headquarters could derive most of this from the launch row and the + * manifest. It is written here anyway, so that the one document a player is + * handed is complete without them. + */ + record Launch(Instant startedAt, String scenarioName, String scenarioSource, + String scenarioLibrary) { + + /** The result's {@code scenario} object. */ + String scenarioJson() { + StringBuilder json = new StringBuilder("{\"name\": ") + .append(quote(scenarioName == null ? "" : scenarioName)) + .append(", \"source\": ") + .append(quote(scenarioSource.isEmpty() ? "unknown" : scenarioSource)); + if (!scenarioLibrary.isEmpty()) { + json.append(", \"library\": ").append(quote(scenarioLibrary)); + } + return json.append('}').toString(); + } + } + /** What this seat still has on the board, by id - {@code IGame.getEntitiesOwnedBy}. */ private static int unitsInPlay(IGame live, int playerId) { return (int) live.getInGameObjects().stream() @@ -691,14 +737,22 @@ public final class MatchHost { * 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. * + *

{@code endedAt} is read as the document is built, so on a decided + * match it is the victory and on an undecided one it is the last thing the + * host saw - which is what "ended" means for a match nobody finished. + * *

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) { + static String resultJson(String ending, Launch launch, IGame live, + Map roster, Map slotsByName, + int victoryTeam, int victoryPlayerId) { return "{\n \"ending\": " + quote(ending) + + ",\n \"startedAt\": " + quote(launch.startedAt().toString()) + + ",\n \"endedAt\": " + quote(Instant.now().toString()) + + ",\n \"scenario\": " + launch.scenarioJson() + ",\n \"round\": " + live.getCurrentRound() + ",\n \"victoryTeam\": " + victoryTeam + ",\n \"victoryPlayerId\": " + victoryPlayerId @@ -707,7 +761,7 @@ public final class MatchHost { + " ]\n}\n"; } - private static void onVictory(Args a, IGame live, Map roster, + private static void onVictory(Args a, Launch launch, IGame live, Map roster, Map slotsByName) throws InterruptedException { System.out.println("\n================================================="); @@ -734,7 +788,7 @@ public final class MatchHost { System.out.println("[host] game is not a Game; the result carries no winner"); } - String json = resultJson(ENDING_VICTORY, live, roster, slotsByName, + String json = resultJson(ENDING_VICTORY, launch, live, roster, slotsByName, victoryTeam, victoryPlayerId); for (Seat p : roster.values()) { System.out.printf(" %-16s units remaining: %d/%d BV: %d/%d%s%n", @@ -784,7 +838,7 @@ public final class MatchHost { /** Flag parsing kept local; the container passes these from the launch manifest. */ private record Args(String scenario, int port, Set humans, Path stateDir, int lingerSeconds, int autoReadySeconds, Path camoIndex, Path namesIndex, - Path teamsIndex) { + Path teamsIndex, String scenarioSource, String scenarioLibrary) { static Args parse(String[] argv) { String scenario = "data/scenarios/TrainingScenarios/1-FirstRun.mms"; @@ -799,6 +853,12 @@ public final class MatchHost { Path camoIndex = null; Path namesIndex = null; Path teamsIndex = null; + // Which of the manifest's two scenario forms this came from, and + // the library path when it was named rather than carried. Empty is + // a legitimate answer - a MatchHost run by hand knows neither - and + // the result says "unknown" for it. + String scenarioSource = ""; + String scenarioLibrary = ""; for (int i = 0; i < argv.length; i++) { String v = i + 1 < argv.length ? argv[i + 1] : null; @@ -820,12 +880,14 @@ public final class MatchHost { case "--camo-index" -> { camoIndex = Path.of(require(argv[i], v)); i++; } case "--names" -> { namesIndex = Path.of(require(argv[i], v)); i++; } case "--teams" -> { teamsIndex = Path.of(require(argv[i], v)); i++; } + case "--scenario-source" -> { scenarioSource = require(argv[i], v); i++; } + case "--scenario-library" -> { scenarioLibrary = require(argv[i], v); i++; } default -> throw new IllegalArgumentException( "unknown argument: " + argv[i] + " (in " + Arrays.toString(argv) + ")"); } } return new Args(scenario, port, humans, stateDir, linger, autoReady, camoIndex, - namesIndex, teamsIndex); + namesIndex, teamsIndex, scenarioSource, scenarioLibrary); } private static String require(String flag, String value) { diff --git a/tests/shell/test-assets.sh b/tests/shell/test-assets.sh index 794f7f7..31b9a44 100755 --- a/tests/shell/test-assets.sh +++ b/tests/shell/test-assets.sh @@ -49,6 +49,13 @@ check "userdata target created through the symlink, on the tmpfs" \ test -d "$TMP/run/userdata" check "scenario fetched" test -f "$TMP/run/scenario/match.mms" check "scenario.env written" grep -q 'ARENA_SCENARIO=' "$TMP/run/scenario.env" +# Which fight this is, for the result document: the staged path is match.mms +# whichever form the manifest used, so the source and the library path are the +# only things that say what was played. +check "and it says the scenario came from the library" \ + grep -qx 'ARENA_SCENARIO_SOURCE=library' "$TMP/run/scenario.env" +check "and which one it picked" \ + grep -qx 'ARENA_SCENARIO_LIBRARY=scenario.mms' "$TMP/run/scenario.env" # A scenario nobody baked into the image: the manifest carries the file itself. # This is what a generated fight - a daily challenge - is launched from. @@ -61,6 +68,12 @@ check "a scenario carried by the manifest is staged" \ grep -qx 'Name=Carried' "$TMP/run/scenario/match.mms" check "and it is what the host is pointed at" \ grep -q "ARENA_SCENARIO=$TMP/run/scenario/match.mms" "$TMP/run/scenario.env" +check "a carried scenario is named as carried" \ + grep -qx 'ARENA_SCENARIO_SOURCE=manifest' "$TMP/run/scenario.env" +# There is no library path for a scenario that was never in a library, and an +# invented one would point at a file this image does not have. +check "and names no library path" \ + grep -qx "ARENA_SCENARIO_LIBRARY=''" "$TMP/run/scenario.env" # Both, which is a manifest disagreeing with itself about which fight this is. # Picking one would hand somebody the wrong match. diff --git a/tests/shell/test-finalize.sh b/tests/shell/test-finalize.sh index a8e36f7..8b6093a 100644 --- a/tests/shell/test-finalize.sh +++ b/tests/shell/test-finalize.sh @@ -86,7 +86,7 @@ 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, +echo '{"ending": "no-victory-at-exit", "round": 4, "victoryTeam": 0, "victoryPlayerId": -1, "players": []}' > "$RESULT" out="$(run_finalize "$EMPTY")"; status=$? diff --git a/tests/shell/test-results-watch.sh b/tests/shell/test-results-watch.sh index 9b14c58..3e75808 100755 --- a/tests/shell/test-results-watch.sh +++ b/tests/shell/test-results-watch.sh @@ -28,7 +28,11 @@ 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 '{"ending": "victory", "round": 7, "players": []}' > "$STATE/result.json" +echo '{"ending": "victory", "startedAt": "2026-08-20T10:00:00Z", + "endedAt": "2026-08-20T10:31:00Z", + "scenario": {"name": "Fight for Farhaven", "source": "library", + "library": "TrainingScenarios/1-FirstRun.mms"}, + "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 @@ -108,6 +112,14 @@ 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" +# When it started, when it ended and which fight it was. The merge is a jq +# object multiply, so a field it drops is a field no reader ever sees again. +check "the merge keeps the timestamps" bash -c \ + "test \"\$(jq -r .startedAt \"\$1/result-final.json\")\" = 2026-08-20T10:00:00Z \ + && test \"\$(jq -r .endedAt \"\$1/result-final.json\")\" = 2026-08-20T10:31:00Z" _ "$STATE" +check "the merge keeps the scenario" bash -c \ + "test \"\$(jq -r .scenario.library \"\$1/result-final.json\")\" \ + = TrainingScenarios/1-FirstRun.mms" _ "$STATE" # 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. diff --git a/tests/shell/test-start-order.sh b/tests/shell/test-start-order.sh index 8076eb2..8ef572f 100755 --- a/tests/shell/test-start-order.sh +++ b/tests/shell/test-start-order.sh @@ -28,8 +28,13 @@ HOST_WAIT="$(line '^await_port "match host"')" READY="$(line '^emit ready')" # The launch, not the mention of it in the header comment. OBSERVER="$(line '^ *arena\.MatchWatcher --host')" +# The host is told which fight it is playing, which is what puts the scenario in +# the result. init/30-assets.sh writes both into run/scenario.env. +SCENARIO_SOURCE="$(line '--scenario-source')" +SCENARIO_LIBRARY="$(line '--scenario-library')" -for var in WS_LAUNCH HOST_LAUNCH WS_WAIT HOST_WAIT READY OBSERVER; do +for var in WS_LAUNCH HOST_LAUNCH WS_WAIT HOST_WAIT READY OBSERVER \ + SCENARIO_SOURCE SCENARIO_LIBRARY; do check "$var was found in the entrypoint" test -n "${!var}" done [ "$fail" = 0 ] || { echo "cannot check ordering; the anchors moved"; exit 1; } @@ -40,6 +45,14 @@ check "the host launches before the wait for Suramadu" \ check "Suramadu launches before the host" \ test "$WS_LAUNCH" -lt "$HOST_LAUNCH" +# On the host's own command line, which is the whole path by which the scenario +# reaches the result document: run/scenario.env -> the entrypoint -> MatchHost. +between() { [ "$1" -gt "$2" ] && [ "$1" -lt "$3" ]; } +check "the host is told which form the scenario came in" \ + between "$SCENARIO_SOURCE" "$WS_LAUNCH" "$HOST_LAUNCH" +check "and which library path it was picked from" \ + between "$SCENARIO_LIBRARY" "$WS_LAUNCH" "$HOST_LAUNCH" + # Readiness means both ports, not just the web server's - headquarters sends a # browser the moment it hears `ready`. check "the ready callback follows the wait for Suramadu" \ -- 2.51.2