Skip to content

Game night recap, deterministic match narrative, and game length histogram - #133

Merged
f4hy merged 3 commits into
mainfrom
feat/game-night-recap
Aug 22, 2026
Merged

Game night recap, deterministic match narrative, and game length histogram#133
f4hy merged 3 commits into
mainfrom
feat/game-night-recap

Conversation

@f4hy

@f4hy f4hy commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Three new surfaces, all built from replay data that was already parsed and cached. None of them adds a derivation or touches DETAILS_VERSION — they're projections of MatchDetails/MatchInfo the same way get_build_orders is.


1. Game Length — ?page=game-length

durations.py (pure) + GET /api/duration_distribution/. Computed over the competitive corpus, so a two-minute disconnect isn't counted as a two-minute game — a spike of those in the first bar would hide the real distribution.

The top bar is an open-ended overflow bin: durations are long-tailed, and a handful of hour-long games would otherwise stretch the axis until everything real sat in the first two bars.

Live numbers: 1,520 games, median 14:16, 17 days of playtime. The per-format table shows the progression cleanly — 1v1 9:59 → 2v2 13:12 → 3v3 16:10 → 4v4 17:23.

Game Length


2. Deterministic match narrative — GET /api/narrative/{match_id}

A match retold as ordered beats, assembled from facts already in the parsed replay: first blood, who reached rank 5 first, superweapon launches, who went hunted, the priciest kill, the money and APM ledger. No model call — free, instant, identical on every request.

The editorial work is the selection. An 8-player game has eight players' worth of rank-ups; dumping them all is a log, not a story. Shared milestones are narrowed to whoever got there first, but every collapse is named — in a team game, which teammate went hunted is the story.

Rendered as "What happened" at the top of match details, and per-game inside the night recap.

Match narrative

The narrative also carries started_at and tournament, so a game shows its wall-clock start and a bracket chip.

Beats split superweapons from generals powers, because they aren't the same thing. timeline_events already drew this line — _SUPERWEAPON_ACTIVATION_KEYWORDS is deliberately wider than BASE_SUPERWEAPON_LAUNCHES so the Event Chart marks the big panel powers too, and the comment there tells anything counting actual launches to filter on the narrow list. superlatives.py does; these modules didn't, so a Spectre Gunship called in seven times was reported as seven superweapon launches. is_base_superweapon is that filter, named:

  • ☢️ superweapon — the three base-bound ones, launched
  • ✴️ power — the heavy generals-panel ones (gunship, EMP, anthrax), called in

Each is grouped by (player, weapon) rather than listed per firing — seven gunships is one habit, and rendered a line at a time it crowds every other beat out.

Four things needed real fixes once it ran against live data:

  • A Spectre Gunship was being called a superweapon (above). On 2026-08-20 this moves "most superweapons" from 9 → 5, and stops the 9.9-minute gunship being reported as the night's first superweapon.
  • FFA games produced an empty headline. Every slot is team 0, so participants is empty. Each competitor is now their own side, with the result read off won rather than winning_team.
  • A Spectre Gunship fired six times ate the whole timeline. superweapon_activated also carries repeatable generals powers. Launches now group by (player, weapon): "Modus fired SpectreGunship (x4)".
  • Map names rendered as stored paths (userdata/maps/…); now shown the way the rest of the app shows them.

3. Game Night recap — ?page=game-night&date=…

One shareable URL per evening, with a "Recap" link added to each night header on the Matches page. Clicking a highlight card opens the "Game by game" section and scrolls to the game it names.

The deterministic half — standings with within-night streaks, highlight cards, formats, maps, clock span — is recomputed per request and available for every night in the corpus.

Game Night

"Game by game" stamps each game with its start time — a game night is a date key, not a sitting, and two disjoint sessions on one evening are only visible from the clock:

Game by game

The LLM half, and why the read path is shaped this way

The written-up paragraph is generated once, by a new 11:00 scheduler job, for the most recent closed night — and never on the read path. A date is an unbounded cache key: a page that generated on a miss would bill a call for every night anybody scrolled back to, unlike the bracket blurbs whose keys (a matchup, a bracket stage) are enumerable and can safely read through. Nights without a row return ai_summary: null and the page omits the section, so this does not backfill the archive.

The 11:00 slot is load-bearing, not cosmetic. utils.game_night_date rolls over at 5am US Eastern, so 11:00 UTC is the first time last night is definitively closed in both EST and EDT. Running it alongside the 04:00 jobs (midnight Eastern) would either freeze a half-played evening as its permanent recap, or leave every recap a full day late. latest_closed_night enforces this independently, so the time is forgiving.

Other guards: at most one row per run, skipped if the night already has one, skipped below MIN_MATCHES_FOR_SUMMARY = 2 (one stray upload shouldn't buy a call), and generation is serialized across both callers by a lock in the generator so the nightly job and the ops endpoint can't both pay for one night.

The summary it actually produced

Generated against the local compose DB for 2026-08-20 — 3,536 input / 399 output tokens on Gemini, ~19s:

What a split-shift marathon Thursday turned into. We logged seven counted games across six wall-clock hours—starting at 8:23pm and pushing past 2:30am—split by a massive three-hour pause in the middle. Between official 1v1 tournament bracket matches and late-night team brawls, it was a crazy mix of lightning sweeps and heavy grinds.

OneThree111 was on a tear, putting up a night-best 5-2 record after sweeping Pancake 3-0 in their tournament Losers Round 2 bracket series. Those 1v1s were absolute speedruns on Canyon of the Dead and TD Nobugscars, with OneThree drawing quick first bloods and ending every match in under five minutes. Pancake got his revenge in casual play, though, pairing up with Modus on District 10 for the biggest upset of the night. On paper, the model gave them just a 30% shot against Tytan and OneThree, but Modus sniped OneThree's nuke launcher with an Infantry Tank Hunter to lock down the 18-minute win.

Once Syn and WildCard joined after midnight, the 3v3s on Aeon of Excalibur brought peak chaos. The first matchup was a 33-minute slog where Tytan launched four Neutron Missiles while WildCard and OneThree called in a ridiculous seven Spectre Gunships apiece before Tytan's side finally broke through. Pancake, Modus, and OneThree answered right back in the rematch, blitzing to a 7-minute victory behind Modus’s night-topping 63 APM.

Pancake definitely took some early bruises in the bracket, but breaking the projection model in 2v2s made the late push worth it. See everyone Thursday! 🚀

It reads the clock for the three-hour gap, separates bracket games from casual ones, attributes the win probability to the model rather than stating it as odds, keeps Neutron Missiles and Spectre Gunships in their own categories, and identifies games by map instead of by number.

An earlier run, before the prompt carried timestamps and tournament tags, described the entire night as casual quick 1v1s and missed the gap completely — three of those games were 2026_1v1_bracket - Losers Round 2. That miss is what the per-game context fixes.


Ratings stay off the page

The night prompt forbids stating or implying a rating level, rank, or leaderboard position — a win probability is fine, a level is not. tests/test_game_night.py::test_highlights_report_no_rating_level_only_probabilities asserts no highlight card leaks one.

And the probability that does ship is framed as what it is: the upset card reads "our model projected them 30% to win", and the prompt tells the model to attribute it the same way. It's a rating system fitted to this group's own games and it's wrong regularly — stating a flat "30% to win" reads as a harder claim than the data supports.

Before merge

  • The migration is already applied in production. b2c9e0a4d715 (one additive CREATE TABLE, no FKs, clean downgrade()) was run ahead of merge; prod is on b2c9e0a4d715 (head) with the table empty. The currently-deployed code is unaffected — nothing before this PR touches the table.
  • docs/screenshots/ is a separate commit — ~750 KB of PNGs that exist only so this description can render them. Drop that commit if they shouldn't live in the repo.
  • The narrative duplicates the existing first-blood banner on match details, and does it better (alias-resolved: "OneThree111" vs the banner's "131"). Left the banner alone rather than deleting existing UI — one-line removal if you want it gone.
  • MAX_EXCUSED in the endpoint smoke test went 25 → 26 for /api/narrative/{match_id}, same category as the already-excused /api/build_orders/{match_id} (reads the replay from S3).

Migrations now apply on deploy

Doing that one by hand exposed that there was no release: phase in the Procfile — every migration in this repo was a manual step somebody had to remember. Added the standard Heroku one:

release: alembic upgrade head
web: fastapi run radarvan/main.py

Three things had to be fixed for it to work at all, each of which would have failed the first deploy after adding it:

  • alembic was in the dev dependency group. Heroku's buildpack installs uv sync --no-dev, so it wasn't in the slug — the phase would have died with alembic: not found. Moved to [project] dependencies, where it belongs now that it's runtime rather than tooling.
  • alembic/env.py didn't normalize postgres://. Heroku's addon-managed DATABASE_URL uses the legacy scheme and SQLAlchemy 2.x has no dialect under that name. db_utils and bootstrap_db.py both normalize — but neither helps here, because alembic upgrade re-reads the raw env var in its own process.
  • env.py printed the full DSN, password included. Release-phase output is app log. Now redacted.

It runs bare alembic upgrade head rather than scripts/bootstrap_db.py on purpose: that script's empty-database branch (create_all + stamp head) is there for a fresh local volume, and on production it would silently manufacture a schema instead of failing loudly if DATABASE_URL ever pointed somewhere empty.

One caveat worth knowing: release phase applies the migration while the old dynos are still serving, so additive changes are safe but a drop or rename needs splitting across two deploys.

tests/test_deploy_config.py pins all of it — no application code imports these, so a plausible-looking cleanup would otherwise only surface on a push to prod.

Checks

64 new tests across test_durations.py, test_match_narrative.py, test_game_night.py, test_schedule_game_night.py — including that adding a spectator changes nothing about any of the three, and that the nightly job returns before opening a DB session when no provider is configured.

920 passing, 5 skipped. make all clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RQ4vgARjcMsiLg3Gk9bnLS

f4hy and others added 3 commits August 22, 2026 17:04
Three surfaces, all built from replay data that was already parsed and
cached — none of them adds a derivation or touches DETAILS_VERSION.

**Game length** (`?page=game-length`) — `durations.py` + `GET
/api/duration_distribution/`. Histogram over the competitive corpus, so a
two-minute disconnect isn't counted as a two-minute game. The top bar is an
open-ended overflow bin: durations are long-tailed, and a handful of hour-long
games would otherwise stretch the axis until everything real sat in the first
two bars.

**Match narrative** (`GET /api/narrative/{match_id}`) — a match retold as
ordered beats, assembled from facts already in MatchDetails. Deterministic: no
model call, identical on every request. A projection of the cached details the
same way get_build_orders is, so it shares that cache and computes nothing new.
Shown as "What happened" on match details and per-game inside the night recap.

**Game night recap** (`?page=game-night&date=…`) — one shareable URL per
evening. The deterministic half (standings, within-night streaks, highlight
cards, formats, maps, clock span) is recomputed per request and available for
every night in the corpus.

The LLM-written paragraph is the exception, and the read path is shaped around
keeping it cheap: it is generated **once**, by a new 11:00 scheduler job, for
the most recent *closed* night, and never on the read path. A date is an
unbounded cache key — a page that generated on a miss would bill a call for
every night anybody scrolled back to, unlike the bracket blurbs whose keys are
enumerable. Nights without a row return ai_summary: null and the page omits the
section, so this does not backfill the archive.

The 11:00 slot is load-bearing, not cosmetic: game_night_date rolls over at 5am
US Eastern, so it is the first time last night is definitively closed in both
EST and EDT. Running it with the 04:00 jobs would freeze a half-played evening
as its permanent recap.

Notes:
- queries/game_night.py holds the corpus selection and detail loading, so the
  scheduler and the route describe the same set of games rather than deriving
  it twice.
- renderHypeText/renderBoldSegments moved out of Bracket.tsx into src/aiText.tsx
  — third consumer of the same renderer.
- The night prompt forbids stating a rating level, rank, or leaderboard
  position; a win probability is fine. tests/test_game_night.py asserts no
  highlight leaks one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RQ4vgARjcMsiLg3Gk9bnLS
Images for the pull request description. GitHub renders them from raw
URLs on this branch, which is the only way to attach them from the CLI —
drop this commit before merge if they shouldn't live in the repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RQ4vgARjcMsiLg3Gk9bnLS
…t context

**A Spectre Gunship is not a superweapon.** `timeline_events` already says so
— `_SUPERWEAPON_ACTIVATION_KEYWORDS` is deliberately wider than
`BASE_SUPERWEAPON_LAUNCHES` so the Event Chart marks the big generals-panel
powers too, and the comment there tells callers counting *actual* launches to
filter on the narrow list. `superlatives.py` does. The new night recap and
match narrative didn't, so a gunship called in seven times was being reported
as seven superweapon launches.

Both are worth showing, so they're kept apart rather than merged:
`match_narrative.is_base_superweapon` is that filter, named, and drives a
`superweapon` beat (launched) and a `power` beat (called in). The night recap
gains a matching "Most generals powers" card. On 2026-08-20 this moves "most
superweapons" from 9 to 5 and stops the 9.9-minute gunship being reported as
the night's first superweapon.

**A game night is a date key, not a sitting.** `MatchNarrative` now carries
`started_at` and `tournament`, so the recap prompt stamps every game with its
wall-clock start (rendered in US Eastern — the timezone the rollover is defined
in, and the one the group actually plays in) and flags bracket games. Both show
in the UI too. The prompt tells the model to read the clock for a gap rather
than assume one continuous run, and to distinguish a bracket game played to win
from a casual game after work.

This was not hypothetical: 2026-08-20 was three tournament bracket games, a
three-hour gap, then casual team games. The previous summary described the
whole night as casual and missed the gap entirely.

**A win probability is a projection, not a fact.** The upset card now reads
"our model projected them 30% to win", and the prompt says to attribute it —
it's a rating model fitted to this group's own games, not observed odds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RQ4vgARjcMsiLg3Gk9bnLS
@f4hy
f4hy merged commit 6322808 into main Aug 22, 2026
3 checks passed
@f4hy
f4hy deleted the feat/game-night-recap branch August 22, 2026 22:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant