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