diff --git a/docs/launch-manifest.md b/docs/launch-manifest.md index 986882f..66b0bdd 100644 --- a/docs/launch-manifest.md +++ b/docs/launch-manifest.md @@ -78,10 +78,14 @@ anyone holding it can read the match setup, so it is fetched once at init into - **`version`** — arena refuses anything it does not know. Adding optional fields is a non-breaking change; changing the meaning of one is not. -- **`scenario.name`** — the only scenario field: a relative path into the - scenario library baked into the arena image. arena rejects traversal and - absolute paths. Custom scenario content and map files are a later manifest - extension, decided when they exist. +- **`scenario.name`** — a relative path into the scenario library baked into the + arena image. arena rejects traversal and absolute paths. +- **`scenario.content`** — the `.mms` itself, for a fight no image has: a daily + challenge, or anything else generated rather than shipped. Exactly one of + `name` and `content`; a manifest giving both is refused, because guessing + which fight was meant costs somebody their match. A carried scenario still + names boards that have to be in the image, which it finds out at load. Custom + *map* files remain a later extension. - **`players[].slot`** — must name a faction that exists in the scenario. arena's match host fails loudly rather than silently reassigning, because a mis-mapped slot hands someone else's units to a player. diff --git a/plan/README.md b/plan/README.md index 95c80ac..e8bcbbb 100644 --- a/plan/README.md +++ b/plan/README.md @@ -131,7 +131,7 @@ The exit criterion is met and something is still open in the file. | [invitations](invitations.md) | Nobody is put in a match without being asked | open | | [player-profile](player-profile.md) | A player has a page that is about them | blocked | | [onboarding](onboarding.md) | Somebody who has never played MegaMek can finish a match | open | -| [daily-challenge](daily-challenge.md) | One fight a day, the same for everyone, scored | blocked | +| [daily-challenge](daily-challenge.md) | One fight a day, the same for everyone, scored | open | | [cost-model](cost-model.md) | What a match costs is measured, and what a busy day costs is known | open | | [forces](forces.md) | A player brings their own units | open | | [post-to-bluesky](post-to-bluesky.md) | A player can post a match to Bluesky, and is never surprised by one | blocked | diff --git a/plan/daily-challenge.md b/plan/daily-challenge.md index 4cb9c54..ebf91b3 100644 --- a/plan/daily-challenge.md +++ b/plan/daily-challenge.md @@ -1,7 +1,7 @@ --- id: daily-challenge title: One fight a day, the same for everyone, scored -status: blocked +status: open repos: [headquarters, arena] dependsOn: [match-records, scenarios, player-profile] exitCriterion: > @@ -21,56 +21,43 @@ than building more of it, which is a useful thing for it to be: it is the test of whether [match-records](match-records.md) actually produced something worth having. -## The card is on the front page already - -Not the feature — the card. `web/src/screens/daily.ts` draws -`web/src/content/daily.json` under the hero, for signed-in and signed-out -readers alike, marked "not ready yet": the challenge's name and a line about -it, the board with both forces on it in MegaMek's own art, the era, each side's -battle value, and how many people have played it today. Nothing is clickable -and no API is called; the placeholder file is bundled with the page, so the -card is up whenever the bucket is, like everything else on that page. - -It is ahead of the feature on purpose. What a player is told *before* they -commit to the day's fight is a design decision worth having settled early, and -the front page is where somebody who has never signed in finds out this exists -at all. The shape that file carries is the shape the API has to answer with; -the numbers in it are invented and the strings are placeholder. - -## What it needs first - -**A comparable result.** Ranking two matches against each other needs a winner, -teams, a starting unit count and timestamps — none of which arena's -`result.json` carries today, and it is not written at all unless the match -reaches victory. That whole list is the blocked half of -[match-records](match-records.md), and none of this works without it. - -**A scenario pick that cannot break.** The catalog is hand-curated against one -arena image tag. A wrong slot list is a launch failure for *every* player that -day rather than for one, which is why [scenarios](scenarios.md) is a -dependency rather than a nicety. - -**Somewhere to show it.** Streaks and history belong on a profile, and there -isn't one — [player-profile](player-profile.md). - -## The day - -- [ ] **Pick the day's scenario deterministically.** Same input, same answer, - computed rather than stored, so no row has to exist before the day - starts and anybody can check the pick was not chosen after seeing the - results. -- [ ] **Decide what a day is.** UTC is the only answer that does not need a - player's timezone, and it means the day rolls over mid-evening for some - people. The alternative is a per-player day, which makes "the same fight - as everyone else" untrue. Pick one and say why. -- [ ] **Decide how many attempts.** One is the version that makes a score mean - something; unlimited is the version people will enjoy more. If it is one, - an abandoned match must not burn it — which is - [match-lifecycle](match-lifecycle.md)'s reaping, from a new direction. -- [ ] **Decide what is scored.** Winning is the obvious axis and a weak one - against Princess, which will usually lose. Units remaining, rounds taken - and damage dealt are all available once the result document is fixed. - This is a game design decision, not an implementation detail. +## The card is on the front page, with the day's real fight on it + +`web/src/screens/daily.ts` draws `web/src/content/daily.json` under the hero, +for signed-in and signed-out readers alike: the challenge's name and the line +about it, the board with both forces on it in MegaMek's own art, what each +side is worth, the years its designs come from, and how many people have taken +it on. The scenario file is a download, and `#daily` is the archive of every +challenge whose day has come. + +Nothing on it is invented any more. The name and the blurb are the scenario's +own `Name=` and `Description=`, the battle values are what MegaMek computes for +those pilots, and the board is where the units actually stand. `daily.json` is +generated by `mms index` rather than written, so the card cannot drift from the +fight it describes. + +The attempt count is the one live figure, and losing it costs the count and +nothing else: everything else is bundled with the page, which is served from S3 +and is up whenever the bucket is. + +## What it needed first + +**A comparable result.** Done, in arena: `result.json` carries each side's +starting and remaining battle value, its starting unit count and its team. +That is the denominator the old document did not have, and it is what a score +is computed from. The rest of [match-records](match-records.md) — the durable, +player-owned copy — is still open and is not in this epic's way. + +**A scenario pick that cannot break.** Solved a different way than expected. +The daily challenges are not catalog entries: they are ten `.mms` files in this +repo, compiled into the API and carried to arena in the launch manifest. So the +pick does not depend on the hand-curated catalog at all, and +[scenarios](scenarios.md) stopped being a blocker rather than being finished. +Every one has been loaded in real MegaMek, which is what says its boards and +units exist. + +**Somewhere to show it.** Still [player-profile](player-profile.md), and still +missing. Streaks and history below are what want it. ## The board @@ -116,6 +103,54 @@ If it happens it is its own epic. challenge is a hook, and a hook that lands people in an interface they cannot use spends their attention once. +## Still to decide + +- [ ] **Whether one attempt is the right number.** It is what makes a score + mean something, and it is also the version that punishes a player whose + connection drops at round nine. The release path covers a match that + *ends* badly; a player who simply walks away holds their attempt until + the container's idle timeout ends the match, which is up to an hour. That + is the gap to watch once real people play it. + ## Done -Nothing closed yet. +- [x] **Pick the day's scenario deterministically.** A schedule, not a + computation over the list: each challenge carries the UTC day it is the + daily. A computed index would have been the same answer until the + eleventh challenge was appended, at which point every past day's pick + would silently change and the archive would start lying. The schedule is + in the binary and in the site's bundle, and a test says the two agree. +- [x] **Decide what a day is.** UTC. A per-player day makes "the same fight as + everyone else" untrue, which is the whole proposition; the cost is that + the day turns over mid-evening for some people. The site and the API each + compute it, and neither asks the other. +- [x] **Decide how many attempts.** One, per player per challenge, enforced by + the `daily_attempt` primary key rather than by a check somebody can + forget. It is claimed before the launch, so two clicks cannot start two + matches, and it is only *spent* when the match produces a result: a match + that fails, or whose result carries no battle value, gives it back. An + attempt lost to our own infrastructure is not a score. That is the + reaping [match-lifecycle](match-lifecycle.md) wanted, from this + direction — the half that needs no ECS poll, because the match's own + terminal status is the trigger. +- [x] **Decide what is scored.** Battle value remaining, both sides, + normalised to 1,000,000: half for what you kept, half for what you took. + Winning is not an input, because Princess usually loses and a victory + axis would rank almost everybody equal first. Half and half is what makes + hiding in a corner and trading your whole force both score 500,000. BV + rather than unit count because MegaMek recomputes a unit's BV from its + current state, so a damaged survivor is worth less than an untouched one + without needing a damage model of our own. +- [x] **The card shows the day's real fight**, and the scenario behind it is + downloadable and playable in desktop MegaMek — `SinglePlayer=true`, so it + sets itself up the same way there. +- [x] **Ten challenges**, balanced on battle value, each verified to load in + real MegaMek. +- [x] **The archive**, at `#daily`: every challenge whose day has come, with + what was on the card, playable and explicitly not scored. +- [x] **Scoring, attempts and reaping** — see the decisions above. +- [x] **A scheduled check**, `scripts/daily.sh`. Not a rollover: which fight is + today's is computed from the schedule by both readers, so nothing has to + run at midnight. What a job is for is noticing the schedule is running + out *before* it does, and that the published files still match the + scenarios. diff --git a/scripts/daily.sh b/scripts/daily.sh new file mode 100755 index 0000000..c531ada --- /dev/null +++ b/scripts/daily.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# +# The daily challenge's schedule: check it, and add a day to it. +# +# scripts/daily.sh check is the schedule stocked and published? +# scripts/daily.sh add [date] +# +# Meant to run on a schedule. There is nothing to "roll over" at midnight — +# which fight is today's is computed from the dates in the schedule, by the +# site and by the API alike, so no job has to be up for the day to change. +# What a job is for is the thing that *does* need a human: noticing that the +# schedule is running out before it does, and saying so while there is still +# time to write another one. +# +# `check` fails when the schedule has fewer than DAYS_AHEAD days left, or when +# the published copies have drifted from the scenarios. Both are conditions +# somebody has to act on, which is what a non-zero exit is for. +# +# `add` is the mechanical half of putting a new challenge in: it validates the +# file, copies it in and appends it to the schedule. It deliberately does not +# write the scenario — that is a design job, for a person or for whatever +# generates one — and it cannot fill in the battle values, which come from +# loading the fight in real MegaMek: +# +# arena: java -cp ... arena.ScenarioReport > report.json +# here: cargo run -p headquarters-mms --features cli --bin mms -- index ... +# +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +DAILY_DIR="services/api/src/matches/daily" +SCHEDULE="$DAILY_DIR/schedule.json" +INDEX="web/src/content/daily.json" + +# How much runway the schedule should keep. Three days is enough that a check +# failing on a Friday can be dealt with on Monday. +DAYS_AHEAD="${DAYS_AHEAD:-3}" + +die() { echo "daily: $*" >&2; exit 1; } + +today() { date -u +%F; } + +# The scheduled days, one per line, in file order. +days() { + python3 -c ' +import json, sys +for slot in json.load(open(sys.argv[1])): + print(slot["date"], slot["slug"]) +' "$SCHEDULE" +} + +check() { + local now last remaining stale=0 + now="$(today)" + + [ -f "$SCHEDULE" ] || die "no schedule at $SCHEDULE" + + # Every scheduled slug has a scenario, and every scenario is scheduled. + local scheduled + scheduled="$(days | awk '{print $2}' | sort)" + local present + present="$(find "$DAILY_DIR" -name '*.mms' -printf '%f\n' | sed 's/\.mms$//' | sort)" + if [ "$scheduled" != "$present" ]; then + echo "daily: the schedule and the scenario files disagree:" >&2 + diff <(echo "$scheduled") <(echo "$present") >&2 || true + stale=1 + fi + + # Every scenario parses. A challenge that does not is one nobody can play. + # This is not the whole check — a file can parse and still name a board no + # image has — which is arena's tests/scenario-load.sh. + cargo run -q -p headquarters-mms --features cli --bin mms -- \ + check "$DAILY_DIR"/*.mms >/dev/null || die "a scenario does not parse" + + # The site's copy of the index is current. + cargo run -q -p headquarters-mms --features cli --bin mms -- index \ + --scenarios "$DAILY_DIR" \ + --schedule "$SCHEDULE" \ + --report "$DAILY_DIR/report.json" \ + --out "$INDEX" --check >/dev/null || { + echo "daily: $INDEX is stale; re-run mms index" >&2 + stale=1 + } + + # And the downloadable copies are the files the API launches. + npm --prefix web run --silent daily-public -- --check >/dev/null || { + echo "daily: web/public/daily/ is out of step; run npm --prefix web run daily-public" >&2 + stale=1 + } + + last="$(days | awk '{print $1}' | sort | tail -1)" + remaining="$(( ( $(date -u -d "$last" +%s) - $(date -u -d "$now" +%s) ) / 86400 ))" + echo "daily: today is $now; the schedule runs to $last ($remaining day(s) left)" + + [ "$stale" -eq 0 ] || die "the published files are out of step with the scenarios" + + if [ "$remaining" -lt "$DAYS_AHEAD" ]; then + die "fewer than $DAYS_AHEAD days of challenges left; write another one" + fi +} + +add() { + local file="${1:-}" date="${2:-}" + [ -n "$file" ] || die "usage: daily.sh add [YYYY-MM-DD]" + [ -f "$file" ] || die "no such file: $file" + + local slug + slug="$(basename "$file" .mms)" + [ -f "$DAILY_DIR/$slug.mms" ] && die "$slug is already a challenge" + + # The day after the last scheduled one, unless told otherwise. A gap in the + # schedule is allowed — for_day falls back to the most recent past challenge + # — but it is never what somebody meant. + if [ -z "$date" ]; then + local last + last="$(days | awk '{print $1}' | sort | tail -1)" + date="$(date -u -d "$last + 1 day" +%F)" + fi + + cargo run -q -p headquarters-mms --features cli --bin mms -- check "$file" \ + || die "$file does not parse" + + cp "$file" "$DAILY_DIR/$slug.mms" + python3 - "$SCHEDULE" "$slug" "$date" <<'PY' +import json, sys +path, slug, date = sys.argv[1], sys.argv[2], sys.argv[3] +schedule = json.load(open(path)) +if any(s["date"] == date for s in schedule): + sys.exit(f"daily: {date} already has a challenge") +schedule.append({"slug": slug, "date": date}) +schedule.sort(key=lambda s: s["date"]) +with open(path, "w") as fh: + json.dump(schedule, fh, indent=2) + fh.write("\n") +PY + echo "daily: $slug is scheduled for $date" + echo + echo "Still to do, because neither can be done from the .mms alone:" + echo " 1. add it to CHALLENGES in $DAILY_DIR/../daily.rs" + echo " 2. load it in arena's ScenarioReport, merge the result into" + echo " $DAILY_DIR/report.json, then re-run mms index and daily-public" +} + +case "${1:-check}" in + check) check ;; + add) shift; add "$@" ;; + *) die "usage: daily.sh " ;; +esac