Game night recap, deterministic match narrative, and game length histogram - #133
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 ofMatchDetails/MatchInfothe same wayget_build_ordersis.1. Game Length —
?page=game-lengthdurations.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.
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.
The narrative also carries
started_atandtournament, 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_eventsalready drew this line —_SUPERWEAPON_ACTIVATION_KEYWORDSis deliberately wider thanBASE_SUPERWEAPON_LAUNCHESso 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.pydoes; these modules didn't, so a Spectre Gunship called in seven times was reported as seven superweapon launches.is_base_superweaponis that filter, named: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:
participantsis empty. Each competitor is now their own side, with the result read offwonrather thanwinning_team.superweapon_activatedalso carries repeatable generals powers. Launches now group by (player, weapon): "Modus fired SpectreGunship (x4)".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 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:
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: nulland the page omits the section, so this does not backfill the archive.The 11:00 slot is load-bearing, not cosmetic.
utils.game_night_daterolls 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_nightenforces 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:
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_probabilitiesasserts 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
b2c9e0a4d715(one additiveCREATE TABLE, no FKs, cleandowngrade()) was run ahead of merge; prod is onb2c9e0a4d715 (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.MAX_EXCUSEDin 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 theProcfile— every migration in this repo was a manual step somebody had to remember. Added the standard Heroku one:Three things had to be fixed for it to work at all, each of which would have failed the first deploy after adding it:
alembicwas in the dev dependency group. Heroku's buildpack installsuv sync --no-dev, so it wasn't in the slug — the phase would have died withalembic: not found. Moved to[project] dependencies, where it belongs now that it's runtime rather than tooling.alembic/env.pydidn't normalizepostgres://. Heroku's addon-managedDATABASE_URLuses the legacy scheme and SQLAlchemy 2.x has no dialect under that name.db_utilsandbootstrap_db.pyboth normalize — but neither helps here, becausealembic upgradere-reads the raw env var in its own process.env.pyprinted the full DSN, password included. Release-phase output is app log. Now redacted.It runs bare
alembic upgrade headrather thanscripts/bootstrap_db.pyon 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 ifDATABASE_URLever 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.pypins 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 allclean.🤖 Generated with Claude Code
https://claude.ai/code/session_01RQ4vgARjcMsiLg3Gk9bnLS