From ea430d939b9ca43d3bb5da7d6521473de56995ab Mon Sep 17 00:00:00 2001 From: xpoes123 Date: Tue, 18 Aug 2026 23:54:18 -0400 Subject: [PATCH 1/2] feat(cards): let /pack daily choose the sport + season Previously the free daily pack was always the newest set across sports. Add optional sport/season args (mirroring /pack open); omitting both keeps the newest-set default. The one-per-day claim gate is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- bot/cogs/cards.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/bot/cogs/cards.py b/bot/cogs/cards.py index 4611dd3..f4eb488 100644 --- a/bot/cogs/cards.py +++ b/bot/cogs/cards.py @@ -77,6 +77,10 @@ def _tradeup_dupes(collection: list[dict], sport: str, season: int, rarity: str) return dupes +def _seasons_for(sets: list[dict], sport: str) -> str: + return ", ".join(str(s["season"]) for s in sets if s["sport"] == sport) or "none yet" + + def _card_line(c: dict) -> str: """One-line label for a pulled/owned card.""" bits = [RARITY_EMOJI.get(c["rarity"], "")] @@ -361,7 +365,15 @@ async def pack_open( await self._grant_achievements(uid, interaction.channel) @pack.command(name="daily", description="Claim your free daily pack") - async def pack_daily(self, interaction: discord.Interaction): + @app_commands.describe( + sport="Which league to claim (defaults to the newest set)", + season="Season year — required if you pick a sport", + ) + @app_commands.choices(sport=SPORT_CHOICES) + async def pack_daily( + self, interaction: discord.Interaction, + sport: app_commands.Choice[str] | None = None, season: int | None = None, + ): await interaction.response.defer() uid = str(interaction.user.id) day = datetime.now(timezone.utc).strftime("%Y-%m-%d") @@ -372,8 +384,18 @@ async def pack_daily(self, interaction: discord.Interaction): if not sets: await interaction.followup.send("No card sets are available yet.") return - # newest season across sports - cset = max(sets, key=lambda s: (s["season"], s["set_id"])) + if sport is not None: + if season is None: + await interaction.followup.send(f"Pick a season too — available: {_seasons_for(sets, sport.value)}") + return + cset = next((s for s in sets if s["sport"] == sport.value and s["season"] == season), None) + if not cset: + await interaction.followup.send( + f"No **{sport.name} {season}** pack. Available {sport.name} seasons: {_seasons_for(sets, sport.value)}" + ) + return + else: + cset = max(sets, key=lambda s: (s["season"], s["set_id"])) # newest season across sports try: cards_out = await queries.mint_pack(uid, cset["set_id"], 5, "daily", _now_iso()) except ValueError as e: From dfc3fae994fb5dd607e39804ca7fb872625b39f8 Mon Sep 17 00:00:00 2001 From: xpoes123 Date: Wed, 19 Aug 2026 00:02:46 -0400 Subject: [PATCH 2/2] feat(cards): 3 free daily packs per user instead of 1 Track a per-day claim count (new card_pack_claims.claims column, upsert increment) and gate at DAILY_PACKS_PER_DAY=3. Reveal title shows packs left. Web daily/status now returns packs_left. Existing rows migrate to 1 claim used. Co-Authored-By: Claude Opus 4.8 (1M context) --- bot/cogs/cards.py | 10 +++++++--- db/queries.py | 16 +++++++++++----- db/schema.py | 7 +++++++ web/cards.py | 11 ++++++++--- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/bot/cogs/cards.py b/bot/cogs/cards.py index f4eb488..f86a32c 100644 --- a/bot/cogs/cards.py +++ b/bot/cogs/cards.py @@ -377,8 +377,11 @@ async def pack_daily( await interaction.response.defer() uid = str(interaction.user.id) day = datetime.now(timezone.utc).strftime("%Y-%m-%d") - if await queries.has_claimed_daily_pack(uid, day): - await interaction.followup.send("You already claimed your free pack today. Come back tomorrow!") + claimed = await queries.daily_packs_claimed(uid, day) + if claimed >= queries.DAILY_PACKS_PER_DAY: + await interaction.followup.send( + f"You've claimed all {queries.DAILY_PACKS_PER_DAY} free packs today. Come back tomorrow!" + ) return sets = [s for s in await queries.list_card_sets(include_closed=False)] if not sets: @@ -402,7 +405,8 @@ async def pack_daily( await interaction.followup.send(f"❌ {e}") return await queries.record_daily_pack_claim(uid, day) - title = f"🎁 Free daily pack — {cset['name']}" + left = queries.DAILY_PACKS_PER_DAY - (claimed + 1) + title = f"🎁 Free daily pack — {cset['name']} ({left} left today)" await self._send_reveal(interaction, cards_out, title, cset["set_id"], fast=await queries.get_cards_fast_open(uid)) await self._dm_wanters(cards_out, interaction.user) diff --git a/db/queries.py b/db/queries.py index 73a1e82..0563066 100644 --- a/db/queries.py +++ b/db/queries.py @@ -4747,19 +4747,25 @@ async def get_design_owners(design_id: int) -> tuple[dict | None, list[dict]]: return {**_design_public(d), "sport": d["sport"], "season": d["season"]}, owners -# --- daily free pack --- -async def has_claimed_daily_pack(user: str, day: str) -> bool: +# --- daily free packs --- +DAILY_PACKS_PER_DAY = 3 + + +async def daily_packs_claimed(user: str, day: str) -> int: async with aiosqlite.connect(DB_PATH) as db: cur = await db.execute( - "SELECT 1 FROM card_pack_claims WHERE discord_user = ? AND day = ?", (user, day) + "SELECT claims FROM card_pack_claims WHERE discord_user = ? AND day = ?", (user, day) ) - return await cur.fetchone() is not None + row = await cur.fetchone() + return row[0] if row else 0 async def record_daily_pack_claim(user: str, day: str) -> None: async with aiosqlite.connect(DB_PATH) as db: await db.execute( - "INSERT OR IGNORE INTO card_pack_claims (discord_user, day) VALUES (?, ?)", (user, day) + "INSERT INTO card_pack_claims (discord_user, day, claims) VALUES (?, ?, 1) " + "ON CONFLICT(discord_user, day) DO UPDATE SET claims = claims + 1", + (user, day), ) await db.commit() diff --git a/db/schema.py b/db/schema.py index 489d7a7..26ab561 100644 --- a/db/schema.py +++ b/db/schema.py @@ -512,6 +512,7 @@ CREATE TABLE IF NOT EXISTS card_pack_claims ( discord_user TEXT NOT NULL, day TEXT NOT NULL, + claims INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (discord_user, day) ); @@ -551,6 +552,12 @@ async def init_db() -> None: await db.commit() except Exception: pass # column already exists + # Migration: multi-claim daily packs — existing rows counted as 1 claim used + try: + await db.execute("ALTER TABLE card_pack_claims ADD COLUMN claims INTEGER NOT NULL DEFAULT 1") + await db.commit() + except Exception: + pass # column already exists await db.execute("CREATE INDEX IF NOT EXISTS idx_games_sport_start ON games(sport, start_time)") await db.execute("CREATE INDEX IF NOT EXISTS idx_odds_snapshots_game_source_kind_time ON odds_snapshots(game_id, source, kind, captured_at)") await db.commit() diff --git a/web/cards.py b/web/cards.py index f22500c..0fed7ba 100644 --- a/web/cards.py +++ b/web/cards.py @@ -92,7 +92,12 @@ async def daily_status(request: Request): if not sess: return {"authenticated": False, "claimed": False} day = datetime.now(timezone.utc).strftime("%Y-%m-%d") - return {"authenticated": True, "claimed": await queries.has_claimed_daily_pack(sess["id"], day)} + claimed = await queries.daily_packs_claimed(sess["id"], day) + return { + "authenticated": True, + "claimed": claimed >= queries.DAILY_PACKS_PER_DAY, + "packs_left": max(0, queries.DAILY_PACKS_PER_DAY - claimed), + } @router.post("/daily") @@ -102,8 +107,8 @@ async def open_daily(request: Request): return JSONResponse({"error": "sign in to open packs"}, status_code=401) uid = sess["id"] day = datetime.now(timezone.utc).strftime("%Y-%m-%d") - if await queries.has_claimed_daily_pack(uid, day): - return JSONResponse({"error": "already claimed today"}, status_code=409) + if await queries.daily_packs_claimed(uid, day) >= queries.DAILY_PACKS_PER_DAY: + return JSONResponse({"error": "all free packs claimed today"}, status_code=409) sets = await queries.list_card_sets(include_closed=False) if not sets: return JSONResponse({"error": "no sets available"}, status_code=404)