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" \