From 7b671c82805bcd5445170d893cb9df152b3274b2 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 14 Aug 2026 23:43:55 -0400 Subject: [PATCH] feat(match-launch): let the manifest carry the scenario itself A match could only play a scenario baked into the image, named by path. It can now carry the .mms whole in scenario.content instead, which is what a generated fight - a daily challenge - needs to be launchable at all. Exactly one of scenario.name and scenario.content: a manifest that gives both disagrees with itself about which fight this is, and guessing costs somebody their match. Boards are unchanged - a carried scenario still names boards that have to be in this image, and staging a .board the same way is left in TODO. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I27cea963ab10d71a2df704da46279c0aba125dec --- TODO.md | 5 ++++ container/init/10-manifest.sh | 7 ++++- container/init/30-assets.sh | 34 +++++++++++++++++++------ tests/shell/test-assets.sh | 23 +++++++++++++++++ tests/shell/test-manifest-validation.sh | 7 +++-- 5 files changed, 65 insertions(+), 11 deletions(-) diff --git a/TODO.md b/TODO.md index df3a246..19eeda2 100644 --- a/TODO.md +++ b/TODO.md @@ -596,6 +596,11 @@ method are already in the repo. into the slot's player, or handed to the client the way the lounge's own "Load Unit List" works — and validation, since a manifest URL is trusted but the file it stages was authored by a player. +- [ ] **Maps from the manifest.** `scenario.content` now carries a whole `.mms`, + so a fight nobody baked into the image can be launched - but the boards + that file names still have to be in this image, and a scenario naming one + that is not fails at load. Staging a `.board` the same way the scenario is + staged is the other half. - [ ] **Security review.** The proxy is the auth boundary; ATProto token custody stays out of the container by design; user-uploaded camo is decoded by other players' browsers, so headquarters must sanitise on upload; observers see diff --git a/container/init/10-manifest.sh b/container/init/10-manifest.sh index 35bd4b8..77e5845 100755 --- a/container/init/10-manifest.sh +++ b/container/init/10-manifest.sh @@ -57,8 +57,13 @@ if [ -n "$expires" ]; then fi fi +# A scenario is either named in the image library or carried whole; 30-assets.sh +# is what enforces "exactly one" and stages it. Here it is only that the +# manifest names a fight at all. scenario_name="$(mq '.scenario.name')" -[ -n "$scenario_name" ] || die "manifest has no scenario.name" +scenario_content="$(mq '.scenario.content')" +[ -n "$scenario_name" ] || [ -n "$scenario_content" ] \ + || die "manifest has neither scenario.name nor scenario.content" # Field names and counts only - values are credentials. log "matchId=$match_id players=$(jq '.players | length' "$MANIFEST") \ diff --git a/container/init/30-assets.sh b/container/init/30-assets.sh index 6154614..10a7b9f 100755 --- a/container/init/30-assets.sh +++ b/container/init/30-assets.sh @@ -50,15 +50,33 @@ mkdir -p "$ARENA_RUN/images-temp" mkdir -p "$SCENARIO_DIR" "$CAMO_DIR" # --- scenario --------------------------------------------------------------- -# Selected by name from the image's own library. Custom scenario content and -# map files are a later manifest extension. +# Either named in the image's own library, or carried whole by the manifest. +# Exactly one: a manifest that says both is a manifest whose author disagrees +# with themselves about which fight this is, and guessing costs a player their +# match. +# +# Content is how a scenario nobody baked into an image gets played - a daily +# challenge, a generated one. It is a `.mms` and nothing else: the boards it +# names still have to be in this image, which the host finds out when it loads +# the file. Custom *map* files remain a later manifest extension. name="$(mq '.scenario.name')" -case "$name" in - ''|/*|*..*) die "scenario.name must be a relative path in the scenario library, got '${name:-}'" ;; -esac -src="$MM_HOME/data/scenarios/$name" -[ -f "$src" ] || die "no such scenario in this image: $name" -cp "$src" "$SCENARIO_DIR/match.mms" +content="$(mq '.scenario.content')" +if [ -n "$name" ] && [ -n "$content" ]; then + die "manifest gives both scenario.name and scenario.content; it must give exactly one" +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" + log "scenario carried by the manifest ($(wc -c < "$SCENARIO_DIR/match.mms") bytes)" +else + case "$name" in + ''|/*|*..*) die "scenario.name must be a relative path in the scenario library, got '${name:-}'" ;; + esac + src="$MM_HOME/data/scenarios/$name" + [ -f "$src" ] || die "no such scenario in this image: $name" + cp "$src" "$SCENARIO_DIR/match.mms" + log "scenario $name from the image library" +fi echo "ARENA_SCENARIO=$SCENARIO_DIR/match.mms" > "$ARENA_RUN/scenario.env" # --- camo ------------------------------------------------------------------- diff --git a/tests/shell/test-assets.sh b/tests/shell/test-assets.sh index d28fb4f..794f7f7 100755 --- a/tests/shell/test-assets.sh +++ b/tests/shell/test-assets.sh @@ -49,6 +49,29 @@ 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" + +# 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. +INLINE="$(jq -nc \ + '{version:1, matchId:"t", + scenario:{content:"MMSVersion=1\nName=Carried\nFactions=A,B\n"}, + players:[{slot:"A",control:"human",did:"did:plc:x"},{slot:"B",control:"bot"}]}')" +run_assets "$INLINE" >/dev/null 2>&1 +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" + +# Both, which is a manifest disagreeing with itself about which fight this is. +# Picking one would hand somebody the wrong match. +BOTH="$(jq -nc \ + '{version:1, matchId:"t", + scenario:{name:"scenario.mms", content:"MMSVersion=1\nFactions=A,B\n"}, + players:[{slot:"A",control:"human",did:"did:plc:x"},{slot:"B",control:"bot"}]}')" +out="$(run_assets "$BOTH" 2>&1)" && status=0 || status=$? +check "a manifest with both a name and content is refused" test "$status" -ne 0 +check "and it says which two it was given" \ + grep -q 'exactly one' <<< "$out" check "a manifest with no camo leaves an empty index" \ test ! -s "$TMP/run/camo/index.tsv" diff --git a/tests/shell/test-manifest-validation.sh b/tests/shell/test-manifest-validation.sh index d837c6b..92a8d72 100755 --- a/tests/shell/test-manifest-validation.sh +++ b/tests/shell/test-manifest-validation.sh @@ -119,8 +119,11 @@ expect_reject "unknown version" \ expect_reject "missing matchId" \ "$(jq -c 'del(.matchId)' <<< "$VALID")" "no matchId" -expect_reject "scenario without a name" \ - "$(jq -c 'del(.scenario.name)' <<< "$VALID")" "scenario.name" +expect_reject "scenario with neither a name nor content" \ + "$(jq -c 'del(.scenario.name)' <<< "$VALID")" "neither scenario.name nor scenario.content" + +expect_accept "a scenario carried by the manifest needs no name" \ + "$(jq -c '.scenario = {content: "MMSVersion=1\nFactions=A,B\n"}' <<< "$VALID")" expect_reject "not JSON" \ 'this is not json' "not valid JSON" -- 2.51.2