GA CARD REGISTRY LIVE
LOG IN WITH DISCORD
···
Build History

Changelog

GA Card Registry & Dropper — Changelog

Version history for the GA Card Registry and its drop system, newest first. Each entry below shows a short summary of what changed — click any entry to expand the full technical detail (useful for other developers or for tracing exactly what changed and why). Versioning: v0.x covers pre-launch foundation work; v1.0 is when cards first started actually dropping; major version bumps (v2.0, v3.0) mark genuinely new core mechanics going live, minor bumps cover everything else. Cross-references GA_mission_taxonomy_reference.md where relevant -- that document is the design/economy reference, this one is the build history.


v6.17 The Site Has a Face Now
The registry finally has its own icon in your browser tab — the hexagon and star from the back of every card. Also works as a home-screen icon if you save the site to a phone.

Added

  • Favicon set drawn from the card back's emblem: favicon.ico (16/32/48), standalone 16 and 32 PNGs, a 180px Apple touch icon, and 192/512 icons with a web manifest for home-screen installs.
  • /favicon.ico is served at the root as well as via <link> tags, for the things that ask for it regardless — Discord link embeds, feed readers, crawlers. It used to 404.

How it's built: the emblem is redrawn by make_favicon.py rather than cropped out of card_back.png, for two reasons. The card is a 1000×1400 portrait, so any square crop throws most of it away; and the mint star sits at almost the same luminance as the pale hexagon containing it (195 vs 234), so at 16px the star dissolves and the icon becomes a featureless blob. The star is therefore inverted to the card's navy, which holds its shape all the way down. Colours are sampled from the artwork itself, so it stays exactly on-palette.

Small sizes push the hexagon nearly edge to edge, since a tab gives you 16 pixels and none should be wasted on margin. The large ones pull it back in — iOS masks home-screen icons with rounded corners, which would otherwise clip the hexagon's left and right vertices.

Icon links carry a ?v= marker: browsers cache favicons well past an ordinary hard reload, so changing the artwork later means bumping it.

v6.16 MAJOR Counter-Offers, Wildcards and Bounties
Haggling arrives. Send back a tweaked version of any offer instead of just declining it. Ask for "any Epic Foil" rather than one exact card. Or post a bounty — "I want this card, take any one of these of mine" — and let them choose.

Added

  • Counter-offers. Any open offer you didn't post can be countered: it opens the builder aimed back at the proposer, and sending it marks the original countered and puts your version in front of them. They can accept, decline, or counter you back — chains are unlimited, and each trade links to the one it answers.
  • Wildcard seeks. Leave the wanted card open and describe it instead: by rarity, by finish (foil or not), by category, by "only cards I don't own", or any combination. Shown as a dashed ✦ any epic foil slot so nobody mistakes it for something concrete.
  • Bounties. Put several of your prints up as a shortlist rather than a bundle — "take any ONE of these" — and let the responder pick. Marked ANY ONE OF THESE on the offer.
  • An offer with an open slot cannot be accepted directly; it can only be answered with a concrete counter. Enforced server-side as well as in the UI, so a crafted request can't skip the choosing step.

How it's built: all three are one mechanism. A counter is an ordinary, fully-specified offer that happens to carry counters_trade_id, which means accepting a counter runs exactly the same code as accepting anything else — there is no second execution path to keep correct. Wildcards and bounties are simply offers with an unresolved slot, and answering one produces a normal counter.

tcg_trade_items was rebuilt (ids preserved) so card_id can be NULL for a wildcard, with filter columns and a choice_group marking rows as alternatives rather than a bundle.

Fixed along the way: the shared trade macros are imported into eight templates, and Jinja does not give imported macros the template context unless asked — so the new slot-describing helper was undefined everywhere. All imports now use with context.

v6.15 Campaign Posts That Don't Get Lost
The unlock countdown's messages were being swallowed by the surrounding drop and achievement notifications. They're now framed by their own separator messages so each one reads as its own block — and the opening announcement has been rewritten to land properly.

Changed

  • Every hall-unlock post — the milestones and the opening itself — is now sent as three messages: a separator, the body, then a closing separator. Discord visually groups consecutive posts from one author, so a rule baked into the same message would sit flush against whatever came before it; only a real message boundary breaks the run. The first ominous post went out mid drop-flurry and was missed entirely, which is what prompted this.
  • Milestones get a light frame, the opening a heavier one, so the final post is visibly different from the countdown that preceded it.
  • The closing separator is only sent if the body actually posted, so a failed send can't leave a lone bar hanging in the channel.
  • The opening announcement was rewritten: longer, more dramatic, and corrected — it previously said the Forge takes "five spare prints", repeating the duplicates myth fixed in v6.14. It now also calls out the 150 undiscovered cards whose originals are still unclaimed, and tells unlinked players they're standing outside.
v6.14 The Forge Doesn't Need Duplicates (and Never Did)
The Forge's wording implied you needed spare duplicates to use it. You don't — anything that isn't an ORIGINAL can go in, which means most players could already forge and didn't know. The warning about using your last copy was also overstated: that card can drop to you again.

Fixed (wording only — no mechanics changed)

  • The Forge described its inputs as "duplicates" and "spare prints" throughout. Measured against live data, 51 of 87 card-holders could already run at least one contract (555 in total), while only two players generate duplicates at all — so the copy was telling the large majority they couldn't use a feature that was open to them. Now stated plainly: five cards of one rarity, anything but an ORIGINAL, no duplicates needed.
  • The last-copy confirmation said forging "removes the card from your collection completely", which reads as permanent. It isn't — the dropper only ever awards cards you don't own, so a forged-away card becomes eligible to drop to you again. The dialog now says your completion dips and explains that it recovers.
  • Tray heading, empty state, badge tooltips and the Trading Hall's Forge banner all reworded to match.
v6.13 MAJOR Website Analytics
The site now records which pages get used and by whom, so engagement can be measured instead of guessed at. A new admin Analytics page shows daily traffic, most-visited pages, per-player activity and which actions people actually take. No IP addresses, user agents or query strings are stored.

Added

  • tcg_web_events plus an after_request hook recording, per request: timestamp, route, method, status, the linked player (if any), whether they were an admin, and a referrer. Failures inside the hook are swallowed — analytics is never worth serving an error over.
  • Admin › Analytics: page views, distinct linked players and logged-out views over 7/30/90 days; a daily traffic chart; the most-visited pages; a per-player table with views, days seen and last seen; the actions people take (form submissions); and external referrers.
  • Deliberately not collected: IP addresses and user agents (no question here needs them, and they turn a stats table into personal data), and query strings (they carry card names, player ids and search terms — the route pattern already identifies the page).
  • Routes are stored as their Flask rule/player/<int:user_id>, not /player/1607 — so a thousand profile views aggregate into one row per page rather than a thousand distinct URLs.
  • Static files, card images, the analytics page itself, dev-login and favicon probes are all excluded; 404s aren't counted as visits.
  • Redirected GETs are skipped so /my-player doesn't double-count, but redirected POSTs are kept — nearly every form here redirects on success, and dropping those would have erased the actions entirely, which is the most useful thing in the table.

Why: there was no usage data at all. Flask sessions are signed cookies, so nothing was ever written when someone logged in or opened a page, and every engagement figure to date has been a proxy inferred from gameplay. History can't be backfilled, so this starts accumulating now.

v6.12 MAJOR The Card Catalogue
A new Cards page listing every card in the registry, with instant search and filters by rarity, category, foil, and — if you're logged in — what you own and what you're missing. It also shows which cards have never dropped to anyone.

Added

  • /cards — the full catalogue, linked from the top nav. Every active card with its rarity, points, how many prints exist and how many people hold one. Click any card for its market page showing who holds each print.
  • Filters apply instantly, client-side: search by name, plus rarity, category and foil chips. Logged in, you also get I own and I'm missing (mutually exclusive), and owned cards are badged in the grid.
  • Undiscovered cards are called out — 150 of the 549 have never dropped to anyone, and there's a filter for exactly those. Their ORIGINALS are all still unclaimed.
  • Ownership-neutral by design: it describes the catalogue itself rather than one player's slice, which is what the old hidden QA view was being used for.

Fixed

  • /player/MasterCollection returned a 500 after the profile rewrite — it rendered the whole catalogue through the player page as if one person owned everything, which the new profile can't do since it needs real per-player context. It now redirects to /cards, which answers the same question properly and in public.
  • The player profile's Collection tab now links to both that player's full collection and the catalogue, rather than only the former.
v6.11 Mercenary Nights on Your Profile
Everything you've ever done on a Mercenary night now lives on your profile: nights played, lifetime K/D, win rate, total kills and healing, your best night, and every leaderboard you've placed on. Podium finishes can go straight into a Showcase slot.

Added (profile merge, stage 3 of 3)

  • A Mercenary tab on every profile: nights played, matches, lifetime K/D, win rate, first places and podium finishes, alongside lifetime totals (kills, deaths, assists, healing, damage, objective points, hours played) and your best night, linked to that night's report.
  • Leaderboard placings across every night — for each board, how many firsts, podiums and top-5 finishes you have, and your best finish. A sixth Showcase widget kind pins one of them: "🏆 Most wins — 1× 1st · 2 podium · 2 top-5".
  • Mercenary numbers also join the Showcase stat pool, so nights played, lifetime kills, K/D, win rate and total podiums are all pinnable.
  • Built by replaying the existing report builder, not by a new aggregation. Profile figures therefore cannot disagree with the Mercenary Nights pages they link to — the same principle that already keeps the admin page and the exported file in step.
  • No new tables and no scheduled job: all eight nights with stats rebuild in about 60ms, memoised against a freshness token that a new night invalidates by itself. A stored accolades table was planned and turned out to be unnecessary once measured.
  • Players with no Mercenary history simply see an empty state; the tab never breaks.

This completes the profile merge begun in v6.9.

v6.10 MAJOR The Showcase — Five Slots, Yours to Curate
Every profile now opens with five slots you choose yourself: a big hero slot and four beside it. Pin a card, an achievement, a speedrun record or one of your own statistics. Lose the thing later and the slot keeps it, faded, with the date — because holding it once still counts.

Added (profile merge, stage 2 of 3)

  • Five Showcase slots at the top of every profile — one hero plus four supporting — holding any of four kinds: cards (with the ORIGINAL stamp and print number, clickable into the 3D viewer), achievements (coloured by difficulty), speedrun records (a mode and category, with the count held), and stat tiles drawn from that player's real numbers — total points, originals held, best hit streak, luck ratio, trades completed, Forge contracts, even worst dry spell.
  • Slots resolve live. A pinned stat ticks up on its own; a pinned record count falls when someone beats you.
  • Losing something doesn't erase the slot. Every pin freezes a snapshot of what it showed. Trade the card away or lose the record and the slot renders it faded, still with its art, labelled with why and when it was pinned. Verified by moving a pinned card to another player mid-test.
  • Nothing is ever blank. Unset slots fall back to an automatic pick from that player's best material — rarest cards, biggest record, hardest achievement, a headline stat — so all 85 profiles look furnished without anyone touching them. Automatic slots are labelled as such to their owner, who can claim any of them.
  • An editor at /player/<id>/showcase, owner-only: pick a slot, pick a category, click the thing. One click pins it — no separate save step.
  • Only what you currently hold can be pinned, checked server-side regardless of what the page offers.
  • Achievements whose names compose from live mode labels now render properly here too, reusing the achievements engine's own display logic rather than printing a raw key.
v6.9 MAJOR One Player Page
The Trophy Cabinet and the player dossier were two half-views of the same person, and they'd drifted apart. There's now a single profile with tabs for Collection, Achievements, Speedruns, Drops and Trading — and it changes what it offers depending on who's looking.

Changed (profile merge, stage 1 of 3)

  • /player/<id> is now the only profile page. /my-player redirects to your own, so the nav link and any bookmarks keep working; if you're logged in but not linked yet it explains how to claim instead.
  • Everything contextual now hangs off two independent facts — is the viewer linked, and is the profile linked — rather than which URL you came in through. That's what decides whether you see the trade button, the claim prompt, or the owner-only Settings tab. Verified across all four viewer/profile combinations.
  • Six tabs: Collection, Achievements, Speedruns, Drops, Trading, and Settings for the owner. The tab is remembered in the URL, so /player/12#drops opens straight to Drops.
  • A new Trading tab — trades completed, distinct partners, originals and prints held, spare duplicates, Forge contracts run, and the five most recent trades in full.
  • The full 550-tile catalogue moved to /player/<id>/collection, keeping its owned-only toggle. The Collection tab now leads with rarity-tier progress and the twelve rarest cards held. The grid was drowning every other section on the page.
  • Discord ping preferences moved into the owner-only Settings tab, and the old Trophy Cabinet template is gone. Nothing it showed was lost — every section it had now lives on the merged page.

Stage 2 (a five-slot showcase players curate themselves) and stage 3 (lifetime Mercenary stats on profiles) follow.

v6.8 The Doors Open Themselves
The Trading Hall no longer opens on a date — it opens when the server has pulled enough duplicates between them. As the count climbs the bot starts posting fragments about something stirring in the archive, and the final duplicate throws the doors open for everyone.

Added

  • Community unlock. Set a target number of duplicate drops in Admin › Trades and the Hall (and the Forge with it) opens by itself the moment the server collectively reaches it. Target 0 disables the mechanism and leaves opening to the manual lever, which still works and still overrides.
  • The bot narrates it. Cryptic milestones fire once each at 10 / 25 / 50 / 75 / 90% — escalating from a recovered signal fragment, to something being lit beneath the registry, to the heat becoming unmistakable — and never quote the actual number, because not knowing how close it is does the work. The 90% post is the one that tells people to get their Discord linked. At 100% the doors open and the bot announces the Hall, the Forge, originals and the ledger.
  • Milestones are recorded in the database, not in memory, so restarting the bot can't replay them. Re-arming them is a checkbox for testing.
  • The counter is monotonic by construction — incremented at mint time rather than derived by counting rows. That matters because the Forge deletes the prints it consumes: a derived count could fall and re-close a hall that had already opened. Once the target is reached the opening is stamped and permanent, verified by wiping the counter afterwards and confirming the Hall stays open.
  • Only genuine duplicate drops count. A card you didn't own doesn't move it, and Forge output never does — that's manufactured, not pulled. Both verified.
  • Whichever process notices first — website or bot — throws the switch, idempotently, so the unlock can't fire twice or depend on the bot being up.
v6.7 MAJOR The Forge — Trade-Up Contracts
Feed five spare prints of one rarity into the Forge and it returns something from the tier above. Foils forge into foils. Originals can never be forged away, and the Forge never produces one — an original is still something you can only find.

Added

  • The Forge at /trading/forge: pick a contract, load the anvil, and watch your prints get pulled into the core and reforged. The reveal is a proper set-piece — inputs arc into a ring and are dragged inward, the core charges, then a shockwave and a particle burst in the output card's rarity colour. Flare scales with the tier produced: an Uncommon gets 26 particles and one shockwave, a Mythical gets 170 and four, with a longer charge. Foil outputs get an extra white burst. Fully disabled under prefers-reduced-motion.
  • Ladder B: two parallel chains that preserve finish and converge at Mythical (which has no foil) — common→uncommon→rare→epic→ legendary→mythical, and the same again for foils, with Legendary Foil→Mythical closing the foil track. Five inputs per rung by default, but each rung's cost is its own config row, so any step can be retuned without code.
  • The ladder is deliberately one rung at a time, which makes the real cost geometric — 5 commons for an uncommon, but 3,125 for a mythical. Luck stays far and away the best route to the top; the Forge is the floor, not the ceiling.
  • Two hard rules, enforced server-side regardless of the UI: originals can never be used as inputs, and the Forge only ever outputs cards that already have an original somewhere — so an original can only be found by playing, never manufactured, and the 151 undiscovered cards stay a genuine frontier.
  • Prints already promised to an open trade offer are held back from the tray, so forging can't silently invalidate a deal.
  • Spare prints are marked ×N; a print that's your only copy of a card is marked ONLY and asks for confirmation before it goes in, since forging it removes that card from your collection.
  • Output is a real print with full provenance, minted with a forge event rather than mint — so a forged card correctly doesn't count toward drop-history achievements. It wasn't pulled, it was made.
  • Consumed prints are deleted but remembered in tcg_tradeup_inputs, including their mint numbers, so a retired print number is never reused by a later drop. Verified: burning print #37 and minting again yields #38, not #37.
v6.6 MAJOR Duplicate Drops: No More Empty Wins
If you've already collected everything a drop could give you, that drop used to vanish. Now it hands you a second print of a card you already own — something you can trade, and soon, trade up. Cards you don't own are still always given first, so nothing changes until the well runs dry.

Added

  • Duplicate drops, behind tcg_drop_policy.duplicates_allowed with an admin toggle on the Drop Rates page. Off by default; flipping it takes effect on the very next roll, no restart.
  • The rule is deliberately narrow: unowned cards are always preferred and the exhaustion cascade is untouched. A duplicate is only minted where the roll would otherwise have paid out nothing at all — the branch that previously returned empty-handed. Nobody loses collection progress they were getting before.
  • The duplicate is of the rarity actually rolled, not wherever the cascade walked to: roll a Common, get a Common.
  • Duplicates score zero (the scoring print per card is the original if you hold it, else any one print — unchanged since v5.13), so this cannot move the leaderboard. Verified: a saturated player minting a duplicate kept exactly the same points and card count.
  • One-copy-max was always a droppable unique index rather than a table constraint precisely for this. It's dropped when the flag goes on and recreated when it goes off — and if real duplicates exist by then, the index is left off with a warning rather than destroying anyone's cards to re-impose it.
  • Trading relaxes in step: with duplicates on you may receive a card you already hold, offer several prints of the same card, and the offer builder stops locking those rows. That's the only rule in trading the switch touches.
  • Grid tiles gain a ×N badge when you hold more than one print of a card.
  • The Drop Rates page now shows live saturation — winning rolls in the last 7 days and what share of them paid out nothing — so the effect of the switch is measurable rather than assumed.

Why now: measured against live data, exhausted wins went from 0% in mid-July to 13% in the first week of August, and the five biggest collections had zero cards remaining across the entire cascade chain — which carries 94% of all drop weight. The most active players were losing almost every drop they earned.

v6.5 Admin Card Maker: Set the Card Number, Link the Player
The admin card maker can now take a hand-specified card number instead of the auto-generated one — including on Player cards, which previously forced it — and can record which player a card belongs to at creation time.

Added

  • Auto / Specify selector on the card number (the number printed on the card face, not the filename sequence — that stays automatic). Auto is unchanged in every case: the deterministic handle+class formula for Players, 1 for everything else. Specify honours whatever is typed, for any category — Player cards could not be overridden at all before, which made reprints and one-offs impossible. Missing or negative numbers in Specify mode are rejected rather than silently falling back.
  • Link to player picker. Records whose card this is against every variant published, writing the same tcg_card_player_overrides table the achievements engine treats as its highest-precedence source — so linking here is what makes "Self Made" resolve for cards the name-matching heuristic would miss or get wrong. Lists all accounts, not just Discord-linked ones, since a card's subject and a claimed account are different things and a subject who links later should already be attached. Shown in the flash message and the audit log.

Not covered: the bulk CSV importer, which still auto-numbers and does not link. Say the word if that's wanted too.

v6.4 Card Pages: Find Out Who Holds What
Every card now has its own page listing every print in existence and who currently holds each one — with an "Ask for it" button that starts a direct trade offer to that player. Open any card in the viewer and follow "Who else holds this".

Added

  • /card/<id>: the art, what it's worth (standard and as THE ORIGINAL), how many prints exist, and the holder of every print in mint order with the original flagged. Any open offers involving that card are listed underneath.
  • Closes the gap the Trading Hall's floor couldn't: if nobody happens to be offering what you need, this is how you find who has one and ask. Each eligible holder gets an "Ask for it" button that opens the offer builder pre-aimed at them.
  • The button only shows for linked players when the hall is open for them, and hides entirely if you already hold a print of that card (you can't receive a second one) — with a pointer to the offer builder for the legal case: putting your own print up to swap for someone's original.
  • Reachable from the 3D viewer on every card tile, via a new "Who else holds this →" link.
  • Public by design: holdings and provenance are already visible on player pages and the trade ledger, so gating this would only make trading harder without hiding anything.
v6.3 Twelve Trading Achievements (Three of Them Secret)
The Trading Hall group grows to twelve: trade with more people (Middleman), hoard originals (Curator), get a card back you once owned (Full Circle), hold the original of your own player card (Self Made) — plus three secret ones you'll have to earn to read.

Added

  • Discord trade announcements now have their own on/off button on Admin › Trades, separate from the hall lever — the drops and mystery feeds already post plenty, so the hall can open quietly and this can be switched on later, or never. Still off by default; toggling is audited.
  • Middleman 1/3/5/10 — trade with that many different players. Rewards trading across the roster rather than round-tripping with one friend.
  • Curator 5/10/25 — hold that many ORIGINAL prints at once. State- based, so first-finders qualify without ever trading; several players unlock these the moment the scan runs.
  • Full Circle — a print you once held returns to your collection.
  • Self Made — hold THE ORIGINAL of your own player card. Needs a card-to-player mapping, built fresh each scan from three sources in precedence order: manual admin overrides, then the card-maker's own submission record, then card name matched to account. The name match is disambiguated deliberately: ~37 usernames are shared by two or three accounts (alts and re-registrations), and a naive match would hand the unlock to a dead account, so candidates are ranked by Discord link, then drop history, then collection size. Verified against live data: 170 of 172 player cards map, every ambiguous name resolves to the genuinely active account, and the two sources disagree exactly once (the card-maker record wins, correctly).
  • Three secrets (hidden until someone earns them): Get Scammed! and You're a Scammer!! — the two ends of one lopsided deal, where a bundle is traded for a single print worth less than the bundle (original premium included in the valuation); and Hot Potato! — trade a print away within an hour of it landing in your collection.
  • Achievement-relevant note: unwound trades count for nothing anywhere in this group — the evaluator replays accepted trades only, and ignores unwind hops when tracing a print's ownership history.
  • New tcg_card_player_overrides table is the escape hatch for player cards that predate the card-maker and whose name no longer matches any account. Exactly one such card exists today (NalonBitch, two prints); adding a row fixes it with no code change.
v6.2 Registry Dispatch: News on the Home Page
The Prints Update announcement now lives as a news panel on the home page, and shows once as a popup on your first visit back. It knows who you are — linked players are told they're ready, everyone else is shown the way in — and it flips to "THE DOORS ARE OPEN" by itself the moment the Trading Hall opens.

Added

  • One shared dispatch partial rendered twice: a Registry Dispatch panel at the top of the home page, and a site-wide first-visit popup (dismiss once per browser, tracked in localStorage against a dispatch id — bump the id when the next news post ships and the popup fires once more for everyone).
  • Auth-aware close: linked players get "you're all set" (or an Enter the Trading Hall button once open), logged-in-but-unlinked get the claim-your-profile path, logged-out get the Discord login CTA.
  • The coming-soon block reads the live hall_open flag, so opening the Hall from Admin › Trades flips the news to "THE DOORS ARE OPEN" with no deploy — the announcement updates itself on opening day.
  • The home-page point-values table gained the ★ Original columns (previous commit), matching the premium that's now live.
v6.1 Trader Achievements & Trade Offers From Dossiers
Six trading achievements are waiting on the achievements page for opening day — from The Handshake to Relic Hunter — and every player dossier now has an "Offer a Trade" button that starts a direct offer to that player.

Added

  • The Trading Hall achievement group (6): The Handshake (first trade), Dealmaker 5/10/25, Relic Hunter (receive an ORIGINAL in a trade), Package Deal (a single trade moving four or more prints). All sit UNCLAIMED until the hall opens and trades happen — visible now on purpose, as a shop window.
  • The evaluator replays accepted trades only: an unwound trade doesn't count toward anything (though an unlock already granted is never revoked, per the module's one-way rule). Both participants count each trade once; Relic Hunter reads the provenance ledger, so it can never misattribute who received the original.
  • "Offer a Trade" on player dossiers — shown when a linked player views another linked player's page and the hall is open for them (admins see it behind the lock too, for testing). Jumps straight into the offer builder with that player pre-selected, their collection as the want-side pool.
v6.0 MAJOR The Trading Hall
Player-to-player trading is built: post open offers on the floor or send direct offers, bundle multiple cards a side, and watch every completed deal land on a public ledger with full per-print provenance. The Hall ships LOCKED behind a coming-soon page — an admin opens the doors when it's time.

Added (TRADING_SPEC.md Phase 3 — a new core mechanic, hence v6.0)

  • Offers: put up prints you hold, name the cards you want back — at least one each side, bundles welcome, no gifts. Open offers sit on the floor for anyone eligible; direct offers go to one named player, who can accept or decline. Withdrawing your own open offer is free.
  • One policy gate decides everything at accept time, atomically: both sides linked, proposer still holds every offered print, acceptor holds every wanted card, nobody receives a card they already own — net of what they give away in the same trade, so trading your reprint plus sweetener for THE ORIGINAL of the same card works. Wants resolve to concrete prints on accept (never auto-taking an original when a reprint would do), and the resolved prints are recorded, so the ledger always shows exactly what moved.
  • Provenance everywhere: each moved print gains a trade hop in its ledger; the card viewer now shows the full journey ("MINTED to X · TRADED X → Y"). Mint dates are permanent; a print's acquired date becomes when its current owner got it.
  • Safety rails: offers expire (14 days open / 7 direct), a traded print cools down 24h before moving again, and accepting a trade instantly invalidates every other open offer containing a print that just moved. All three are config rows, tunable without code.
  • Public trade ledger at /trading/ledger — every completed trade, forever, unwound ones included.
  • Admin tools: full trade list, an audited unwind that returns every print to its pre-trade owner with acquisition dates restored exactly from the ledger (and refuses, changing nothing, if a print has since moved on), and the launch lever — the Hall ships LOCKED: players keep the coming-soon page until an admin opens the doors from Admin › Trades. Admins bypass the lock, so the whole system is testable on live before opening day.
  • Discord: completed-trade announcements (originals get the loud version) and direct-offer pings that respect the mention opt-out — built, but OFF by default behind a toggle, same as achievements.
  • Drop-history achievements (first drop, lifetime totals, first foil/rarity) now read the immutable mint ledger, so trading a card away can never un-earn a drop milestone; collection achievements (category %, all Mythicals) follow current holdings and can genuinely unlock via a trade. The mystery-drop carousel likewise remembers who was GIVEN each card, forever.
  • Verified end-to-end on a live-data copy: create/accept/decline/ withdraw, the original-buyback exchange rule, overlap invalidation, cooldown enforcement, and an unwind restoring pre-trade state byte-for-byte.
v5.13 MAJOR Originals Are Now Worth More
Holding THE ORIGINAL print of a card now scores 1.5× its points (rounded up) on the leaderboard — first-finders get their premium immediately, and when the Trading Hall opens, trading for an original will mean trading for its premium too. Rankings shift on day one: every player with originals gets a boost, and the more originals you hold, the bigger it is.

Added (TRADING_SPEC.md Phase 2 — the premium)

  • A scoring original is worth ceil(points × 1.5): Common 1 → 2, Mythical 11 → 17, Mythical PLAYERS card 15 → 23. Applied per card, never to summed totals. The fraction lives in a new tcg_original_premium config row (seeded 3/2) — exact integer math end to end, retunable by editing the row, no code change.
  • Applies everywhere points appear and always agrees with itself: the leaderboard, the player-page tiles and total (original tiles show their premium value, tooltip says why), and the Discord bot's rank-flavor math — including drops that mint a brand-new original, which now announce with the premium gain.
  • Locked tiles deliberately keep showing base value: dropping that card today would mint a non-original print, so base is what it would actually be worth to you.
  • The scoring print per (player, card) is the original when you hold it — so once duplicate prints exist, the premium can never be stranded on a duplicate (TRADING_SPEC.md §4).
  • Verified three ways on a copy of live data: pure-Python recompute, the site's SQL and the bot's SQL agree for all 76 collectors. Top of the table moves but doesn't flip: RoundTwo 548 → 616, Zen 516 → 597. Deeper down, genuine rank changes happen where original counts differ — that's the point: originals now carry mechanical weight, ahead of the Trading Hall opening.
v5.12 MAJOR Every Card Is Now a Print
Groundwork for trading: every owned card is now a numbered "print" with permanent provenance — the card viewer shows its mint number and date, and originals are stamped forever rather than recalculated. No points, badges or pages change, with one tiny exception: eight cards where two players tied for "first" now show exactly one ORIGINAL badge instead of two.

Changed (TRADING_SPEC.md Phase 1 — prints & provenance)

  • tcg_player_cards rebuilt in place (idempotent, runs automatically on the first schema ensure after deploy): row ids preserved exactly and promoted to permanent print identities; new is_original and mint_number columns; the table-level UNIQUE(user_id, card_id) replaced by a droppable unique index so enabling duplicate drops later is a one-statement config change, not another rebuild.
  • is_original stamped once at migration from the old earliest- acquired_at derivation and never recomputed. Exactly one original per card: the parity run found 8 cards where two players tied on the earliest timestamp — the old derivation showed BOTH an ORIGINAL badge; the earliest-inserted row now keeps it. The affected eight players each lose one duplicate badge; nothing else moves.
  • New tcg_print_events provenance ledger — one mint row per print, backfilled for all 2,153 existing prints, written by every future drop. Trades and unwinds append here in Phase 3.
  • All three print writers (dropper, mystery roll, exhausted-pick rescue) now go through one mint_print() helper; all deleters (instance rerolls, catalog deletes, full reset) clean the ledger too. The Discord bot's schema ensure now delegates to the dropper's first, so no process can write prints to an unmigrated table.
  • Every reader (leaderboard, stats, player pages, achievements engine, Discord bot flavor) rewritten duplicate-tolerant: counts are per distinct card, originals read the stored flag. Verified identical output on a full copy of the live data — per-user points, card counts and originals all match, modulo the 8 tie-breaks above.
  • The 3D card viewer gains a provenance line: "PRINT #4 · MINTED 2026-07-25", or "THE ORIGINAL · MINTED …" in gold. No art files are duplicated — prints are database rows referencing the same single image per card.
v5.11 The Trading Hall — Coming Soon
A new Trading entry in the nav leads to a teaser page for player-to-player card trading. The page tells you whether you're ready for opening day: linked Discord accounts are all set, everyone else is shown how to get linked.

Added

  • A "Trading" link in the top nav, between Drop Log and Changelog — gold, bold, with a soft pulsing glow so it reads as a call to action while staying a plain text link (the button treatment stays reserved for CREATE PLAYER CARD). The pulse respects prefers-reduced-motion, same as the site's other animations.
  • /trading: a coming-soon page for the Trading Hall. Big glowing headline, a short pitch, and a readiness panel driven by the same Discord-link state the rest of the site uses (my_ga_user, an approved Discord ↔ player link):
  • Linked: green dot — you're all set, you'll be able to trade the moment the hall opens.
  • Logged in but unclaimed: amber dot, pointed at the leaderboard to claim their player profile.
  • Logged out: amber dot plus a LOG IN WITH DISCORD button that returns to /trading after auth.
  • No trading logic ships with this — it's deliberately hype-only. The route renders the template and nothing else, so there's nothing to misfire before the real feature lands.
v5.10 Bonus Drops Count Toward Streaks
Hot Streak and Broke the Curse achievements now count bonus drops, matching what the stats page and the Discord "on fire" callout always counted. Everyone short-changed by the old rule gets their unlock automatically.

Fixed

  • The achievements engine measured hit and miss streaks on the primary roll alone, while the stats page's streak leaderboards and the bot's on-fire callout both counted any drop — so a player could be announced "10 drops in a row" while the achievement scan called the same run a string of misses. skaloo's ten-in-a-row on 2026-08-05, six of them bonus-only hits, earned no Hot Streak 10; the engine's own comment (and ACHIEVEMENTS_SPEC.md §4B) claimed it matched the stats page all along.
  • All three definitions now agree: a roll is a streak hit if it landed anything — primary, bonus, or a fully-exhausted win (a won roll the cascade had no card left to pay out).
  • No action needed on anyone's part: the scan replays full history every cycle, so the next scheduled run back-awards everything the old rule missed, stamped with the roll that actually crossed the threshold. Verified against live data: 14 unlocks across 13 players — one Hot Streak 10 (skaloo), eight Hot Streak 5s, five Hot Streak 3s. Nothing is revoked: re-evaluation never takes an unlock away, so curse-breakers earned under the old, harsher miss-streak reading stand.
v5.9 Per-Class Leaderboards on Mercenary Reports
The leaderboards on a Mercenary report now come in tabs: Overall as before, plus one tab each for Assault, Medic, Recon and Robotic showing only the stats earned while playing that class.

Added

  • An Overall / Assault / Medic / Recon / Robotic tab bar above the leaderboards. Overall is exactly the report as it was; each class tab applies the same treatment (same top-5 cut, same boards, K/D and wins) to only the stats earned on that class.
  • Class is attributed per match, not per night: each kept stint's character resolves to its class, and that match's stats count toward that class's tab. On a typical Sunday two thirds of the roster plays more than one class, so a player can — correctly — appear in several tabs, with the numbers split between them.
  • The K/D board's "3+ games" floor counts games on that class, so two games as Medic is still treated as too small a sample even for a player with ten games overall.
  • A class nobody played that night doesn't get a tab, matching the report's habit of leaving out what didn't happen.
  • Class icons — the same artwork the speedrun page uses — appear on the class tabs themselves and, on the Overall tab, next to each player's name: one icon per class they played that night, with the game count on hover. Website only; the exported file leaves them out the same way it already leaves out card art and player links.
  • The tabs work everywhere the report renders: the admin page, the public page, and the exported standalone file (offline included — the tab styles and script travel inside the shared report body).
  • The Discord digest is unchanged: it stays a short overall summary and links to the full report for the class breakdown.
v5.8 Shared Top Spots in Report Highlights
When two or more players finish joint first, the highlights at the foot of a Mercenary report now name all of them instead of crediting whichever one came first.

Fixed

  • The highlight lines picked a single leader per category, so a shared first place quietly credited one player and left the other out. Kelrior and Deadly both finished 2026-08-04 on 49 kills and only Kelrior was mentioned. All joint leaders are now named, however many there are.
  • Wording adapts to the tie rather than reading awkwardly — and where the shared figure would need a per-player detail that isn't shared (top kills across different numbers of games, say), the line leaves that detail out instead of quoting one player's number as if it applied to everybody.
  • Fixing it turned up a second case nobody had noticed: Kontract and Phoron both died 137 times on 2026-07-05, and only one of them got the sympathy.
  • Highlights that would have reported a zero — nobody died, nobody killed a bot — are now left out rather than stated.
v5.7 Mercenary Reports Ignore Non-Matches
Abandoned and crashed matches, and anything else that never really got going, no longer clutter a Mercenary report. Nothing anybody actually earned is affected.

Changed

  • A Mercenary report now only counts matches that look like real games. Abandoned and crashed ones are left out, as is anything that never had at least two players on the board with something to show for it.
  • The thresholds were chosen against the real history rather than picked by feel: every genuinely decisive match on record clears them, with a single exception — one nine-minute match that recorded no kills at all and reads as a test.
  • The date list uses exactly the same rule, so the match count it advertises is the one the report goes on to show.
  • Dates whose every match was abandoned now read as empty and drop out of the list, which is the honest answer: nothing was really played.
  • Matches too old for the stats sync to reach still say so explicitly rather than pretending no matches happened.

Unaffected

  • No card drop and no achievement changes as a result of any of this. The drop system has always ignored matches that didn't resolve, so there was never anything to win in the ones now being hidden.
v5.6 1v1 Duels in Mercenary Reports
An optional toggle folds the night's 1v1 arena duels into a Mercenary report — matches, stats, card drops and achievements together. Off by default.

Added

  • An "Include 1v1 duels" option on the Mercenary report. 1v1 is its own queue and has never appeared in these reports; this pulls it in when you want it. Default is off — a Mercenary night stays a Mercenary night unless you say otherwise.
  • The setting travels with the report rather than living on one page: the shareable public link, the standalone HTML export, the Discord digest and the command-line tool all honour it, so every copy of a given night describes the same night.
  • Card drops and achievements earned in those duels come along with them, so a duel can't show up with its cards while the achievement won in it goes missing.
  • A report that folded duels in says so at the top, and only when there were actually duels to fold — somebody opening a shared link didn't press the button and would otherwise have no way to know.
v5.5 Recaps Moved to Admin
The recap generator is an operator tool rather than something to browse, so it's moved behind the admin login and out of the navigation bar.

Changed

  • The recap generator now lives on the admin hub rather than the public nav. Nothing about how it works changed — only who can reach it.
  • That frees a slot in the navigation bar, which was getting crowded.
v5.4 MAJOR Mercenary Session Reports
A full write-up of any Mercenary night: totals, ten leaderboards, a per-player table, match-by-match detail, the cards that dropped and the achievements unlocked. Public and shareable, exportable as a standalone file, and optionally posted to Discord.

Added

  • Mercenary Nights in the nav — a public page per session, no login needed, so a report can just be linked to. The index lists Sunday sessions, which is when the real nights happen; any other date is still reachable directly or generated ad hoc from the admin tool.
  • Ten leaderboards (kills, healing, damage dealt and taken, assists, objective points, deaths, bot kills, best K/D over 3+ games, and most wins), a full per-player table, and every match with its map, size, duration and result.
  • The cards that dropped that night, with a hover-fan of the five rarest — the same fanned deck used on player pages, clickable through to the full card. Player names throughout link to their profile, and card names open the card viewer.
  • The Mercenary achievements unlocked during the session, grouped by player.
  • Export produces a self-contained HTML file that works offline, for attaching or archiving.
  • An optional Discord digest with the headline numbers and a link back to the full report. Opt-in per report: generating or exporting never posts anything, and posting needs its own tickbox, so ad-hoc reports stay silent.
  • Player stats are deduplicated the same way the rest of the site does it, so a reconnect mid-match can't double-count somebody's kills.

Fixed

  • Older dates could error rather than load. Some early matches were recorded before per-player statistics were being captured, and the report didn't expect a match with nobody in it. Those dates now explain that statistics weren't recorded rather than failing, and the index marks them.
v5.3 Achievement Filters, and Opting Out of Pings
Search, filter and sort the achievements page — including a flat, ungrouped view so you can see your most recent unlocks across everything. Plus a setting to stop the bot @mentioning you.

Added

  • Free-text search, plus filters for status (unlocked or locked), difficulty, section, and "only show" toggles for secrets and for achievements nobody has claimed yet. Sort by hardest, rarest, name, or most recently unlocked. A running count shows how many of the full set you're looking at.
  • A Group by section toggle. Switch it off and everything becomes one flat list, so sorting applies across the whole page instead of restarting inside each section — which is the only way to actually see your most recent unlocks in order.
  • Exempt me from Discord pings on My Player. Tick it and the bot uses your name instead of @mentioning you, on drops and achievements alike. You still appear in every announcement; you just don't get the notification.

Fixed

  • Unticking "Locked" still left a large number of locked achievements on screen. Status is now simply unlocked or locked, and the two add up to the whole set; "unclaimed by anyone" became its own separate filter, which is how it actually behaves.
  • A hidden secret's name is never included in the page's search data, so filtering can't be used to reveal one early.
v5.2 Achievements in Discord
Unlocks now announce themselves — one message per player carrying everything they earned that cycle, with the rank they placed, special treatment for first-ever unlocks and secrets, and a little commentary for the less flattering ones.

Added

  • One message per player per cycle, listing every achievement they just unlocked. Five people earning two things each is five messages, not ten.
  • Each unlock says how many players have managed it, and a first-ever unlock is called out as such. On a group achievement everyone on the roster is genuinely first together.
  • Secrets get their own treatment: the first person to crack one gets a declassification, and everyone after is told who found it first.
  • The less dignified achievements get commentary to match.
  • Linked players are @mentioned; anyone who's opted out, or isn't linked, is named instead.
  • The achievements engine now runs automatically on the normal refresh cycle, so unlocks appear on their own rather than needing a manual scan.
  • Announcements are off by default and enabled deliberately. When first switched on, the bot deliberately skips everything already earned, so turning it on can't dump a backlog into the channel.

Fixed

  • A busy match could produce a drop-announcement message longer than Discord accepts. The send failed, and because that stopped the run being marked as announced, the same drops were re-posted every cycle. Long messages are now split across as many as they need, and the duplicate loop is gone.
v5.1 Achievement Class Icons and Difficulty Glow
Class-specific achievements now show that class's icon, the four-class ones show all four (greyed until earned), and every achievement carries a difficulty glow so the hard ones stand out.

Added

  • Class icons on every per-class achievement, reusing the same art as the speedruns page. The "as all four classes" achievements show all four, greyed out until you've done that class, so progress is readable at a glance.
  • A difficulty glow on every tile, using the same colours as card rarities — so a Super Agent flawless run reads as a very different proposition to a first Medium Security clear. The very hardest also catch the light as you pass over them.
  • Locked achievements glow too, just dimmed, so the glow works as a signpost for what's worth chasing rather than only a reward for what's done.
  • Difficulty reflects how hard the achievement actually is, not how many people happen to hold it — so it doesn't drift as the server plays. Each tile still shows how many players have it.
v5.0 MAJOR Achievements
A new /achievements page tracking 169 achievements across security tiers, raids, drops, collections, maps, PvP and Mercenary -- including nine secret ones that stay stamped TOP SECRET until somebody, somewhere, unlocks them. Achievement progress now also appears on My Player and on every player page.

Added

  • /achievements -- every achievement grouped by security tier, raid and category, showing what you've unlocked, how many players hold each one, and how far along you are on the multi-part ones. Locked achievements stay visible on purpose: the goals are the point. A recently-unlocked feed runs at the bottom.
  • The security-tier matrix (72). Per tier: run completed, solo, solo deathless, group, group deathless -- plus a solo variant for each of the four classes, and two "do it as all four classes" capstones. The matrix is deliberately not uniform. Solo variants exist only where solo actually happens; where they don't, "group" and "run completed" would fire on identical runs, so only one ships. Super Agent gets an escalating death ladder (run, within the bonus threshold, under 30, under 20, under 10, zero) instead of a single deathless flag, since no D5 run has ever been deathless.
  • Drop economy, streaks, collection, maps, volume, PvP and record holding (64). First drop through first Mythical, the double drop, hit streaks, luck ratios, per-category collection milestones, "win on every map in a tier", Cartographer for every map in the registry, lifetime run counts, Grand Tour, Raider, Mercenary and Duel win ladders, and records-held tiers including Dethroned for taking a record off somebody else.
  • Mercenary set (33). Complete and win 1/5/10 Mercenary matches as each class, plus nine secret achievements covering the sort of thing you only find out about by doing it. A secret shows nothing but a red TOP SECRET stamp -- no name, no description, nothing in the page source either -- until the first person on the server unlocks it, at which point it's revealed permanently for everybody.
  • Achievement sections on My Player and player pages, alongside the existing trophy cabinet and speedrun badges.
  • Admin tool at /admin/achievements: preview-first scanning, the scan-floor control, and a reset. Nothing is ever awarded without showing you exactly what it would award first, and every write is recorded in the admin audit log.

Notes

  • Achievements are computed from real match history rather than from the drop log, so they cover matches the drop system never saw. Scan scope matches the speedruns page exactly, including its test-data exclusions.
  • Re-running a scan is always safe -- an achievement can't be awarded twice, and unlock dates reflect when the match actually happened rather than when the scan ran.
  • Achievements are never hard-deleted. A withdrawn one is retired rather than removed, and past unlocks are kept.
v4.36 Discord: Pinged on Your Own Drops
If you've linked your Discord account, the bot now @mentions you on your drop line instead of just printing your name — once per cycle, however many cards you got. Toggleable from the control panel.

Added

  • A linked player gets a real @mention on their drop line, so a drop actually reaches them rather than relying on them scrolling back through the channel.
  • Only once per polling cycle, no matter how many cards you picked up in it — a good run shouldn't turn into a pile of notifications. Every drop line after the first in the same cycle falls back to your name.
  • Unlinked players are unaffected and are never called out for it.
  • A toggle on the control panel's Discord Bot tab turns mentions off entirely without touching the code.
v4.35 Super-Admin Card Maker
A no-limits card creation tool for the root admin — every rarity including mythical, no per-player caps, no approval queue, and bulk creation from a CSV. Publishes straight into the live catalog.

Added

  • A separate creation tool from the player-facing one: no rarity restrictions, no creation caps, and no approval step, since the person using it is the person who'd be approving it anyway.
  • Bulk import from a CSV, for building out a whole set in one go rather than one card at a time.
  • Cards published here go live immediately, using the same catalog-sync step the approval queue already uses.
v4.34 Faster Refresh, Quieter Background Job
Match data now syncs every 5 minutes instead of the previous slower cadence, and the scheduled job runs without popping a window open.

Changed

  • The scheduled data refresh now runs every 5 minutes, so drops, leaderboards and the site's countdown all track real play much more closely.
  • The job runs silently in the background instead of opening a console window each time it fires.
  • Some private configuration moved out of tracked files into the environment, so it can't end up in the repository.
v4.33 Speedrun Page Rework: Ladder & Raid Tables
Replaced the old filterable widget-grid /speedruns page with two compact tables (PVE ladder and raids), class icons, a click-through run summary popup, and filters that actually narrow the view -- including a security-tier filter that was missing from the first pass.

Added

  • Two pivoted tables replace the per-map widget grid: the PVE ladder table (map rows x the 5 difficulty tiers -- Medium/High/Maximum/Ultra Max/Super Agent -- each split into Solo/Solo Deathless/Group/Group Deathless sub-columns) and a Raids table (grouped by raid type). Solo columns show one icon per class in a single cell rather than exploding into their own columns.
  • Real class icons (Assault/Medic/Recon/Robotics) instead of colored dots. Clicking any name, player count, or class icon opens a run summary popup -- full team, class, deaths, time, and instance number -- without leaving the page. A shared run that holds both a category's normal and deathless record (common on a 0-death run) gets a visual connector between the two cells instead of repeating the same info twice.
  • Filtering: a map checklist, a security/difficulty tier filter (missed in the initial pass, added this round), category toggles (Solo/Solo Deathless/Group/Group Deathless), and a player search that highlights cells where that player is an actual record holder -- deliberately not "their personal best regardless of whether it's the record," which read as misleading. Linked players get a one-click "Highlight my runs" shortcut. Any tier, column, or map row with zero matching records for the current filters hides automatically, with a toggle to show them anyway, and a running summary explains what's filtered and what's hidden and why.
  • A collapsible "how to read this table" key, and a horizontal scrollbar that stays reachable at the bottom of the viewport while scrolling a tall page, rather than only appearing at the bottom of the table itself.
  • My Player and player.html's speedrun badge tiles now deep-link straight into the new table, pre-filtered to that player and tier.
v4.32 MAJOR Card Catalog: Super-Admin Card Deletion
A new super-admin-only page lists every card ever made and can delete (or restore) one -- soft-delete only, the art moves to a recovery folder rather than being destroyed, and a card's exact identity stays permanently reserved even after deletion.

Added

  • A new Card Catalog admin page, gated to the root admin account only -- not just the delete/restore actions, the whole page is invisible to any other admin, since this is the one tool that can retroactively remove a card from every player who owns it.
  • Deleting a card never removes its database row -- only flags it. That soft-delete is what permanently reserves the card's exact name, rarity, and sequence number, so nothing can ever be reissued under an identity that once existed, deleted or not. The art file itself moves to a recovery folder rather than being destroyed, and ownership is revoked from every player who had it, immediately.
  • Deleting either half of a foil/non-foil pair automatically deletes the other with it -- a real pair is one card with two renders, not two independent identities. Restoring cascades the same way.
  • If every variant of a self-service card-creation submission is deleted, the player's creation-cap slot (one legendary, eight non-legendary) frees up automatically, and re-consumes symmetrically if the card is restored -- confirmed this can't be gamed by deleting and restoring a card to mint an extra slot.
  • Restoring re-publishes the card immediately (same catalog-sync step the approval queue already uses), not at the next scheduled scan.
  • A one-time cleanup script catches up any creation-cap slots that should have freed from card deletions made before this automatic handling existed.
v4.31 MAJOR Leave/Rejoin Drop-Eligibility Fix & Targeted Reroll
A player who left mid-match to swap class and rejoined before the end was being wrongly disqualified as having "left early" -- fixed, and a follow-up scan found real players this had already happened to. Also adds the ability to reroll one specific player within a match instead of the whole instance.

Fixed

  • Drop eligibility's "were they present at the end" check used to measure a player's LAST leave event against the match's own end time -- but a mid-match class swap produces a genuine leave immediately followed by a rejoin, and the old check couldn't tell that apart from someone who left for good. Fixed to look at the player's actual last join-or-leave transition instead: only a leave with no later rejoin counts against them now. Confirmed against a real reported case, and a genuine early-leave-with-no-rejoin still correctly disqualifies.
  • A follow-up scan of existing match history (safe to run again any time) found several more real players already wrongly marked ineligible under the old logic and corrected them -- one of them turned out to have a legitimate card waiting that the bug had been blocking.

Added

  • Targeted reroll: an admin can now reroll a specific player (or players) within a match instance without touching anyone else's existing roll or cards -- available both from the Reroll admin page (a "Target Players" panel showing the instance's roster and current eligibility) and from the command-line tool, alongside the existing whole-instance reroll.
v4.30 Drop the Red Cross for Unlinked Players
The Discord bot briefly showed a red cross next to a player's name on drop announcements if they hadn't linked their Discord account -- pulled after feedback that it read as a public callout rather than a helpful nudge.

Changed

  • Linked players still get a green check next to their name on drop announcements. Unlinked players now get no marker at all instead of a negative one.
v4.29 Discord Bot: Admin Jokes, Link Status & Cold-Streak Records
New situational commentary on drop announcements: a joke about rigged odds when an admin gets a drop, a marker showing whether the recipient has linked their account, escalating callouts as a player's cold streak closes in on the all-time record, a "finally" message when a long dry spell ends, a hot-streak shoutout, and a nod to whoever's still dead last on the leaderboard.

Added

  • A joke about rigged odds when the person who just got a drop is a site admin -- reuses the website's own admin-permission logic directly, so it can never disagree with who's actually an admin.
  • A green check next to a player's name on drop lines showing they've linked their Discord account (later adjusted -- see the next entry).
  • Escalating messages as a player's live cold streak closes in on, ties, or breaks the all-time record for consecutive misses, plus a distinct message for extending a streak that's already past the old record -- only kicks in once a real record of some meaningful length exists, so a freshly-set record of one or two misses doesn't trigger anything.
  • A comical "finally" message the first time a streak of five or more misses ends, every time it happens, not just once ever.
  • A hot-streak shoutout for three or more drops in a row, and an underdog callout for whoever's still dead last on the leaderboard even after getting a drop (correctly credits everyone tied for last, not just whichever tied player happened to sort last).
  • All of the above are previewable through the existing test-message system (both the admin GUI and the command line) without touching real state.
v4.28 Trophy Cabinet Redesign: Tiered Display Shelf
The Trophy Cabinet's rarest-10 grid became a tiered display shelf -- the single rarest card spotlighted alone on top, the rest arranged on two wider shelves below, each card leaned at a slightly different angle like a physical card-display stand.

Changed

  • Replaced the flat grid with a 3-tier riser layout (1 card on top, 4 in the middle, 5 on the bottom), the top card enlarged with a soft glow and a "#1 Rarest" label underneath. Every card leans at a slightly different angle rather than sitting perfectly aligned, and straightens, lifts, and shows its existing foil/glare effect on hover -- that hover behavior is fully reused, unchanged, from everywhere else cards are shown on the site.
  • Degrades gracefully for a player with fewer than 10 cards -- a tier with nothing in it simply doesn't render rather than showing an empty shelf.
v4.27 MAJOR Self-Service Web Card Creation Tool
Linked players can now build their own Player card entirely in the browser -- upload any image, position/crop it live against the real card frame, and submit for admin approval -- replacing the old external Photoshop-crop-then-desktop-tool workflow for this one card type.

Added

  • /create-card: gated behind having a linked (approved) Discord ↔ player account -- 404s rather than blocking, so the tool is invisible rather than "access denied" to anyone not linked yet. Category is always Player, rarity excludes Mythical, and Class is a locked Assault/Recon/Medic/Robotics dropdown -- none of these are free text.
  • Live in-browser positioning: upload any resolution image, drag to pan and slide to zoom inside a 1000x1400-ratio viewport (same idea as cropping an avatar on most social platforms), with the crop always constrained so the image can never shrink smaller than the frame. The browser only handles the live preview; the final render always re-crops the full-resolution original server-side for quality. "Update Preview" renders both the normal and foil variant in one click, with a toggle switch to flip between the two cached results instead of a second round trip.
  • Caps enforced server-side, never just client-side: one legendary card ever (checked against both the existing tcg_cards catalog from the old tool AND this tool's own submission history, so nobody can get a free second one), and eight non-legendary cards total -- a rejected submission does not count against that cap, but a pending or approved one does.
  • Nothing publishes automatically. Every submission renders into a PENDING staging folder and a new Card Submissions admin queue (alongside the existing five admin tools) shows both rendered variants for review. Approve computes the real final filename (reusing the original desktop tool's own lowest-free-number sequencing), moves the files into the real PLAYERS folder, and calls scan_card_catalog.py's ingestion directly so it's live immediately rather than waiting on the next scheduled scan. Reject requires a reason and leaves the staged files on disk untouched, matching this project's preserve-history convention elsewhere. Both actions are audit-logged.
  • Reinstated two pieces of the old Google-Sheet-driven workflow that the new tool would otherwise have dropped: the per-card serial number (hashed deterministically from name+class, same formula the sheet used) shown top-left, and the "Player" set label shown top-right.
  • The Cards folder path is a per-checkout setting (--cards-root, configured in the control panel's Website tab) rather than a shared machine-wide environment variable -- dev and live run on the same physical machine and need genuinely different values here, unlike every other secret/config this project has used setx for so far.
v4.26 MAJOR My Player Trophy Cabinet
The My Player page went from a placeholder linking out to the public player page to a real personalized dashboard: top 10 rarest cards, visual speedrun record badges, and a set of stats pulled together from data the site was already computing.

Added

  • A Trophy Cabinet section showing a player's 10 rarest owned cards in rarity order (the existing category-view builder gained a configurable count parameter for this -- other pages that need the usual top 5 were left unchanged), plus their speedrun records surfaced as visual badges rather than a plain table, reusing the same badge-counting logic the public speedrun page already had.
  • Additional stats folded in from data already computed elsewhere on the site: luck ratio, hit streaks, originals-owned count, and rarity completion.
  • Moved from player_links.py into cards_app.py -- the page needs helpers that already live there (category view, speedrun badges, player stats), and moving it avoided introducing a circular import between the two modules.
v4.25 Admin Action Audit Log & Claimant IP Logging
Every admin action across every tool -- tournament results, account linking, reroll/revoke, drop-rate edits, admin grants -- now writes to one consolidated, searchable log, and profile claim requests capture the requester's IP for manual cross-checking.

Added

  • A new admin-only /admin/audit-log view backed by a single tcg_admin_audit_log table, capturing who took the action, what it was, when, and from what IP address. Threaded into all 23 admin write routes across the tournament, linking, reroll, and drop-rate tools -- manual match-result overrides and admin grants/revokes were the two that mattered most to get right, since those are the highest-impact actions an admin can take.
  • Tables that already tracked their own per-row audit fields (link decisions, admin grants) keep those too -- the consolidated log is additive, not a replacement for row-level history.
  • Claim requests now capture the requester's IP address, shown in the admin linking panel for manual cross-checking against in-game account activity. Display only, by design -- nothing is validated against it programmatically.
v4.24 Nav Redesign & General Admin Hub
The top nav was getting cluttered as admin tools accumulated -- reworked into conditional links that only appear when relevant, with a single /admin hub listing every tool rather than a nav submenu per tool.

Changed

  • Tournaments and My Player links only appear in the nav when relevant (an active tournament exists; the visitor has a linked account) -- Admin only appears for admins. Top-right action buttons (login/claim card/log out) separated out from the main link row instead of mixed in with it.
  • One general /admin hub page lists every admin tool as a card (Tournament Admin, User Linking, Reroll/Revoke, Drop Rates, Audit Log) rather than building out nav submenus per tool -- adding a new admin tool going forward only means adding one more entry here.
v4.23 MAJOR Drop-Rate Matrix Editor & Solo-Run Drop Bonus
Per-mode drop chances are now editable live from an admin page instead of requiring a code change, and solo runs get a configurable drop-chance bonus -- quadrupled by default, hard-capped at 70% -- to make going in alone a real (if risky) choice rather than a pure loss.

Added

  • A matrix-style admin page for editing every mode's drop chance and per-player bonus directly, reusing the existing drop-rate config tables rather than adding new ones -- config lives in SQL, not Python, same as everywhere else on this site.
  • Solo-run drop bonus: when Medium/High/Max Security is completed by exactly one real participant, the effective drop chance is multiplied (4.0x by default) and hard-capped at a configurable ceiling (70% by default) regardless of what the multiplier alone would produce -- both numbers editable from the same admin page. "Real participant" reuses the same ghost-row-filtered roster definition the speedrun page already relies on (nonzero kills/damage/healing/objective points/deaths), not a raw event-table join count, so a drive-by non-participant joining an instance can't block a genuine solo run's bonus.
  • The home page now surfaces the solo bonus percentage and ceiling per mode, so the bonus is visible before running the mission, not just reflected after the fact in the drop chance shown post-run.
v4.22 Web Reroll/Revoke Admin Tool
A browser port of the desktop Reroll/Revoke tool -- preview, revoke-and-reroll, or revoke-only a single match instance's drops without needing the Tkinter control panel open.

Added

  • /admin/reroll: preview an instance's current drops, then either revoke-and-reroll (using the exact same roll_drops.py logic the live dropper uses, not a reimplementation) or revoke-only with a required reason. Strictly one instance actioned at a time, matching how the desktop tool is used in practice.
  • A compact, scrollable instance table with mission-failed rows filtered out by default, disqualified matches highlighted with a red band, and internal map codenames translated to their friendly display names -- all added after using the first pass against real data surfaced how noisy the raw instance list actually was.
v4.21 MAJOR Tournament Management
A full tournament system: admin-run round-robin scheduling, automatic win detection reusing the live dropper's own task-force logic, and public standings/results pages -- the first real feature built on top of the new site-wide Discord login.

Added

  • Admin panel to create tournaments, build teams, and generate a round-robin schedule, with roster and captain entry via a dropdown against real ga_users rows rather than free text -- an early free-text version turned out to be ambiguous whenever two players shared a similar in-game name, so it was replaced before shipping. Schedules can be regenerated when it's still safe to do so, and both teams and whole tournaments can be deleted.
  • Automatic win resolution on match completion, reusing roll_drops.py's task-force detection (which side each player was actually on) rather than its majority-vote variant, which is correct for normal PvE play but wrong for a tournament match between two real teams.
  • Public tournament pages: standings and per-round results, with a conditional nav link that only appears while a tournament is active.
v4.20 MAJOR Site-Wide Discord Login & Account Linking
The first real use of Discord OAuth2 on the site -- login is now the foundation every admin and player-account feature from here on builds on, plus a self-service flow for linking a Discord account to an in-game player profile.

Added

  • Site-wide Discord OAuth2 login (/oauth/login, /oauth/callback, /logout) -- built as a general-purpose foundation rather than scoped to any one feature, since tournament admin, account linking, and every admin tool since all needed the same login.
  • Self-service Discord ↔ player account linking: a Claim Profile button starts a request, which sits in an admin approval queue until decided -- nothing links automatically. A placeholder My Player page was added at this point too, later rebuilt into the full Trophy Cabinet.
  • Admin access is an explicit allowlist rooted at the GA_CARD_ADMIN environment variable, not a Discord server role -- role assignment in the Discord server isn't tightly controlled, so basing admin access on it would have been a weaker boundary than intended. The GA_CARD_ADMIN account can grant or revoke admin access for other accounts through the linking tool, scoped to already-approved links only, and only that one account can grant -- a granted admin can't promote anyone else.
  • db.py extracted from cards_app.py so every blueprint's schema setup composes correctly regardless of which route happens to run first in a request -- the foundation every blueprint added from here on (and the several added later) registers into.
v4.19 Player Speedrun Record Badges
Player profiles now show how many speedrun records a player holds, broken out by mode and by whether it was a group or solo effort, deathless or not -- built entirely from the existing speedrun leaderboard logic, no new data path.

Added

  • A record-count badge next to the username on /player/<id>, and a new "Speedrun Records" section below it laid out as four cards (Group, Group Deathless, Solo, Solo Deathless) matching the existing Drop Stats card grid -- each card lists the modes where that player holds at least one record of that type, with modes at zero simply left out rather than shown as empty rows.
  • Every one of the three existing record types (Fastest Completion, Fastest Deathless, Fastest Solo) resolves into exactly one of the four categories based on team size at the time of that run, with no overlap: a solo run that happens to also be the outright fastest completion is credited as a Solo record, not Group; a deathless win is credited as Group Deathless or Solo Deathless depending on team size, never a single combined "Deathless" bucket. An earlier draft used three columns with team-size-independent Deathless, which blurred the exact distinction this feature is for -- reworked to the four-way split before shipping.
  • Built by reusing the existing speedrun leaderboard computation directly rather than re-deriving eligibility, roster, or win detection a second time -- same pattern already used for per-player drop stats. Verified against real data: spot-checked a specific player's badge counts against the underlying leaderboard entries by hand for two known maps and confirmed an exact match, and confirmed the section (and the header badge) simply don't render at all for a player with zero speedrun records rather than showing an empty table.
v4.18 Speedrun Instance IDs & Class Resolution Investigation
Each speedrun record now shows its instance ID for lookups, and a reported bug where some players show no class on the speedrun page was investigated and traced to a stale reference table rather than a logic bug.

Added

  • Instance ID displayed alongside each of the three record times (Fastest Completion, Fastest Deathless, Fastest Solo) on /speedruns, using data the leaderboard computation was already building -- no backend change needed, just surfacing it in the template.

Investigated

  • Looked into a report that some players show with no class label next to their name on certain speedrun entries (e.g. a player showing just as a bare username instead of "Username (Class)"). Confirmed by hand against two specific real reported cases that the class resolution query, join, and fallback logic are all working exactly as designed -- the real cause is that the character-reference table those matches depend on hasn't been kept current: it's missing every character created after a certain point, which affects roughly one in ten player-match class lookups across the full history, skewed toward more recent matches. Not a code defect in the site -- whatever step is responsible for refreshing that table from the live game server needs to actually include it going forward.
v4.17 Speedrun Page Tiled Layout
The speedrun page's per-map cards now tile into a responsive grid instead of stacking full-width, at request -- same information, more compact.

Changed

  • Per-map cards on /speedruns now lay out in a wrapping grid (one grid per mode section, so headers still separate modes cleanly) instead of a single full-width column -- several maps now visible on screen at once without scrolling. Existing mode/map filtering continues to work unchanged against the new layout, including correctly hiding a mode's grid entirely when every card in it is filtered out, not just the cards themselves.
  • Each map card's internal layout reworked from a fixed-width label column to a stacked format (label, then time, then meta, then team below) to actually fit the narrower tile width -- the old layout was built assuming full page width and didn't hold up once cards got narrower.
v4.16 Home Carousel Missing Bonus Drops
The "recent drops" carousel on the home page was silently skipping every bonus drop -- fixed.

Fixed

  • The query only ever joined on the primary card column, so any card won through the low-death bonus roll never appeared in the carousel at all, even though it showed correctly everywhere else (drop log, Discord, player pages). Fixed with a UNION covering both primary and bonus awards, tagged so the carousel can show a distinct marker for bonus entries. Verified against a real, very recent bonus recipient who previously would never have appeared -- now does.
v4.15 Speedrun Filtering, Solo Runs & Scope Refinement
Mode and map filtering for the speedrun page, a new "Fastest Solo Completion" category per map, and Double Agent removed from the page's scope.

Added

  • Mode dropdown and a map text search above the leaderboard list, both instant with no page reload -- section headers for modes with zero visible results hide automatically rather than leaving empty headings behind, with a live "X of Y shown" counter. Tested all combinations (mode only, map only, both together, clearing back to the full list) through actual browser automation, not just the underlying logic.
  • New "Fastest Solo Completion" category per mode/map, alongside the existing fastest-overall and fastest-deathless ones -- only shown when a genuine solo run (exactly one real player) actually exists for that map. Verified against a real instance: cross-checked its duration and confirmed only one real stat row exists for that match, not just a lightly-populated team.

Changed

  • Double Agent excluded from the speedrun page specifically, at request -- implemented as the existing shared bonus-eligible category set minus Double Agent, computed locally to this page rather than altering the shared constant itself, since Double Agent still has a genuine bonus tier for actual card drops everywhere else in the system. Confirmed the exclusion removed exactly the expected number of groups and left every other mode's numbers completely unchanged.
v4.14 MAJOR Speedrun Leaderboards
New page tracking the fastest successful completion and fastest deathless completion per mode and per map, with full team/class breakdowns -- built from a first draft, reviewed, and fixed against real data along the way.

Added

  • /speedruns -- per (mode, map), the fastest genuine win and the fastest genuine win with zero team deaths, each showing duration, team size, deaths, and every team member with their class for that match.
  • Class composition resolved via ga_match_events.actor_character_id -> ga_characters.id -> ga_characters.profile_id, mapped through the game's own 4 class IDs. Confirmed directly against real data that profile_id only ever takes exactly those 4 values across the whole table, and independently verified one specific player's resolved class against the raw event data by hand. ~98% of players use one consistent class for a whole match; the rare exceptions get whichever they used most, not an arbitrary pick.
  • Friendly map names, same join pattern already used on the drop log.

Fixed

  • Reviewed a first-draft version of this feature and found the actual root cause of a real reported bug: valid, correctly-configured matches (confirmed matching their real queue in the game's own config) were showing under an "unknown" heading. The cause was mode resolution depending on the card dropper's own processed-match table, which only ever covers matches after its cutoff date -- this page needed the full historical archive instead, so mode is now resolved directly per match rather than borrowed from a table that doesn't cover the whole dataset. Confirmed zero "unknown" groups remain afterward, where there were more than a dozen before.
  • The mode/queue scope was a hardcoded list of internal IDs; replaced with the same live, self-maintaining category configuration the card dropper itself already uses, so a future new mode is picked up automatically here too rather than needing this list kept in sync by hand.
  • The command-misuse disqualification check was being applied to every mode uniformly; the actual card-drop rules only ever apply it to two specific mode categories (a third, Double Agent, is deliberately exempt there). Fixed to match those exact conditions rather than a blanket rule that would have been stricter than what card drops themselves use. Verified against a real Double Agent match that genuinely has a disqualifying event on record, confirming it correctly stays included.
  • Regression-checked the whole rebuild against a real match whose exact duration, death count, and team size had already been independently verified earlier -- came back identical after every fix above, now additionally showing the correct friendly map name and each player's correct class.
v4.13 Mystery Roll
A tiny, independent chance on every automatic check for a random active player to be handed a random Mythical card out of nowhere -- completely separate from actual matches.

Added

  • A ~0.2% chance, checked once per automatic poll (not on manual triggers), to pick a random player who's genuinely participated before and award them a random Mythical they don't already own, with its own distinct "A Mystery Unfolds" announcement style. At the default poll interval this averages roughly one hit every few hours of uptime -- pure independent probability each check, not a fixed schedule, so it can cluster or go quiet for a stretch like any real RNG.
  • Deliberately wired into the automatic poll only, never the manual "Poll Now" trigger, confirmed directly in the code -- otherwise anyone re-clicking Poll Now could reroll the odds on demand, which would defeat the point of it being rare and unpredictable.
  • Uses a reserved placeholder value (never a real match ID) to record how the card was acquired, specifically chosen so it can never collide with, or get accidentally caught up in, tools that operate on real match data.
  • Tested directly rather than just written: forced a guaranteed miss and a guaranteed hit and confirmed the database changes exactly matched (nothing on the miss, one new card and one message on the hit), and separately confirmed the edge case where the chosen player already owns every Mythical fails gracefully with no crash and no message, not just in theory.
v4.12 Player Page Visual Overhaul
A more polished player profile -- an interactive card stack you can browse through, a foil shimmer effect, rarity completion bars, and a note on cards showing who had them first.

Added

  • The card stack at the top of a player's page now shows their real 5 rarest cards rather than 1 real card with placeholder backs behind it. Hovering fans them out and moving across the fan brings whichever card is under the cursor to the front, so all 5 can actually be browsed and clicked individually.
  • A one-shot diagonal light sweep on foil cards when the cursor enters, layered alongside the existing mouse-tracking holographic effect rather than replacing it.
  • Six thin completion bars, one per rarity tier, showing real owned/total progress for that player.
  • Cards that aren't a player's original pull now show, on hover, who the actual first owner was.

Fixed during testing

  • The first version of the card-fan hover interaction looked correct but wasn't reliably usable -- real browser testing found that with partial overlap between cards, a higher-stacked neighbor could fully block a card underneath from ever being reachable by the cursor, regardless of how the spacing was tuned within that approach. Fixed by switching to full separation between cards on hover instead of partial overlap, then re-verified that all 5 cards are individually reachable, not just visually spread apart.
v4.11 Recaps Function Added
RECAPS FUNCTION ADDED.

Added

  • RECAPS FUNCTION ADDED. The desktop recap tool has been retired in favor of the website version.
v4.10 Original Card Badge
Player pages now show a gold "ORIGINAL" badge on any card you were genuinely the first person ever to receive.

Added

  • A card_id can be independently owned by multiple different players (ownership is per-player, there's no single global 1-of-1 pool), so "original" specifically means whoever's own acquisition timestamp is the earliest across every owner of that card -- not just "only one person owns it." Computed via one MIN(acquired_at) GROUP BY card_id query shared across the whole player page render, not a per-card lookup.
  • Verified against a real card with 23 independent owners: exactly one (the genuine chronological first) shows the badge, all 22 others correctly show nothing. Badge sits top-right on the card tile, alongside the existing foil tag (top-left) and points badge (bottom-right).
  • Worth noting honestly: hit a confusing stretch of false negatives while testing this, where a card that should have shown the badge kept appearing not to. Traced it all the way down (raw database values, the Python function in isolation, diffing deployed files against source, finally the raw HTML by hand) before finding the feature had been working correctly the whole time -- the verification script's own context window was too narrow and cutting itself off before reaching the badge in the page.
v4.9 Retroactive Exhaustion Fix Tool
A one-time tool to give players a fair second chance at the specific rolls that came back empty under the old exhaustion rule, without touching any card that was already successfully awarded.

Added

  • reroll_exhausted_picks.py -- re-runs ONLY the card-pick step for rolls that came back fully exhausted under the old cascade logic (before v4.7), using the exact rarity already rolled at the time (not a fresh random roll). Deliberately narrower than reroll_instances.py, which operates on a whole instance and would have disturbed every other player in the same match who already got a real card -- this only ever touches rows that gave nothing the first time, so there's nothing to revoke.
  • Verified at the strictest level practical: compared every row in tcg_drop_rolls before and after, byte-for-byte. Exactly 21 rows changed (the 20 primary + 1 bonus exhaustion case that existed), every other row in the entire table came back identical. Also confirmed safely idempotent -- running it a second time correctly finds nothing left to do rather than double-awarding anything.
  • All 21 rescued cases landed at Common Foil, the very next step in the new chain -- exactly the players who'd completed all 25 Common cards now correctly got a genuine upgrade instead of the dead roll they got the first time.
v4.8 Session Recap Generator
A reusable tool for generating shareable "session recap" graphics for Discord -- total drops, biggest streaks, rarest pulls, and more, for any mode and any range of matches.

Added

  • session_recap.py -- computes a full stat package (total drops, hit rate, biggest haul, rarest pull, hottest/coldest streaks, highest point value haul, most duplicated pull, a ranked leaderboard with correct tied-rank numbering) for any --mode and instance ID range, and writes a self-contained dark-themed HTML file styled to match the site -- open it in any browser and screenshot it, no need to come back for a fresh one each time.
  • Built after manually producing the first one (a "Mercenary Night" recap) as an inline graphic -- reusing that exact styling and query logic here, not a separate design.
  • Caught and fixed a real error in the process: the original manual graphic had mislabeled RoundTwo's longest hit streak (5) as their miss streak, when their actual longest miss streak was only 2. Building this properly, with explicit column unpacking instead of positional indexing, surfaced the mistake -- Zaxik genuinely had the night's worst miss streak (4), not RoundTwo.
v4.7 MAJOR Exhaustion Cascade Reworked
Running out of a card tier used to mean some rolls could come back completely empty with no fallback. Now, for Common through Rare Foil, exhaustion bumps you up to the next tier in a fixed chain instead of failing outright -- Epic and above are never affected, so pulling one of those is always a genuine, unassisted hit.

Changed

  • Old cascade: on exhaustion, stepped straight DOWN one drop-probability rank at a time, stopping (and failing) below Common with nowhere further to fall back to. Investigated after reports of exhaustion firing "when it shouldn't" -- confirmed directly against real data that every exhaustion event ever recorded (20 of them, zero exceptions) started at Common specifically, because Common is by far the largest card pool and the one several of the most active players had already fully completed.
  • New cascade: a fixed chain, Common → Common Foil → Uncommon → Uncommon Foil → Rare → Rare Foil -- deliberately the "points" ordering (each rarity's foil directly after its own base), not the drop-probability ladder used elsewhere, since it reads as a natural incremental upgrade path. On exhaustion, walks FORWARD from wherever the roll landed (rolling Uncommon Foil only tries Rare then Rare Foil next, never falls back to Common behind it). Capped hard at Rare Foil -- Epic, Epic Foil, Legendary, Legendary Foil, and Mythical get zero cascade in either direction now.
  • Verified against the exact real players who surfaced the original report: both own all 25 Common cards in the catalog, and both now correctly cascade up to Common Foil instead of failing immediately. Full 1054-match historical reroll confirmed every exhaustion case's recorded cascade_steps exactly matches "positions remaining from where it started to the end of the chain," with zero anomalies and zero Epic+ tiers ever appearing in the exhausted list.
v4.6 Discord Reliability Fixes
Fixed two real bugs: new-card messages showing "9 cards" while only 4 images ever appeared, and duplicate/crashing announcements when an automatic check and a manual "Poll Now" happened to overlap.

Fixed

  • Discord's actual image-gallery tiling cap turns out to be 4 per message, not the 9 the batching was using -- embeds beyond 4 still sent, they just never visually grouped into the tile grid, so the summary text and what actually showed silently disagreed. Fixed to batch in groups of 4.
  • Fixed a timing issue that could cause duplicate or crashing Discord announcements under certain conditions. Verified with a direct before/after comparison that the fix actually resolves it, not just suppresses the symptom.
v4.5 Discord: Test Message Preview System
A way to preview exactly how every message type (achievements, streaks, rare pulls, and more) actually renders in Discord, without waiting for a real match to produce the right conditions.

Added

  • 13 test scenarios, triggerable from the GUI (a new dropdown + button) or the CLI (--test-message <scenario>). Reuses the exact same functions the real pipeline calls rather than duplicated templates, so a test send can never drift out of sync with a real one.
  • Prefers a genuine real example pulled from current data where one exists (confirmed: "Rich Get Richer" used the real current #1, "First Rarity" found an actual player's actual card, "Miss Streak" found a real live streak) rather than always fabricating one, and says explicitly which kind it used every time.
  • Every test message is prefixed "TEST MESSAGE" and never touches real state -- no watermark changes, nothing marked as announced. Also discovered and fixed a real edge case while building this: sending a test now briefly cancels the bot's own background polling for that instant, so a test send can't accidentally race a real poll cycle and post genuine content as a side effect.
v4.4 Discord: Achievement & Roast Messages
Drops and misses now get contextual commentary — leaderboard movement ("the rich get richer", overtaking someone for a top-3 spot), personal and server-wide firsts, rare-pull odds callouts, miss streaks ("git gud"), and a joke when exactly one person in a match misses while everyone else scores.

Added

  • On a drop: fires when a player was already #1 in points or cards and stays there ("the rich get richer" / "your horde grows"), and separately when a drop pushes someone newly into (or up within) the top 3 in either metric, naming exactly who got overtaken. Verified against constructed scenarios covering retain-#1, genuine overtakes, and "gained something but nowhere near enough to matter" (correctly silent) — caught and fixed a bug in the test itself along the way (conflating "after" and "gained" values made a real overtake look broken; the underlying code was correct).
  • Personal milestones (first card of a given rarity tier, first card of a given category), plus two additions beyond what was asked for: a probability-quoted callout for Legendary/Mythical pulls (e.g. "just beat 0.415% odds"), and a "first person ever to own this exact card" server-wide first.
  • Miss-streak roast ("git gud", correct ordinal) — verified against a full, unbounded recomputation of real match history for 15 different players, not just spot-checked, correctly silent below 2 misses in a row.
  • Lone-misser joke — fires only when everyone else in that specific match got something and exactly one person didn't.

Changed

  • All of the above for one instance batches into a SINGLE follow-up message, not one ping per condition — the point of the earlier gallery rework (v4.3) was cutting message volume, so this was built to not undo that. Confirmed on real data: a player who was simultaneously the lone misser and on a 5-match miss streak correctly got one combined message, not two.
  • Re-ran the interruption-safety test from v4.1 with all this new logic active (fresh reset this time, after a first attempt had too little residual data left to meaningfully interrupt) — confirmed a genuine mid-batch interruption still resumes cleanly with zero duplicate messages.
v4.3 Discord: Compact Gallery Layout
New-card and drop announcements no longer sprawl vertically down the channel — multiple cards now tile into one compact message instead of one full block per card.

Fixed

  • Both new-card and drop announcements previously sent one full embed per card (title, description, image each repeated), stacking vertically and burying older messages fast in an active channel.

Added

  • build_digest_message() — one summary embed (title + a compact text line per item, colored by the rarest item in the batch) followed by bare image-only embeds that all share the exact same url value, which is the actual mechanism Discord uses to tile embed images into a grid instead of stacking them. announce_new_cards was already using this from earlier in the session; this round brought announce_new_drops in line with it too, since it had been missed.
  • Verified everything that's actually checkable without a live Discord client: confirmed real batches genuinely produce every embed sharing one identical URL (the literal requirement for grouping to trigger), confirmed the summary text is fully accurate and readable regardless of how the images end up tiling, confirmed the color reflects the rarest item, and confirmed the existing no-luck/disqualified text-only messages are completely unaffected. The actual visual tiling behavior itself can only be confirmed by watching it render in a real Discord client.
v4.2 Discord: Live Status
The bot now shows "N cards dropped in the last 24 hours" as its status text under its name in Discord, refreshing live.

Added

  • Custom status via discord.CustomActivity, recomputed every poll cycle (no watermark needed, no reset logic needed — it's just a live rolling count) and set immediately on connect rather than waiting up to 30s for the first poll.

Fixed

  • First version used a UTC calendar-day boundary ("today"), then switched to a rolling 24-hour window instead since the playerbase spans many timezones and a midnight-UTC reset would arbitrarily zero out mid-afternoon for some players and not others.
  • The rolling-window switch surfaced a real bug, caught by testing before it shipped: a naive string comparison against the stored timestamp format silently included rows up to ~25 hours old as "within the last 24 hours", because the stored format's date/time separator sorts differently than SQLite's own datetime() output. Fixed by normalizing both sides through datetime() before comparing, then specifically re-tested the exact 25-hours-ago boundary case to confirm it's now correctly excluded.
v4.1 Discord Bot: Desktop Control Panel
A proper window for running the bot instead of a raw console — start/stop, edit settings, trigger an immediate check, and watch live activity, without editing the script by hand.

Fixed

  • Diagnosed a real production issue: the bot appeared to silently stop announcing drops. Root cause was Windows Console QuickEdit Mode — clicking into the console window pauses ALL of its output until a key is pressed, and since this bot's print() calls ran on the same thread as the Discord gateway connection, a paused console froze the entire event loop, including the heartbeat that keeps the connection alive. Confirmed via a heartbeat-blocked warning and a traceback pointing directly at a print() call.
  • Fixed at two levels: disabling QuickEdit Mode addresses the root cause, and discord_bot.py itself now routes all console output through a safe_print() helper that offloads the actual write to a worker thread, so even if stdout blocks, it can never again freeze the thread running the Discord connection. The first automated attempt at this fix broke several single-line print() statements (a find-and-replace split the opening parenthesis from its content); caught immediately because the file stopped parsing, fixed by hand, then re-ran the full functional test suite to confirm the actual behavior still matched exactly, not just that it parsed.

Added

  • discord_bot_gui.py — Tkinter desktop app running the real bot (the same class the CLI uses, not a reimplementation) in a background thread with its own event loop, communicating with the GUI thread via a queue for logs and run_coroutine_threadsafe for actions. Configuration (DB path, both channel IDs, poll interval, optional token) is editable in the GUI and persisted to a local JSON file instead of hand-editing Python constants.
  • Verified what's actually testable without a live Discord connection: config persisting and reloading correctly, the log/status-message pipeline correctly separating internal signals from human-readable log lines, the Skip History button performing a real verified database operation, and — the most important case — what happens when the connection fails. Deliberately started the bot with a setup that couldn't connect and confirmed the GUI stayed fully responsive throughout, logged the failure clearly, reset its status correctly, and re-enabled the Start button, rather than hanging or crashing.
v4.0 MAJOR Discord Bot Goes Live
A real Discord bot now posts previews of new cards and live drop notifications — the first piece of the eventual account-linking and trading roadmap.

Added

  • discord_bot.py — built as a persistent bot (discord.py, a real gateway connection) rather than one-shot webhooks, specifically because the stated roadmap after this (Discord↔player account linking, then trading) needs actual interaction — slash commands, DMs — which webhooks can't do. Starting here means this doesn't need rebuilding later.
  • New-card previews: posts the moment scan_card_catalog.py finds new art, with the actual card image attached directly from disk (not a website link, so it keeps working even if the web server is down).
  • Live drop feed mirroring /log: an embed per card actually awarded, primary and bonus rolls both, tagged distinctly for bonus drops. No-drop rolls stay silent for now, per explicit instruction (deferred intentionally, not an oversight).
  • Two new "no drop" message types, previously totally silent: a lighthearted message when literally nobody in a match got anything, and a pointed callout naming the specific offender(s) when a team got disqualified by command misuse (pulling the exact event types and counts from the same table the website's drop log uses) — separately addressed to the actual offenders versus the innocent teammates who lost their shot through no fault of their own.
  • --skip-history setup flag: marks everything already in the database as already-announced without posting anything, so the bot's first real run doesn't flood the channel with the entire existing catalog and drop history. Discovered this was necessary by testing without it first and watching exactly that happen (375 cards, 2,245 drops all queued to post at once).
  • Interruption-safety: cards and drops are marked as announced incrementally, chunk by chunk / instance by instance, not once at the end of a whole batch. Verified directly — simulated a channel that fails partway through a 375-card batch, confirmed only the successfully-sent portion got marked (not zero, not all of it), then confirmed a retry sent exactly the remainder with no duplicates. Same test repeated on the drops side against 2,032 real drops.
v3.4 PVP Arena: Variable Drop Rate & Rename
The mode internally called "1v1" isn't actually a strict one-on-one format (real matches show 2 or 4 players), so its drop chance now scales with how many people are actually in the match, and it's labelled "PVP Arena" everywhere on the site.

Investigated

  • Looked into a report that PVP Arena "doesn't seem to be dropping cards." Found no actual bug: of 16 total attempted matches ever, only 4 (25%) reached a resolved outcome (7 never finished, 5 abandoned) — the drop system correctly only rolls for resolved matches, same as every other mode. Of the 4 resolved matches, all 4 were already correctly processed, and one genuinely awarded a card. One hit out of 4 rolls at a 10% base rate isn't statistically unusual — the perception of "never dropping" is fully explained by very low real volume, not a defect.
  • That same investigation surfaced the real player counts on those 4 matches: 2, 2, 4, and 4 -- confirming this queue isn't a strict 1v1 duel despite its internal name, which motivated the rename below.

Added

  • Variable drop chance for PVP Arena: drop_chance_pct + per_player_bonus_pct × qualifying players, seeded at 20% base + 3% per participation- qualifying player (same participation rule as everywhere else -- ≥10% of match duration and present at the end). New per_player_bonus_pct column on tcg_mode_drop_rates, defaulting to 0 for every mode -- a strict no-op everywhere except PVP Arena, same principle as every other schema addition in this project. Verified against all 4 real historical matches: 2-player matches compute 26.0% exactly, 4-player matches compute 32.0% exactly, and every other mode's rate is completely unaffected (confirmed via a full regression run -- every non-PVP-Arena mode still shows exactly one flat rate across all its rolls).
  • Home page's drop-odds table now shows the actual formula for any variable-rate mode (e.g. "20.0% + 3.0%/player") instead of the flat base number it was silently showing before -- caught this while helping verify a manual test where the base was temporarily set to 50%, confirmed the table now reads "50.0% + 3.0%/player" correctly rather than just "50.0%". Per-rarity columns for that row show the base-rate minimum with a footnote explaining they scale up proportionally with more players.

Changed

  • Renamed "1v1 Duel" to "PVP Arena" across every player-facing label (odds table, bonus table, eligibility text) -- the drop log's technical mode badge still shows the raw internal identifier ("1v1") unchanged, so it stays traceable for anyone checking match-level detail.
v3.3 Double Agent Bonus Fixed to Per-Side
The low-death bonus on Double Agent now correctly looks at each side's own deaths separately, instead of combining attackers and defenders into one number. The drop log also now shows Double Agent matches split into Attackers and Defenders sections.

Fixed

  • Double Agent's bonus eligibility was being computed from the whole instance's combined deaths (attackers + defenders summed together), which meant one side's rough match could silently deny the other side's bonus even if they'd played cleanly. Verified against a real match (instance 3210): an attacker with 8 deaths and a defender with 3 deaths, threshold 6 — under the old logic neither would ever qualify (11 combined ≥ 6); correctly, only the defender should.
  • get_team_total_deaths() now takes an optional side (attackers vs defenders) and returns just that side's own count, reading directly from ga_instances.count_deaths_attackers/count_deaths_defenders (already split by side server-side, just hadn't been used that way). For every other bonus-eligible mode, where only one side ever has real players, this produces the exact same number as before — a strict generalization, not a special case, confirmed with a full regression run showing zero change to any non-Double-Agent mode's bonus outcomes.
  • Verified end-to-end against instance 3210 post-fix: the attacker (8 deaths) correctly denied the bonus, the defender (3 deaths) correctly granted it, both still eligible for their normal roll either way.

Changed

  • Double Agent's bonus tier, previously excluded entirely (a leftover assumption from early investigation that its configured threshold was a rollout placeholder), is now confirmed live and enabled — appears correctly on the home page's bonus table under the PVP section.
  • Drop log now splits Double Agent match entries into separate Attackers and Defenders sections, each showing that side's own death count against the threshold and its own bonus badge. Every other mode's log entry is unchanged (still one flat participant list) — the row-rendering markup was refactored into a shared macro so both views share the same code rather than duplicating it.
  • Home page's bonus rules text corrected: Double Agent is now called out as the one mode where the death limit is evaluated per side rather than as one team-wide total.
v3.2 Home Page Mode Grouping & Desert Raid Goes Live
Home page's odds and bonus tables are now grouped into Security Missions / Raids / PVP sections for easier reading. Desert Raid is now live with real drop odds.

Added

  • desert_raids given a real stage-1 drop rate (25%) via a plain SQL INSERT against tcg_mode_drop_rates — no code change, no restart, confirming the self-extending work from v3.1 actually works end-to-end: the mode appeared correctly across every part of the system (drop rolling, bonus threshold, home page tables) the moment the one config row existed.

Changed

  • Home page's drop-odds table and low-death-bonus table both reorganized into three labelled sections instead of one flat list: Security Missions (Medium/High/Max/Ultra-Max/Super Agent, with Low Security pre-positioned to slot in correctly whenever it's eventually configured server-side), Raids (Sonoran Raid, Desert Raid, Dome Defence Raid), PVP (1v1 Duel, Mercenary, Double Agent — Double Agent grouped here purely for display; its underlying rules scope stays exactly as distinct as before everywhere else in the system). Purely a presentation change -- rendered as real section-header rows in each table now, not the previous subtle divider line.
v3.1 Self-Extending Mode Resolution
Behind-the-scenes improvement so new mission types the dev team adds get picked up automatically from now on.

Changed

  • resolve_mode_key() in roll_drops.py rewritten from a fixed name list to pattern-based matching for the two known alias shapes (*_pve_high/max/umax, double_agent_*). Verified this actually generalizes, not just fixes the one known case, by testing it against hypothetical queue names that don't exist yet (arctic_pve_high, winter_pve_umax) and confirming correct resolution with zero code changes -- the next reskin pool, whatever theme it uses, will just work without needing another visit here.
  • Any queue that doesn't match a known alias pattern now resolves to its own real name as the mode_key, instead of silently collapsing into a generic "unclassified_mode" bucket. A genuinely new queue is now immediately visible and traceable in the drop log the moment it's played, rather than requiring another investigation to even notice it exists.
  • Home page's mode listings now auto-extend with any mode present in tcg_mode_drop_rates but not in the curated display order -- a newly-configured mode appears on the odds table, the bonus table, and the eligibility explanation's mode-label lists automatically, with no template change required.
  • Full regression test: reran the entire pipeline against 893 real matches from a fresh snapshot. Every existing mode resolved identically to before: only two rows fell through to genuinely unclassifiable (a different, unrelated new map family -- 1P_SDDweller02_Portalled_P/1P_SDDweller03_Portalled_P, no queue reference at all -- flagged as something else worth watching, safely inert for now).

Confirmed

  • desert_raids (a new raid type added by the dev team, confirmed via bonus_queue_flag -- which splits 100% cleanly across all 16 queues into exactly {ddr, sr, desert_raids} vs. everything else) is now correctly auto-detected under its own mode_key rather than falling into unclassified_mode.
  • ga_queues.victory_bonus_lives for desert_raids is now properly configured -- picked up automatically by the existing live threshold lookup from v2.1, no code change needed on this side.
  • Verified the home page needs no manual changes for any of the above -- since it already reads live from the config tables, a newly-configured mode shows up the instant a tcg_mode_drop_rates row exists for it.
v3.0 MAJOR Card Point Values & the Leaderboard
Every card is now worth points, not just counting toward your total. Roster renamed to Leaderboard, sortable by cards or points, so ties get broken fairly.

Added

  • Card point-value system. Every card is now worth points on top of counting toward the collection total — a second metric to break ties between players sitting on the same card count. Scale confirmed against two anchor points the user gave (non-player Common=1/Common Foil=2, player Common=5/Common Foil=6 — both pairs differ by exactly +4, which only holds if each rarity's foil sits directly after its own base rather than the interleaved order the drop-rarity curve uses): Common 1, Common Foil 2, Uncommon 3, Uncommon Foil 4, Rare 5, Rare Foil 6, Epic 7, Epic Foil 8, Legendary 9, Legendary Foil 10, Mythical 11. PLAYERS-category cards get a flat +4 on top of all of the above; every other category uses the base value directly.
  • New tcg_point_values config table, seeded automatically, live-tunable via SQL — same "never hardcode what belongs in a config table" principle as the rarity weights and mode drop rates.
  • Verified against real data: manually recomputed one player's total points independently in Python from the raw tcg_player_cards/ tcg_cards join and confirmed it matched the app's SQL-computed total exactly.
  • Roster renamed to Leaderboard (/roster now redirects to /leaderboard, old links don't break). New sortable Points column alongside the existing card count — ?sort=points or ?sort=cards, active sort highlighted. Confirmed the two sorts genuinely produce different orderings: four players tied at 4 cards each under sort=cards resolve into a clear order under sort=points once foil/player-card weighting is factored in.
  • Player dossier page: each owned card now shows a point-value badge overlay, and the page header shows the player's total points alongside their existing collection count/percentage.
  • Home page: new "Card Point Values" section explaining the scale, with a live table (both the standard and player-card columns) pulled from tcg_point_values rather than hardcoded.
v2.1 Bonus System Switched to Live Game-Server Data
The bonus system now uses the game's own official numbers instead of ones we'd worked out ourselves — same rules, more accurate under the hood.

Changed

  • The dev team added new columns purpose-built for this system: ga_queues.victory_bonus_lives (the configured threshold per queue) and ga_instances.count_deaths_attackers / count_deaths_defenders (per-match team death totals, populated after the match resolves). Both were investigated before adopting either, same rigor as the earlier DEATH event log investigation:
  • Death-count fields: cross-referenced against the already-trusted ga_match_player_stats-based calculation across two snapshots taken a few hours apart. First snapshot: 36 of 40 sampled matches showed a flat 0 despite real deaths up to 82 in the same matches — not reliable yet. Second snapshot: matches from roughly instance 3026 onward showed correct population, everything older still legacy/zero — confirmed this was "we've only just started populating the fields," not a broken feature. Since drop rolling only ever processes new matches going forward from the cutoff date, this is a safe cutover with no fallback needed.
  • Threshold values: victory_bonus_lives mostly matched what had been manually configured (Ultra-Max, Super Agent, Sonoran Raid, DDR all matched exactly) but Medium/High/Max were all flattened to a uniform 4 (previously 1/2/1) — confirmed with the team this is the new authoritative source rather than a rollout artifact.
  • get_team_total_deaths() in roll_drops.py now reads directly from ga_instances instead of recomputing via the roster-dedup + activity filter approach against ga_match_player_stats -- one less piece of duplicated logic that could drift from whatever the server itself considers a death.
  • Bonus threshold lookup now reads ga_queues.victory_bonus_lives live, via the specific queue_id on each instance (not routed through mode-name aliasing) -- verified this correctly pulls a desert_pve_high match's own threshold value rather than falling back to a generic "high" lookup, in case an aliased queue's threshold is ever tuned differently from its counterpart. Falls back to the sr queue's value for legacy Sonoran Raid instances still sitting on a NULL queue_id. Scope (which modes get a bonus at all) intentionally stayed on this project's own category logic rather than the server's bonus_queue_flag column -- that flag's rollout status was ambiguous at the time (later confirmed in v3.1 to reliably mark raid-type content specifically).

Removed

  • This project's own tcg_bonus_thresholds config table and seed data — fully retired, no longer created or queried anywhere.
  • Home page's bonus threshold table now reads ga_queues.victory_bonus_lives live instead of the retired table -- same display, same live-data principle, different (now authoritative) source.
v2.0 MAJOR Low-Death Team Bonus
New low-death bonus! Play cleanly (few team deaths) in eligible missions and you get a SECOND, completely separate chance at a card drop.

Fixed

  • ga_queues had been silently excluded from sync_source_tables.py since the very first version of the sync script -- every other source table refreshed every cycle, but ga_queues was frozen at whatever state it was in the day the pipeline was first set up. Any new queue added server-side would never have appeared in the live DB. Added to the synced table list; confirmed safe since it's a read-only lookup table every engine only ever selects from.
  • Investigated what looked like a new "Low Security" PvE tier in a fresh snapshot and found it was a red herring: the difficulty value itself (Low Security) is still unassigned to any queue. What's actually there is a new desert_pve_high/max/umax map pool sharing the exact same difficulty values as the existing high/max/umax queues -- a new map skin for existing tiers, not a new tier. Updated resolve_mode_key() to alias these to their existing counterparts rather than treating them as unclassified.
  • Sonoran Raid gained a proper queue registration (sr) in the same snapshot, applied retroactively to most (not all) historical Sonoran matches. resolve_mode_key() now checks for the sr queue first, keeping the original map-name list as a fallback for the small number of older rows still sitting on a NULL queue_id.

Added

  • Low-death team bonus. For the standard PvE ladder and both raid types (same scope as the command-disqualification rule -- not Double Agent, not PvP): if a player is already drop-eligible under every existing rule, and the team's combined deaths for the match stay under that mode's threshold, they get a second, fully independent roll through the exact same pipeline -- same stage-1 and stage-2 odds as their first roll, not boosted odds. A bonus-qualifying player can end a match with zero, one, or two cards.
  • Thresholds (team total deaths, strictly less than): Medium 1, High 2, Max 1, Ultra-Max 4, Sonoran Raid 6, Dome Defence Raid 10, Super Agent 40 (deliberately flat across all Super Agent maps despite the large map-to-map difficulty spread -- maps are planned to be rebalanced over time rather than compensated for here).
  • Team deaths counted the same way as every other stat in this system: roster-deduplicated, real-activity-filtered players only.
  • Bonus eligibility is entirely downstream of normal eligibility -- a player who fails participation, presence, or the command-disqualifier never gets a bonus roll regardless of team deaths.
  • New tcg_bonus_thresholds config table (live-tunable via SQL, same pattern as the rarity/mode-rate tables) -- later retired in v2.1. New bonus_* columns added to the existing, already-populated tcg_drop_rolls table via safe ALTER TABLE calls -- verified against real production data that every pre-existing row correctly shows NULL/0 in the new columns rather than being retroactively touched, and that a full reroll correctly applies the bonus to historical matches when explicitly requested via reset_dropper.py.
  • Drop log shows the team's bonus status per match and each bonus player's second roll outcome as its own distinct line under their primary result.
  • Home page now explains the bonus mechanic directly, with a live threshold table.
  • "Submit Your Player Card" CTA button in the top nav, linking out to the card submission form, opens in a new tab.
v1.9 Expanded Ineligibility Rules & Refresh Countdown
New rule: using certain commands during a mission disqualifies the WHOLE team from that match's drop, not just whoever used it. Also added a countdown showing when the site next refreshes.

Changed

  • The PVE-only disqualification rule (previously: any player with a TEAM_CHANGE event was individually disqualified) has been reworked significantly:
  • Expanded to six event types: TEAM_CHANGE, CMD_SPAWNHENCHMAN, CMD_DEPLOYFRIEND, CMD_CHANGETEAM, CMD_TOPDOWN, CMD_DEPLOYENEMY. Confirmed against real data that all but the last two currently occur (CMD_TOPDOWN / CMD_DEPLOYENEMY are wired in for when they do).
  • Scope changed from per-player to per-instance. If anyone in a standard PvE ladder or raid match triggers any of these events, every player in that instance is disqualified from that match's drop -- not just whoever triggered it. Scope is still limited to the same two mode categories as before (pve_ladder, raid) -- Double Agent and PvP remain unaffected, where these actions are normal play.
  • Every occurrence is now recorded in a new tcg_disqualifying_events table (instance, player, event type, count) rather than just a yes/no check, so the drop log can show exactly who did what.
  • Not retroactive to already-processed matches -- same principle as the cutoff-date policy (§18). Verified against the current data: a handful of real PvE-ladder/raid matches already processed before this change contain qualifying events (CMD_SPAWNHENCHMAN in one Ultra-Max match, CMD_CHANGETEAM in two Super Agent matches and one Sonoran Raid match) that won't be retroactively caught unless those specific matches are reset and re-rolled.

Added

  • Drop log now distinguishes the actual offender(s) from teammates disqualified because of someone else's actions: offenders see exactly which commands they used and how many times each (ineligible — used: TEAM_CHANGE ×2); everyone else disqualified in the same instance sees a generic ineligible — due to command use. Both link directly back to the home page's eligibility rules section.
  • Refresh countdown, visible in the top bar on every page. Backed by a new refresh_status.json file written by refresh_db.ps1 at the end of every completed cycle (including no-op cycles, but deliberately not on a failed cycle -- a run of failures shows up as an overdue countdown on the site rather than a falsely healthy one). Ticks down live client-side between server polls rather than hammering the API every second.
  • id="eligibility" anchor added to the home page's rules section so the drop log's offender links can jump straight to it.

Known follow-up

  • The home page's exact wording for the new disqualifying-events rule is a placeholder pending manual refinement -- the mechanism and the dynamic mode-name lists are live and correct, but the specific phrasing is intentionally left for hands-on editing rather than authored end-to-end here.
v1.8 Recent Drops Carousel
Recent drops on the home page now show as scrollable card art instead of plain text.

Changed

  • The home page's "recent drops" section, originally a text-row list (matching the Drop Log's style), rebuilt as an actual horizontally scrollable carousel of card art — arrow buttons plus native scroll/swipe, showing the most recent 20 drops (up from 15) as real card tiles rather than text rows. Each card is clickable into the same 3D viewer as everywhere else, and each has the player's name (linked to their dossier) and a timestamp beneath it.
  • Refactor: the cursor-tracked tilt/glare hover effect on card tiles — previously duplicated in player.html's page-specific script — moved into the shared script in base.html, alongside the click-to-open viewer logic that was already shared. This is what let the carousel cards get the identical hover treatment for free, with no page-specific script needed on the home page at all beyond the two carousel arrow buttons.
v1.7 Home Page, Roster Move, and the Owned-Only Toggle
New home page showing recent drops and the odds table, plus a toggle on your own page to show only cards you've actually unlocked.

Added

  • A proper home page at /, replacing what used to be the roster there. Three live sections:
  • Recent drops — the most recent successful drops across every player, newest first.
  • Drop odds table — mode x rarity, computed live from the actual tcg_rarity_weights / tcg_mode_drop_rates config tables, not a hardcoded copy of the numbers in GA_mission_taxonomy_reference.md §17. If a rate is ever retuned in SQL, this page reflects it automatically and can't silently drift out of sync with what roll_drops.py is actually doing.
  • Eligibility explanation — user-facing, plain-language rules covering the participation requirement, the present-at-the-end requirement, which modes need a win vs. just a resolved outcome, and the PVE-only TEAM_CHANGE disqualifier from v1.0. The lists of which modes fall into each category are also pulled live from the config tables, so the explanation text can't drift out of sync with reality either — only the specific numeric thresholds (10%, "present at the end") are hardcoded, since those are code constants rather than DB-configurable values.
  • The old roster/leaderboard content moved to /roster, reachable from the nav, unchanged in content.
  • An "unlocked cards only" toggle on the player dossier page — hides locked tiles, and any category section left completely empty as a result also disappears rather than showing a bare header. Preference persists across visits via localStorage.

Changed

  • Site nav updated to Home / Roster / Drop Log / Changelog.
v1.6 Changelog Integrated Into The Site
This changelog page went live, so updates like this one are readable directly on the site.

Added

  • /changelog route in cards_app.py, rendering CHANGELOG.md (this file) as styled HTML via the markdown library, rather than it only existing as a standalone file. Reads from disk on every page load, so editing this file is the only step needed to update what shows there — no template or route change required.
  • "Changelog" added to the site-wide nav.

Fixed

  • The first version of this file had bullet lists silently failing to render as lists — Markdown requires a blank line between a paragraph (**Added**) and a following bullet list, or it treats them as one run-on paragraph. Fixed at the source (17 places), not papered over in the renderer, so the file is correctly-formatted Markdown on its own merits.
v1.5 Deployment
Made the site reachable from outside the local machine — no visible change to how it looks or works.

Added

  • Made the site reachable outside the local machine.
v1.4 Operational Tooling
Added simple internal tools for managing the system. No player-facing changes.

Added

  • A set of double-clickable .bat files (C:\GA DB\scripts) wrapping every routine and occasional operation: adding cards/art changes, adding a new category, full flatten and rebuild, forcing an immediate refresh cycle, rerolling only, resetting drop history only, starting the web app, and a read-only status check — plus a README documenting which of these are actually the same underlying operation under different names (art-change and new-cards are identical; new-category is the same scan with one extra one-time step first).
  • Destructive batch operations (full flatten, history-only reset) require typing YES in the console before proceeding, on top of the underlying scripts' own --confirm requirement — two deliberate confirmation layers for anything irreversible.
v1.3 Data Integrity, Policy & Operational Procedures
Behind-the-scenes reliability work — including a fix for a bug that could have wiped everyone's collection during a routine update.

Fixed

  • Diagnosed a real bug where different players' pages showed different total card counts per category (should only ever differ in how many are unlocked, never in the total). Root cause: a folder restructure plus card relabeling had left the catalog internally inconsistent — old entries under old names/paths sitting alongside new ones, since the scanner matches on filename and treats a rename as a new entry rather than an update to the old one.

Added

  • reset_dropper.py --wipe-catalog — full-flatten mode, clearing the catalog and all drop history together (required together, since card IDs auto-increment and a catalog rebuild invalidates old ownership regardless). Verified end-to-end: flatten → rescan → reroll produced identical pool-size figures to the original correct catalog, and fixed the actual per-player inconsistency bug in practice.

Changed

  • Established the no-guaranteed-drops policy explicitly (an earlier design pass considered anchoring Mercenary to "at least one player per match gets a drop" — dropped in favor of every roll being fully independent, no per-match guarantee mechanic).
  • Established the operational procedures for the three routine situations: art rework (straight file swap, no rescan strictly required), new category folder (one-time scanner allow-list edit, then routine), and full flatten (the destructive multi-step procedure above).
v1.2 Visual & Interaction Polish
Cards now have a proper 3D flip-and-spin viewer, glowing rarities, shiny foils, and a flashy reveal animation when you open one.

Added

  • Rarity-colored glow on owned cards, intensity ramping by tier (Mythical pulses continuously).
  • Foil cards: persistent ambient holographic drift.
  • True interactive 3D card viewer on click — drag to rotate, click/space to flip, correct foil-vs-standard back selected automatically.
  • Click-to-open zoom entrance with a rarity-colored radial flash and a particle burst, count/size scaling with both rarity and foil status.
  • Cursor-tracked light reflection and subtle tilt on hover, replacing an earlier fixed/canned sweep animation that didn't actually react to the mouse.
  • A procedurally generated standard card back (card_back.png, via Pillow) to pair with the user-supplied foil back (card_back_foil.png), after the original standard back from an earlier session turned out to be unrecoverable — rebuilt from the same generation recipe (radar grid, HUD corner brackets, hexagon badge, four-point beacon star, wordmark) rather than left missing.
  • A stamped "LOCKED" ribbon over redacted cards, using the correct foil/standard back depending on the hidden card's actual foil status.
  • Removed the on-card name/rarity/foil text overlay from grid tiles once it became clear the card art already carries this information — info is still available via hover tooltip.
  • Drop Log: readable map names (reusing the same map_game_info.friendly_name_msg_id → asm_data_set_msg_translations join the MMR dashboard already uses), and expanded to show every participant in a match (not just successful drops), each with their actual outcome or ineligibility reason.
  • Drop Log entries and player names both made clickable — cards open the 3D viewer, names link to that player's dossier page.

Fixed

  • Reduced-motion accessibility setting was suppressing the entire entrance/flash/particle effect (by design, but not the intended default for this use case) — diagnosed by reproducing the exact symptom (instant snap, no particles/flash, identical across Chrome and Edge) via headless-browser emulation of prefers-reduced-motion. Resolved by pointing at the correct Windows OS-level animation-effects setting rather than a code change, since the app was working as designed.
  • Particles were rendering in front of the card, covering the art — root cause was a missing explicit z-index/position on the card's entrance wrapper, so the positioned particle layer painted above it by default CSS stacking rules. Fixed by explicitly stacking the card above the particle field, so particles now correctly read as bursting from behind/around the card, never across the face.
  • Text-selection and native image-drag-ghost artifacts were appearing during click-and-drag rotation in the 3D viewer — fixed with user-select: none, draggable="false" on both card faces, and preventDefault() on the relevant pointer events.
  • A latent PowerShell/Flask relative-path bug where a card image route could 404 depending on the app's working directory — same underlying fix as the catalog absolute-path fix in v1.0.

Changed

  • Particle system substantially "juiced up" after feedback that the first pass wasn't dramatic enough: counts roughly doubled per rarity tier, added a foil-specific multiplier (1.6x) on top of the rarity scaling, wider burst radius, stronger dual-layer glow, slight rotation on particles in flight, and the radial flash now scales in both size and peak brightness by rarity, not just color.
  • Rarity glow intensity reduced ~30% across all tiers after it read as too strong in practice.
  • The 3D viewer and its supporting script were refactored out of player.html and into base.html so every page can use it — this is what made Drop Log cards clickable without duplicating the viewer logic per page. The click trigger is now a generic card-clickable class rather than being hardcoded to the player grid.
v1.1 Web Frontend (Card Registry)
The card collection website went live — see your own cards and everyone else's collection.

Added

  • cards_app.py — separate Flask process from the MMR dashboard (app.py), different port, read-only.
  • Routes: / (roster, ranked by collection completion), /player/<id> (full dossier grouped by category), /log (per-match drop audit, paginated), /card-image/<id> (serves art from tcg_cards).
  • /player/MasterCollection — hidden QA view showing every card as owned, deliberately unlinked from the UI, only reachable by URL.
  • HUD/dossier visual design system: gunmetal/steel palette, amber accent, Rajdhani/IBM Plex Sans/IBM Plex Mono type roles, rarity color coding.
  • Locked/redacted card tiles.
v1.0 MAJOR Card Dropper Engine
Cards started dropping for real! Every eligible completed match now has a chance to award a card, with odds that vary by rarity and mission type.

Added

  • scan_card_catalog.py — walks C:\GA Cards\Cards, populates tcg_cards. Category determined by containing folder, never filename.
  • roll_drops.py — the engine itself: resolves each match's mode (including the Sonoran map-name lookup), applies the participation rule, rolls stage-1/stage-2 dice, handles the exhaustion cascade, writes a full per-match/per-player/per-roll audit trail to tcg_drop_rolls regardless of outcome, and successful awards to tcg_player_cards. Idempotent via a tcg_drop_processed watermark, same operating model as the MMR engines.
  • reset_dropper.py — operational reset tool; defaults to a dry run, requires --confirm. Later extended with --wipe-catalog for a full flatten (see v1.3).
  • New SQL tables: tcg_cards, tcg_rarity_weights, tcg_mode_drop_rates (config, seeded from the design in v0.3), tcg_drop_rolls, tcg_player_cards, tcg_drop_processed.

Fixed

  • scan_card_catalog.py originally stored whatever path was passed via --root, which could be relative — this broke image serving once the web app ran from a different working directory. Fixed to always store absolute paths regardless of how --root was invoked.

Changed

  • Added a cutoff date (CUTOFF_STARTED_AT, currently 2026-07-12 UTC) to the eligible-matches query — matches before this are permanently excluded, enforced in SQL, not as an after-the-fact filter.
  • Added a PVE-only disqualifier: any player with a TEAM_CHANGE event during a match in the standard PvE ladder or either raid type (not Double Agent, not PvP) is ineligible for that match's drop regardless of participation — verified against real data to fire correctly (84 real cases) and never misfire on PvP/Double Agent modes, where team swaps are normal.
v0.3 Mission Taxonomy & Drop Economy Design
Planning and maths behind the card system's mission types and drop odds — still nothing live.

(Full detail lives in GA_mission_taxonomy_reference.md §1-§18; this is the build-history summary.)

Added

  • Derived the full PvE difficulty ladder (Medium → High → Max → Ultra-Max → Super Agent) directly from the DB via ga_instances.queue_id → ga_queues.difficulty_value_id → asm_data_set_valid_values.
  • Identified Double Agent as a distinct PvPvE mode riding on top of three PvE difficulty tiers, not a difficulty tier itself.
  • Identified Raids (Sonoran — 4 maps sharing an orphaned queue_id — and Dome Defence Raid, which turned out to be the same content as the ddr queue) via map name, since queue linkage doesn't cover them reliably.
  • Solved the 11-tier rarity curve (geometric decay, anchored so Mythical = exactly 0.25%).
  • Derived the flat per-mode stage-1 drop-chance table (no guaranteed drops; PvE difficulty drives rarity, PvP mode drives frequency).
  • Solved the participation rule: ≥10% of the match's actual observed duration (not configured mission time, which was found to be wrong by up to 2.5x for some modes) + present at the end, with credited time summed across every qualifying segment rather than crediting only the longest stint (deliberately different from the MMR system's rule).
  • Computed the master mode × rarity effective-probability table (§17).
  • Established the no-retroactive-drops cutoff policy (§18).
  • Confirmed the exhaustion cascade rule (roll down one rarity rank at a time on ownership exhaustion, silent, no floor beyond Common) and later confirmed Mythical is genuinely a single, non-unique-per-player card — multiple players can each earn their own copy.

Changed

  • Original two-stage roll design (rarity, then a separate foil coin-flip) was dropped in favor of one flat roll against all 11 combined rarity+foil weights directly, per user preference.
v0.2 MMR Reference Reconciliation
Internal documentation cleanup for the ranking system. No player-facing changes.

Fixed

  • Two MMR reference docs (MMR_SETUP_AND_OPERATIONS.md and MMR_SYSTEM_ARCHITECTURE.md) disagreed on two points: whether compute_mmr.py runs incrementally (with a watermark) or does a full rebuild every run, and whether ga_player_class_ratings is still written. Confirmed against the actual current code: the Setup doc was correct (incremental + watermarked; ga_player_class_ratings retired), the Architecture doc was stale.
v0.1 Automated Database Refresh Pipeline
Behind-the-scenes work to keep the card database synced automatically with the game. Nothing player-facing yet.

Added

  • refresh_db.ps1 — scheduled Windows Task Scheduler script that pulls the latest redacted DB export on a timer and runs the full pipeline.
  • sync_source_tables.py — syncs only the game server's six source tables (ga_instances, ga_match_player_stats, ga_instance_players, ga_match_events, ga_users, map_game_info) from a fresh export, leaving engine-owned tables untouched.

Fixed

  • Critical: the original refresh design swapped the entire DB file on every cycle, which would have silently wiped the MMR engines' derived history tables (and later, the dropper's) every single refresh, forcing an unwanted full replay each time. Redesigned around sync_source_tables.py using ATTACH DATABASE to selectively sync only source tables instead.
  • PowerShell's Invoke-WebRequest progress-bar rendering has a known bug that makes large downloads appear to hang indefinitely — fixed with $ProgressPreference = 'SilentlyContinue'.
  • schtasks scheduled-task command was missing quotes around a path containing a space (C:\GA DB\...) — fixed.
  • compute_mmr.py / mmr_tracker.py had a DB_PATH set via os.path.join(BASE_DIR, "C:\GA DB\...") — the absolute path silently discarded BASE_DIR, and unescaped backslashes triggered Python SyntaxWarnings. Fixed to a plain raw string (r"C:\GA DB\...").
Card back
WHO ELSE HOLDS THIS →
DRAG TO ROTATE · CLICK OR SPACE TO FLIP · ESC TO CLOSE
Registry Dispatch · The Prints Update

Originals, Prints & The Trading Hall

Your cards just became real objects with history. Every card you hold is now a numbered print — open one in the viewer to see its mint number and date. Print #1 of every card is stamped ★ THE ORIGINAL, permanently: exactly one exists per card, whoever ends up holding it.

Originals now score 1.5× points (rounded up) on the leaderboard — a Mythical jumps 11 → 17, a Mythical Player card 15 → 23. First-finders already have their premium; the rankings have moved. Full table on the home page.

🔒 Coming soon — THE TRADING HALL. Trade cards with each other: open offers on a public floor, direct offers, many-for-many bundles, and a permanent trade ledger. Trade for an original and its premium comes with it. Six trading achievements are already on the board, waiting.

Log in with Discord Then claim your player profile — be inside when the doors open.