diff --git a/bot/cogs/cards.py b/bot/cogs/cards.py index 4611dd3..60d1a81 100644 --- a/bot/cogs/cards.py +++ b/bot/cogs/cards.py @@ -660,8 +660,11 @@ async def wishlist_remove(self, interaction: discord.Interaction, player: str): user="Who to trade with", give="Instance id(s) you're giving, comma-separated", want="Instance id(s) you want, comma-separated", + coins="Coins to sweeten your side (positive = you add, negative = you want coins)", ) - async def trade_offer(self, interaction: discord.Interaction, user: discord.User, give: str, want: str): + async def trade_offer( + self, interaction: discord.Interaction, user: discord.User, give: str, want: str, coins: int = 0, + ): await interaction.response.defer() uid = str(interaction.user.id) if user.id == interaction.user.id: @@ -674,20 +677,28 @@ async def trade_offer(self, interaction: discord.Interaction, user: discord.User await interaction.followup.send("Instance ids must be numbers, comma-separated.") return mine = await queries.get_owned_instances(uid, give_ids) - if len(mine) != len(give_ids) or not give_ids: + if len(mine) != len(give_ids): await interaction.followup.send("You must own every card you offer (check the instance ids).") return + if not give_ids and coins <= 0: + await interaction.followup.send("Offer at least one card or some coins.") + return theirs = await queries.get_owned_instances(str(user.id), want_ids) if len(theirs) != len(want_ids) or not want_ids: await interaction.followup.send(f"{user.display_name} must own every card you request.") return - tid = await queries.create_card_trade(uid, str(user.id), give_ids, want_ids, _now_iso()) - give_s = ", ".join(f"{m['rarity'].title()} {m['subject_name']}" for m in mine) + tid = await queries.create_card_trade(uid, str(user.id), give_ids, want_ids, _now_iso(), coins=coins) + give_s = ", ".join(f"{m['rarity'].title()} {m['subject_name']}" for m in mine) or "β" want_s = ", ".join(f"{t['rarity'].title()} {t['subject_name']}" for t in theirs) + coin_note = "" + if coins > 0: + coin_note = f" + **{coins}πͺ**" + elif coins < 0: + coin_note = f" (and wants **{-coins}πͺ** back)" emb = discord.Embed( title=f"π Trade offer #{tid}", description=( - f"{interaction.user.mention} offers **{give_s}**\n" + f"{interaction.user.mention} offers **{give_s}**{coin_note}\n" f"for {user.mention}'s **{want_s}**\n\n" f"{user.mention}: `/cardtrade accept {tid}` or `/cardtrade decline {tid}`" ), diff --git a/db/queries.py b/db/queries.py index 3b67f47..b1bb836 100644 --- a/db/queries.py +++ b/db/queries.py @@ -4644,10 +4644,11 @@ async def get_collection(user: str) -> tuple[list[dict], float]: db.row_factory = aiosqlite.Row cur = await db.execute( "SELECT i.*, d.subject_name, d.team, d.rarity, d.headshot_url, d.stats, d.total_copies, " - " s.sport, s.season " + " s.sport, s.season, (tl.instance_id IS NOT NULL) AS listed " "FROM card_instances i JOIN card_designs d ON i.design_id = d.design_id " - "JOIN card_sets s ON d.set_id = s.set_id WHERE i.owner_id = ? " - "ORDER BY i.book_value DESC", + "JOIN card_sets s ON d.set_id = s.set_id " + "LEFT JOIN card_trade_listings tl ON tl.instance_id = i.instance_id " + "WHERE i.owner_id = ? ORDER BY i.book_value DESC", (user,), ) rows = await cur.fetchall() @@ -4667,6 +4668,7 @@ async def get_collection(user: str) -> tuple[list[dict], float]: "book_value": r["book_value"], "headshot_url": r["headshot_url"], "stats": json.loads(r["stats"]) if r["stats"] else {}, + "listed": bool(r["listed"]), } for r in rows ] @@ -4690,6 +4692,7 @@ async def sell_instance(user: str, instance_id: int) -> tuple[dict, int]: raise ValueError("you don't own that card") coins = max(1, round(r["book_value"] * QUICK_SELL_FRACTION)) await db.execute("DELETE FROM card_instances WHERE instance_id = ?", (instance_id,)) + await db.execute("DELETE FROM card_trade_listings WHERE instance_id = ?", (instance_id,)) await db.execute( "INSERT INTO casino_wallets (discord_user, balance) VALUES (?, ?) " "ON CONFLICT(discord_user) DO UPDATE SET balance = balance + ?", @@ -4946,13 +4949,16 @@ async def get_card_wanters(design_id: int) -> list[str]: # --- trading (card-for-card) --- async def create_card_trade( - from_user: str, to_user: str, offer_ids: list[int], want_ids: list[int], now_iso: str + from_user: str, to_user: str, offer_ids: list[int], want_ids: list[int], now_iso: str, + coins: int = 0, ) -> int: + """Create a pending trade. `coins` is a signed sweetener: >0 = from_user adds coins to + their side (paid to to_user on accept); <0 = from_user requests coins from to_user.""" async with aiosqlite.connect(DB_PATH) as db: cur = await db.execute( - "INSERT INTO card_trades (from_user, to_user, offer_ids, want_ids, created_at) " - "VALUES (?, ?, ?, ?, ?)", - (from_user, to_user, json.dumps(offer_ids), json.dumps(want_ids), now_iso), + "INSERT INTO card_trades (from_user, to_user, offer_ids, want_ids, coins, created_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + (from_user, to_user, json.dumps(offer_ids), json.dumps(want_ids), coins, now_iso), ) await db.commit() return cur.lastrowid @@ -4964,11 +4970,15 @@ async def get_card_trade(trade_id: int) -> dict | None: r = await (await db.execute("SELECT * FROM card_trades WHERE trade_id = ?", (trade_id,))).fetchone() if r is None: return None - return { - "trade_id": r["trade_id"], "from_user": r["from_user"], "to_user": r["to_user"], - "offer_ids": json.loads(r["offer_ids"]), "want_ids": json.loads(r["want_ids"]), - "status": r["status"], "created_at": r["created_at"], - } + return _trade_row(r) + + +def _trade_row(r) -> dict: + return { + "trade_id": r["trade_id"], "from_user": r["from_user"], "to_user": r["to_user"], + "offer_ids": json.loads(r["offer_ids"]), "want_ids": json.loads(r["want_ids"]), + "coins": r["coins"], "status": r["status"], "created_at": r["created_at"], + } async def list_incoming_card_trades(user: str) -> list[dict]: @@ -4978,14 +4988,17 @@ async def list_incoming_card_trades(user: str) -> list[dict]: "SELECT * FROM card_trades WHERE to_user = ? AND status = 'pending' ORDER BY trade_id DESC", (user,), ) - return [ - { - "trade_id": r["trade_id"], "from_user": r["from_user"], "to_user": r["to_user"], - "offer_ids": json.loads(r["offer_ids"]), "want_ids": json.loads(r["want_ids"]), - "status": r["status"], "created_at": r["created_at"], - } - for r in await cur.fetchall() - ] + return [_trade_row(r) for r in await cur.fetchall()] + + +async def list_outgoing_card_trades(user: str) -> list[dict]: + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + cur = await db.execute( + "SELECT * FROM card_trades WHERE from_user = ? AND status = 'pending' ORDER BY trade_id DESC", + (user,), + ) + return [_trade_row(r) for r in await cur.fetchall()] async def set_card_trade_status(trade_id: int, status: str) -> None: @@ -5023,6 +5036,27 @@ async def _owned_by(ids: list[int], owner: str) -> bool: raise ValueError("the offering player no longer owns those cards") if not await _owned_by(want_ids, to_user): raise ValueError("you no longer own the requested cards") + # Coin sweetener: >0 from_user pays to_user, <0 to_user pays from_user. Verify the + # payer's balance NOW (no escrow) and move coins inside this same transaction. + coins = t["coins"] or 0 + if coins != 0: + amt = abs(coins) + payer, payee = (from_user, to_user) if coins > 0 else (to_user, from_user) + bal = await (await db.execute( + "SELECT balance FROM casino_wallets WHERE discord_user = ?", (payer,) + )).fetchone() + have = bal["balance"] if bal else 0 + if have < amt: + who = "you don't" if payer == accepting_user else "the offering player doesn't" + raise ValueError(f"{who} have enough coins for this trade ({have}/{amt})") + await db.execute( + "UPDATE casino_wallets SET balance = balance - ? WHERE discord_user = ?", (amt, payer) + ) + await db.execute( + "INSERT INTO casino_wallets (discord_user, balance) VALUES (?, ?) " + "ON CONFLICT(discord_user) DO UPDATE SET balance = balance + ?", + (payee, CASINO_STARTING_COINS + amt, amt), + ) for iid in offer_ids: await db.execute( "UPDATE card_instances SET owner_id = ?, source = 'trade' WHERE instance_id = ?", @@ -5033,14 +5067,110 @@ async def _owned_by(ids: list[int], owner: str) -> bool: "UPDATE card_instances SET owner_id = ?, source = 'trade' WHERE instance_id = ?", (from_user, iid), ) + # Traded cards leave the public board. + swapped = offer_ids + want_ids + if swapped: + await db.execute( + f"DELETE FROM card_trade_listings WHERE instance_id IN ({','.join('?' * len(swapped))})", + tuple(swapped), + ) await db.execute("UPDATE card_trades SET status = 'accepted' WHERE trade_id = ?", (trade_id,)) await db.commit() - return {"offer_ids": offer_ids, "want_ids": want_ids, "from_user": from_user, "to_user": to_user} + return { + "offer_ids": offer_ids, "want_ids": want_ids, "coins": coins, + "from_user": from_user, "to_user": to_user, + } except Exception: await db.execute("ROLLBACK") raise +async def get_instances_public(instance_ids: list[int]) -> dict[int, dict]: + """Card preview (name/rarity/headshot/serial/owner) for a set of instance ids, keyed by + instance_id. Used to render trade-offer and market rows without per-card round-trips.""" + if not instance_ids: + return {} + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + q = ( + "SELECT i.instance_id, i.owner_id, i.serial, i.is_holo, i.gem, i.book_value, " + "d.subject_name, d.team, d.rarity, d.headshot_url, d.total_copies, s.sport, s.season " + "FROM card_instances i JOIN card_designs d ON i.design_id = d.design_id " + "JOIN card_sets s ON d.set_id = s.set_id " + f"WHERE i.instance_id IN ({','.join('?' * len(instance_ids))})" + ) + rows = await (await db.execute(q, tuple(instance_ids))).fetchall() + return { + r["instance_id"]: { + "instance_id": r["instance_id"], "owner_id": r["owner_id"], "name": r["subject_name"], + "team": r["team"], "rarity": r["rarity"], "headshot_url": r["headshot_url"], + "serial": r["serial"], "total_copies": r["total_copies"], "is_holo": bool(r["is_holo"]), + "gem": r["gem"], "book_value": r["book_value"], "sport": r["sport"], "season": r["season"], + } + for r in rows + } + + +async def create_trade_listing(instance_id: int, owner_id: str, note: str | None, now_iso: str) -> None: + """List a card on the public trade board (open to offers). Verifies ownership; upsert so + re-listing just refreshes the note. Raises ValueError if the caller doesn't own it.""" + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + owns = await (await db.execute( + "SELECT 1 FROM card_instances WHERE instance_id = ? AND owner_id = ?", (instance_id, owner_id) + )).fetchone() + if owns is None: + raise ValueError("you don't own that card") + await db.execute( + "INSERT INTO card_trade_listings (instance_id, owner_id, note, created_at) VALUES (?, ?, ?, ?) " + "ON CONFLICT(instance_id) DO UPDATE SET owner_id = excluded.owner_id, note = excluded.note", + (instance_id, owner_id, (note or "").strip()[:120] or None, now_iso), + ) + await db.commit() + + +async def remove_trade_listing(instance_id: int, owner_id: str) -> None: + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + "DELETE FROM card_trade_listings WHERE instance_id = ? AND owner_id = ?", (instance_id, owner_id) + ) + await db.commit() + + +async def list_trade_market(limit: int = 200) -> list[dict]: + """Active listings for the public Market: card preview + owner name/avatar + note. Skips any + listing whose card has since changed hands (self-heals stale rows).""" + async with aiosqlite.connect(DB_PATH) as db: + db.row_factory = aiosqlite.Row + cur = await db.execute( + "SELECT l.instance_id, l.note, l.created_at, l.owner_id, " + " i.serial, i.is_holo, i.gem, i.book_value, i.owner_id AS cur_owner, " + " d.subject_name, d.team, d.rarity, d.headshot_url, d.total_copies, s.sport, s.season, " + " u.username, u.avatar_url " + "FROM card_trade_listings l " + "JOIN card_instances i ON i.instance_id = l.instance_id " + "JOIN card_designs d ON i.design_id = d.design_id " + "JOIN card_sets s ON d.set_id = s.set_id " + "LEFT JOIN discord_users u ON u.discord_user = l.owner_id " + "WHERE i.owner_id = l.owner_id " # self-heal: skip listings whose card moved + "ORDER BY l.created_at DESC LIMIT ?", + (limit,), + ) + rows = await cur.fetchall() + return [ + { + "instance_id": r["instance_id"], "note": r["note"], "owner_id": r["owner_id"], + "owner_name": r["username"] or f"Player {str(r['owner_id'])[:6]}", + "owner_avatar": r["avatar_url"], + "name": r["subject_name"], "team": r["team"], "rarity": r["rarity"], + "headshot_url": r["headshot_url"], "serial": r["serial"], "total_copies": r["total_copies"], + "is_holo": bool(r["is_holo"]), "gem": r["gem"], "book_value": r["book_value"], + "sport": r["sport"], "season": r["season"], + } + for r in rows + ] + + async def get_owned_instances(user: str, instance_ids: list[int]) -> list[dict]: """Fetch specific owned instances (for validating a proposed trade).""" if not instance_ids: diff --git a/db/schema.py b/db/schema.py index 800f61a..b1b77cb 100644 --- a/db/schema.py +++ b/db/schema.py @@ -535,10 +535,20 @@ to_user TEXT NOT NULL, offer_ids TEXT NOT NULL, -- JSON list of instance_ids offered want_ids TEXT NOT NULL, -- JSON list of instance_ids requested + coins INTEGER NOT NULL DEFAULT 0, -- signed sweetener: >0 from_user pays to_user, <0 requests status TEXT NOT NULL DEFAULT 'pending', -- pending/accepted/declined/cancelled created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_card_trades_to ON card_trades(to_user, status); + +-- Public trade board: a card its owner has opted onto the market (open to offers). +CREATE TABLE IF NOT EXISTS card_trade_listings ( + instance_id INTEGER PRIMARY KEY REFERENCES card_instances(instance_id), + owner_id TEXT NOT NULL, + note TEXT, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_card_trade_listings_owner ON card_trade_listings(owner_id); """ @@ -814,6 +824,12 @@ async def init_db() -> None: await db.commit() except Exception: pass + # Migration: coin sweetener on card trades (signed; >0 from_user pays to_user) + try: + await db.execute("ALTER TABLE card_trades ADD COLUMN coins INTEGER NOT NULL DEFAULT 0") + await db.commit() + except Exception: + pass # column already exists # Migration: card set-completion claims (one reward per user per set) try: await db.execute( diff --git a/docs/superpowers/specs/2026-08-19-card-trade-market-design.md b/docs/superpowers/specs/2026-08-19-card-trade-market-design.md new file mode 100644 index 0000000..29b7db4 --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-card-trade-market-design.md @@ -0,0 +1,157 @@ +# Card Trading Market β Design + +**Date:** 2026-08-19 +**Status:** Approved design, pending implementation plan +**Origin:** Port of the `nsba-markets` card trading market +(`backend/app/routers/cards_market.py`) onto SharpLab's existing card system. + +## 1. Summary + +Give SharpLab cards a **public trade board** on the web. An owner **lists** a card as +open to offers (with an optional "looking forβ¦" note); anyone browsing the **Market** +tab can **make a trade offer** on it β their own card(s) **Β± coins** for the listed +card. The owner accepts or declines; accepting atomically swaps the cards **and** the +coins. This extends SharpLab's existing card-for-trade system, which today is +**directed** (`/cardtrade offer @user`) and **Discord-only**, with (a) coins on trades +and (b) a web discovery + offer surface. + +**Selected scope:** web trade offers + coin-sweetened trades. +**Explicitly out of scope:** fixed-price auto-buy marketplace, timed auctions. + +## 2. What already exists (reused, not rebuilt) + +- `card_trades` table + queries `create_card_trade`, `get_card_trade`, + `list_incoming_card_trades`, `accept_card_trade`, `set_card_trade_status`, + `get_owned_instances` (`db/queries.py`). Directed card-for-card offers, + accept/decline, atomic ownership swap verified at accept time (**no escrow**). +- Discord `/cardtrade offer|accept|decline` (`bot/cogs/cards.py`). +- Web collection page with the sell/filter/collectors features shipped in #393. +- Coin economy: `casino_wallets`, `get_casino_balance`, `adjust`/credit patterns + used by `sell_instance` and `mint_pack`. + +## 3. Model + +- **Listing** β a card its owner has opted onto the public board. Lightweight: it + carries only an optional note. Not an escrow; the owner still holds the card and + can sell/trade/unlist it at any time. +- **Offer** β a directed `card_trade` from the offerer to the listed card's owner: + `offer_ids` (offerer's cards, may be empty), `want_ids` (the listed card, β₯1), + `coins` (signed sweetener). Reuses the existing trade row + accept flow. +- **Coins (signed).** `coins` on a trade = coins flowing **offerer β owner** on accept. + `+N` = offerer adds N coins to their side. `βN` = offerer requests N coins from the + owner. Empty `offer_ids` + `coins > 0` = a pure coin bid (a soft "buy" that still + requires the owner to accept β this is intentionally *not* an auto-buy marketplace). + +## 4. Data model (two additions) + +- `card_trades` gains `coins INTEGER NOT NULL DEFAULT 0` β signed sweetener. Added as + an idempotent `try: ALTER TABLE β¦ ADD COLUMN β¦ except Exception: pass` migration in + `db/schema.py::init_db`, matching the house idiom. +- New table `card_trade_listings`: + ```sql + CREATE TABLE IF NOT EXISTS card_trade_listings ( + instance_id INTEGER PRIMARY KEY REFERENCES card_instances(instance_id), + owner_id TEXT NOT NULL, + note TEXT, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_card_trade_listings_owner ON card_trade_listings(owner_id); + ``` + One row per listed card (PK = instance_id, so a card is listed at most once). A + listing is cleared (row deleted) when its card is **traded, sold, or unlisted**, and + is defensively skipped in market reads if `owner_id` no longer matches the instance's + current owner (self-heals if a card changes hands outside the market). + +## 5. Backend + +### 5.1 Queries (`db/queries.py`) + +- `create_trade_listing(instance_id, owner_id, note, now_iso)` β verify the caller owns + the instance; upsert a listing row. Raises `ValueError` if not owned. +- `remove_trade_listing(instance_id, owner_id)` β delete the listing (owner-scoped). +- `list_trade_market(limit=200)` β all active listings joined to design/instance/owner, + skipping any whose `owner_id` β the instance's current owner. Returns card summary + + owner name/avatar + note. Reuses the `discord_users` join pattern from `list_collectors`. +- Extend `create_card_trade(..., coins=0)` β persist the `coins` field. Callers that + omit it default to 0 (Discord back-compat). +- Extend `get_card_trade` / `list_incoming_card_trades` β return `coins`. +- `list_outgoing_card_trades(user)` β pending trades where `from_user = user` (for the + "sent offers" view + cancel). New. +- Extend `accept_card_trade(trade_id, accepting_user)` β inside the existing + `BEGIN IMMEDIATE` transaction, after the ownership checks: + - Determine payer/payee from `sign(coins)`: `coins>0` β from_user pays to_user; + `coins<0` β to_user pays from_user; `coins==0` β no coin move. + - Verify the payer's `casino_wallets.balance β₯ |coins|`; raise `ValueError` + ("not enough coins to complete this trade") otherwise. + - Debit payer, credit payee by `|coins|` (same wallet-upsert pattern as `sell_instance`). + - Then swap card ownership as today. Delete any `card_trade_listings` rows for the + swapped instances (both sides), so a traded card leaves the board. + - Return dict now includes `coins`. +- `sell_instance` β also delete any `card_trade_listings` row for the sold instance + (a sold card must leave the board). One extra `DELETE`. + +### 5.2 Web endpoints (`web/cards.py`) + +All session-gated via `auth.read_session` like `/sell`: + +- `GET /api/v1/cards/market` β `{listings: [...]}` (public; `list_trade_market`). +- `POST /api/v1/cards/list` `{instance_id, note?}` β list a card (owner only). +- `POST /api/v1/cards/unlist` `{instance_id}` β remove a listing. +- `POST /api/v1/cards/trade` `{want_ids, offer_ids?, coins?}` β create an offer. The + server resolves the target owner from the listed `want_ids` (all must belong to one + owner and be currently listed, unless the caller passes an explicit directed trade β + v1 only supports offering on listed cards). Validates the caller owns every + `offer_id`, owner β caller, and (if `coins>0`) the caller has the balance *now* as a + soft pre-check (authoritative check is still at accept). Returns the trade id. +- `GET /api/v1/cards/trades` β `{incoming: [...], outgoing: [...]}` with card previews + (name/rarity/headshot per instance id) and `coins`. +- `POST /api/v1/cards/trades/{id}/accept` β `accept_card_trade`; returns `{balance}`. +- `POST /api/v1/cards/trades/{id}/decline` β set status declined (recipient only). +- `POST /api/v1/cards/trades/{id}/cancel` β set status cancelled (sender only). + +Card previews for offer/trade rows reuse a small `get_instances_public(ids)` helper +(name, rarity, headshot, serial) β one query, no per-card round-trips. + +## 6. Web UI (`web/static/cards.js` + `cards.css`) + +- **Market tab** (new, alongside My Collection / Packs / Collectors). Grid of listed + cards: tile + owner chip + note + a **Make offer** button. Filter/sort bar reused + from the collection view. +- **List for trade** β a button on your own collection tiles (next to Sell) toggling + list/unlist, with an optional note prompt. Listed cards show a small "π Listed" badge. +- **Make offer modal** β pick which of your cards to give (multi-select from your + collection, filtered), set a coin amount (Β± via a signed number field), review, submit. +- **My Offers** β a section (in the Market tab or its own sub-view): **Incoming** + (accept/decline, shows what you'd give up and get) and **Outgoing** (cancel). Each + row previews both sides' cards + the coin delta and the resulting effect on balance. +- Balance chip updates via the existing `applyBalance` after accept. +- `?mock=1` fixtures extended for `/market`, `/trades`, and offer/accept so the page + previews without a backend. + +## 7. Discord (`bot/cogs/cards.py`) + +`/cardtrade offer` gains an optional `coins: int = 0` argument (positive = you add +coins, negative = you want coins), threaded into `create_card_trade`. The accept embed +shows the coin sweetener. No other Discord changes; listings/market are web-only in v1. + +## 8. Testing (`tests/test_cards.py`) + +- `accept_card_trade` moves coins: from_user pays to_user (`coins>0`), balances change + by exactly `|coins|`, cards swap. And the reverse sign (`coins<0`). +- Accept rejected when payer lacks coins (raises, nothing changes β transaction rolls back). +- List β offer (via web endpoint) β accept round-trip; the listing row is gone after. +- Pure-coin offer: `offer_ids=[]`, `coins>0`, want a listed card β accept transfers the + card and the coins. +- `sell_instance` clears a listing for the sold card. +- `list_trade_market` skips a listing whose card changed owners (self-heal). +- Web: `POST /list` rejects listing a card you don't own (owner check). + +## 9. Non-goals (v1) + +- No fixed-price **auto-buy** marketplace (a listing is not a "Buy now" β every transfer + goes through an owner-accepted offer). +- No timed **auctions** / bidding / buy-now / settlement job. +- No escrow β cards and coins are verified and moved only at accept time. +- No trade fees / house cut. +- No offering on **unlisted** cards from the browse-collection view in v1 (offers target + listed cards only; directed Discord offers still work as before). diff --git a/tests/test_cards.py b/tests/test_cards.py index 4a3b188..feda967 100644 --- a/tests/test_cards.py +++ b/tests/test_cards.py @@ -518,6 +518,160 @@ async def go(): _run(go()) +# --- Trade market: coin-sweetened trades + listings -------------------------- + + +async def _two_traders(tmp_db): + """A owns instance 1, B owns instance 2 (both 'rare'). Returns nothing; ids are 1 and 2.""" + _sid, designs = await _make_set("nba", 2024, [("rare", 2, 30)]) + d = _dids(designs, "rare") + await _give("A", d[0], 1, "rare") # instance_id 1 + await _give("B", d[1], 1, "rare") # instance_id 2 + + +def test_accept_trade_moves_coins_positive(tmp_db): + async def go(): + await _two_traders(tmp_db) + await _fund("A", 500) + await _fund("B", 500) + tid = await _queries.create_card_trade("A", "B", [1], [2], "t", coins=100) # A adds 100 + res = await _queries.accept_card_trade(tid, "B") + assert res["coins"] == 100 + assert (await _queries.get_instances_public([1]))[1]["owner_id"] == "B" # cards swapped + assert (await _queries.get_instances_public([2]))[2]["owner_id"] == "A" + assert await _queries.get_casino_balance("A") == 400 # paid 100 + assert await _queries.get_casino_balance("B") == 600 # received 100 + + _run(go()) + + +def test_accept_trade_moves_coins_negative(tmp_db): + async def go(): + await _two_traders(tmp_db) + await _fund("A", 500) + await _fund("B", 500) + tid = await _queries.create_card_trade("A", "B", [1], [2], "t", coins=-100) # A requests 100 + await _queries.accept_card_trade(tid, "B") + assert await _queries.get_casino_balance("A") == 600 # received 100 + assert await _queries.get_casino_balance("B") == 400 # paid 100 + + _run(go()) + + +def test_accept_trade_insufficient_coins_rolls_back(tmp_db): + async def go(): + await _two_traders(tmp_db) + await _fund("A", 50) # A can't cover a 1000-coin sweetener + await _fund("B", 50) + tid = await _queries.create_card_trade("A", "B", [1], [2], "t", coins=1000) + with pytest.raises(ValueError): + await _queries.accept_card_trade(tid, "B") + # nothing moved β transaction rolled back + assert (await _queries.get_instances_public([1]))[1]["owner_id"] == "A" + assert await _queries.get_casino_balance("A") == 50 + assert (await _queries.get_card_trade(tid))["status"] == "pending" + + _run(go()) + + +def test_pure_coin_offer_transfers_card(tmp_db): + async def go(): + await _two_traders(tmp_db) + await _fund("A", 500) + # A offers NO cards, just 300 coins, for B's instance 2 + tid = await _queries.create_card_trade("A", "B", [], [2], "t", coins=300) + await _queries.accept_card_trade(tid, "B") + assert (await _queries.get_instances_public([2]))[2]["owner_id"] == "A" + assert await _queries.get_casino_balance("A") == 200 + + _run(go()) + + +def test_trade_listing_lifecycle_and_selfheal(tmp_db): + async def go(): + await _two_traders(tmp_db) + await _queries.create_trade_listing(1, "A", "want rookies", "t") + mkt = await _queries.list_trade_market() + assert len(mkt) == 1 and mkt[0]["instance_id"] == 1 and mkt[0]["note"] == "want rookies" + with pytest.raises(ValueError): # can't list a card you don't own + await _queries.create_trade_listing(1, "B", None, "t") + # self-heal: move the card to B; the stale listing (owner A) drops out of the market + async with aiosqlite.connect(_queries.DB_PATH) as db: + await db.execute("UPDATE card_instances SET owner_id = 'B' WHERE instance_id = 1") + await db.commit() + assert await _queries.list_trade_market() == [] + await _queries.remove_trade_listing(1, "A") + + _run(go()) + + +def test_accept_trade_clears_listing(tmp_db): + async def go(): + await _two_traders(tmp_db) + await _queries.create_trade_listing(2, "B", None, "t") # B lists their card + tid = await _queries.create_card_trade("A", "B", [1], [2], "t") + await _queries.accept_card_trade(tid, "B") + assert await _queries.list_trade_market() == [] # traded card left the board + + _run(go()) + + +def test_sell_clears_listing(tmp_db): + async def go(): + _sid, designs = await _make_set("nba", 2024, [("rare", 1, 30)]) + await _give("A", _dids(designs, "rare")[0], 1, "rare") + await _queries.create_trade_listing(1, "A", None, "t") + await _queries.sell_instance("A", 1) + assert await _queries.list_trade_market() == [] + + _run(go()) + + +def test_web_list_offer_accept_roundtrip(tmp_db, monkeypatch): + async def go(): + await _two_traders(tmp_db) # A owns 1, B owns 2 + await _fund("A", 500) + monkeypatch.setattr(_webcards.auth, "read_session", lambda req: {"id": "B"}) + assert await _webcards.list_for_trade(_FakeReq(), _webcards.ListBody(instance_id=2, note="open")) == {"ok": True} + # A offers card 1 + 50 coins for B's listed card 2 + monkeypatch.setattr(_webcards.auth, "read_session", lambda req: {"id": "A"}) + r = await _webcards.make_offer(_FakeReq(), _webcards.TradeBody(want_ids=[2], offer_ids=[1], coins=50)) + tid = r["trade_id"] + # B sees it incoming (with previews) and accepts + monkeypatch.setattr(_webcards.auth, "read_session", lambda req: {"id": "B"}) + tr = await _webcards.my_trades(_FakeReq()) + assert len(tr["incoming"]) == 1 + assert tr["incoming"][0]["want_cards"][0]["instance_id"] == 2 + assert tr["incoming"][0]["coins"] == 50 + res = await _webcards.accept_offer(tid, _FakeReq()) + assert res["ok"] + assert (await _queries.get_instances_public([2]))[2]["owner_id"] == "A" # card moved to A + assert await _queries.list_trade_market() == [] # listing cleared + assert await _queries.get_casino_balance("A") == 450 # A paid 50 + + _run(go()) + + +def test_web_list_rejects_unowned(tmp_db, monkeypatch): + async def go(): + await _two_traders(tmp_db) + monkeypatch.setattr(_webcards.auth, "read_session", lambda req: {"id": "A"}) + res = await _webcards.list_for_trade(_FakeReq(), _webcards.ListBody(instance_id=2)) # A doesn't own 2 + assert getattr(res, "status_code", None) == 400 + + _run(go()) + + +def test_web_offer_on_unlisted_rejected(tmp_db, monkeypatch): + async def go(): + await _two_traders(tmp_db) + monkeypatch.setattr(_webcards.auth, "read_session", lambda req: {"id": "A"}) + res = await _webcards.make_offer(_FakeReq(), _webcards.TradeBody(want_ids=[2], offer_ids=[1])) + assert getattr(res, "status_code", None) == 400 # card 2 isn't listed + + _run(go()) + + if __name__ == "__main__": import inspect diff --git a/web/cards.py b/web/cards.py index 7aeae7e..8ab71d3 100644 --- a/web/cards.py +++ b/web/cards.py @@ -1,6 +1,6 @@ -"""Read-only web API for the sports-card collection page (web/static/cards.*). -Buying/opening happens in Discord; this just serves browse views. Mounted under -/api/v1/cards (Caddy only proxies /api/* to uvicorn). See db/queries.py for storage.""" +"""Web API for the sports-card page (web/static/cards.*): browse, open packs, quick-sell, +and the trade market (list cards, make coin-sweetened offers, accept/decline). Mounted +under /api/v1/cards. See db/queries.py for storage.""" from __future__ import annotations @@ -145,3 +145,149 @@ async def open_daily(request: Request): return JSONResponse({"error": str(e)}, status_code=400) await queries.record_daily_pack_claim(uid, day) return await _reveal_payload(uid, cset, cards) + + +# ββ Trade market ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ + + +class ListBody(BaseModel): + instance_id: int + note: str | None = None + + +class UnlistBody(BaseModel): + instance_id: int + + +class TradeBody(BaseModel): + want_ids: list[int] # the listed card(s) being offered on (one owner) + offer_ids: list[int] = [] # the caller's cards offered (may be empty) + coins: int = 0 # signed sweetener: >0 caller adds coins, <0 caller wants coins + + +@router.get("/market") +async def market(request: Request): + """Public trade board: all cards listed as open to offers.""" + return {"listings": await queries.list_trade_market()} + + +@router.post("/list") +async def list_for_trade(request: Request, body: ListBody): + sess = auth.read_session(request) + if not sess: + return JSONResponse({"error": "sign in to list cards"}, status_code=401) + try: + await queries.create_trade_listing(body.instance_id, sess["id"], body.note, _now_iso()) + except ValueError as e: + return JSONResponse({"error": str(e)}, status_code=400) + return {"ok": True} + + +@router.post("/unlist") +async def unlist(request: Request, body: UnlistBody): + sess = auth.read_session(request) + if not sess: + return JSONResponse({"error": "sign in"}, status_code=401) + await queries.remove_trade_listing(body.instance_id, sess["id"]) + return {"ok": True} + + +@router.post("/trade") +async def make_offer(request: Request, body: TradeBody): + """Offer on listed card(s). Resolves the target owner from the listing, validates the + caller owns their offered cards, then creates a pending directed trade.""" + sess = auth.read_session(request) + if not sess: + return JSONResponse({"error": "sign in to make an offer"}, status_code=401) + uid = sess["id"] + if not body.want_ids: + return JSONResponse({"error": "pick a card to offer on"}, status_code=400) + # Every wanted card must currently be listed, and all owned by the same person (β you). + market_by_id = {m["instance_id"]: m for m in await queries.list_trade_market()} + owners = set() + for iid in body.want_ids: + m = market_by_id.get(iid) + if not m: + return JSONResponse({"error": "that card is no longer listed for trade"}, status_code=400) + owners.add(m["owner_id"]) + if len(owners) != 1: + return JSONResponse({"error": "all cards must come from the same collector"}, status_code=400) + owner = owners.pop() + if owner == uid: + return JSONResponse({"error": "that's your own card"}, status_code=400) + if body.offer_ids: + owned = await queries.get_owned_instances(uid, body.offer_ids) + if len(owned) != len(set(body.offer_ids)): + return JSONResponse({"error": "you must own every card you offer"}, status_code=400) + if not body.offer_ids and body.coins <= 0: + return JSONResponse({"error": "offer at least a card or some coins"}, status_code=400) + if body.coins > 0 and (await queries.get_casino_balance(uid) or 0) < body.coins: + return JSONResponse({"error": "you don't have that many coins"}, status_code=400) + tid = await queries.create_card_trade( + uid, owner, body.offer_ids, body.want_ids, _now_iso(), coins=body.coins + ) + return {"trade_id": tid} + + +async def _decorate_trades(trades: list[dict]) -> list[dict]: + """Attach card previews to each trade's offer_ids/want_ids in one query.""" + ids = [i for t in trades for i in (t["offer_ids"] + t["want_ids"])] + prev = await queries.get_instances_public(ids) + out = [] + for t in trades: + out.append({ + **t, + "offer_cards": [prev[i] for i in t["offer_ids"] if i in prev], + "want_cards": [prev[i] for i in t["want_ids"] if i in prev], + }) + return out + + +@router.get("/trades") +async def my_trades(request: Request): + sess = auth.read_session(request) + if not sess: + return JSONResponse({"incoming": [], "outgoing": [], "authenticated": False}, status_code=401) + uid = sess["id"] + incoming = await _decorate_trades(await queries.list_incoming_card_trades(uid)) + outgoing = await _decorate_trades(await queries.list_outgoing_card_trades(uid)) + return {"incoming": incoming, "outgoing": outgoing} + + +@router.post("/trades/{trade_id}/accept") +async def accept_offer(trade_id: int, request: Request): + sess = auth.read_session(request) + if not sess: + return JSONResponse({"error": "sign in"}, status_code=401) + try: + await queries.accept_card_trade(trade_id, sess["id"]) + except ValueError as e: + return JSONResponse({"error": str(e)}, status_code=400) + balance = await queries.get_casino_balance(sess["id"]) or 0 + return {"ok": True, "balance": balance} + + +@router.post("/trades/{trade_id}/decline") +async def decline_offer(trade_id: int, request: Request): + sess = auth.read_session(request) + if not sess: + return JSONResponse({"error": "sign in"}, status_code=401) + t = await queries.get_card_trade(trade_id) + if not t or t["to_user"] != sess["id"]: + return JSONResponse({"error": "that offer isn't addressed to you"}, status_code=400) + if t["status"] == "pending": + await queries.set_card_trade_status(trade_id, "declined") + return {"ok": True} + + +@router.post("/trades/{trade_id}/cancel") +async def cancel_offer(trade_id: int, request: Request): + sess = auth.read_session(request) + if not sess: + return JSONResponse({"error": "sign in"}, status_code=401) + t = await queries.get_card_trade(trade_id) + if not t or t["from_user"] != sess["id"]: + return JSONResponse({"error": "that isn't your offer"}, status_code=400) + if t["status"] == "pending": + await queries.set_card_trade_status(trade_id, "cancelled") + return {"ok": True} diff --git a/web/static/cards.css b/web/static/cards.css index 212e022..243f355 100644 --- a/web/static/cards.css +++ b/web/static/cards.css @@ -343,3 +343,75 @@ .collectorrow .cuser { font-weight: 700; flex: 1; } .collectorrow .ccount { font-size: 12px; color: var(--muted); } .collectorrow .cval { font-weight: 800; color: var(--gold); font-variant-numeric: tabular-nums; } + +/* ββ Tile action buttons (list / offer) ββ */ +.tileactions { display: flex; flex-direction: column; gap: 6px; margin-top: 7px; } +.tileactions .sellbtn { margin-top: 0; } +.listbtn, .offerbtn { + width: 100%; font-family: inherit; font-size: 12px; font-weight: 700; cursor: pointer; + color: var(--fg); background: var(--panel2); border: 1px solid var(--line); + border-radius: 8px; padding: 6px 8px; transition: background .12s, border-color .12s, color .12s; +} +.listbtn:hover { border-color: var(--accent); color: var(--accent); } +.listbtn.on { border-color: var(--r-legendary); color: var(--r-legendary); } +.offerbtn { background: var(--accent); color: var(--bg); border-color: var(--accent); } +.offerbtn:hover { filter: brightness(1.08); } +.listed-badge { + position: absolute; top: 6px; right: 6px; font-size: 9px; font-weight: 800; + color: var(--bg); background: var(--r-legendary); padding: 2px 6px; border-radius: 6px; +} +.cowner { font-size: 12px; color: var(--accent); font-weight: 600; } +.cnote { font-size: 11px; color: var(--muted); font-style: italic; margin: 2px 0; } + +/* ββ Market tab ββ */ +.markethdr { font-size: 15px; margin: 6px 0 12px; color: var(--muted); } +.tabbadge, .badge { + display: inline-block; min-width: 16px; text-align: center; font-size: 11px; font-weight: 800; + background: var(--red); color: #fff; border-radius: 999px; padding: 0 5px; margin-left: 4px; +} + +/* ββ Trade offers ββ */ +.offers { display: flex; flex-direction: column; gap: 18px; margin-bottom: 26px; } +.offergroup h3 { font-size: 14px; margin: 0 0 10px; } +.offerrow { + display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap; + background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; + margin-bottom: 8px; +} +.offerlegs { display: flex; align-items: center; gap: 14px; flex: 1; flex-wrap: wrap; } +.offerleg { display: flex; flex-direction: column; gap: 5px; } +.leglbl { font-size: 10px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); } +.legcards { display: flex; gap: 5px; flex-wrap: wrap; align-items: center; } +.offerarrow { font-size: 20px; color: var(--muted); } +.tchip { + display: inline-flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; + background: var(--panel2); border: 1px solid var(--line); border-radius: 999px; padding: 3px 9px; +} +.tchip .tdot { width: 8px; height: 8px; border-radius: 50%; background: var(--r, var(--muted)); } +.tchip.rarity-common { --r: var(--r-common); } .tchip.rarity-uncommon { --r: var(--r-uncommon); } +.tchip.rarity-rare { --r: var(--r-rare); } .tchip.rarity-epic { --r: var(--r-epic); } +.tchip.rarity-legendary { --r: var(--r-legendary); } +.tnone { color: var(--muted); } +.coindelta { font-weight: 800; font-variant-numeric: tabular-nums; } +.coindelta.plus { color: var(--r-uncommon); } +.coindelta.minus { color: var(--red); } +.offeractions { display: flex; gap: 6px; } +.offeractions .btn { font-size: 12px; padding: 6px 12px; } + +/* ββ Make-offer modal (extends .hubov/.hubcard) ββ */ +.offercard { max-width: 460px; } +.offerhint { font-size: 13px; color: var(--muted); margin-bottom: 12px; } +.pickgrid { display: flex; flex-direction: column; gap: 6px; max-height: 260px; overflow-y: auto; margin-bottom: 12px; } +.pickcard { + display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 13px; + background: var(--panel2); border: 1px solid var(--line); border-radius: 9px; padding: 8px 11px; +} +.pickcard.on { border-color: var(--accent); } +.pickcard .pickname { flex: 1; font-weight: 600; } +.coinrow { display: flex; flex-direction: column; gap: 5px; font-size: 12px; color: var(--muted); margin-bottom: 14px; } +.coinrow input { + font-family: inherit; font-size: 14px; color: var(--fg); background: var(--panel2); + border: 1px solid var(--line); border-radius: 8px; padding: 8px 11px; +} +.offerfoot { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.offersummary { font-size: 13px; color: var(--muted); } diff --git a/web/static/cards.js b/web/static/cards.js index bd74bd1..b802097 100644 --- a/web/static/cards.js +++ b/web/static/cards.js @@ -57,10 +57,15 @@ const state = { collectors: null, // Collectors directory (list) viewing: null, // user_id whose collection is open (null = my own) viewMine: {}, // cache of other users' collections by user_id + market: null, // trade board listings + trades: null, // { incoming, outgoing } }; // ββ Card tile ββ -function cardTile(c, sellable) { +// opts: { sellable, listable, offerable } β which action buttons to show. `c.listed` +// (from /mine) toggles the List/Unlist button + badge. Market tiles pass ownerName/note. +function cardTile(c, opts) { + opts = opts || {}; const rarity = (c.rarity || "common").toLowerCase(); const holo = c.is_holo ? " holo" : ""; const sport = (c.sport || "").toLowerCase(); @@ -73,27 +78,44 @@ function cardTile(c, sellable) { ? `${GEM_EMOJI[String(c.gem).toLowerCase()] || "π "} ${esc(c.gem)}` : ""; const rookie = c.is_rookie ? `RC` : ""; + const listed = c.listed ? `π Listed` : ""; + const owner = opts.ownerName ? `