尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗The FPL League Platform is a League of Legends fantasy-league site for the Premier and Academy leagues. It combines league operations, live drafting, match reporting, stats, betting, and player-card features in one Next.js app.
The original live auction draft is still a core workflow: captains nominate players, teams bid in real time, and a database transaction settles each lot. The site now also includes:
Use the sections relevant to the task; setup and operations are documented below.
FPL and FPL Academy are paired experiences with the same information architecture. The active brand in the header identifies the current league; selecting the FPL/FPL Academy brand opens the league chooser, which owns league switching and preserves the current paired destination (including supported query strings). Premium HQ is the intentional exception: its Premier/Academy toggle keeps the selected card and fantasy destinations in the same league.
The header keeps My Team as the direct personal link and groups other
pages into four menus, defined in src/components/SiteNavigation.tsx:
Sign up is a prominent action on /info, not a fifth About destination. League
resources live at /info#league-resources; the guide at /economy includes
both the play-money ledger and the card glossary. Membership & support at
/membership includes Premium access, developer payment options, and current
patrons. Older League Links, Support the Devs, Patrons, and Glossary URLs keep
redirecting to their surviving sections.
Premium HQ remains the gated hub for Betting, The Daily Stu, Player Cards, Draft League, Match Drafter, and the card economy.
Admin appears beside the avatar and Broadcaster is listed with League links
for authorized staff. Their visibility is only a presentation hint: /admin
and /broadcaster retain their existing server-side access checks and
redirect or deny unauthorized users regardless of what the header displays.
/my-team and /academy/my-team dashboards for that team’s
schedule, roster, private tournament codes, draft viewing, and opponent
scouting. A player links their signed-in profile by claiming an exact public
roster spot, then waits for captain or admin approval./info#league-resources).Next.js 16.3 App Router, React 19, TypeScript, Tailwind CSS v4, Supabase (Auth, Postgres, RLS, PL/pgSQL RPCs, Realtime, and Edge Functions), Vercel, GitHub Actions, Vitest, Playwright, and pgTAP. Riot, Discord, Twitch, and Anthropic are external integrations used by specific workflows.
src/app/ — App Router pages, server actions, auth callback, API routes,
share-image routes, and the Discord interactions endpoint.src/components/ — page and interactive UI components.src/lib/ — domain queries, rules, formatting, access checks, Supabase
clients, and reusable server/client logic.src/app/my-team/ and src/app/academy/my-team/ — the paired, role-aware
My Team routes. The older Captain routes remain redirect-only compatibility
URLs.supabase/migrations/ — the complete append-only database history: tables,
views, RLS policies, grants, triggers, and RPCs.supabase/tests/ — pgTAP database contract and authorization tests.supabase/functions/ — Deno Edge Functions, currently the scheduled
Discord betting announcer/watchdog.scripts/ — local seeders, stats ingestion, scheduled-job entry points,
Discord registration, and data utilities..github/workflows/ — scheduled and manual ingestion, settlement, homepage/card jobs, and releases.e2e/ — Playwright specs and their self-seeding fixtures.The weekly card drop follows ingest → moment-card minting → card drop → weekly
draw. A playoff week archives a Send-off edition instead of a weekly one, and
prints nothing at all until that week's fixtures carry scores — re-run
npx tsx scripts/archive-card-edition.ts YYYY-MM-DD for the week once they
do. After each league's weekly edition is archived successfully, the drop
also refreshes Higher or Lower for that run's current America/New_York
calendar date from the two newest frozen editions. The refresh is additive: a
date that already has a pool can temporarily contain three weeks when a new
edition lands, while later dates select the newest two. Page load and starting
a run retain the same lazy refresh as recovery.
If a refresh fails, both league drops finish, then the job exits nonzero so
GitHub Actions exposes the incomplete refresh and the success-dependent weekly
draw does not continue. Operators can retry only this connection with
npm run refresh:higher-lower.
From Premier S6 and Academy A2, cards are rated by playstyle: each game is graded on its champion's job against the league's history of that style (design). S1–S5 and A1 keep the rating they were printed with. Before each new season's first card drop, rebuild that history from the seasons that just finished and commit the result:
npx tsx scripts/build-style-yardstick.ts S6 A2 # the new season codes
It reads raw_stats (read-only, SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY)
and rewrites src/lib/cards/styleYardsticks.json, keeping every other
season's entry. Skipping it grades the new season against the previous
yardstick rather than failing. Re-running it for a season whose cards are
already out changes that season's ratings, so do it before the first drop.
The quickest path is the repository helper. It checks Node and Docker,
stages and starts local Supabase, updates .env.local with the local API URL
and anon key, and starts Next.js:
npm run run-locally
For a manual setup:
npm install
npm run db:stage
npx supabase start --workdir supabase/.staged
cp .env.example .env.local
npx supabase status --workdir supabase/.staged
npm run dev
Local Supabase runs from a staged copy of the project in the gitignored
supabase/.staged/, not from supabase/ directly. Two immutable migrations
cannot run on an empty database as written, so npm run db:stage
(node scripts/supabase-migrations.mjs stage supabase/.staged --fresh) writes
config.toml, the edge functions, every migration with the reviewed
overrides applied, and the numbered pgTAP files.
A plain npx supabase start or db reset from the repository root fails on
the first of those two migrations. The staged config.toml keeps the same
project_id, so it drives the same containers and volume, and
npx supabase status and stop from the root still reach them. After pulling
new migrations, restage and rebuild the local database:
npm run db:stage
npx supabase db reset --workdir supabase/.staged
Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY in
.env.local from supabase status. The local defaults are
http://127.0.0.1:54321 and the local anon key. .env.example also lists
server-only integration variables; never expose or commit
SUPABASE_SERVICE_ROLE_KEY, Riot keys, bot tokens, or other secrets.
Open http://localhost:3000. On /login, local URLs show a dev
email/password sign-in form below the Discord button. Use it with the seeded
users or another local Supabase Auth user without a real Discord account.
To build a full local demonstration draft and betting fixture:
npm run seed:demo
The script is idempotent and creates the demo users described in
scripts/seed-demo.ts.
Making yourself an admin locally: sign up/in once so a profiles row
exists, then in the local Supabase Studio SQL editor (or via psql) run:
update profiles set is_admin = true where id = '<your auth user uuid>';
npm run lint # ESLint
npm run typecheck # generate Next.js route types, then check TypeScript
npm test # Vitest unit/component suite
npm run test:python # Python mapper and settlement suites
npm run build # production Next.js build
npm run db:stage # stage reviewed fresh-database migrations and pgTAP files
npm run test:db # fresh local migration replay and pgTAP suite
npm run e2e # fresh local stack, production build, and browser journeys
npm run test:infra # pgTAP plus production build and all browser journeys
Python tests require Python 3 with requests and python-dotenv installed
in an active virtual environment. npm run test:db replays migrations and
runs pgTAP against its disposable local stack; npm run test:infra also builds
the production app and runs all browser journeys. npm run db:stage is
available for manual work with supabase/.staged. See
docs/testing.md for test
discovery, fixtures, cleanup conventions, and focused commands.
Choose checks using Testing. Documentation-only edits need link, command, and diff review; they do not need application tests or a production build. Browser tests use self-seeding local fixtures and the isolated runner; see the Playwright setup.
develop is where work lands. Every pull request targets it. Vercel
never builds it (see below), so merging there costs nothing.main is what Vercel deploys. Nobody merges to it by hand..github/workflows/release.yml: every Monday at 13:00
UTC, and on demand from the Actions tab (Release to production → Run
workflow), it merges develop into main with a merge commit and
Vercel builds once. It refuses when CI on develop is not green, and
does nothing when develop has nothing main lacks.develop, then run the release workflow.CI also checks migration history against the PR base commit (using the tested
merge result) or the previous branch tip for pushes. It rejects new duplicate SQL
migration versions, malformed filenames, edits/deletions/renames of existing
migrations, and additions whose versions do not sort after the base's highest
version. Duplicates already present in the base are reported as warnings;
this guard does not rewrite existing migration history. A migration that is
already on origin/main byte for byte counts as history too, so a hotfix that
went straight to main can be merged down to develop without re-versioning
it (MIGRATION_RELEASED names the release ref; empty turns that off). Run it
locally with node scripts/check-migrations.mjs <base-commit> [head-commit].
This check uses Git history, not the production database: it cannot detect
remote-only migrations or prove that production RPCs exist. Database migration
and verification must still precede deployment of dependent application code.
For this repository's linked database, use the migration wrapper:
node scripts/supabase-migrations.mjs list
node scripts/supabase-migrations.mjs push --dry-run
node scripts/supabase-migrations.mjs push
The immutable history contains two files at each of the versions
20260915000001, 20261005000001 and 20261009000001. The wrapper stages
each pair's SQL in filename order as one migration for the CLI, without
editing either source file, and rejects any other duplicate version.
A raw supabase db push still sees the duplicate and can incorrectly offer
to replay God Packs. Do not use --include-all to get past that warning.
The wrapper does not repair history or mark missing SQL applied.
The wrapper stages reviewed overrides from supabase/migration-overrides/
under the original version and name. Each is pinned in
scripts/supabase-migrations.mjs to the git blob ids of the original and of
the override, so a changed file fails staging (and npm test) instead of
being replaced silently:
20260922052204_rebuild_season_end_draft_after_hash_fix.sql is a data
repair restored after it was applied to the linked database. It sorts
before the migration that creates season_end_releases, so only
stage --fresh (local stacks and CI) replaces it with an empty stand-down.
A fresh database has no draft to repair. list and push stage it
unchanged.The card-art migration's language sql functions now have replayable
select case bodies in tracked history. The migration-history checker pins
the previously reviewed applied-history repair; staging needs no replacement.
.github/workflows/ci.yml runs type-check, ESLint, Vitest, Python tests, and
the complete npm run test:infra gate on every pull request and every push to
develop or main. The infrastructure command covers fresh migration replay,
pgTAP, a production build, and all browser journeys inside the existing
checks job. The release workflow waits on that job. The shared npm run typecheck command generates
Next.js route types before checking TypeScript, so it also works on a fresh
checkout. Production builds retain Next.js type checking; next.config.ts
does not enable ignoreBuildErrors.
Vercel builds are billed per CPU-minute rounded up, and most builds here
were building nothing anyone looked at, so vercel.json points the Ignored
Build Step at scripts/vercel-ignore.sh:
develop included, does
not build. Push a branch named preview/<anything> when you want a
preview URL.next build.This section covers initial provisioning. For an existing FPL deployment, use Branches and releases and the migration release contracts.
This app needs its own Supabase cloud project, a Discord OAuth app, and a Vercel deployment. Each step below needs a human logged into that service's dashboard in a browser.
Do not touch the existing "ocepp" Supabase project. This app must get a brand-new, separate Supabase project — the free tier allows up to 2 active projects, so create a second one rather than linking or pushing migrations into "ocepp". Before running a migration push (or any destructive command), re-check
npx supabase projects list/ the project ref you passed tosupabase linkand confirm it is the new project, not "ocepp".
At supabase.com, create a new project
(free tier). Note its project ref (the short id in its dashboard URL
and in https://<ref>.supabase.co).
From the repo root:
npx supabase login
npx supabase link --project-ref <new-project-ref>
Double-check the ref you just linked is the new project's, not "ocepp", before continuing.
node scripts/supabase-migrations.mjs list
node scripts/supabase-migrations.mjs push --dry-run
node scripts/supabase-migrations.mjs push
Review the dry run before the final push. The wrapper applies pending SQL while handling the legacy duplicate versions described above.
Verify in the cloud project's SQL editor:
select count(*) from pg_proc where proname in ('nominate','place_bid','close_lot');
Expect 3.
https://<new-project-ref>.supabase.co/auth/v1/callbackhttp://localhost:3000 under Additional
Redirect URLs (so local dev keeps working against the cloud
project if you ever point .env.local at it).https://<site-origin>/api/discord/interactions. Copy the application's
public key into DISCORD_PUBLIC_KEY and register the commands with
npm run register:discord-commands using the bot/app environment values.
That script is the only thing that tells Discord which commands exist, so
re-run it after any change to src/lib/betting/discord/commandDefs.ts —
including the card commands /rip (open the free daily pack) and /flex
(post a card out of your collection, pictured as the copy you own — the
player and copy options autocomplete from what you hold).SundayKoi/FPL_website
GitHub repo (framework is auto-detected as Next.js)..env.example. At minimum,
production needs:
NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEYNEXT_PUBLIC_SITE_URLSUPABASE_URLSUPABASE_SERVICE_ROLE_KEY (server-only; required by trusted server
actions such as public-lobby creation and betting)DISCORD_PUBLIC_KEY (for /api/discord/interactions)DISCORD_BOT_TOKEN, DISCORD_GUILD_ID, and the configured betting role
IDs when Discord-gated betting is enabledDISCORD_GUILD_ID and DISCORD_REQUIRED_ROLE_ID for the shared FPL
Premium gate (the older DRAFTER_GUILD_ID/DRAFTER_ROLE_ID names remain
supported as a fallback)TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET if the live-channel status
feature is usedCRON_SECRET (any random string) so the expeditions sweep can be
called; without it the sweep refuses to run and a run only resolves when
someone opens it. The five-minute calls come from the database's
expedition-sweep pg_cron job, which needs two Vault secrets in
Supabase: expedition_sweep_url (https://<site>/api/expeditions/sweep)
and cron_secret (the same value as CRON_SECRET). The daily cron in
vercel.json is a backstop; Vercel's Hobby plan allows no more than one
run a dayGITHUB_ACTIONS_TOKEN (server-only) for the offseason admin's Ingest now
button: a fine-grained token for this repository with Actions read and
write. Without it the button links to the workflow insteadVisit the deployed site and sign in with a real Discord account.
In the Supabase dashboard (Table editor or SQL editor), set that account's profile to admin:
update profiles set is_admin = true where id = '<uuid>';
As that admin, create a small 2-team draft, link two real Discord accounts as captains, and run one auction from two different devices (e.g. phone + laptop).
Verify the countdown stays in sync between devices and the lot settles (player moves to the winning roster) on both without a refresh.
update profiles set is_admin = true where id = '<uuid>'
in the Supabase SQL editor (find the uuid in the profiles/auth.users
table by their Discord email or username).name,role[,rank[,opgg_url]] (role is one of the game's five roles;
rank and the op.gg URL are optional) in the admin draft setup page.Staff open Manage on /schedule to browse and edit fixtures; owners also
find the season, reward, and schedule tools there. The drawer starts closed and
shows one selected tool at a time. Fixture edits open from the matching schedule
row, and the fixture list defaults to the selected phase/week with an explicit
All season filter.
Owners can use two schedule generators from Manage → Fixtures:
Round 2 can be seeded on the night: the stats ingest does not run between the two rounds, so where a round-1 fixture has no score yet the panel reads the captains' match report instead and tells you which of the two each result came from. A report whose sides do not match the fixture's is ignored rather than guessed at.
Both replace what they draw — generating twice does not double the season — and each is scoped to its own stages, so the gauntlet draw never touches the regular season or the playoffs.
A bracket that has already been decided goes in as a file rather than by
hand: scripts/data/brackets/*.json holds the matchups, kickoffs and
series lengths (the Premier and Academy files are named for their league),
and the Seed bracket workflow puts them on the schedule — Actions →
"Seed bracket" → Run workflow.
The workflow defaults to premier-2026-playoffs.json. Select the Academy
file explicitly when seeding Academy. Premier initialization also requires
the Premier playoff migrations on the target database and a reviewed bracket
file with publishing_approved: true; the checked-in file starts with this
flag false while its kickoff dates are provisional. The Premier write uses
an atomic RPC, and later rounds stay TBD until staff publish advancement.
Dry run is ticked by default. A dry run reads the database, checks every team name against the selected draft and canonical team list, prints the exact fixtures it would insert, update or leave alone, and writes nothing — a misspelt team fails the run with the bad names listed instead of landing a fixture whose send-off prints nobody. Read that log, then run it again with the box unticked.
Writing is repeatable: rows are keyed by season, stage and sort order,
so a second run rewrites the rows it wrote rather than adding a second
copy. Academy leaves a scored fixture alone; Premier refuses to change a
fixture with scores or dependent work. Nothing is ever deleted. Update the
file and rerun the dry run before writing. Same thing locally, with SUPABASE_URL and
SUPABASE_SERVICE_ROLE_KEY set:
npx tsx scripts/seed-bracket.ts scripts/data/brackets/academy-2026-playoffs.json --dry-run
Every pick and ban the league has recorded can be pulled out as a
spreadsheet or JSON with a read-only script. It reads both places a draft
can live — the site's own drafter, and the drafter.lol links captains attach
to match reports — through the same loader the scouting page uses, so the
export matches what /my-team/scouting shows for both leagues. It uses the
website's public keys, so anyone with the repo can run it:
export NEXT_PUBLIC_SUPABASE_URL=https://<project>.supabase.co
export NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon key>
npm run export:drafts -- --out=drafts.csv # both leagues
npm run export:drafts -- --league=academy --format=json --out=academy.json
npm run export:drafts -- --season=S5 --team="Neon Dynasty" --stage=week_3
npm run export:drafts -- --complete-only
npm run export:drafts -- --open # include the public /drafter lobbies
No local setup at all: the Export drafts workflow under the repo's
Actions tab runs the same script on GitHub (pick league, season, stage,
team, format) and attaches the file to the run as an artifact named
drafts.
CSV is one row per draft step (twenty per game): league, season, stage,
fixture, game number, where the draft came from (site or drafter.lol),
side, team, pick or ban, slot, overall pick/ban order, whether it was the
game's first pick, the champion, the player who drafted it, and the role
once captains confirmed positions. JSON is one object per game with each
side's bans and picks in order. The column list is at the top of
scripts/export-drafts.ts.
Game-night League of Legends stats are pulled from the Riot API and
written directly to public.raw_stats in Supabase by
scripts/riot_stats_ingest.py. The old Google Sheets flow (gspread +
credentials.json) is retired — Supabase is now the source of truth for
stats, and the /stats page reads from it.
Run it after a game night, once you know the season/phase to tag the games with:
# Explicit match ids
python scripts/riot_stats_ingest.py NA1_5558429844 NA1_5558431122 --season S5 --phase Regular
# Or discover match ids from the players in PLAYER_RIOT_IDS (scripts/riot_stats_ingest.py)
# across a date window, filtered to the custom-game queue:
python scripts/riot_stats_ingest.py --dates 2026-08-11 --season S5 --phase Regular
# Preview the mapped rows without writing anything to Supabase:
python scripts/riot_stats_ingest.py NA1_5558429844 --dry-run
Setup:
pip install requests python-dotenv.env.example to .env and fill in RIOT_API_KEY, SUPABASE_URL,
and SUPABASE_SERVICE_ROLE_KEY (the ingester writes with the service
role key, bypassing RLS, so guard .env like any other secret).--season/--phase are required for a real write (they fill the
season/season_phase columns); omit them only with --dry-run.Writes are idempotent: rows POST to raw_stats with
Prefer: resolution=ignore-duplicates on the (match_id, summoner_name)
unique index, so re-running the ingester for a night you've already
imported is a safe no-op for existing rows.
team_name)Riot match data has no concept of an FPL fantasy team — it only knows Riot
IDs and in-game side (Blue/Red) — so the ingester cannot derive
team_name on its own. Left unfilled, team_name lands NULL for those
rows. Three views/tabs treat that gracefully rather than breaking: the
Teams tab's stats_team_agg excludes null/blank-team_name rows from
standings entirely (a player's individual/champion stats are unaffected),
and the Timeline tab's stats_game_log displays 'Unknown' in place of a
blank team name (see migration 20260810100005_null_team_guard.sql). But
a player's games still won't show up under their real team on the Teams
tab until team_name is filled in, one of two ways:
Option A — --team-map at ingest time (preferred for ongoing use).
Pass a JSON file mapping each player's "SummonerName#TAG" Riot ID to
their FPL team name; the ingester fills team_name from it for every
matched participant, leaving unmatched players blank (same as passing
nothing):
{
"AfkBoulder#c9win": "Blue Squad",
"DeFaux#ttm": "Red Squad"
}
python scripts/riot_stats_ingest.py NA1_5558429844 --season S5 --phase Regular --team-map team_map.json
If any rows still end up with a null team_name after a real (non
--dry-run) write — a player missing from the map, or --team-map never
passed — the ingester prints a loud [WARN] block at the end listing
exactly which players, so it isn't a silent gap.
Option B — backfill after the fact. Update the already-written rows
directly in Supabase once you know each player's team for that season,
e.g. via the SQL editor or psql:
update raw_stats
set team_name = 'Blue Squad'
where summoner_name in ('AfkBoulder', 'DeFaux')
and season = 'S5';
Run one update per team (adjust the summoner_name list and
team_name value), then re-check the Teams tab for that season. This is
also the right approach for backfilling seasons ingested before
--team-map existed.
--from-reports)Captains file finished series on /captain (or /matches), which queues
rows in match_reports/match_report_games. A GitHub Actions workflow —
.github/workflows/ingest-stats.yml
— runs python scripts/riot_stats_ingest.py --from-reports every Tuesday at
07:23 UTC (cron: "23 7 * * 2"; GitHub cron is UTC-only) and is also runnable
on demand. After ingest completes, a separate downstream workflow runs
python scripts/settle-betting-from-stats.py; another advances Academy
playoff winners into the next scheduled fixture. It uses winner_to
destinations from scripts/data/brackets/academy-2026-playoffs.json and fills
a slot only when the latest linked report is ingested without a score warning
and the fixture score clinches its best-of series. It runs after partial
ingests too, so a failed report cannot block a different completed series
from advancing. The updater is idempotent and can also backfill a completed
week.
To review or backfill advancement, open GitHub Actions → "Advance Academy
playoffs". Manual runs default to dry-run; untick it to write the listed empty
slots. Locally, with SUPABASE_URL=https://tyywoneobreracfnujdk.supabase.co
and SUPABASE_SERVICE_ROLE_KEY set, preview first and use --apply to write:
npx tsx scripts/advance-academy-playoffs.ts --dry-run
npx tsx scripts/advance-academy-playoffs.ts --apply
This is now the normal path for game-night stats; the manual
--dates/explicit-match-id invocations above stay available for one-off
backfills.
Manual trigger: GitHub → Actions tab → "Ingest match reports" → Run
workflow. Useful for testing after a report is filed, or to retry after
fixing a failed report on /captain.
Environment secrets (Settings → Environments → Production):
RIOT_API_KEY — a personal Riot key (doesn't expire daily like a
development key).SUPABASE_SERVICE_ROLE_KEYThe workflow and local script environment must supply SUPABASE_URL for
the FPL cloud project. Check the workflow definition for whether it supplies
that value directly or through the Production environment secret.
What it does, per report with status in pending/needs_sides:
for each game, skip it (and mark it ingested) if its match id is
already in raw_stats; otherwise fetch the match from Riot, resolve
which side (Blue/Red) is which FPL team, write the stats rows with
team_name set from the resolved side and season/season_phase from
the report itself (not --season/--phase — each report carries its
own, since an ingest batch can span multiple), and mark the game
ingested. Once every game in a report is ingested, it tallies game
wins per team and compares them to the reported score, setting
warning_text (shown on /captain) on a mismatch — this catches a
mistyped match id without blocking the ingest.
needs_sides: side resolution works two ways — a game's blue_team_id
being set explicitly (from the reporting form) wins immediately; otherwise
the ingester looks up each of the ten players' Riot ID in
roster_memberships for that season and tallies which of the report's two
teams show up on which side. If that tally comes back ambiguous (no
roster matches, or conflicting ones), the game is marked needs_side and
the report needs_sides. Fix it on /captain: a needs_sides report
shows an inline "which team was blue?" picker per unresolved game for
captains and admins to set; picking a side flips the report back to
pending for the next scheduled run (or a manual trigger) to pick up.
A report that ends failed (a Riot fetch error, an unknown match id, or
a Supabase write failure) needs an admin to fix the underlying issue and
retry it from /captain — failed reports are not re-attempted
automatically (only pending/needs_sides are re-fetched), and the
workflow run exits non-zero (so GitHub emails the repo owner) whenever
any report ends failed.
scripts/settle-betting-from-stats.py scans every linked, unsettled Premier
and Academy team-winner market, including older fixtures. It follows
betting_markets.fixture_id → match_reports → match_report_games.match_id → raw_stats, counts each unique match once, and pays only after raw stats prove
the floor(best_of / 2) + 1 threshold. Captain-entered scores, fixture
scores, odds, and report status are never winning evidence. Props and manual
unlinked markets remain manual.
Preview a recovery or inspect an unresolved fixture without writing anything:
python scripts/settle-betting-from-stats.py --dry-run
python scripts/settle-betting-from-stats.py --season S5 --dry-run
python scripts/settle-betting-from-stats.py --fixture-id <fixture-uuid> --dry-run
The real run uses SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY, writes all
wallet/ledger changes through the transactional settlement RPC, and then calls
the existing resolve_pickem only for cards whose every leg is resolved or
cancelled. It is safe to retry: resolved and cancelled markets are preserved,
the settlement evidence is frozen, and a repeat does not add ledger rows or
change balances. Later conflicting stats are recorded for review and never
reverse an already-paid market. Incomplete games, ambiguous mappings,
conflicting reports, and forfeits without enough verified played wins are
reported as pending/conflicts and do not pay.
Riot API key regeneration warning. The predecessor script (
updated_stats.py, since deleted) had a live Riot API key hardcoded in its source. That file was never committed to git, but it existed as a local working file whose contents (including the key) were shared outside of git. Treat that key as compromised regardless — regenerate a new key at the Riot Developer Portal and put it only in.env(gitignored), never in a script.
Every card copy in circulation is one raffle ticket. A GitHub Actions
workflow — .github/workflows/weekly-draw.yml
— runs npx tsx scripts/weekly-draw.ts after a successful automated
weekly card drop via workflow_run. There is no independent draw cron or fixed
delay. A manually dispatched upstream card drop does not cascade into a draw;
dispatch the draw itself when that operation is intended. For each card season
it calls the run_weekly_draw RPC, which picks one copy uniformly at
random, stamps it with a winner's laurel, pays the pot into the owner's
betting balance, and comps them a standard pack — then posts the winner to
the cards Discord. The week's winner shows on /cards (/academy/cards),
and every past week on /cards/draw (/academy/cards/draw).
Manual trigger: GitHub → Actions tab → "Weekly card draw" → Run
workflow. Leave week blank to draw the last completed week; set it to a
Monday (YYYY-MM-DD) to draw a missed week. A non-Monday is
rejected rather than opening an off-grid week.
Environment secrets (Settings → Environments → Production, the same
place the card drop reads them):
SUPABASE_SERVICE_ROLE_KEYDISCORD_CARDS_WEBHOOK_URL — only the announcement needs it; without it
the draw still records, pays, and comps, it just says nothing.The workflow must receive SUPABASE_URL for the FPL cloud project, either
directly in its definition or through the Production environment secret.
Admin fallback: /schedule → Manage → Rewards → Weekly draw
has a Run the draw button when the automated chain does not complete. It draws the last completed week (the date isn't
typeable there) and posts nothing to Discord. The RPC is idempotent, so a
workflow run and a button press — in either order, or overlapping — still
leave exactly one winner per week; the later one just reports who already
won.
Every gated page (Betting, the cards pages, the daily games, the Match
Drafter, the skin-line preview) renders one shared wall,
src/components/access/AccessWall.tsx. A signed-out visitor gets "Sign in
with Discord" back to the page they wanted; a signed-in visitor without the
premium role gets "Join the Discord", "What FPL Premium is" (/membership,
the page that explains the $10 role, monthly patronage, and developer support),
and on a cards page the public Browse door. The invite comes from
NEXT_PUBLIC_DISCORD_INVITE_URL; until it is set the button falls back to
/info#league-resources. Guess the Card is still in admin testing and is listed
only for staff (the header, Premium HQ and the site directory all hide it
from members); every other refusal renders a wall rather than redirecting.
Orientation lives across /info (the league and its resources),
/membership (Premium, patronage, and support), and /economy (every way
betting dollars come in and go out — src/lib/economy/ledger.ts, every
figure imported from the config that enforces it — plus glossary terms from
src/lib/site/glossary.ts); the home
page opens on HomeOrientation (a real heading, this week's game, three
doors — the third follows who is looking, resolved read-only by
src/lib/home/viewer.ts); and Premium HQ shows PremiumStartHere, a
five-step checklist with live ticks, until all five are done. Search marks
gated pages with a "Premium" pill (gated on SiteDestination).
The header is four menus — League, Cards, Play, About — plus My Team at
the top level and, for staff, Admin beside the avatar; it goes horizontal
at md, not sm. Every money-moving surface (Fantasy, the Weekly Draw,
Listings, Bounties, Trades, auto-dust) opens with a RulesPanel ("How
this works", first section open); deep pages (a card, a public binder,
scouting, and the Rulebook) keep links back to their parent or About section.
Send three owned cards out on a route and answer the forks as the squad
reaches them. /cards/expeditions (/academy/cards/expeditions) offers seven
runs — one of them, the Gilded Road, for patrons only — and the page prints
every rule before the click ("The rules of the road"):
| Run | Away | Forks | Entry | Worst case |
|---|---|---|---|---|
| Scouting Run | 8h | 1 | anyone | nothing |
| The Gilded Road | 48h | 2 | patrons only, 6 shine, three autographs | wounded |
| Deep Raid | 24h | 2 | 12 shine, a foil | wounded |
| Legend Hunt | 48h | 3 | 20 shine, two foils, an autograph | lost |
| Rescue | 12h | 1 | 8 shine, and a lost card to go after | lost |
| Exorcism | 8h | 0 | a $400 fee, and a Haunted or Cursed card in the squad | nothing |
| Legendary route | 72h | 4 | 24 shine, two foils, an autograph, three map fragments | dead |
Shine is what a squad's card tiers, parallels and autographs add up to. A
run with N forks pauses N times at evenly spaced checkpoints; each fork
waits until the next checkpoint for an answer, then the run moves on.
Silence camps — the safe option — and the cron sweep
(/api/expeditions/sweep, every five minutes) pings the owner in the
cards channel as each fork opens. Pushing adds to a loot multiplier (capped
at 2.5x) and rolls one harm on one card; a signed card can call in a
favour (push with no risk, once a run), a foil can light a dark fork (half
the risk), and three cards from one roster can rally (double loot, half
again the risk). Some forks are warned: push anyway and have it go wrong
and the card comes home Cursed. Each day also carries a brief that pays 20%
more when the squad fields the named role.
Every run draws its route as a map with the squad moving along it, and keeps a trail journal that fills in as the hours pass. Some legs carry an encounter: a merchant on the trail adds a flat $75 to the payout, a storm holds the squad up two hours (the clock on the page moves with it), and on a route that can lose cards the squad may find someone else's lost card and bring it home — the owner gets it back wounded, the finder banks a $150 bounty. At each fork one of the squad has a word to say about the choice.
The league's own calendar reaches the routes too. On a day a team plays,
any run carrying one of its cards brings home 20% more (the match-day
surge, scored against the launch day like the brief; the board says who is
on tonight). A squad drawn from one roster that reaches the Legendary
route's second fork hears its real next opponent named as what is singing
under the floor. A moment card carried on a run has a 7.5% chance to echo:
the route drops a copy of a card from the game the moment happened in,
minted off that week's edition. And /cards/expeditions/ledger is the
public ledger of the fallen and the found — every card lost, missing,
rescued, ransomed, carried home or buried, league-wide, with the owner and
the route.
Convoys: start one at launch and you get a six-character code; a partner joins the same route with it before your first fork opens, and their run is written on your clock so every fork opens and closes for both at once. You each answer your own forks, and a fork pushes only if you both push (a camp or silence on either side camps it). Each squad rolls its own loot and harm. Every answer goes to the cards channel with a mention for whoever still has to decide. A convoy nobody joins is just a run.
What a card can come home as: wounded (benched from expeditions for
three days), lost (a hold row keeps it locked in the
collection for seven days, during which a Rescue or a ransom — $300 plus
$40 per point of shine — brings it home wounded; then it is gone),
dead (the Legendary route only, only after two pushes, no rescue — the
card goes to the graveyard on the page), or mutated: Irradiated,
Hardened, Haunted, Cursed or Voidtouched, one per copy, permanent (an
Exorcism removes the two bad ones), drawn on the card everywhere it shows
and read by Fantasy scoring and dust pricing
(src/lib/cards/mutations.ts, MUTATION_EFFECTS). Insurance ($150 at
launch; one policy an Eastern week, two for a patron with the first free)
turns lost into wounded and dead into lost. An Eclipse, a moment, a champions relic or a team plate
never boards a route that can lose it. A one-roster Legend Hunt squad
ignored at two forks is lost as one. Map fragments drop off Legend Hunts
(35% on solid, every jackpot) and Deep Raid jackpots (25%).
Everyone gets one launch a day; patrons get two. Deployed and lost copies
are locked — the dust and trade screens refuse them, and
card_inventory_expedition_guard refuses the write anyway — and a fresh
Cursed card cannot change hands for seven days
(card_inventory_curse_guard). Every tunable is in
src/lib/expeditions/config.ts (gates,
durations, payouts, insurance, ransom) and
src/lib/expeditions/routes.ts (every
fork's story, odds and rewards); expectedDailyDollars fails a test if a
run's day starts earning more than a maxed daily streak. A Legend Hunt
jackpot, a Voidtouched homecoming and a death post to the cards Discord
webhook (DISCORD_CARDS_WEBHOOK_URL); without it the claim still pays.