From 3151ab1306fe4fead4eb5ba322efd03e74a31750 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sat, 15 Aug 2026 00:49:24 -0400 Subject: [PATCH] feat(daily-challenge): a scheduled check, and the epic's decisions written down scripts/daily.sh check is what a cron job runs. There is nothing to roll over at midnight - which fight is today's is computed from the schedule, by the site and the API alike - so the job's work is noticing the schedule is running out before it does, and that the published index and the downloadable copies still match the scenarios. It fails on either, because both need a person. `add` is the mechanical half of putting a new challenge in: validate, copy, append to the schedule. It deliberately stops short of the two things it cannot do - designing the fight, and getting its battle values out of MegaMek - and prints them rather than pretending. The epic's four open design questions are answered in it now: a UTC day, a schedule rather than a computed index, one attempt that only a result spends, and battle value both ways. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: Icae4f6273cef8f9844ad723ef1692398cefaecb9 --- docs/launch-manifest.md | 12 ++-- plan/README.md | 2 +- plan/daily-challenge.md | 139 +++++++++++++++++++++++-------------- scripts/daily.sh | 150 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 246 insertions(+), 57 deletions(-) create mode 100755 scripts/daily.sh 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 -- 2.51.2