Web frontend and supporting services for lance.blue
headquarters docs match-artifacts.md
33 kB

What a match emits, and where it goes #

Every file a finished match produces: who writes it, what shape it is, how big it is, where it lands today, and what it should become.

The question this exists to answer is which artifacts belong in a player's ATProto records, which belong in S3, and which are only worth reading once — extracted into a number and thrown away.

There is a deadline on that question. matches/ in the artifacts bucket expires after 30 days (match_artifact_retention_days in infra's artifacts module), and the module says why: S3 is a staging area, and the durable record of a match belongs in ATProto. Nothing extracted before day 31 can be extracted after it.

Status: description and proposal. The inventory is what the code does today. The routing in Proposed routing is a proposal to argue with, not a decision that has been taken. The epics that would act on it are match-records, after-action and appview.

The telemetry map #

Every artifact a match emits: which process writes it, where it lives in the container, and which prefix it lands in.

This draws what runs. The prefixes it replaced were result.json turns/ shots/ diagnostics/, and four artifacts drawn as collected here used to be deleted with the container. The readers ask for the current keys only; the fallbacks that let an older arena image's objects answer are gone, along with the objects.

Read left to right. The sources are the four things running inside one Fargate task. The filesystem is laid out the way the container's is. The sinks are what survives it. A line from a source is "writes into this directory"; a line out of a file is "this is where it goes". A file with no line out does not leave the container, which its dashed amber border says.

A file's fill says whose format it is — slate for MegaMek, teal for Suramadu, indigo for arena. The sources are left uncoloured on purpose, because the two do not line up: two of these JVMs are arena's own programs with MegaMek's engine inside them, so MatchHost is ours while game_actions_N.tsv, written inside its JVM, is MegaMek's.

%%{init: {"flowchart": {"htmlLabels": true, "nodeSpacing": 18, "rankSpacing": 100, "curve": "linear"}, "themeVariables": {"fontSize": "18px"}}}%%
flowchart LR
    classDef mmKept fill:#1b2a38,stroke:#35d08a,stroke-width:2px,color:#dbe8f2
    classDef mmGone fill:#1b2a38,stroke:#ffb066,stroke-width:2px,stroke-dasharray:5 3,color:#dbe8f2
    classDef surKept fill:#0b3336,stroke:#35d08a,stroke-width:2px,color:#d5eef3
    classDef surGone fill:#0b3336,stroke:#ffb066,stroke-width:2px,stroke-dasharray:5 3,color:#d5eef3
    classDef arKept fill:#2a1f4d,stroke:#35d08a,stroke-width:2px,color:#e2dcf7
    classDef arGone fill:#2a1f4d,stroke:#ffb066,stroke-width:2px,stroke-dasharray:5 3,color:#e2dcf7
    classDef src fill:#141c27,stroke:#4c8dff,stroke-width:2px,color:#dbe6f7
    classDef sink fill:#1b1420,stroke:#b98ad6,color:#ecdff6
    classDef absent fill:#181818,stroke:#6b6b6b,color:#a8a8a8,stroke-dasharray:4 3
    classDef svc fill:#101d22,stroke:#4fc1d6,color:#d5eef3
    classDef input fill:#241d10,stroke:#c9a227,color:#f2e6c7

    subgraph fargate["one Fargate task — one match, one container"]
        direction LR

        subgraph procs["what is running"]
            direction TB
            subgraph suramadu["Suramadu JVM (Webswing)"]
                direction TB
                pSura(["<b>Suramadu JVM</b><br/>streams the client to the browser"]):::src
                pClient(["<b>MegaMek client JVM</b><br/>Swing, headless, launched by Suramadu"]):::src
            end
            pHost(["<b>MegaMek server JVM</b><br/>arena's MatchHost embeds the engine"]):::src
            pWatch(["<b>MatchWatcher JVM</b><br/>arena's observer client"]):::src
            pScripts(["<b>arena's scripts</b><br/>init · collect.sh · finalize.sh"]):::src
        end

        subgraph fs["the container's filesystem"]
            direction TB

            subgraph dScenario["/run/arena/scenario/"]
                direction TB
                fScenario[/"<b>match.mms</b><br/>the fight, staged from the manifest<br/>text · ~2 KB"/]:::arKept
            end

            subgraph dSummaries["$MM_HOME/logs/gameSummaries/"]
                direction TB
                fBoard[/"<b>board-round_*.png</b><br/>the board, once a phase<br/>PNG 2037×1260 · 1.6–3.3 MB each × 31<br/><i>one frame kept, as the share card</i>"/]:::mmKept
                fMini[/"<b>minimap-round_*.png</b><br/>PNG 394×251 · 28 KB × 46<br/><i>the GIF's own frames</i>"/]:::mmGone
                fGif[/"<b>minimap-&lt;uuid&gt;.gif</b><br/>the whole match, animated<br/>GIF · 2.14 MB"/]:::mmKept
            end

            subgraph dLogs["$MM_HOME/logs/"]
                direction TB
                fActions[/"<b>game_actions_N.tsv</b><br/>every action and attack,<br/>attacker → target<br/>TSV, 34 cols · 15 KB–1 MB"/]:::mmKept
                fGamelog[/"<b>gamelog.html</b><br/>the full round-by-round report<br/>HTML · 125 KB–1.76 MB"/]:::mmKept
                fStatus[/"<b>entitystatus.txt</b><br/>record sheets at the end<br/>text · 18.6 KB"/]:::mmKept
                fMul[/"<b>salvage.mul · Bot_*.mul</b><br/>whichever MULs the game wrote<br/>MUL XML · 2–13 KB each"/]:::mmKept
                fMmLog[/"<b>megamek.log</b><br/>the client's own logging<br/>text · 226 KB"/]:::mmKept
                fUnified[/"<b>unified_log.log</b> + .gz<br/>root logger, third parties too<br/>text · 3 KB + rollovers"/]:::mmKept
                fRanker[/"<b>bot_path_ranker.log</b><br/>Princess ranking every move<br/>text · 745 KB"/]:::mmGone
            end

            subgraph dSaves["$MM_HOME/savegames/"]
                direction TB
                fSave[/"<b>Round-N-autosave.sav.gz</b><br/>the whole game, replayable<br/>gzip · ~400 KB each"/]:::mmGone
            end

            subgraph dSpool["/run/arena/spool/"]
                direction TB
                fTurns[/"<b>NNN-rNN-PHASE.txt</b><br/>board, armour, phase report<br/>text · 2–93 KB × ~11 a round<br/><i>spooled, collected by nobody</i>"/]:::arGone
            end

            subgraph dState["/run/arena/state/"]
                direction TB
                fIdent[/"<b>identity.json</b><br/>slot → DID, handle and team<br/>JSON · 276 B · <i>sent at init</i>"/]:::arKept
                fResult[/"<b>result.json</b><br/>ending, timestamps, scenario,<br/>teams, per-unit state<br/>JSON · 18–70 KB · written from<br/>the first tick, not just at victory"/]:::arKept
                fStats[/"<b>stats-summary.txt</b><br/>Suramadu's stats lines, reduced<br/>text · 1.1 KB"/]:::arKept
                fCard[/"<b>share-card.png</b><br/>the victory board, chosen<br/>PNG · <i>the only tenant of socials/</i>"/]:::arKept
                fMarkers[/"<b>host.status · game-over</b><br/>coordination between scripts<br/>markers · bytes"/]:::arGone
            end

            subgraph dWeb["/run/arena/web/ — Suramadu serves this"]
                direction TB
                fDecided[/"<b>decided.json</b><br/>the result, for the player's page<br/>JSON · result + camo paths"/]:::arGone
            end

            subgraph dOut["stdout — no file at all"]
                direction TB
                fLines[/"<b>stats, lines</b><br/>latency, bytes, heap, per tick<br/>4122 latency samples a match"/]:::surGone
            end
        end
    end

    subgraph durable["what leaves the container"]
        direction TB
        subgraph bucket["S3 matches/&lt;id&gt;/ — expires at 30 days"]
            direction TB
            kManifest[("manifest.json · camo-&lt;slot&gt;.png<br/>the inputs — headquarters writes these<br/>before the task starts, and the container<br/>fetches the manifest through a presigned URL")]:::input
            subgraph artifacts["artifacts/ — what the container produced"]
                direction TB
                kLaunch[("launch/<br/>what it was given")]:::sink
                kRaw[("raw/<br/>verbatim MegaMek")]:::sink
                kDerived[("derived/<br/>ours")]:::sink
                kSocials[("derived/socials/<br/>pictures we picked")]:::sink
                kDiag[("diagnostics/<br/>operational")]:::sink
            end
        end
        browser["the player's browser"]:::sink
        cw[("CloudWatch")]:::sink
        posthog[("PostHog")]:::sink
    end

    api(["headquarters-api"]):::svc
    consumers["card.rs · share.rs · daily.rs"]:::svc
    records[("participant PDS records<br/>games.permadeath.match")]:::absent

    pScripts --> dScenario
    pScripts --> dState
    pScripts --> dWeb
    pClient --> dSummaries
    pClient --> dLogs
    pHost --> dLogs
    pHost --> dSaves
    pHost --> dState
    pWatch --> dSpool
    pSura --> dOut

    fScenario --> kLaunch
    fIdent --> kLaunch
    fIdent -->|"a salted DID hash"| posthog
    fActions --> kRaw
    fGamelog --> kRaw
    fStatus --> kRaw
    fMul --> kRaw
    fBoard --> kSocials
    fGif --> kRaw
    fResult --> kDerived
    fStats --> kDiag
    fCard --> kSocials
    fMmLog --> kDiag
    fUnified --> kDiag
    fDecided --> browser
    fLines --> cw

    bucket --> api --> consumers
    consumers -.->|"nothing writes these yet"| records

Sizes on the boxes are one match's, and a match's size varies by an order of magnitude with its length — see what a real match actually weighs for the same layout measured in production.

Three things the layout makes visible.

launch/ is the only class that is complete before the match is played. It does not depend on victory, which is what makes an abandoned match attributable and what a born-live record is written from.

raw/ is evidence and derived/ is a claim. Anything under raw/ can be re-read with MegaMek's own tools and does not depend on us being correct; anything under derived/ is a rendering, an extraction or a selection we made.

What is still dropped is dropped on purpose. The per-phase minimap frames are the GIF's own frames, bot_path_ranker.log is Princess narrating every move she considered, and the autosaves are MegaMek's serialization at one version.

Captured today #

Sizes in this section and the next are from one 8-round match and a desktop MegaMek install. They are the large end; what a real match actually weighs has the same files measured in production, where a short match runs an order of magnitude smaller.

Uploaded to matches/<matchId>/ by container/lib/collect.sh in arena, under the four upload keys the launch manifest carries.

Artifact S3 key Written by Format Count Size
Result document result.json MatchHost at victory, identity.json merged in by collect.sh JSON 1 ~5.8 KB per unit: ~18 KB at 3 units, ~50 KB at 8, ~70 KB at 12
Board renders shots/board-round_RRR_PP_PHASE.png MegaMek client, GameSummaryBoardView PNG 2037×1260 31 over 8 rounds mean 3.32 MB, total 102.8 MB
Minimap frames shots/minimap-round_R_PP_PHASE.png MegaMek client, GameSummaryMinimap PNG 394×251 46 over 8 rounds 28 KB each, 1.3 MB
Minimap animation shots/minimap-<game-uuid>.gif MegaMek client, GifGameSummaryMinimap + patch 0013 GIF 1 2.14 MB
Share card source shots/share-card.png collect_share_card, a copy of the last board render PNG 1 3.3 MB
Latency table diagnostics/stats-summary.txt finalize.sh, reduced from Suramadu's stats lines fixed-width text, 12 metrics 1 1.1 KB
Client log diagnostics/megamek.log MegaMek log4j2 text 1 226 KB
Unified log diagnostics/unified_log.log, unified_log_N.log.gz MegaMek log4j2, rolls at 1 MB text, gzip 1 + rollovers 3 KB + 0.2–1.2 KB each
Profiles diagnostics/profile-*.collapsed async-profiler, only when ARENA_PROFILE is set collapsed stacks 0 normally —

A match ships about 110 MB, and 99% of it is 31 near-identical board renders. That is the single largest fact in this document.

Two notes on the share card. The comment in collect_share_card calls it "a few hundred kilobytes"; the file is 3.3 MB. It does not reach a feed at that size — card.rs redraws its own 1200×630 card from the result and the board — so this is a cost paid in S3 and bandwidth for an input, not a bad card.

Produced in the run, not captured #

All of this is written inside the container and dies with it.

Artifact Written by Format Size Note
logs/game_actions_N.tsv TWGameManager, an unconditional static GameDatasetLogger 34-column TSV, three row types: UnitState, UnitAttack, UnitAction 0.5–1.1 MB Keep, and extract from. Attacker→target rows, per-phase unit state, damage, BV, positions, move paths. In one sample: 3853 state rows, 415 attacks, 287 actions
logs/gamelog.html MegaMek client, keepGameLog HTML, the whole round-by-round report 1.76 MB Keep — upstream's own account of the fight
logs/entitystatus.txt MegaMek client, saveEntityStatus at game end ASCII record sheets for every unit 18.6 KB Keep — cheap, and MegaMek's own wording for the same state
logs/salvage.mul, logs/Bot_*.mul ClientGUI.gameEnd MUL XML 13 KB / 6.4 KB Keep — the canonical, checkable form of what was destroyed
savegames/Round-N-autosave_*.sav.gz, autosave.sav.gz server AutosaveService gzipped Java serialization ~400 KB each The only fully replayable artifact, and locked to a MegaMek version
spool/NNN-rNN-PHASE.txt MatchWatcher via TextRenderer text: ASCII board, unit table, per-location armour, last phase report 2–93 KB × ~11 a round; 831 KB across 55 files in one 4-round match Spooled and not collected — see below
logs/bot_path_ranker.log (+ gz rollovers) Princess text 745 KB + ~50 KB per rollover Excluded on purpose; large, repetitive, nothing has asked for it
run/manifest.json init/10-manifest.sh JSON 5.2 KB A bearer credential. Stays in the container
state/identity.json init/20-identity.sh JSON, slot → control/did/handle 276 B Already merged into the uploaded result
web/decided.json, web/camo/* publish_result, publish_camo JSON + PNG result + camo paths The player's own page reads this; paths are container-local by design
state/host.status, host.activity, game-over MatchHost markers bytes Coordination between the container's own scripts

The logs/ numbers above were measured on a real MegaMek install rather than inside a match container, and arena has never inventoried these files — no script, patch or doc in that repo mentions game_actions, gamelog, entitystatus or savegames. MegaMek's source says they should all appear (game_actions unconditionally, the rest on client end-of-game paths, the autosaves from the server), but one docker exec ls on a live match would turn that from an inference into a fact.

Live only, never persisted #

Stream Producer Shape Measured
Pixels to the browser Suramadu WebSocket frames mean 11.6 KB per message, p90 15 KB, max 2.19 MB
Input from the browser Suramadu WebSocket frames mean 28.5 B
Session stats Suramadu, log4j2-server.xml HH:mm:ss.SSS stats,<instance>,<metric>,<value> on stdout 4122 latency samples in one match; reduced to stats-summary.txt
Client-side latency suramadu/web/perf.js in-page ring buffer, 4096 records Only with ?perf=1, read over CDP by a headless pilot. Never uploaded
Flares suramadu/web/flare.js → POST /api/flare player-authored report Goes to userinput.app, not to the match's artifacts
Container log entrypoint, tail -F over the Suramadu log text CloudWatch
Match counted finalize.sh one PostHog event per human, against a salted DID hash Carries no handle, slot or DID

The layout #

Everything used to land under four prefixes — result.json, turns/, shots/, diagnostics/ — and those names said which upload key sent it, which is a fact about arena's scripts rather than about the data. shots/ was the worst of them: it held MegaMek's own board renders and a picture we composed, side by side, with nothing distinguishing the two.

matches/<id>/ has two writers, and artifacts/ is what separates them. Above it are the match's inputs, put there by headquarters before the container exists: the manifest, which is how the container is told what to play and reaches it as a presigned URL, and each seat's staged camo. Inside it is everything the container produced. So the first question the layout answers is not what a file is but whether the match was handed it or made it, and the prefixes below only have to sort out the second.

Within artifacts/, the boundary worth drawing is provenance: what the match was handed, what MegaMek wrote, and what we made out of it. A verbatim MegaMek artifact is evidence — it can be re-read with MegaMek's own tools, and its meaning does not depend on us being correct. Ours is a claim: a rendering, an extraction, a selection we made. And an input is neither: it is the question the match was an answer to, and it is the only class that is complete before a single turn is played. A reader should be able to tell the three apart without knowing which script ran.

matches/<id>/
  manifest.json  what the match was told to be — written by headquarters,
                 fetched once by the container through a presigned URL
  camo-<slot>.png  each seat's colours, staged by headquarters

  artifacts/     everything the container produced — arena writes only here,
                 and the segment is headquarters' (`matches/aws.rs`), not a
                 name any upload key carries
    launch/        what the match was given, before it was played
      scenario.mms              the exact file MegaMek was handed
      identity.json             slot → DID, handle and team
    raw/           verbatim MegaMek, byte-for-byte as it wrote it
      game_actions_N.tsv        every action and attack, attacker → target
      gamelog.html              the full round-by-round report
      entitystatus.txt          record sheets at the end
      salvage.mul  Bot_*.mul    whichever MULs the game wrote
      minimap.gif               the match, animated
    derived/       ours: extracted, rendered or selected by arena
      result.json               outcome, teams, per-unit state
      socials/                  pictures we picked, for people to look at
        share-card.png          the board at victory, chosen from 31 renders
    diagnostics/   operational, nobody's evidence
      megamek.log  unified_log.log*  stats-summary.txt  profile-*.collapsed

Four things change from what runs today.

Capture what the match was given, at init. launch/ is written before the first turn and is the one class that does not depend on the match finishing.

identity.json leaves the container today only by being merged into result.json, and result.json is only written at victory — so a match that is abandoned, crashes or is reaped uploads turn reports that no longer say who was playing. It is 276 bytes and complete at init.

scenario.mms is the fight itself — about 2 KB, measured across the daily challenges — and it reaches S3 in no form at all today. Headquarters sends either scenario.name, a path into the arena image's library, or scenario.content, the file itself — a daily challenge is compiled into the API binary and travels that way. init/30-assets.sh resolves both to one staged file, /run/arena/scenario/match.mms, which is what makes this cheap: upload the staged file and a library scenario and a carried one are the same artifact afterwards.

Storing the name instead would not be equivalent. A library path is a reference into a particular arena image, and the image's scenario library changes between releases, so TrainingScenarios/1-FirstRun.mms in March and the same path in September are not promised to be the same fight. The bytes are what pins it.

Together they are what a record written at match start — the born-live half of match-records — has to be built from.

Keep the events. game_actions_N.tsv is captured rather than deleted. It is the only artifact that records who shot whom, it costs about 1 MB, and it is the source every kill, damage and accuracy figure would be extracted from.

Stop keeping thirty board renders. One is read — as the backdrop card.rs draws on — and thirty-one are uploaded, at 3.3 MB each. Keeping the victory frame as derived/socials/share-card.png saves about 99 MB a match and loses nothing any consumer asks for. The per-phase minimap PNGs go the same way: they are the GIF's own frames.

Keep the end-of-game record. gamelog.html, entitystatus.txt and the MULs are ~1.8 MB together — a rounding error beside what the boards cost — and they are MegaMek's own account of the fight, in formats MegaMek can read back. They belong in raw/.

Give the pictures we pick their own place. derived/socials/ holds images arena selected for a person to look at. Today there is exactly one: the board as it stood at victory, chosen out of the 31 renders MegaMek made. The bytes are MegaMek's and unaltered, but choosing that frame and calling it the share card is our act, not the engine's, and what the file is for is somebody looking at it — so it is derived, and it is social.

It sits under derived/ rather than beside it because it is not a third kind of provenance: a picture we chose is ours, like the result and the turn reports, and what makes it worth its own folder is only who it is for. That folder is also where a screenshot of an interesting moment mid-match would go, if we ever take one; today the last frame is the only one.

Headquarters draws the actual card — 1200×630, with the forces and the outcome lettered over it — on request, from this image plus the result. Caching that composed card beside its backdrop is the obvious next tenant of this folder.

The one argument for hoisting socials/ back to the top is retention: a card someone posted to Bluesky is fetched long after 30 days, and a class that needs its own lifetime wants its own prefix. It does not survive contact with S3. Lifecycle rules filter on a literal prefix, and every path here is matches/<id>/…, so the id sits above the class and no single rule can name one class across all matches — socials/ at the top level is no more reachable than derived/socials/ is. Giving a class its own retention means inverting the layout to <class>/<id>/…, which is a different decision from this one and should be taken on its own. Until then one rule covers everything under matches/, which is what runs today.

One consequence worth naming: the prefixes are a contract. The launch manifest's upload keys (result, turnReports, screenshots, diagnostics) are written by headquarters and read by arena, and share.rs reads derived/socials/share-card.png by a constant. Renaming is a coordinated change across both repos, and an arena image running the old keys has to keep working while it is deployed.

Routing #

Four destinations, and the test for each is different. A record is small, public, and something a stranger should be able to read out of a player's repo. A blob is bytes a player's PDS is being asked to host. S3 is a 30-day staging area. Extract means read it once, keep the number, let the file expire.

Artifact Proposal Why
Result: ending, round, startedAt/endedAt, scenario, per-force unitsStart/Remaining, bvStart/Remaining Record, ~1–2 KB Everything the pilot card, a rating and a leaderboard need. This is games.permadeath.match
Result: units[] Record, condensed 5.8 KB per unit is critical slots, equipment mounts and ammo bins. Keep chassis, model, BV, status, damage level, crew and armour percentage — about 300 B per unit, so a 12-unit match is ~4 KB rather than ~70 KB
Result: full per-unit detail derived/, referenced A record sheet is worth reading once, by one person, on a page. Not worth putting on the firehose
game_actions_N.tsv raw/, and extract from it Kills with attribution, damage dealt and taken, accuracy. The extracted figures go in the record; the TSV is the evidence they were read from
gamelog.html, entitystatus.txt, and whichever MULs the game wrote raw/ MegaMek's own account, in formats it can read back. Well under 2 MB together
Board renders derived/socials/share-card.png, the victory frame only One is read, thirty-one are uploaded. Keeping the frame we chose saves ~99 MB a match
Minimap frames Drop They are the GIF's own frames
Minimap GIF raw/; blob only if asked for 2.14 MB is a lot to ask a player's PDS to hold for a replay nobody has requested yet
The card hq composes from it derived/socials/, and a blob ~270 KB once hq has drawn it, and the one image a post needs. Nothing writes it yet: it is drawn per request
Turn reports Not collected A live progress feed for an AppView that does not exist, and nothing ever read one. raw/ holds the same match as evidence at a thirtieth of the size
Autosaves Drop Replay is a real feature and this is not the mechanism for it: MegaMek's serialization, at one version
megamek.log, unified_log*, stats-summary.txt, profiles diagnostics/, expire Operational. Never a record, and not evidence about the match either
identity.json launch/, uploaded at init, and a record Slots, DIDs, handles and teams. The only file that says who played, and today it leaves only inside a result that an abandoned match never writes
scenario.mms (staged) launch/, uploaded at init The fight itself, and the only thing that pins which version of a library scenario was played. Reaches S3 in no form today
Manifest, markers Stay in the container The manifest carries the upload URLs and the match token, so it is a bearer credential and cannot be an artifact. What is worth keeping out of it is already in launch/

The record that falls out of this #

Roughly 1–2 KB for the match, plus ~300 B per unit if the condensed units[] lands in the same record. A 12-unit match is about 5 KB written twice per participant (born live, dies complete — see match-records), which is a size the firehose and a PDS can both hold without anyone thinking about it.

What it costs #

today proposed
Board renders 31 × 3.3 MB = 103 MB 1 × 3.3 MB
Minimap frames 46 × 28 KB = 1.3 MB dropped
Share card 3.3 MB, a second copy of a board ~270 KB, composed
MegaMek's end-of-game record not kept ~1.8 MB
The events not kept ~1 MB
What the match was given not kept ~2 KB
Per match ~110 MB ~9 MB

Those are the 8-round match's figures. A shorter one is smaller throughout — the first production match came to 2.2 MB — but the ratio holds, because what the change removes is per-phase renders and what it adds is kilobytes.

What has to change first #

Done since this was written

  • A result always exists. MatchHost re-writes result.json once a phase and replaces it at victory, so a match that is abandoned, times out or is killed still says what became of it. ending is victory or no-victory-at-exit, and an undecided document carries no winner — victoryTeam: 0, victoryPlayerId: -1, the sentinels share.rs already reads as "nobody won".
  • Timestamps and the scenario are in it. startedAt, endedAt, and a scenario object naming the fight and whether it came from the image's library or was carried by the manifest.
  • launch/ is sent at init, so identity no longer depends on an ending.

Still open

  • Kill attribution. MegaMek tracks it per unit (Entity.getKillerId) and UnitReport does not emit it; it can also be derived from raw/game_actions_N.tsv, which is now kept for every match. What is undecided is where the deriving lives — in the container at victory, on two cores, while a player waits to be handed on, or in headquarters off S3 where it can be re-run when the logic improves. arena's TODO carries the argument. Deliberately parked until matches are on-lexicon, because what the numbers are for decides where they should be computed.

    Worth being exact about what changed: this is unimplemented, not unrecoverable. Before the events were captured, no match that had ever been played could say who shot whom, and nothing would have recovered it. Every match from here keeps the file that answers it.

  • One consequence of a result that always exists. A daily challenge abandoned mid-play now scores on the partial fight, because settle_daily reads a document that is there. That is deliberate. A match abandoned before deployment still scores nothing and releases the attempt, since bvStart is 0 and score refuses a zero denominator.

What a real match actually weighs #

A four-round match in production, once every part of the layout worked:

launch/identity.json · launch/scenario.mms JSON, text 1.4 KiB
raw/game_actions_0.tsv TSV 27.7 KiB
raw/entitystatus.txt · raw/Bot_@lance.blue.mul text, MUL 4.5 KiB
raw/gamelog.html HTML 372.8 KiB
raw/minimap.gif GIF 813.8 KiB
derived/result.json JSON 39.7 KiB
derived/socials/share-card.png PNG 1.9 MiB
diagnostics/ (three files) text 78.9 KiB
manifest.json, two camo-<slot>.png JSON, PNG 7.8 KiB
total ~3.2 MB

Nearly two thirds of that is two pictures. The whole authoritative record of what happened — every action, every attack, attacker to target — is the 27.7 KB TSV.

The tables above this section were measured on an 8-round match captured by hand and on a desktop MegaMek install, which is a person's own long games: larger throughout, gamelog.html at 1.76 MB against 372 KB here. Both are true. What scales is rounds, units and board size, so read those as the long end and this as an ordinary one.

The turn reports, and why they are gone #

The first production listing had no derived/turns/ at all, which this document recorded as unexplained. The cause was not the upload path. MatchWatcher called connect() once before its wait loop, and MegaMek's AbstractClient.connect() opens the socket once and never retries — so a single attempt, fired while MatchHost was still loading its unit cache, decided every match. No turn report had ever been uploaded, in any match, in the project's history. Retrying the socket inside the deadline fixed it.

The match that then produced them produced 55 files and 831 KB, half of it in consecutive duplicates: a dump carries phaseReport, which does not change between phases, so FIRING_REPORT and PHYSICAL arrive as the same 93 KB twice. And nothing read them. The AppView they were written for does not exist, and headquarters reads the result and the board and nothing else.

So they are no longer uploaded. The observer still runs — MatchHost leaves it unflagged so an all-human match has one not-done player holding the game shut — and still spools, because a local run keeps its artifacts and the dumps are the readable form when something needs debugging by eye. They die with the container.

That decision was only answerable because raw/ exists. The same match, as evidence, is 27.7 KB and structured; as a rendered convenience view, 831 KB and not. Filing artifacts by what they are turns "do we still need this?" from an argument into a comparison.

Re-measuring #

./scripts/matches/artifacts.sh <match-id>          # infra

That prints every object a match left, with sizes, grouped by class — which is where the table above came from. ./scripts/matches/list.sh gives the match ids, and both are in the infra repo.

For anything the container did not upload, there is no substitute for looking inside one: keep a container's state/ and MM_HOME/logs/ after a match and measure them with du. That is how the amber rows in the inventory above were counted, and it is the only way to count them, since by definition they never leave.