From 975a714ad4b8b274f3f8fc0df7366ed298bfc52b Mon Sep 17 00:00:00 2001 From: Palawi Date: Wed, 4 Nov 2026 11:37:36 +0100 Subject: [PATCH 1/2] =?UTF-8?q?feat(cases):=20r=C3=A8gles,=20tables,=20tir?= =?UTF-8?q?age=20et=20API=20des=20caisses=20(suggestion=20n=C2=B0=20126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/routes/core.ts | 1 + apps/api/src/routes/economy.ts | 11 + apps/api/src/services/cases.ts | 346 + apps/api/src/services/feed.ts | 2 +- apps/api/src/services/free-cases.ts | 32 + apps/api/src/services/players.ts | 6 +- apps/api/src/services/privacy.ts | 3 + apps/api/src/services/progression.ts | 20 +- apps/api/src/services/wheel.ts | 4 + apps/api/test/cases.test.ts | 183 + packages/db/migrations/0055_cases.sql | 48 + .../db/migrations/meta/0055_snapshot.json | 8619 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 84 +- packages/game/src/achievements.ts | 2 + packages/game/src/cases.test.ts | 63 + packages/game/src/cases.ts | 154 + packages/game/src/index.ts | 1 + packages/game/src/pass.ts | 27 +- packages/game/src/progression.test.ts | 19 +- packages/game/src/shiny.ts | 4 +- packages/game/src/wheel.test.ts | 21 +- packages/game/src/wheel.ts | 18 +- packages/shared/src/dto.ts | 81 +- 24 files changed, 9718 insertions(+), 38 deletions(-) create mode 100644 apps/api/src/services/cases.ts create mode 100644 apps/api/src/services/free-cases.ts create mode 100644 apps/api/test/cases.test.ts create mode 100644 packages/db/migrations/0055_cases.sql create mode 100644 packages/db/migrations/meta/0055_snapshot.json create mode 100644 packages/game/src/cases.test.ts create mode 100644 packages/game/src/cases.ts diff --git a/apps/api/src/routes/core.ts b/apps/api/src/routes/core.ts index a113fbb..9f938ae 100644 --- a/apps/api/src/routes/core.ts +++ b/apps/api/src/routes/core.ts @@ -89,6 +89,7 @@ export const FEATURE_ANNOUNCEMENTS: { key: string; until: string }[] = [ { key: "suggestions", until: "2026-12-01T00:00:00+01:00" }, { key: "updates", until: "2026-12-01T00:00:00+01:00" }, { key: "wheels", until: "2026-12-01T00:00:00+01:00" }, + { key: "cases", until: "2026-12-15T00:00:00+01:00" }, { key: "battle-decks", until: "2026-12-15T00:00:00+01:00" }, { key: "boss-v2", until: "2026-12-31T00:00:00+01:00" }, { key: "leaderboard-achievements", until: "2026-12-31T00:00:00+01:00" }, diff --git a/apps/api/src/routes/economy.ts b/apps/api/src/routes/economy.ts index 5351b99..0d01694 100644 --- a/apps/api/src/routes/economy.ts +++ b/apps/api/src/routes/economy.ts @@ -39,6 +39,7 @@ import { upgradeSeriesHistory, upgradeSeriesPreview, } from "../services/upgrade.js"; +import { casesState, openCase } from "../services/cases.js"; import { spinWheel, wheelState } from "../services/wheel.js"; const id = z.coerce.number().int().positive(); @@ -240,6 +241,16 @@ export function economyRoutes(api: FastifyInstance, ctx: Ctx) { api.get("/upgrade/series/history", auth, async (req) => upgradeSeriesHistory(ctx, req.user.id)); api.get("/wheel", auth, async (req) => wheelState(ctx, req.user.id)); api.post("/wheel/spin", limited(10), async (req) => spinWheel(ctx, req.user.id)); + // Caisses (suggestion n° 126) : contenu visible, une issue par ouverture, ×5 d'un coup. + api.get("/cases", auth, async (req) => casesState(ctx, req.user.id)); + api.post("/cases/:id/open", limited(60), async (req) => { + const { id } = parse(idParams, req.params); + const b = parse( + z.object({ count: z.union([z.literal(1), z.literal(5)]).default(1), free: z.boolean().default(false) }), + req.body ?? {}, + ); + return openCase(ctx, req.user.id, id, b.count, b.free); + }); // Limite serrée : pas de devinette de codes à la chaîne. api.post("/codes/redeem", limited(10), async (req) => { const { code } = parse(z.object({ code: z.string().trim().min(1).max(40) }), req.body); diff --git a/apps/api/src/services/cases.ts b/apps/api/src/services/cases.ts new file mode 100644 index 0000000..ce06868 --- /dev/null +++ b/apps/api/src/services/cases.ts @@ -0,0 +1,346 @@ +import { and, asc, desc, eq, gt, gte, lte, schema, sql } from "@palacards/db"; +import { + buildReel, + CASE_MULTI_OPEN, + CASE_ODDS, + CASE_PRICES, + CASE_TIERS, + CASE_TOTAL, + caseExpectedValue, + pickLot, + REEL_WIN_INDEX, + recycleValue, + rollCase, + rollSourceShiny, + type CaseTier, + type Rarity, + type ReelTile, +} from "@palacards/game"; +import type { + CardDTO, + CaseCardDTO, + CaseCollectionDTO, + CaseDTO, + CaseOpenDTO, + CaseResultDTO, + CasesDTO, + CaseWinDTO, +} from "@palacards/shared"; +import type { Ctx } from "../context.js"; +import { badRequest, conflict, notFound } from "../errors.js"; +import { instancesByIds, loadMediaInBackground } from "./cards.js"; +import { announcePulls, logPulls } from "./feed.js"; +import { afterCommit } from "./notifications.js"; +import { + activeSeason, + lockPlayer, + logMovement, + movePw, + ownedCount, + pushWallet, + wallet, + type DbOrTx, +} from "./players.js"; +import { freeCases } from "./free-cases.js"; +import { emit } from "./progression.js"; +import { articleUrl } from "./wiki.js"; + +type Tx = Parameters[0]>[0]; +type CaseRow = typeof schema.caseBoxes.$inferSelect; + +const cb = schema.caseBoxes; +const cc = schema.caseCards; +const fc = schema.playerFreeCases; + +/** Ouvertures montrées dans « Gros tirages » : une légendaire ou mieux. */ +const BIG_WIN_VALUE = 1_000; +const RECENT_WINS = 12; + +const tierRank = (t: CaseTier) => CASE_TIERS.indexOf(t); + +/** Caisses en vente maintenant dans la saison active, du Bronze à la Légendaire. */ +async function onSale(db: DbOrTx, now: Date, season: number): Promise { + const rows = await db + .select() + .from(cb) + .where(and(eq(cb.season, season), lte(cb.startsAt, now), gt(cb.endsAt, now))) + .orderBy(asc(cb.endsAt), asc(cb.collection)); + return rows.sort((a, b) => a.collection.localeCompare(b.collection) || tierRank(a.tier) - tierRank(b.tier)); +} + +type ContentRow = { + case_id: string; + card_id: string; + rarity: Rarity; + title: string; + atk: number; + def: number; + views_12m: string; + thumb_url: string | null; + page_url: string | null; + owned: boolean; +}; + +/** Contenu des caisses, dans l'ordre d'affichage (rareté décroissante, puis le plus lu). */ +async function contents(db: DbOrTx, caseIds: number[], season: number, viewerId: string) { + if (!caseIds.length) return new Map(); + const rows = await db.execute(sql` + select k.case_id, k.card_id, k.rarity, c.title, c.atk, c.def, c.views_12m, w.thumb_url, w.page_url, + exists (select 1 from card_instances o where o.owner_id = ${viewerId} and o.card_id = k.card_id) as owned + from case_cards k + join cards c on c.season = ${season} and c.id = k.card_id + left join wiki_summaries w on w.page_id = k.card_id + where k.case_id in (${sql.join( + caseIds.map((id) => sql`${id}`), + sql`, `, + )}) + order by k.case_id, k.pos + `); + const out = new Map(caseIds.map((id) => [id, []])); + for (const r of rows) out.get(Number(r.case_id))!.push(r); + return out; +} + +const catalogCard = (r: ContentRow, season: number): CardDTO => ({ + instanceId: null, + cardId: Number(r.card_id), + season, + title: r.title, + rarity: r.rarity, + atk: r.atk, + def: r.def, + level: 1, + views12m: Number(r.views_12m), + thumbUrl: r.thumb_url, + pageUrl: r.page_url ?? articleUrl(r.title), + owned: r.owned, +}); + +function caseDTO(c: CaseRow, cards: ContentRow[], season: number): CaseDTO { + const count = (r: Rarity) => cards.filter((x) => x.rarity === r).length; + const single = (r: Rarity) => + CASE_ODDS[c.tier].filter((o) => o.rarity === r && o.lot === 1).reduce((s, o) => s + o.weight, 0) / CASE_TOTAL; + return { + id: c.id, + collection: c.collection, + tier: c.tier, + price: CASE_PRICES[c.tier], + startsAt: c.startsAt.toISOString(), + endsAt: c.endsAt.toISOString(), + cards: cards.map((r): CaseCardDTO => ({ ...catalogCard(r, season), chance: single(r.rarity) / count(r.rarity) })), + outcomes: CASE_ODDS[c.tier].map((o) => ({ rarity: o.rarity, lot: o.lot, chance: o.weight / CASE_TOTAL })), + expectedValue: Math.round(caseExpectedValue(c.tier)), + }; +} + +async function recentWins(db: DbOrTx, viewerId: string): Promise { + const rows = await db + .select({ + id: schema.caseOpenings.id, + userId: schema.caseOpenings.userId, + rarity: schema.caseOpenings.rarity, + lot: schema.caseOpenings.lot, + value: schema.caseOpenings.value, + instanceIds: schema.caseOpenings.instanceIds, + createdAt: schema.caseOpenings.createdAt, + collection: cb.collection, + tier: cb.tier, + username: schema.user.username, + displayName: sql`coalesce(${schema.user.displayUsername}, ${schema.user.name})`, + avatar: schema.players.avatar, + }) + .from(schema.caseOpenings) + .innerJoin(cb, eq(cb.id, schema.caseOpenings.caseId)) + .innerJoin(schema.user, eq(schema.user.id, schema.caseOpenings.userId)) + .innerJoin(schema.players, eq(schema.players.userId, schema.caseOpenings.userId)) + .where(gte(schema.caseOpenings.value, BIG_WIN_VALUE)) + .orderBy(desc(schema.caseOpenings.createdAt)) + .limit(RECENT_WINS); + const firstIds = rows.map((r) => r.instanceIds[0]).filter((id): id is number => id !== undefined); + const cards = new Map((await instancesByIds(db, firstIds, viewerId)).map((c) => [c.instanceId!, c])); + return rows.map((r) => ({ + id: r.id, + player: { username: r.username ?? "", displayName: r.displayName, avatar: r.avatar }, + collection: r.collection, + tier: r.tier, + rarity: r.rarity, + lot: r.lot, + value: r.value, + card: cards.get(r.instanceIds[0]!) ?? null, + createdAt: r.createdAt.toISOString(), + })); +} + +export async function casesState(ctx: Ctx, userId: string): Promise { + const season = await activeSeason(ctx.db); + const list = await onSale(ctx.db, ctx.now(), season); + const content = await contents( + ctx.db, + list.map((c) => c.id), + season, + userId, + ); + const collections = new Map(); + for (const c of list) { + const col = collections.get(c.collection) ?? { name: c.collection, endsAt: c.endsAt.toISOString(), cases: [] }; + col.cases.push(caseDTO(c, content.get(c.id)!, season)); + if (c.endsAt.toISOString() < col.endsAt) col.endsAt = c.endsAt.toISOString(); + collections.set(c.collection, col); + } + // Vignettes pas encore en cache : chargées en arrière-plan, la page se met à jour toute seule. + const missing = [...content.values()].flat().filter((r) => !r.thumb_url); + if (missing.length) + loadMediaInBackground( + ctx, + userId, + missing.slice(0, 60).map((r) => ({ cardId: Number(r.card_id), title: r.title })), + ); + return { + collections: [...collections.values()], + free: await freeCases(ctx.db, userId), + recent: await recentWins(ctx.db, userId), + }; +} + +/** + * Ouvre une caisse (`count` = 1 ou 5), avec des PW ou avec les caisses gratuites du joueur de ce niveau. Le + * résultat est tiré ici, avant toute animation ; la bande renvoyée est tirée selon les vraies chances. + */ +export async function openCase( + ctx: Ctx, + userId: string, + caseId: number, + count: number, + useFree: boolean, +): Promise { + if (count !== 1 && count !== CASE_MULTI_OPEN) throw badRequest("case_count", "Une ou cinq caisses à la fois."); + const now = ctx.now(); + const res = await ctx.db.transaction(async (tx) => { + const player = await lockPlayer(tx, userId); + const season = await activeSeason(tx); + const [box] = await tx.select().from(cb).where(eq(cb.id, caseId)); + if (!box || box.season !== season || box.startsAt > now || box.endsAt <= now) + throw notFound("Cette caisse n'est plus en vente."); + const content = (await contents(tx, [box.id], season, userId)).get(box.id)!; + const byRarity: Partial> = {}; + content.forEach((r) => (byRarity[r.rarity] ??= []).push(r)); + const price = CASE_PRICES[box.tier]; + const ref = `case:${box.id}`; + + if (useFree) { + const used = await tx + .update(fc) + .set({ count: sql`${fc.count} - ${count}` }) + .where(and(eq(fc.userId, userId), eq(fc.tier, box.tier), gte(fc.count, count))) + .returning({ count: fc.count }); + if (!used.length) + throw conflict( + "no_free_case", + count > 1 ? "Pas assez de caisses gratuites de ce niveau." : "Plus de caisse gratuite de ce niveau.", + ); + await logMovement(tx, userId, "free_case", -count, used[0]!.count, "case_open", ref); + } else { + if (player.balance - player.lockedBalance < price * count) + throw conflict( + "insufficient_pw", + `Il faut ${price * count} PW pour ${count > 1 ? "ces caisses" : "cette caisse"}.`, + ); + await movePw(tx, player, -price * count, "case_purchase", ref); + } + + const indexOf = new Map(content.map((r, i) => [Number(r.card_id), i])); + const indices: Partial> = {}; + for (const [r, list] of Object.entries(byRarity)) + indices[r as Rarity] = list.map((x) => indexOf.get(Number(x.card_id))!); + + const opened: { tiles: ReelTile[]; rarity: Rarity; lot: number; instanceIds: number[]; value: number }[] = []; + const pulls: Parameters[3] = []; + let shinies = 0; + for (let i = 0; i < count; i++) { + const outcome = CASE_ODDS[box.tier][rollCase(box.tier, ctx.random)]!; + const pool = byRarity[outcome.rarity] ?? []; + if (!pool.length) throw conflict("case_empty", "Cette caisse est vide pour cette rareté : préviens l'admin."); + const won = outcome.lot > 1 ? pickLot(pool, outcome.lot, ctx.random) : [pool[ctx.random(pool.length)]!]; + const values = won.map((card) => ({ card, shiny: rollSourceShiny("case", ctx.random) })); + const rows = await tx + .insert(schema.cardInstances) + .values( + values.map(({ card, shiny }) => ({ + ownerId: userId, + cardId: Number(card.card_id), + season, + rarity: card.rarity, + atk: card.atk, + def: card.def, + shiny, + source: "case" as const, + obtainedAt: now, + })), + ) + .returning({ id: schema.cardInstances.id }); + shinies += values.filter((v) => v.shiny).length; + for (const { card, shiny } of values) + pulls.push({ cardId: Number(card.card_id), season, rarity: card.rarity, shiny, title: card.title }); + const win: ReelTile = + outcome.lot > 1 + ? { kind: "lot", lot: outcome.lot } + : { kind: "card", index: indexOf.get(Number(won[0]!.card_id))! }; + const value = values.reduce((s, v) => s + recycleValue(v.card.rarity, v.shiny), 0); + const instanceIds = rows.map((r) => r.id); + await tx.insert(schema.caseOpenings).values({ + userId, + caseId: box.id, + free: useFree, + price: useFree ? 0 : price, + rarity: outcome.rarity, + lot: outcome.lot, + value, + instanceIds, + createdAt: now, + }); + opened.push({ + tiles: buildReel(box.tier, indices, win, ctx.random), + rarity: outcome.rarity, + lot: outcome.lot, + instanceIds, + value, + }); + } + await logMovement(tx, userId, "card", pulls.length, await ownedCount(tx, userId), "case_open", ref); + const notable = await logPulls(tx, userId, "case", pulls); + return { player, content, opened, pulls, notable, shinies, tier: box.tier }; + }); + + const all = await instancesByIds( + ctx.db, + res.opened.flatMap((o) => o.instanceIds), + userId, + ); + const byId = new Map(all.map((c) => [c.instanceId!, c])); + const cardIdAt = (index: number) => Number(res.content[index]!.card_id); + await afterCommit(ctx, async () => { + pushWallet(ctx, res.player); + void emit( + ctx, + userId, + { type: "case_opened", count: res.opened.length }, + ...(res.shinies ? [{ type: "shiny_obtained" as const, source: "case" as const, count: res.shinies }] : []), + "collection", + ); + loadMediaInBackground( + ctx, + userId, + res.pulls.map((p) => ({ cardId: p.cardId, title: p.title })), + ); + await announcePulls(ctx, userId, res.notable); + }); + const results: CaseResultDTO[] = res.opened.map((o) => ({ + tiles: o.tiles.map((t) => (t.kind === "lot" ? t : { kind: "card", cardId: cardIdAt(t.index) })), + winIndex: REEL_WIN_INDEX, + rarity: o.rarity, + lot: o.lot, + cards: o.instanceIds.map((id) => byId.get(id)).filter((c): c is CardDTO => !!c), + value: o.value, + })); + return { results, free: await freeCases(ctx.db, userId), wallet: wallet(res.player), usedFree: useFree }; +} diff --git a/apps/api/src/services/feed.ts b/apps/api/src/services/feed.ts index 6108342..eea0e31 100644 --- a/apps/api/src/services/feed.ts +++ b/apps/api/src/services/feed.ts @@ -23,7 +23,7 @@ const SHINY_DAYS = 60; export const FEED_PAGE = 24; /** `shop` : légendaire achetée en boutique (affichée « achetée en boutique », jamais comptée comme un tirage). */ -export type PullSource = "pack" | "theme" | "wheel" | "upgrade" | "boss" | "shop"; +export type PullSource = "pack" | "theme" | "wheel" | "upgrade" | "boss" | "shop" | "case"; export interface PullInput { cardId: number; diff --git a/apps/api/src/services/free-cases.ts b/apps/api/src/services/free-cases.ts new file mode 100644 index 0000000..fe3cf55 --- /dev/null +++ b/apps/api/src/services/free-cases.ts @@ -0,0 +1,32 @@ +import { eq, schema, sql } from "@palacards/db"; +import { CASE_TIERS, type CaseTier } from "@palacards/game"; +import type { Ctx } from "../context.js"; +import { logMovement, type DbOrTx } from "./players.js"; + +type Tx = Parameters[0]>[0]; +const fc = schema.playerFreeCases; + +export async function freeCases(db: DbOrTx, userId: string): Promise> { + const rows = await db.select({ tier: fc.tier, count: fc.count }).from(fc).where(eq(fc.userId, userId)); + const out = Object.fromEntries(CASE_TIERS.map((t) => [t, 0])) as Record; + for (const r of rows) out[r.tier] = r.count; + return out; +} + +/** Caisses gratuites gagnées (roues, passe) : un compteur par niveau. */ +export async function addFreeCases( + tx: Tx, + userId: string, + tier: CaseTier, + count: number, + reason: "wheel" | "season_pass", + ref: string, +) { + if (count <= 0) return; + const [row] = await tx + .insert(fc) + .values({ userId, tier, count }) + .onConflictDoUpdate({ target: [fc.userId, fc.tier], set: { count: sql`${fc.count} + ${count}` } }) + .returning({ count: fc.count }); + await logMovement(tx, userId, "free_case", count, row!.count, reason, `${ref}:${tier}`); +} diff --git a/apps/api/src/services/players.ts b/apps/api/src/services/players.ts index 636e970..5c1cd91 100644 --- a/apps/api/src/services/players.ts +++ b/apps/api/src/services/players.ts @@ -71,7 +71,9 @@ export type LedgerReason = | "community_goal" | "shop_purchase" | "higher_lower" - | "portfolio_design"; + | "portfolio_design" + | "case_purchase" + | "case_open"; /** * Change le solde de PW d'un joueur verrouillé et écrit la ligne de ledger correspondante. @@ -162,7 +164,7 @@ export async function transferInstances( export async function logMovement( tx: Tx, userId: string, - kind: "card" | "pack" | "bonus_pack" | "theme_pack", + kind: "card" | "pack" | "bonus_pack" | "theme_pack" | "free_case", delta: number, balanceAfter: number, reason: LedgerReason, diff --git a/apps/api/src/services/privacy.ts b/apps/api/src/services/privacy.ts index ff5fa34..f569d6e 100644 --- a/apps/api/src/services/privacy.ts +++ b/apps/api/src/services/privacy.ts @@ -157,6 +157,9 @@ export async function exportData(ctx: Ctx, userId: string) { .from(s.playerPortfolioDesigns) .where(eq(s.playerPortfolioDesigns.userId, userId)), portfolioLikes: await db.select().from(s.portfolioLikes).where(eq(s.portfolioLikes.userId, userId)), + // Caisses (suggestion n° 126) : ouvertures et caisses gratuites en attente. + caseOpenings: await db.select().from(s.caseOpenings).where(eq(s.caseOpenings.userId, userId)), + freeCases: await db.select().from(s.playerFreeCases).where(eq(s.playerFreeCases.userId, userId)), // Portfolios partagés d'amis : invitations reçues et participations. portfolioMemberships: await db.select().from(s.portfolioMembers).where(eq(s.portfolioMembers.userId, userId)), // Admin : cartes ajoutées ou retirées à la main d'un filtre d'images. diff --git a/apps/api/src/services/progression.ts b/apps/api/src/services/progression.ts index 9378a50..10a4d8a 100644 --- a/apps/api/src/services/progression.ts +++ b/apps/api/src/services/progression.ts @@ -33,6 +33,7 @@ import { type DbOrTx, type Player, } from "./players.js"; +import { addFreeCases } from "./free-cases.js"; import { afterCommit, Effects } from "./notifications.js"; import { bannersOf } from "./banners.js"; import { battleRewarded, onBattleFinished } from "./battles.js"; @@ -131,13 +132,16 @@ async function grantXp(tx: Tx, player: Player, season: number, xp: number, fx: E const r = passReward(n); if (r.pw) await movePw(tx, player, r.pw, "season_pass", `s${season}:${n}`); await addBonusPacks(tx, player, r.packs, "season_pass", `s${season}:${n}`); + if (r.case) await addFreeCases(tx, player.userId, r.case, 1, "season_pass", `s${season}:${n}`); } if (gained.length) { const last = gained.at(-1)!; - const total = gained.map(passReward).reduce((a, r) => ({ pw: a.pw + r.pw, packs: a.packs + r.packs }), { - pw: 0, - packs: 0, - }); + const total = gained + .map(passReward) + .reduce<{ pw: number; packs: number; cases: number }>( + (a, r) => ({ pw: a.pw + r.pw, packs: a.packs + r.packs, cases: a.cases + (r.case ? 1 : 0) }), + { pw: 0, packs: 0, cases: 0 }, + ); await fx.notify(tx, player.userId, "pass_level", { level: last, levels: gained.length, reward: total }); } return gained; @@ -337,7 +341,7 @@ export async function collectionEvent(db: DbOrTx, userId: string): Promise(sql` with pulled as ( select i.card_id, i.rarity, i.season from card_instances i - where i.owner_id = ${userId} and i.source in ('pack', 'upgrade', 'wheel', 'boss') and i.season = ${season} + where i.owner_id = ${userId} and i.source in ('pack', 'upgrade', 'wheel', 'boss', 'case') and i.season = ${season} ) select (select count(distinct card_id)::int from pulled) as unique_cards, @@ -419,9 +423,9 @@ async function historicalStats(db: DbOrTx, userId: string): Promise 1000 diff --git a/apps/api/src/services/wheel.ts b/apps/api/src/services/wheel.ts index 26f5b28..31874e2 100644 --- a/apps/api/src/services/wheel.ts +++ b/apps/api/src/services/wheel.ts @@ -16,6 +16,7 @@ import { conflict } from "../errors.js"; import { instancesByIds, loadMediaInBackground } from "./cards.js"; import { announcePulls, logPulls } from "./feed.js"; import { afterCommit } from "./notifications.js"; +import { addFreeCases } from "./free-cases.js"; import { drawCard } from "./packs.js"; import { activeSeason, @@ -134,6 +135,9 @@ export async function spinWheel(ctx: Ctx, userId: string): Promise } else if (reward.kind === "packs") { await addBonusPacks(tx, p, reward.amount, ref); prize = reward; + } else if (reward.kind === "case") { + await addFreeCases(tx, userId, reward.tier, 1, "wheel", ref); + prize = reward; } else if (reward.kind === "theme") { const theme = await endingSoonestTheme(tx, now); if (theme) { diff --git a/apps/api/test/cases.test.ts b/apps/api/test/cases.test.ts new file mode 100644 index 0000000..8f46fba --- /dev/null +++ b/apps/api/test/cases.test.ts @@ -0,0 +1,183 @@ +import { eq, schema, sql } from "@palacards/db"; +import { CASE_PRICES, REEL_LENGTH, REEL_WIN_INDEX, recycleValue } from "@palacards/game"; +import { afterAll, describe, expect, it } from "vitest"; +import { makeApp, signUp, type Client } from "./helpers.js"; + +const { app, ctx } = await makeApp(); +afterAll(() => app.close()); + +const season = async () => + Number((await ctx.db.execute<{ id: number }>(sql`select id from seasons where status = 'active'`))[0]!.id); + +/** Caisse en vente maintenant, remplie de cartes de la saison (`counts` cartes par rareté). */ +async function makeCase( + tier: "bronze" | "silver" | "gold" | "legendary", + counts: Record, + collection = "Essai", +) { + const s = await season(); + const [box] = await ctx.db + .insert(schema.caseBoxes) + .values({ + collection, + tier, + season: s, + startsAt: new Date(Date.now() - 60_000), + endsAt: new Date(Date.now() + 3_600_000), + }) + .returning(); + let pos = 0; + for (const [rarity, n] of Object.entries(counts)) { + const rows = await ctx.db.execute<{ id: string }>(sql` + select id from cards where season = ${s} and rarity = ${rarity}::rarity order by views_12m desc limit ${n} + `); + expect(rows.length).toBeGreaterThan(0); + await ctx.db + .insert(schema.caseCards) + .values(rows.map((r) => ({ caseId: box!.id, cardId: Number(r.id), rarity: rarity as "UR", pos: pos++ }))); + } + return { + id: box!.id, + size: pos, + legendaries: Number( + counts.L + ? Math.min( + counts.L, + ( + await ctx.db.execute<{ n: number }>( + sql`select count(*)::int n from case_cards where case_id = ${box!.id} and rarity = 'L'`, + ) + )[0]!.n, + ) + : 0, + ), + }; +} + +const balance = async (p: Client) => (await p.get("/me")).body.wallet.balance as number; + +describe("caisses (suggestion n° 126)", () => { + it("montre les caisses en vente avec leur contenu, les chances et les caisses gratuites", async () => { + const { id, size } = await makeCase("gold", { UR: 4, L: 5 }, `Vitrine ${Date.now()}`); + const p = await signUp(app); + const res = await p.get("/cases"); + expect(res.status).toBe(200); + const box = res.body.collections + .flatMap((c: { cases: { id: number }[] }) => c.cases) + .find((c: { id: number }) => c.id === id); + expect(box.price).toBe(CASE_PRICES.gold); + expect(box.cards).toHaveLength(size); + // Chances : la somme des cartes seules et des lots fait 1. + const lots = box.outcomes + .filter((o: { lot: number }) => o.lot > 1) + .reduce((s: number, o: { chance: number }) => s + o.chance, 0); + const singles = box.cards.reduce((s: number, c: { chance: number }) => s + c.chance, 0); + expect(singles + lots).toBeCloseTo(1, 6); + expect(box.expectedValue).toBeLessThan(box.price); + expect(res.body.free).toEqual({ bronze: 0, silver: 0, gold: 0, legendary: 0 }); + }); + + it("ouvre une caisse contre des PW : une issue, la bande honnête, la carte dans la collection", async () => { + const { id } = await makeCase("bronze", { R: 3, SR: 3, UR: 2, L: 1 }); + const p = await signUp(app); + await p.post("/test/grant-pw", { amount: 1_000 }); + const before = await balance(p); + const res = await p.post(`/cases/${id}/open`, { count: 1 }); + expect(res.status).toBe(200); + expect(await balance(p)).toBe(before - CASE_PRICES.bronze); + const [r] = res.body.results; + expect(r.tiles).toHaveLength(REEL_LENGTH); + expect(r.winIndex).toBe(REEL_WIN_INDEX); + expect(r.cards).toHaveLength(1); + expect(r.value).toBe(recycleValue(r.cards[0].rarity, !!r.cards[0].shiny)); + // La case gagnante est bien la carte reçue. + expect(r.tiles[REEL_WIN_INDEX]).toEqual({ kind: "card", cardId: r.cards[0].cardId }); + const [row] = await ctx.db + .select() + .from(schema.cardInstances) + .where(eq(schema.cardInstances.id, r.cards[0].instanceId)); + expect(row!.source).toBe("case"); + expect(row!.ownerId).toBe(p.userId); + const [opening] = await ctx.db.select().from(schema.caseOpenings).where(eq(schema.caseOpenings.userId, p.userId)); + expect(opening!.price).toBe(CASE_PRICES.bronze); + }); + + it("ouvre cinq caisses d'un coup, refuse sans assez de PW", async () => { + const { id } = await makeCase("silver", { SR: 3, UR: 3, L: 3 }); + const p = await signUp(app); + const poor = await p.post(`/cases/${id}/open`, { count: 5 }); + expect(poor.status).toBe(409); + expect(poor.body.error).toBe("insufficient_pw"); + await p.post("/test/grant-pw", { amount: 5 * CASE_PRICES.silver }); + const res = await p.post(`/cases/${id}/open`, { count: 5 }); + expect(res.status).toBe(200); + expect(res.body.results).toHaveLength(5); + expect(res.body.results.flatMap((r: { cards: unknown[] }) => r.cards).length).toBeGreaterThanOrEqual(5); + expect((await p.post(`/cases/${id}/open`, { count: 3 })).status).toBe(400); + }); + + it("utilise une caisse gratuite du même niveau sans toucher aux PW", async () => { + const { id } = await makeCase("gold", { UR: 3, L: 5 }); + const p = await signUp(app); + expect((await p.post(`/cases/${id}/open`, { free: true })).body.error).toBe("no_free_case"); + await ctx.db.insert(schema.playerFreeCases).values({ userId: p.userId, tier: "gold", count: 1 }); + const before = await balance(p); + const res = await p.post(`/cases/${id}/open`, { free: true }); + expect(res.status).toBe(200); + expect(res.body.usedFree).toBe(true); + expect(res.body.free.gold).toBe(0); + expect(await balance(p)).toBe(before); + expect(res.body.results[0].cards.every((c: { rarity: string }) => c.rarity === "UR" || c.rarity === "L")).toBe( + true, + ); + }); + + it("donne un lot de légendaires différentes et le montre dans les gros tirages", async () => { + const { id, legendaries } = await makeCase("legendary", { UR: 2, L: 10 }); + const p = await signUp(app); + await p.post("/test/grant-pw", { amount: 5 * CASE_PRICES.legendary }); + // Tirage forcé sur le lot de 10 légendaires (dernière issue) puis hasard normal. + const random = ctx.random; + let first = true; + ctx.random = (max: number) => { + if (first && max === 10_000) { + first = false; + return 9_999; + } + return random(max); + }; + try { + const res = await p.post(`/cases/${id}/open`, { count: 1 }); + expect(res.status).toBe(200); + const [r] = res.body.results; + expect(r.lot).toBe(10); + expect(r.cards).toHaveLength(10); + // Toutes différentes tant que la caisse en a assez (la saison de test n'en a que quelques-unes). + expect(new Set(r.cards.map((c: { cardId: number }) => c.cardId)).size).toBe(Math.min(10, legendaries)); + expect(r.tiles[REEL_WIN_INDEX]).toEqual({ kind: "lot", lot: 10 }); + const recent = (await p.get("/cases")).body.recent; + expect(recent[0]).toMatchObject({ lot: 10, rarity: "L", player: { username: p.username } }); + } finally { + ctx.random = random; + } + }); + + it("refuse une caisse qui n'est plus en vente", async () => { + const { id } = await makeCase("bronze", { R: 2 }); + await ctx.db + .update(schema.caseBoxes) + .set({ endsAt: new Date(Date.now() - 1_000), startsAt: new Date(Date.now() - 5_000) }) + .where(eq(schema.caseBoxes.id, id)); + const p = await signUp(app); + await p.post("/test/grant-pw", { amount: 500 }); + expect((await p.post(`/cases/${id}/open`)).status).toBe(404); + }); + + it("figure dans l'export des données", async () => { + const p = await signUp(app); + const res = await p.get("/me/export"); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("caseOpenings"); + expect(res.body).toHaveProperty("freeCases"); + }); +}); diff --git a/packages/db/migrations/0055_cases.sql b/packages/db/migrations/0055_cases.sql new file mode 100644 index 0000000..8bf9d4b --- /dev/null +++ b/packages/db/migrations/0055_cases.sql @@ -0,0 +1,48 @@ +CREATE TABLE "cases" ( + "id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "cases_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1), + "collection" text NOT NULL, + "tier" text NOT NULL, + "season" smallint NOT NULL, + "starts_at" timestamp with time zone NOT NULL, + "ends_at" timestamp with time zone NOT NULL, + "created_by" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "cases_dates_ok" CHECK ("cases"."ends_at" > "cases"."starts_at") +); +--> statement-breakpoint +CREATE TABLE "case_cards" ( + "case_id" bigint NOT NULL, + "card_id" bigint NOT NULL, + "rarity" "rarity" NOT NULL, + "pos" smallint NOT NULL, + CONSTRAINT "case_cards_case_id_card_id_pk" PRIMARY KEY("case_id","card_id") +); +--> statement-breakpoint +CREATE TABLE "case_openings" ( + "id" bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "case_openings_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1), + "user_id" text NOT NULL, + "case_id" bigint NOT NULL, + "free" boolean NOT NULL, + "price" integer NOT NULL, + "rarity" "rarity" NOT NULL, + "lot" smallint NOT NULL, + "value" integer NOT NULL, + "instance_ids" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "player_free_cases" ( + "user_id" text NOT NULL, + "tier" text NOT NULL, + "count" integer DEFAULT 0 NOT NULL, + CONSTRAINT "player_free_cases_user_id_tier_pk" PRIMARY KEY("user_id","tier"), + CONSTRAINT "player_free_cases_count_ok" CHECK ("player_free_cases"."count" >= 0) +); +--> statement-breakpoint +ALTER TABLE "case_cards" ADD CONSTRAINT "case_cards_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "case_openings" ADD CONSTRAINT "case_openings_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "case_openings" ADD CONSTRAINT "case_openings_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "player_free_cases" ADD CONSTRAINT "player_free_cases_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "cases_ends_idx" ON "cases" USING btree ("ends_at");--> statement-breakpoint +CREATE INDEX "case_openings_user_idx" ON "case_openings" USING btree ("user_id","created_at");--> statement-breakpoint +CREATE INDEX "case_openings_value_idx" ON "case_openings" USING btree ("value","created_at"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0055_snapshot.json b/packages/db/migrations/meta/0055_snapshot.json new file mode 100644 index 0000000..4661b09 --- /dev/null +++ b/packages/db/migrations/meta/0055_snapshot.json @@ -0,0 +1,8619 @@ +{ + "id": "a83d4fff-ad8d-4a05-ac37-42dd18db9cc9", + "prevId": "d0fe3274-18ca-44b1-861e-c52dd9228de8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.achievement_archives": { + "name": "achievement_archives", + "schema": "", + "columns": { + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "achievement_key": { + "name": "achievement_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlocked_at": { + "name": "unlocked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "achievement_archives_user_idx": { + "name": "achievement_archives_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "achievement_archives_user_id_user_id_fk": { + "name": "achievement_archives_user_id_user_id_fk", + "tableFrom": "achievement_archives", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "achievement_archives_season_user_id_achievement_key_pk": { + "name": "achievement_archives_season_user_id_achievement_key_pk", + "columns": [ + "season", + "user_id", + "achievement_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.achievements_progress": { + "name": "achievements_progress", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "achievement_key": { + "name": "achievement_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unlocked_at": { + "name": "unlocked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "achievements_progress_user_id_user_id_fk": { + "name": "achievements_progress_user_id_user_id_fk", + "tableFrom": "achievements_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "achievements_progress_user_id_achievement_key_pk": { + "name": "achievements_progress_user_id_achievement_key_pk", + "columns": [ + "user_id", + "achievement_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auctions": { + "name": "auctions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "auctions_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "instance_id": { + "name": "instance_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "seller_id": { + "name": "seller_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "shiny": { + "name": "shiny", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "start_price": { + "name": "start_price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "buyout": { + "name": "buyout", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "current_bid": { + "name": "current_bid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "current_bidder_id": { + "name": "current_bidder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bid_count": { + "name": "bid_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auctions_status_ends_idx": { + "name": "auctions_status_ends_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "ends_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auctions_instance_idx": { + "name": "auctions_instance_idx", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auctions_open_instance_uq": { + "name": "auctions_open_instance_uq", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auctions\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auctions_instance_id_card_instances_id_fk": { + "name": "auctions_instance_id_card_instances_id_fk", + "tableFrom": "auctions", + "tableTo": "card_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "auctions_seller_id_user_id_fk": { + "name": "auctions_seller_id_user_id_fk", + "tableFrom": "auctions", + "tableTo": "user", + "columnsFrom": [ + "seller_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auctions_current_bidder_id_user_id_fk": { + "name": "auctions_current_bidder_id_user_id_fk", + "tableFrom": "auctions", + "tableTo": "user", + "columnsFrom": [ + "current_bidder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "auctions_prices_ok": { + "name": "auctions_prices_ok", + "value": "\"auctions\".\"start_price\" > 0 AND (\"auctions\".\"buyout\" IS NULL OR \"auctions\".\"buyout\" >= \"auctions\".\"start_price\")" + } + }, + "isRLSEnabled": false + }, + "public.battle_decks": { + "name": "battle_decks", + "schema": "", + "columns": { + "battle_id": { + "name": "battle_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slot": { + "name": "slot", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "atk": { + "name": "atk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "def": { + "name": "def", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "battle_decks_battle_id_battles_id_fk": { + "name": "battle_decks_battle_id_battles_id_fk", + "tableFrom": "battle_decks", + "tableTo": "battles", + "columnsFrom": [ + "battle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "battle_decks_user_id_user_id_fk": { + "name": "battle_decks_user_id_user_id_fk", + "tableFrom": "battle_decks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "battle_decks_battle_id_user_id_slot_pk": { + "name": "battle_decks_battle_id_user_id_slot_pk", + "columns": [ + "battle_id", + "user_id", + "slot" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "battle_decks_slot": { + "name": "battle_decks_slot", + "value": "\"battle_decks\".\"slot\" BETWEEN 1 AND 5" + } + }, + "isRLSEnabled": false + }, + "public.battle_queue": { + "name": "battle_queue", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "deck": { + "name": "deck", + "type": "bigint[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "battle_queue_user_id_user_id_fk": { + "name": "battle_queue_user_id_user_id_fk", + "tableFrom": "battle_queue", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.battle_turns": { + "name": "battle_turns", + "schema": "", + "columns": { + "battle_id": { + "name": "battle_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "turn": { + "name": "turn", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "attacker_id": { + "name": "attacker_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "defender_id": { + "name": "defender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attack_slot": { + "name": "attack_slot", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "attack_auto": { + "name": "attack_auto", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "shield_slot": { + "name": "shield_slot", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "shield_auto": { + "name": "shield_auto", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "question": { + "name": "question", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "served_at": { + "name": "served_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "answered_at": { + "name": "answered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "choice": { + "name": "choice", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "correct": { + "name": "correct", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "answer_ms": { + "name": "answer_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "raw_damage": { + "name": "raw_damage", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "shield_pct": { + "name": "shield_pct", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "damage": { + "name": "damage", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "reflected": { + "name": "reflected", + "type": "smallint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "battle_turns_defender_idx": { + "name": "battle_turns_defender_idx", + "columns": [ + { + "expression": "defender_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "battle_turns_battle_id_battles_id_fk": { + "name": "battle_turns_battle_id_battles_id_fk", + "tableFrom": "battle_turns", + "tableTo": "battles", + "columnsFrom": [ + "battle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "battle_turns_attacker_id_user_id_fk": { + "name": "battle_turns_attacker_id_user_id_fk", + "tableFrom": "battle_turns", + "tableTo": "user", + "columnsFrom": [ + "attacker_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "battle_turns_defender_id_user_id_fk": { + "name": "battle_turns_defender_id_user_id_fk", + "tableFrom": "battle_turns", + "tableTo": "user", + "columnsFrom": [ + "defender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "battle_turns_battle_id_turn_pk": { + "name": "battle_turns_battle_id_turn_pk", + "columns": [ + "battle_id", + "turn" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "battle_turns_turn": { + "name": "battle_turns_turn", + "value": "\"battle_turns\".\"turn\" BETWEEN 1 AND 8" + } + }, + "isRLSEnabled": false + }, + "public.battles": { + "name": "battles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "battles_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "challenger_id": { + "name": "challenger_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opponent_id": { + "name": "opponent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "seed": { + "name": "seed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "winner_id": { + "name": "winner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "turn": { + "name": "turn", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "phase_started_at": { + "name": "phase_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "phase_ends_at": { + "name": "phase_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "first_attacker_id": { + "name": "first_attacker_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "challenger_hp": { + "name": "challenger_hp", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "opponent_hp": { + "name": "opponent_hp", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "challenger_idle": { + "name": "challenger_idle", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "opponent_idle": { + "name": "opponent_idle", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "forfeit_by": { + "name": "forfeit_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "challenger_elo_delta": { + "name": "challenger_elo_delta", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "opponent_elo_delta": { + "name": "opponent_elo_delta", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "battles_challenger_idx": { + "name": "battles_challenger_idx", + "columns": [ + { + "expression": "challenger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "battles_opponent_idx": { + "name": "battles_opponent_idx", + "columns": [ + { + "expression": "opponent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "battles_challenger_id_user_id_fk": { + "name": "battles_challenger_id_user_id_fk", + "tableFrom": "battles", + "tableTo": "user", + "columnsFrom": [ + "challenger_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "battles_opponent_id_user_id_fk": { + "name": "battles_opponent_id_user_id_fk", + "tableFrom": "battles", + "tableTo": "user", + "columnsFrom": [ + "opponent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "battles_winner_id_user_id_fk": { + "name": "battles_winner_id_user_id_fk", + "tableFrom": "battles", + "tableTo": "user", + "columnsFrom": [ + "winner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "battles_first_attacker_id_user_id_fk": { + "name": "battles_first_attacker_id_user_id_fk", + "tableFrom": "battles", + "tableTo": "user", + "columnsFrom": [ + "first_attacker_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "battles_forfeit_by_user_id_fk": { + "name": "battles_forfeit_by_user_id_fk", + "tableFrom": "battles", + "tableTo": "user", + "columnsFrom": [ + "forfeit_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "battles_distinct": { + "name": "battles_distinct", + "value": "\"battles\".\"challenger_id\" <> \"battles\".\"opponent_id\"" + } + }, + "isRLSEnabled": false + }, + "public.bids": { + "name": "bids", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "bids_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "auction_id": { + "name": "auction_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "bidder_id": { + "name": "bidder_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bids_auction_idx": { + "name": "bids_auction_idx", + "columns": [ + { + "expression": "auction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bids_auction_id_auctions_id_fk": { + "name": "bids_auction_id_auctions_id_fk", + "tableFrom": "bids", + "tableTo": "auctions", + "columnsFrom": [ + "auction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bids_bidder_id_user_id_fk": { + "name": "bids_bidder_id_user_id_fk", + "tableFrom": "bids", + "tableTo": "user", + "columnsFrom": [ + "bidder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "bids_amount_ok": { + "name": "bids_amount_ok", + "value": "\"bids\".\"amount\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.boss_assaults": { + "name": "boss_assaults", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "boss_assaults_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "damage": { + "name": "damage", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "boss_assaults_day_user_uq": { + "name": "boss_assaults_day_user_uq", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "boss_assaults_day_idx": { + "name": "boss_assaults_day_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "boss_assaults_user_id_user_id_fk": { + "name": "boss_assaults_user_id_user_id_fk", + "tableFrom": "boss_assaults", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.boss_days": { + "name": "boss_days", + "schema": "", + "columns": { + "day": { + "name": "day", + "type": "date", + "primaryKey": true, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "max_hp": { + "name": "max_hp", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "hp": { + "name": "hp", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "phase": { + "name": "phase", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "damage": { + "name": "damage", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "weakness": { + "name": "weakness", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resistance": { + "name": "resistance", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "killed_at": { + "name": "killed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "killed_by": { + "name": "killed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finalized_at": { + "name": "finalized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "boss_days_killed_by_user_id_fk": { + "name": "boss_days_killed_by_user_id_fk", + "tableFrom": "boss_days", + "tableTo": "user", + "columnsFrom": [ + "killed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "boss_days_state_ok": { + "name": "boss_days_state_ok", + "value": "\"boss_days\".\"hp\" >= 0 AND \"boss_days\".\"damage\" >= 0 AND \"boss_days\".\"phase\" >= 1" + } + }, + "isRLSEnabled": false + }, + "public.boss_hits": { + "name": "boss_hits", + "schema": "", + "columns": { + "assault_id": { + "name": "assault_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "idx": { + "name": "idx", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "atk": { + "name": "atk", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "question": { + "name": "question", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "served_at": { + "name": "served_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "shown_at": { + "name": "shown_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "answered_at": { + "name": "answered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "choice": { + "name": "choice", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "correct": { + "name": "correct", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "answer_ms": { + "name": "answer_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "guess_year": { + "name": "guess_year", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mult": { + "name": "mult", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "damage": { + "name": "damage", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "boss_hits_assault_id_boss_assaults_id_fk": { + "name": "boss_hits_assault_id_boss_assaults_id_fk", + "tableFrom": "boss_hits", + "tableTo": "boss_assaults", + "columnsFrom": [ + "assault_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boss_hits_assault_id_idx_pk": { + "name": "boss_hits_assault_id_idx_pk", + "columns": [ + "assault_id", + "idx" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.boss_phases": { + "name": "boss_phases", + "schema": "", + "columns": { + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "phase": { + "name": "phase", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "max_hp": { + "name": "max_hp", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fallen_at": { + "name": "fallen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "fallen_by": { + "name": "fallen_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "boss_phases_fallen_by_user_id_fk": { + "name": "boss_phases_fallen_by_user_id_fk", + "tableFrom": "boss_phases", + "tableTo": "user", + "columnsFrom": [ + "fallen_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boss_phases_day_phase_pk": { + "name": "boss_phases_day_phase_pk", + "columns": [ + "day", + "phase" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.boss_question_history": { + "name": "boss_question_history", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "asked_at": { + "name": "asked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "boss_question_history_user_id_user_id_fk": { + "name": "boss_question_history_user_id_user_id_fk", + "tableFrom": "boss_question_history", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boss_question_history_user_id_card_id_type_key_pk": { + "name": "boss_question_history_user_id_card_id_type_key_pk", + "columns": [ + "user_id", + "card_id", + "type", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.boss_rewards": { + "name": "boss_rewards", + "schema": "", + "columns": { + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phases": { + "name": "phases", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "boss_rewards_user_id_user_id_fk": { + "name": "boss_rewards_user_id_user_id_fk", + "tableFrom": "boss_rewards", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "boss_rewards_day_user_id_pk": { + "name": "boss_rewards_day_user_id_pk", + "columns": [ + "day", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.broadcast_reads": { + "name": "broadcast_reads", + "schema": "", + "columns": { + "broadcast_id": { + "name": "broadcast_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "broadcast_reads_user_idx": { + "name": "broadcast_reads_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "broadcast_reads_broadcast_id_broadcasts_id_fk": { + "name": "broadcast_reads_broadcast_id_broadcasts_id_fk", + "tableFrom": "broadcast_reads", + "tableTo": "broadcasts", + "columnsFrom": [ + "broadcast_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "broadcast_reads_user_id_user_id_fk": { + "name": "broadcast_reads_user_id_user_id_fk", + "tableFrom": "broadcast_reads", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "broadcast_reads_broadcast_id_user_id_pk": { + "name": "broadcast_reads_broadcast_id_user_id_pk", + "columns": [ + "broadcast_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.broadcasts": { + "name": "broadcasts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "broadcasts_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tone": { + "name": "tone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "link_url": { + "name": "link_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_label": { + "name": "link_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "broadcasts_status_idx": { + "name": "broadcasts_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sent_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.card_instances": { + "name": "card_instances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "card_instances_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "atk": { + "name": "atk", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "def": { + "name": "def", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "shiny": { + "name": "shiny", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "favorite": { + "name": "favorite", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pinned_slot": { + "name": "pinned_slot", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "obtained_at": { + "name": "obtained_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "card_instances_owner_card_idx": { + "name": "card_instances_owner_card_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "card_instances_card_season_idx": { + "name": "card_instances_card_season_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "card_instances_owner_obtained_idx": { + "name": "card_instances_owner_obtained_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "obtained_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "card_instances_pinned_uq": { + "name": "card_instances_pinned_uq", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pinned_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"card_instances\".\"pinned_slot\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "card_instances_owner_id_user_id_fk": { + "name": "card_instances_owner_id_user_id_fk", + "tableFrom": "card_instances", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "card_instances_season_card_id_cards_season_id_fk": { + "name": "card_instances_season_card_id_cards_season_id_fk", + "tableFrom": "card_instances", + "tableTo": "cards", + "columnsFrom": [ + "season", + "card_id" + ], + "columnsTo": [ + "season", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "card_instances_level_ok": { + "name": "card_instances_level_ok", + "value": "\"card_instances\".\"level\" BETWEEN 1 AND 5" + }, + "card_instances_pinned_ok": { + "name": "card_instances_pinned_ok", + "value": "\"card_instances\".\"pinned_slot\" IS NULL OR \"card_instances\".\"pinned_slot\" BETWEEN 1 AND 5" + } + }, + "isRLSEnabled": false + }, + "public.cards": { + "name": "cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "atk": { + "name": "atk", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "def": { + "name": "def", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "views_12m": { + "name": "views_12m", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "page_len": { + "name": "page_len", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rand_key": { + "name": "rand_key", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": "random()" + }, + "search_title": { + "name": "search_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "lower(f_unaccent(title))", + "type": "stored" + } + } + }, + "indexes": { + "cards_search_trgm_idx": { + "name": "cards_search_trgm_idx", + "columns": [ + { + "expression": "\"search_title\" gin_trgm_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "cards_rarity_rand_idx": { + "name": "cards_rarity_rand_idx", + "columns": [ + { + "expression": "season", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rarity", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rand_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cards_views_idx": { + "name": "cards_views_idx", + "columns": [ + { + "expression": "season", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views_12m", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cards_hl_views_idx": { + "name": "cards_hl_views_idx", + "columns": [ + { + "expression": "season", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "views_12m", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cards\".\"rarity\" in ('SR', 'UR', 'L')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "cards_atk_idx": { + "name": "cards_atk_idx", + "columns": [ + { + "expression": "season", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "atk", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cards_def_idx": { + "name": "cards_def_idx", + "columns": [ + { + "expression": "season", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "def", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cards_title_idx": { + "name": "cards_title_idx", + "columns": [ + { + "expression": "season", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cards_id_idx": { + "name": "cards_id_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "cards_season_id_pk": { + "name": "cards_season_id_pk", + "columns": [ + "season", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cards_next": { + "name": "cards_next", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "atk": { + "name": "atk", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "def": { + "name": "def", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "views_12m": { + "name": "views_12m", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "page_len": { + "name": "page_len", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cases": { + "name": "cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "cases_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "collection": { + "name": "collection", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cases_ends_idx": { + "name": "cases_ends_idx", + "columns": [ + { + "expression": "ends_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "cases_dates_ok": { + "name": "cases_dates_ok", + "value": "\"cases\".\"ends_at\" > \"cases\".\"starts_at\"" + } + }, + "isRLSEnabled": false + }, + "public.case_cards": { + "name": "case_cards", + "schema": "", + "columns": { + "case_id": { + "name": "case_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "pos": { + "name": "pos", + "type": "smallint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "case_cards_case_id_cases_id_fk": { + "name": "case_cards_case_id_cases_id_fk", + "tableFrom": "case_cards", + "tableTo": "cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "case_cards_case_id_card_id_pk": { + "name": "case_cards_case_id_card_id_pk", + "columns": [ + "case_id", + "card_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.case_openings": { + "name": "case_openings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "case_openings_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "case_id": { + "name": "case_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "free": { + "name": "free", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "lot": { + "name": "lot", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "instance_ids": { + "name": "instance_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "case_openings_user_idx": { + "name": "case_openings_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "case_openings_value_idx": { + "name": "case_openings_value_idx", + "columns": [ + { + "expression": "value", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "case_openings_user_id_user_id_fk": { + "name": "case_openings_user_id_user_id_fk", + "tableFrom": "case_openings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "case_openings_case_id_cases_id_fk": { + "name": "case_openings_case_id_cases_id_fk", + "tableFrom": "case_openings", + "tableTo": "cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.community_goal_rewards": { + "name": "community_goal_rewards", + "schema": "", + "columns": { + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "community_goal_rewards_user_id_user_id_fk": { + "name": "community_goal_rewards_user_id_user_id_fk", + "tableFrom": "community_goal_rewards", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "community_goal_rewards_season_tier_user_id_pk": { + "name": "community_goal_rewards_season_tier_user_id_pk", + "columns": [ + "season", + "tier", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.community_goals": { + "name": "community_goals", + "schema": "", + "columns": { + "season": { + "name": "season", + "type": "smallint", + "primaryKey": true, + "notNull": true + }, + "distinct_cards": { + "name": "distinct_cards", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "season_cards": { + "name": "season_cards", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tiers_reached": { + "name": "tiers_reached", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.daily_articles": { + "name": "daily_articles", + "schema": "", + "columns": { + "day": { + "name": "day", + "type": "date", + "primaryKey": true, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "clues": { + "name": "clues", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "attrs": { + "name": "attrs", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.daily_guesses": { + "name": "daily_guesses", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "guesses": { + "name": "guesses", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "found": { + "name": "found", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "reward": { + "name": "reward", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "daily_guesses_user_id_user_id_fk": { + "name": "daily_guesses_user_id_user_id_fk", + "tableFrom": "daily_guesses", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "daily_guesses_user_id_day_pk": { + "name": "daily_guesses_user_id_day_pk", + "columns": [ + "user_id", + "day" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.friendships": { + "name": "friendships", + "schema": "", + "columns": { + "user_a": { + "name": "user_a", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_b": { + "name": "user_b", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "friendships_b_idx": { + "name": "friendships_b_idx", + "columns": [ + { + "expression": "user_b", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "friendships_user_a_user_id_fk": { + "name": "friendships_user_a_user_id_fk", + "tableFrom": "friendships", + "tableTo": "user", + "columnsFrom": [ + "user_a" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friendships_user_b_user_id_fk": { + "name": "friendships_user_b_user_id_fk", + "tableFrom": "friendships", + "tableTo": "user", + "columnsFrom": [ + "user_b" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friendships_requested_by_user_id_fk": { + "name": "friendships_requested_by_user_id_fk", + "tableFrom": "friendships", + "tableTo": "user", + "columnsFrom": [ + "requested_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "friendships_user_a_user_b_pk": { + "name": "friendships_user_a_user_b_pk", + "columns": [ + "user_a", + "user_b" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "friendships_order": { + "name": "friendships_order", + "value": "\"friendships\".\"user_a\" < \"friendships\".\"user_b\"" + } + }, + "isRLSEnabled": false + }, + "public.game_settings": { + "name": "game_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "game_settings_updated_by_user_id_fk": { + "name": "game_settings_updated_by_user_id_fk", + "tableFrom": "game_settings", + "tableTo": "user", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.guild_members": { + "name": "guild_members", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "guild_id": { + "name": "guild_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "guild_members_guild_idx": { + "name": "guild_members_guild_idx", + "columns": [ + { + "expression": "guild_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "guild_members_user_id_user_id_fk": { + "name": "guild_members_user_id_user_id_fk", + "tableFrom": "guild_members", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "guild_members_guild_id_guilds_id_fk": { + "name": "guild_members_guild_id_guilds_id_fk", + "tableFrom": "guild_members", + "tableTo": "guilds", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.guild_objective_contributions": { + "name": "guild_objective_contributions", + "schema": "", + "columns": { + "objective_id": { + "name": "objective_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "guild_objective_contributions_objective_id_guild_objectives_id_fk": { + "name": "guild_objective_contributions_objective_id_guild_objectives_id_fk", + "tableFrom": "guild_objective_contributions", + "tableTo": "guild_objectives", + "columnsFrom": [ + "objective_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "guild_objective_contributions_user_id_user_id_fk": { + "name": "guild_objective_contributions_user_id_user_id_fk", + "tableFrom": "guild_objective_contributions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "guild_objective_contributions_objective_id_user_id_pk": { + "name": "guild_objective_contributions_objective_id_user_id_pk", + "columns": [ + "objective_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.guild_objectives": { + "name": "guild_objectives", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "guild_objectives_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "guild_id": { + "name": "guild_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "week_start": { + "name": "week_start", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "guild_objectives_week_uq": { + "name": "guild_objectives_week_uq", + "columns": [ + { + "expression": "guild_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "week_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "guild_objectives_guild_id_guilds_id_fk": { + "name": "guild_objectives_guild_id_guilds_id_fk", + "tableFrom": "guild_objectives", + "tableTo": "guilds", + "columnsFrom": [ + "guild_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.guilds": { + "name": "guilds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "guilds_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emblem": { + "name": "emblem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "guilds_name_uq": { + "name": "guilds_name_uq", + "columns": [ + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "guilds_tag_uq": { + "name": "guilds_tag_uq", + "columns": [ + { + "expression": "lower(\"tag\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hl_runs": { + "name": "hl_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "hl_runs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "try_no": { + "name": "try_no", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'playing'" + }, + "step": { + "name": "step", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streak": { + "name": "streak", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "kept_card_id": { + "name": "kept_card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "challenger_card_id": { + "name": "challenger_card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "deadline": { + "name": "deadline", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "end_reason": { + "name": "end_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward": { + "name": "reward", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "practice": { + "name": "practice", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pair_renewed": { + "name": "pair_renewed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "hl_runs_try_uq": { + "name": "hl_runs_try_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "try_no", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hl_runs_playing_idx": { + "name": "hl_runs_playing_idx", + "columns": [ + { + "expression": "deadline", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"hl_runs\".\"status\" = 'playing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "hl_runs_month_idx": { + "name": "hl_runs_month_idx", + "columns": [ + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "streak", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hl_runs_user_id_user_id_fk": { + "name": "hl_runs_user_id_user_id_fk", + "tableFrom": "hl_runs", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hl_seen": { + "name": "hl_seen", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "hl_seen_user_id_user_id_fk": { + "name": "hl_seen_user_id_user_id_fk", + "tableFrom": "hl_seen", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hl_seen_user_id_day_card_id_pk": { + "name": "hl_seen_user_id_day_card_id_pk", + "columns": [ + "user_id", + "day", + "card_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hl_steps": { + "name": "hl_steps", + "schema": "", + "columns": { + "run_id": { + "name": "run_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "step": { + "name": "step", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "kept_card_id": { + "name": "kept_card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "challenger_card_id": { + "name": "challenger_card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "kept_views": { + "name": "kept_views", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "challenger_views": { + "name": "challenger_views", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "pick": { + "name": "pick", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correct": { + "name": "correct", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "timeout": { + "name": "timeout", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "renewed": { + "name": "renewed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "answer_ms": { + "name": "answer_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "answered_at": { + "name": "answered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hl_steps_run_id_hl_runs_id_fk": { + "name": "hl_steps_run_id_hl_runs_id_fk", + "tableFrom": "hl_steps", + "tableTo": "hl_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hl_steps_run_id_step_pk": { + "name": "hl_steps_run_id_step_pk", + "columns": [ + "run_id", + "step" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.image_filter_cards": { + "name": "image_filter_cards", + "schema": "", + "columns": { + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "image_filter_cards_card_id_theme_pk": { + "name": "image_filter_cards_card_id_theme_pk", + "columns": [ + "card_id", + "theme" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.image_filter_overrides": { + "name": "image_filter_overrides", + "schema": "", + "columns": { + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "flagged": { + "name": "flagged", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "image_filter_overrides_card_id_theme_pk": { + "name": "image_filter_overrides_card_id_theme_pk", + "columns": [ + "card_id", + "theme" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ledger": { + "name": "ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "ledger_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pw'" + }, + "delta": { + "name": "delta", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "balance_after": { + "name": "balance_after", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ledger_user_created_idx": { + "name": "ledger_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ledger_user_id_user_id_fk": { + "name": "ledger_user_id_user_id_fk", + "tableFrom": "ledger", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ledger_delta_nonzero": { + "name": "ledger_delta_nonzero", + "value": "\"ledger\".\"delta\" <> 0" + } + }, + "isRLSEnabled": false + }, + "public.message_reads": { + "name": "message_reads", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "message_reads_user_id_user_id_fk": { + "name": "message_reads_user_id_user_id_fk", + "tableFrom": "message_reads", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "message_reads_user_id_channel_pk": { + "name": "message_reads_user_id_channel_pk", + "columns": [ + "user_id", + "channel" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "messages_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "card_season": { + "name": "card_season", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "messages_channel_idx": { + "name": "messages_channel_idx", + "columns": [ + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_sender_id_user_id_fk": { + "name": "messages_sender_id_user_id_fk", + "tableFrom": "messages", + "tableTo": "user", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "messages_body_len": { + "name": "messages_body_len", + "value": "char_length(\"messages\".\"body\") BETWEEN 0 AND 1000" + } + }, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "notifications_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_user_idx": { + "name": "notifications_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_user_id_user_id_fk": { + "name": "notifications_user_id_user_id_fk", + "tableFrom": "notifications", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pack_stats": { + "name": "pack_stats", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "packs": { + "name": "packs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "luck_packs": { + "name": "luck_packs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pulled_points": { + "name": "pulled_points", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expected_points": { + "name": "expected_points", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "pack_stats_user_id_user_id_fk": { + "name": "pack_stats_user_id_user_id_fk", + "tableFrom": "pack_stats", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pack_stats_user_id_season_pk": { + "name": "pack_stats_user_id_season_pk", + "columns": [ + "user_id", + "season" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.player_avatars": { + "name": "player_avatars", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "image": { + "name": "image", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "player_avatars_user_id_user_id_fk": { + "name": "player_avatars_user_id_user_id_fk", + "tableFrom": "player_avatars", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "player_avatars_size_ok": { + "name": "player_avatars_size_ok", + "value": "octet_length(\"player_avatars\".\"image\") <= 150000" + } + }, + "isRLSEnabled": false + }, + "public.player_banners": { + "name": "player_banners", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "image": { + "name": "image", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "player_banners_user_id_user_id_fk": { + "name": "player_banners_user_id_user_id_fk", + "tableFrom": "player_banners", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "player_banners_size_ok": { + "name": "player_banners_size_ok", + "value": "octet_length(\"player_banners\".\"image\") <= 180000" + } + }, + "isRLSEnabled": false + }, + "public.player_free_cases": { + "name": "player_free_cases", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "player_free_cases_user_id_user_id_fk": { + "name": "player_free_cases_user_id_user_id_fk", + "tableFrom": "player_free_cases", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "player_free_cases_user_id_tier_pk": { + "name": "player_free_cases_user_id_tier_pk", + "columns": [ + "user_id", + "tier" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "player_free_cases_count_ok": { + "name": "player_free_cases_count_ok", + "value": "\"player_free_cases\".\"count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.player_portfolio_designs": { + "name": "player_portfolio_designs", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "design_key": { + "name": "design_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "acquired_at": { + "name": "acquired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "player_portfolio_designs_user_id_user_id_fk": { + "name": "player_portfolio_designs_user_id_user_id_fk", + "tableFrom": "player_portfolio_designs", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "player_portfolio_designs_user_id_design_key_pk": { + "name": "player_portfolio_designs_user_id_design_key_pk", + "columns": [ + "user_id", + "design_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.player_quests": { + "name": "player_quests", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "slot": { + "name": "slot", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reward_pw": { + "name": "reward_pw", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reward_xp": { + "name": "reward_xp", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rerolled": { + "name": "rerolled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "player_quests_user_id_user_id_fk": { + "name": "player_quests_user_id_user_id_fk", + "tableFrom": "player_quests", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "player_quests_user_id_period_period_start_slot_pk": { + "name": "player_quests_user_id_period_period_start_slot_pk", + "columns": [ + "user_id", + "period", + "period_start", + "slot" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "player_quests_target_ok": { + "name": "player_quests_target_ok", + "value": "\"player_quests\".\"target\" > 0 AND \"player_quests\".\"progress\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.player_stats": { + "name": "player_stats", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "player_stats_user_id_user_id_fk": { + "name": "player_stats_user_id_user_id_fk", + "tableFrom": "player_stats", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "player_stats_user_id_key_pk": { + "name": "player_stats_user_id_key_pk", + "columns": [ + "user_id", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.player_theme_packs": { + "name": "player_theme_packs", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme_id": { + "name": "theme_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "opened": { + "name": "opened", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "player_theme_packs_theme_idx": { + "name": "player_theme_packs_theme_idx", + "columns": [ + { + "expression": "theme_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "player_theme_packs_user_id_user_id_fk": { + "name": "player_theme_packs_user_id_user_id_fk", + "tableFrom": "player_theme_packs", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "player_theme_packs_theme_id_themes_id_fk": { + "name": "player_theme_packs_theme_id_themes_id_fk", + "tableFrom": "player_theme_packs", + "tableTo": "themes", + "columnsFrom": [ + "theme_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "player_theme_packs_user_id_theme_id_pk": { + "name": "player_theme_packs_user_id_theme_id_pk", + "columns": [ + "user_id", + "theme_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "player_theme_packs_count_ok": { + "name": "player_theme_packs_count_ok", + "value": "\"player_theme_packs\".\"count\" >= 0" + }, + "player_theme_packs_opened_ok": { + "name": "player_theme_packs_opened_ok", + "value": "\"player_theme_packs\".\"opened\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.player_titles": { + "name": "player_titles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "board": { + "name": "board", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rank": { + "name": "rank", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "awarded_at": { + "name": "awarded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "player_titles_user_id_user_id_fk": { + "name": "player_titles_user_id_user_id_fk", + "tableFrom": "player_titles", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "player_titles_user_id_season_board_pk": { + "name": "player_titles_user_id_season_board_pk", + "columns": [ + "user_id", + "season", + "board" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.players": { + "name": "players", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "balance": { + "name": "balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "locked_balance": { + "name": "locked_balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "packs_stored": { + "name": "packs_stored", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "packs_updated_at": { + "name": "packs_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "bonus_packs": { + "name": "bonus_packs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "packs_full_notified": { + "name": "packs_full_notified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pity_counter": { + "name": "pity_counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "elo": { + "name": "elo", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1000 + }, + "elo_peak": { + "name": "elo_peak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1000 + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "animation_speed": { + "name": "animation_speed", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "auto_recycle_max": { + "name": "auto_recycle_max", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_recycle_keep_new": { + "name": "auto_recycle_keep_new", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "hide_arthropods": { + "name": "hide_arthropods", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hide_gore": { + "name": "hide_gore", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "hide_nudity": { + "name": "hide_nudity", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "quick_upgrade": { + "name": "quick_upgrade", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "confirm_recycle": { + "name": "confirm_recycle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "public_tags": { + "name": "public_tags", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notification_prefs": { + "name": "notification_prefs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "login_streak": { + "name": "login_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_login_day": { + "name": "last_login_day", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "last_wheel_day": { + "name": "last_wheel_day", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "wheel_step": { + "name": "wheel_step", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "wheel_last_at": { + "name": "wheel_last_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "season_xp": { + "name": "season_xp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pass_season": { + "name": "pass_season", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "pass_rewarded": { + "name": "pass_rewarded", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stats_version": { + "name": "stats_version", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_quest_reroll_day": { + "name": "last_quest_reroll_day", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "seen_features": { + "name": "seen_features", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suggestion_banner_until": { + "name": "suggestion_banner_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "title_season": { + "name": "title_season", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "title_board": { + "name": "title_board", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_note": { + "name": "status_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_note_at": { + "name": "status_note_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "banner_card_id": { + "name": "banner_card_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "players_user_id_user_id_fk": { + "name": "players_user_id_user_id_fk", + "tableFrom": "players", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "players_balance_ok": { + "name": "players_balance_ok", + "value": "\"players\".\"balance\" >= \"players\".\"locked_balance\" AND \"players\".\"locked_balance\" >= 0" + }, + "players_packs_ok": { + "name": "players_packs_ok", + "value": "\"players\".\"packs_stored\" >= 0 AND \"players\".\"bonus_packs\" >= 0 AND \"players\".\"pity_counter\" >= 0" + }, + "players_xp_ok": { + "name": "players_xp_ok", + "value": "\"players\".\"season_xp\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.portfolio_backgrounds": { + "name": "portfolio_backgrounds", + "schema": "", + "columns": { + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "image": { + "name": "image", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "mime": { + "name": "mime", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "portfolio_backgrounds_portfolio_id_portfolios_id_fk": { + "name": "portfolio_backgrounds_portfolio_id_portfolios_id_fk", + "tableFrom": "portfolio_backgrounds", + "tableTo": "portfolios", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "portfolio_backgrounds_size_ok": { + "name": "portfolio_backgrounds_size_ok", + "value": "octet_length(\"portfolio_backgrounds\".\"image\") <= 180000" + } + }, + "isRLSEnabled": false + }, + "public.portfolio_cards": { + "name": "portfolio_cards", + "schema": "", + "columns": { + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "pos": { + "name": "pos", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "portfolio_cards_instance_idx": { + "name": "portfolio_cards_instance_idx", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "portfolio_cards_portfolio_id_portfolios_id_fk": { + "name": "portfolio_cards_portfolio_id_portfolios_id_fk", + "tableFrom": "portfolio_cards", + "tableTo": "portfolios", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "portfolio_cards_instance_id_card_instances_id_fk": { + "name": "portfolio_cards_instance_id_card_instances_id_fk", + "tableFrom": "portfolio_cards", + "tableTo": "card_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "portfolio_cards_portfolio_id_instance_id_pk": { + "name": "portfolio_cards_portfolio_id_instance_id_pk", + "columns": [ + "portfolio_id", + "instance_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "portfolio_cards_pos_ok": { + "name": "portfolio_cards_pos_ok", + "value": "\"portfolio_cards\".\"pos\" BETWEEN 0 AND 29" + } + }, + "isRLSEnabled": false + }, + "public.portfolio_likes": { + "name": "portfolio_likes", + "schema": "", + "columns": { + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "portfolio_likes_user_idx": { + "name": "portfolio_likes_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "portfolio_likes_portfolio_id_portfolios_id_fk": { + "name": "portfolio_likes_portfolio_id_portfolios_id_fk", + "tableFrom": "portfolio_likes", + "tableTo": "portfolios", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "portfolio_likes_user_id_user_id_fk": { + "name": "portfolio_likes_user_id_user_id_fk", + "tableFrom": "portfolio_likes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "portfolio_likes_portfolio_id_user_id_pk": { + "name": "portfolio_likes_portfolio_id_user_id_pk", + "columns": [ + "portfolio_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portfolio_members": { + "name": "portfolio_members", + "schema": "", + "columns": { + "portfolio_id": { + "name": "portfolio_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'contributor'" + }, + "profile_slot": { + "name": "profile_slot", + "type": "smallint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "portfolio_members_user_idx": { + "name": "portfolio_members_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "portfolio_members_profile_slot_idx": { + "name": "portfolio_members_profile_slot_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "portfolio_members_portfolio_id_portfolios_id_fk": { + "name": "portfolio_members_portfolio_id_portfolios_id_fk", + "tableFrom": "portfolio_members", + "tableTo": "portfolios", + "columnsFrom": [ + "portfolio_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "portfolio_members_user_id_user_id_fk": { + "name": "portfolio_members_user_id_user_id_fk", + "tableFrom": "portfolio_members", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "portfolio_members_portfolio_id_user_id_pk": { + "name": "portfolio_members_portfolio_id_user_id_pk", + "columns": [ + "portfolio_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.portfolios": { + "name": "portfolios", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "portfolios_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "background": { + "name": "background", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'papier'" + }, + "frame": { + "name": "frame", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'filet'" + }, + "accent": { + "name": "accent", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'or'" + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "profile_slot": { + "name": "profile_slot", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "portfolios_user_idx": { + "name": "portfolios_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "portfolios_created_idx": { + "name": "portfolios_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "portfolios_profile_uq": { + "name": "portfolios_profile_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"portfolios\".\"profile_slot\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "portfolios_user_id_user_id_fk": { + "name": "portfolios_user_id_user_id_fk", + "tableFrom": "portfolios", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "portfolios_name_len": { + "name": "portfolios_name_len", + "value": "char_length(\"portfolios\".\"name\") BETWEEN 1 AND 40" + }, + "portfolios_description_len": { + "name": "portfolios_description_len", + "value": "char_length(\"portfolios\".\"description\") <= 280" + }, + "portfolios_profile_ok": { + "name": "portfolios_profile_ok", + "value": "\"portfolios\".\"profile_slot\" IS NULL OR \"portfolios\".\"profile_slot\" BETWEEN 1 AND 3" + } + }, + "isRLSEnabled": false + }, + "public.promo_codes": { + "name": "promo_codes", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "pw": { + "name": "pw", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "packs": { + "name": "packs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "theme_id": { + "name": "theme_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "theme_packs": { + "name": "theme_packs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uses": { + "name": "uses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "promo_codes_theme_id_themes_id_fk": { + "name": "promo_codes_theme_id_themes_id_fk", + "tableFrom": "promo_codes", + "tableTo": "themes", + "columnsFrom": [ + "theme_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "promo_codes_amounts_ok": { + "name": "promo_codes_amounts_ok", + "value": "\"promo_codes\".\"pw\" >= 0 AND \"promo_codes\".\"packs\" >= 0 AND \"promo_codes\".\"theme_packs\" >= 0" + }, + "promo_codes_uses_ok": { + "name": "promo_codes_uses_ok", + "value": "\"promo_codes\".\"uses\" >= 0 AND (\"promo_codes\".\"max_uses\" IS NULL OR \"promo_codes\".\"uses\" <= \"promo_codes\".\"max_uses\")" + }, + "promo_codes_code_ok": { + "name": "promo_codes_code_ok", + "value": "\"promo_codes\".\"code\" ~ '^[A-Z0-9_-]{3,32}$'" + } + }, + "isRLSEnabled": false + }, + "public.promo_redemptions": { + "name": "promo_redemptions", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "promo_redemptions_user_idx": { + "name": "promo_redemptions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "promo_redemptions_code_promo_codes_code_fk": { + "name": "promo_redemptions_code_promo_codes_code_fk", + "tableFrom": "promo_redemptions", + "tableTo": "promo_codes", + "columnsFrom": [ + "code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "promo_redemptions_user_id_user_id_fk": { + "name": "promo_redemptions_user_id_user_id_fk", + "tableFrom": "promo_redemptions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "promo_redemptions_code_user_id_pk": { + "name": "promo_redemptions_code_user_id_pk", + "columns": [ + "code", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pull_reactions": { + "name": "pull_reactions", + "schema": "", + "columns": { + "pull_id": { + "name": "pull_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pull_reactions_pull_id_pulls_id_fk": { + "name": "pull_reactions_pull_id_pulls_id_fk", + "tableFrom": "pull_reactions", + "tableTo": "pulls", + "columnsFrom": [ + "pull_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pull_reactions_user_id_user_id_fk": { + "name": "pull_reactions_user_id_user_id_fk", + "tableFrom": "pull_reactions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "pull_reactions_pull_id_user_id_emoji_pk": { + "name": "pull_reactions_pull_id_user_id_emoji_pk", + "columns": [ + "pull_id", + "user_id", + "emoji" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pulls": { + "name": "pulls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "pulls_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "shiny": { + "name": "shiny", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "genres": { + "name": "genres", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pulls_created_idx": { + "name": "pulls_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pulls_card_idx": { + "name": "pulls_card_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pulls_season_card_idx": { + "name": "pulls_season_card_idx", + "columns": [ + { + "expression": "season", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pulls_user_idx": { + "name": "pulls_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pulls_user_id_user_id_fk": { + "name": "pulls_user_id_user_id_fk", + "tableFrom": "pulls", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sales": { + "name": "sales", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "sales_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "auction_id": { + "name": "auction_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "sold_at": { + "name": "sold_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sales_card_sold_idx": { + "name": "sales_card_sold_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sold_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sales_auction_id_auctions_id_fk": { + "name": "sales_auction_id_auctions_id_fk", + "tableFrom": "sales", + "tableTo": "auctions", + "columnsFrom": [ + "auction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_decks": { + "name": "saved_decks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "saved_decks_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cards": { + "name": "cards", + "type": "bigint[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "saved_decks_user_idx": { + "name": "saved_decks_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "saved_decks_user_id_user_id_fk": { + "name": "saved_decks_user_id_user_id_fk", + "tableFrom": "saved_decks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.season_archives": { + "name": "season_archives", + "schema": "", + "columns": { + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "collection_score": { + "name": "collection_score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "elo": { + "name": "elo", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "wealth": { + "name": "wealth", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "guild_id": { + "name": "guild_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "season_archives_user_id_user_id_fk": { + "name": "season_archives_user_id_user_id_fk", + "tableFrom": "season_archives", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "season_archives_season_user_id_pk": { + "name": "season_archives_season_user_id_pk", + "columns": [ + "season", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seasons": { + "name": "seasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "smallint", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "seasons_one_active": { + "name": "seasons_one_active", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"seasons\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shop_offers": { + "name": "shop_offers", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "slot": { + "name": "slot", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "purchased_at": { + "name": "purchased_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shop_offers_card_uq": { + "name": "shop_offers_card_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "day", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shop_offers_user_id_user_id_fk": { + "name": "shop_offers_user_id_user_id_fk", + "tableFrom": "shop_offers", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "shop_offers_user_id_day_slot_pk": { + "name": "shop_offers_user_id_day_slot_pk", + "columns": [ + "user_id", + "day", + "slot" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shop_offers_price_ok": { + "name": "shop_offers_price_ok", + "value": "\"shop_offers\".\"price\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.suggestion_automation": { + "name": "suggestion_automation", + "schema": "", + "columns": { + "suggestion_id": { + "name": "suggestion_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "triage_status": { + "name": "triage_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verdict": { + "name": "verdict", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning": { + "name": "reasoning", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spec": { + "name": "spec", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "questions": { + "name": "questions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "proposed_reply": { + "name": "proposed_reply", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duplicate_of": { + "name": "duplicate_of", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "injection": { + "name": "injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "triaged_at": { + "name": "triaged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "build_status": { + "name": "build_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ci_conclusion": { + "name": "ci_conclusion", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "player_reply": { + "name": "player_reply", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "announcement": { + "name": "announcement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "suggestion_automation_triage_idx": { + "name": "suggestion_automation_triage_idx", + "columns": [ + { + "expression": "triage_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "suggestion_automation_build_idx": { + "name": "suggestion_automation_build_idx", + "columns": [ + { + "expression": "build_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "suggestion_automation_suggestion_id_suggestions_id_fk": { + "name": "suggestion_automation_suggestion_id_suggestions_id_fk", + "tableFrom": "suggestion_automation", + "tableTo": "suggestions", + "columnsFrom": [ + "suggestion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.suggestion_messages": { + "name": "suggestion_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "suggestion_messages_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "suggestion_id": { + "name": "suggestion_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "from_admin": { + "name": "from_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "admin_seen_at": { + "name": "admin_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "suggestion_messages_suggestion_idx": { + "name": "suggestion_messages_suggestion_idx", + "columns": [ + { + "expression": "suggestion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "suggestion_messages_suggestion_id_suggestions_id_fk": { + "name": "suggestion_messages_suggestion_id_suggestions_id_fk", + "tableFrom": "suggestion_messages", + "tableTo": "suggestions", + "columnsFrom": [ + "suggestion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.suggestions": { + "name": "suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "suggestions_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "reply": { + "name": "reply", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "replied_at": { + "name": "replied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "admin_seen_at": { + "name": "admin_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "suggestions_user_idx": { + "name": "suggestions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "suggestions_status_idx": { + "name": "suggestions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "suggestions_user_id_user_id_fk": { + "name": "suggestions_user_id_user_id_fk", + "tableFrom": "suggestions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.theme_cards": { + "name": "theme_cards", + "schema": "", + "columns": { + "theme_id": { + "name": "theme_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "pos": { + "name": "pos", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "theme_cards_card_idx": { + "name": "theme_cards_card_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "theme_cards_draw_idx": { + "name": "theme_cards_draw_idx", + "columns": [ + { + "expression": "theme_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "rarity", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pos", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "theme_cards_theme_id_themes_id_fk": { + "name": "theme_cards_theme_id_themes_id_fk", + "tableFrom": "theme_cards", + "tableTo": "themes", + "columnsFrom": [ + "theme_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "theme_cards_theme_id_card_id_pk": { + "name": "theme_cards_theme_id_card_id_pk", + "columns": [ + "theme_id", + "card_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.themes": { + "name": "themes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "themes_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "card_count": { + "name": "card_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "indexed_season": { + "name": "indexed_season", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "by_rarity": { + "name": "by_rarity", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "preview_ids": { + "name": "preview_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "themes_ends_idx": { + "name": "themes_ends_idx", + "columns": [ + { + "expression": "ends_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "themes_price_ok": { + "name": "themes_price_ok", + "value": "\"themes\".\"price\" > 0" + }, + "themes_dates_ok": { + "name": "themes_dates_ok", + "value": "\"themes\".\"ends_at\" > \"themes\".\"starts_at\"" + } + }, + "isRLSEnabled": false + }, + "public.trade_items": { + "name": "trade_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "trade_items_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "trade_id": { + "name": "trade_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "side": { + "name": "side", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "shiny": { + "name": "shiny", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "atk": { + "name": "atk", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "def": { + "name": "def", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "fate": { + "name": "fate", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fate_at": { + "name": "fate_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "trade_items_trade_instance_uq": { + "name": "trade_items_trade_instance_uq", + "columns": [ + { + "expression": "trade_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trade_items_instance_idx": { + "name": "trade_items_instance_idx", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trade_items_trade_id_trades_id_fk": { + "name": "trade_items_trade_id_trades_id_fk", + "tableFrom": "trade_items", + "tableTo": "trades", + "columnsFrom": [ + "trade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "trade_items_instance_id_card_instances_id_fk": { + "name": "trade_items_instance_id_card_instances_id_fk", + "tableFrom": "trade_items", + "tableTo": "card_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trades": { + "name": "trades", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "trades_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "from_id": { + "name": "from_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_id": { + "name": "to_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_pw": { + "name": "from_pw", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "to_pw": { + "name": "to_pw", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "trades_to_idx": { + "name": "trades_to_idx", + "columns": [ + { + "expression": "to_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "trades_from_idx": { + "name": "trades_from_idx", + "columns": [ + { + "expression": "from_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "trades_from_id_user_id_fk": { + "name": "trades_from_id_user_id_fk", + "tableFrom": "trades", + "tableTo": "user", + "columnsFrom": [ + "from_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "trades_to_id_user_id_fk": { + "name": "trades_to_id_user_id_fk", + "tableFrom": "trades", + "tableTo": "user", + "columnsFrom": [ + "to_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "trades_pw_ok": { + "name": "trades_pw_ok", + "value": "\"trades\".\"from_pw\" >= 0 AND \"trades\".\"to_pw\" >= 0" + }, + "trades_distinct": { + "name": "trades_distinct", + "value": "\"trades\".\"from_id\" <> \"trades\".\"to_id\"" + } + }, + "isRLSEnabled": false + }, + "public.upgrade_series_runs": { + "name": "upgrade_series_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "upgrade_series_runs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "rarity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "lots": { + "name": "lots", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "successes": { + "name": "successes", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "refund": { + "name": "refund", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upgrade_series_runs_user_idx": { + "name": "upgrade_series_runs_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "upgrade_series_runs_user_id_user_id_fk": { + "name": "upgrade_series_runs_user_id_user_id_fk", + "tableFrom": "upgrade_series_runs", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_tags": { + "name": "user_tags", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "tag": { + "name": "tag", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_tags_instance_id_card_instances_id_fk": { + "name": "user_tags_instance_id_card_instances_id_fk", + "tableFrom": "user_tags", + "tableTo": "card_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_tags_instance_id_tag_pk": { + "name": "user_tags_instance_id_tag_pk", + "columns": [ + "instance_id", + "tag" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_tags_len": { + "name": "user_tags_len", + "value": "char_length(\"user_tags\".\"tag\") BETWEEN 1 AND 24" + } + }, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.weird_cards": { + "name": "weird_cards", + "schema": "", + "columns": { + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "genre": { + "name": "genre", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "weird_cards_card_id_genre_pk": { + "name": "weird_cards_card_id_genre_pk", + "columns": [ + "card_id", + "genre" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wiki_attributes": { + "name": "wiki_attributes", + "schema": "", + "columns": { + "page_id": { + "name": "page_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "qid": { + "name": "qid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "human": { + "name": "human", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "country_id": { + "name": "country_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "continents": { + "name": "continents", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "year": { + "name": "year", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "year_kind": { + "name": "year_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "year_in_article": { + "name": "year_in_article", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wiki_summaries": { + "name": "wiki_summaries", + "schema": "", + "columns": { + "page_id": { + "name": "page_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "extract": { + "name": "extract", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumb_url": { + "name": "thumb_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "page_url": { + "name": "page_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "arthropod": { + "name": "arthropod", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wishlist": { + "name": "wishlist", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wishlist_card_idx": { + "name": "wishlist_card_idx", + "columns": [ + { + "expression": "card_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wishlist_user_id_user_id_fk": { + "name": "wishlist_user_id_user_id_fk", + "tableFrom": "wishlist", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "wishlist_user_id_card_id_pk": { + "name": "wishlist_user_id_card_id_pk", + "columns": [ + "user_id", + "card_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.rarity": { + "name": "rarity", + "schema": "public", + "values": [ + "C", + "PC", + "R", + "SR", + "UR", + "L" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 52a3d00..c3c2228 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -386,6 +386,13 @@ "when": 1793717960336, "tag": "0054_portfolio_members", "breakpoints": true + }, + { + "idx": 55, + "version": "7", + "when": 1793788577430, + "tag": "0055_cases", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index ad7631a..3273d02 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -326,7 +326,7 @@ export const cardInstances = pgTable( pinnedSlot: smallint("pinned_slot"), /** `upgrade` (upgrader) et `wheel` (roue quotidienne) comptent, comme `pack`, pour les succès de collection. */ source: text("source", { - enum: ["pack", "market", "trade", "admin", "upgrade", "wheel", "boss", "shop"], + enum: ["pack", "market", "trade", "admin", "upgrade", "wheel", "boss", "shop", "case"], }).notNull(), obtainedAt: tstz("obtained_at").notNull().defaultNow(), }, @@ -406,7 +406,7 @@ export const ledger = pgTable( { id: id(), userId: userRef("user_id").notNull(), - kind: text("kind", { enum: ["pw", "bonus_pack", "pack", "card", "theme_pack"] }) + kind: text("kind", { enum: ["pw", "bonus_pack", "pack", "card", "theme_pack", "free_case"] }) .notNull() .default("pw"), delta: bigint("delta", { mode: "number" }).notNull(), @@ -652,6 +652,84 @@ export const playerThemePacks = pgTable( ], ); +// --------------------------------------------------------------------------- +// Caisses (suggestion n° 126, « case opening » à la CS) : règles et chances dans packages/game/src/cases.ts +// --------------------------------------------------------------------------- + +/** + * Caisse en vente : un thème de la semaine (`collection`, commun à ses quatre niveaux) à un niveau + * (bronze, silver, gold, legendary). Le prix et les chances viennent du niveau (code du jeu). + */ +export const caseBoxes = pgTable( + "cases", + { + id: id(), + collection: text("collection").notNull(), + tier: text("tier", { enum: ["bronze", "silver", "gold", "legendary"] }).notNull(), + /** Saison des cartes de la caisse : elle n'est plus en vente dans une autre saison. */ + season: smallint("season").notNull(), + startsAt: tstz("starts_at").notNull(), + endsAt: tstz("ends_at").notNull(), + createdBy: text("created_by"), + createdAt: tstz("created_at").notNull().defaultNow(), + }, + (t) => [ + index("cases_ends_idx").on(t.endsAt), + check("cases_dates_ok", sql`${t.endsAt} > ${t.startsAt}`), + ], +); + +/** Contenu visible d'une caisse : chaque carte et sa rareté (figée à la création), dans l'ordre d'affichage. */ +export const caseCards = pgTable( + "case_cards", + { + caseId: bigint("case_id", { mode: "number" }) + .notNull() + .references(() => caseBoxes.id, { onDelete: "cascade" }), + cardId: bigint("card_id", { mode: "number" }).notNull(), + rarity: rarityEnum("rarity").notNull(), + pos: smallint("pos").notNull(), + }, + (t) => [primaryKey({ columns: [t.caseId, t.cardId] })], +); + +/** Caisses gratuites d'un joueur (roues, passe), à ouvrir dans n'importe quelle caisse en vente de ce niveau. */ +export const playerFreeCases = pgTable( + "player_free_cases", + { + userId: userRef("user_id").notNull(), + tier: text("tier", { enum: ["bronze", "silver", "gold", "legendary"] }).notNull(), + count: integer("count").notNull().default(0), + }, + (t) => [primaryKey({ columns: [t.userId, t.tier] }), check("player_free_cases_count_ok", sql`${t.count} >= 0`)], +); + +/** Ouvertures : ce qui est sorti, pour l'historique du joueur et les gros tirages de la page. */ +export const caseOpenings = pgTable( + "case_openings", + { + id: id(), + userId: userRef("user_id").notNull(), + caseId: bigint("case_id", { mode: "number" }) + .notNull() + .references(() => caseBoxes.id, { onDelete: "cascade" }), + /** Caisse gratuite (roue, passe) plutôt que payée. */ + free: boolean("free").notNull(), + price: integer("price").notNull(), + /** Issue tirée : rareté et taille du lot (1 : une seule carte). */ + rarity: rarityEnum("rarity").notNull(), + lot: smallint("lot").notNull(), + /** Valeur de recyclage de ce qui est sorti (brillances comprises). */ + value: integer("value").notNull(), + instanceIds: jsonb("instance_ids").$type().notNull(), + createdAt: tstz("created_at").notNull().defaultNow(), + }, + (t) => [ + index("case_openings_user_idx").on(t.userId, t.createdAt), + index("case_openings_value_idx").on(t.value, t.createdAt), + ], +); + /** Code promo : PW, paquets bonus et/ou boosters à thème, une fois par joueur. */ export const promoCodes = pgTable( "promo_codes", @@ -1264,7 +1342,7 @@ export const pulls = pgTable( rarity: rarityEnum("rarity").notNull(), shiny: boolean("shiny").notNull().default(false), /** `shop` : légendaire achetée en boutique (fil d'activité « achetée en boutique », jamais comptée comme un tirage). */ - source: text("source", { enum: ["pack", "theme", "wheel", "upgrade", "boss", "shop"] }).notNull(), + source: text("source", { enum: ["pack", "theme", "wheel", "upgrade", "boss", "shop", "case"] }).notNull(), /** Genres « bizarres » repérés au tirage par les mots-clés du titre (les catégories sont lues à part). */ genres: text("genres") .array() diff --git a/packages/game/src/achievements.ts b/packages/game/src/achievements.ts index 815c448..d28b7bf 100644 --- a/packages/game/src/achievements.ts +++ b/packages/game/src/achievements.ts @@ -46,6 +46,8 @@ export type GameEvent = | { type: "daily_login" } | { type: "friends"; count: number } | { type: "wheel_spun" } + /** Caisses ouvertes d'un coup (suggestion n° 126). */ + | { type: "case_opened"; count: number } | { type: "recycled"; rarities: Rarity[] } | { type: "upgrade"; success: boolean } /** Brillantes obtenues hors paquets (upgrader, roue, boutique) : succès « Ça brille ! » selon `SHINY_SOURCE_COUNTS`. */ diff --git a/packages/game/src/cases.test.ts b/packages/game/src/cases.test.ts new file mode 100644 index 0000000..e58f2d9 --- /dev/null +++ b/packages/game/src/cases.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + buildReel, + CASE_CONTENT, + CASE_ODDS, + CASE_PRICES, + CASE_TIERS, + CASE_TOTAL, + caseExpectedValue, + caseReturnRate, + pickLot, + REEL_LENGTH, + REEL_WIN_INDEX, + rollCase, +} from "./cases.js"; + +const seq = (values: number[]) => { + let i = 0; + return (max: number) => values[i++ % values.length]! % max; +}; + +describe("caisses", () => { + it("chaque niveau somme à 10 000 et rend entre 55 et 70 % de son prix, jamais plus que son prix", () => { + for (const tier of CASE_TIERS) { + expect(CASE_ODDS[tier].reduce((s, o) => s + o.weight, 0)).toBe(CASE_TOTAL); + expect(caseReturnRate(tier)).toBeGreaterThanOrEqual(0.55); + expect(caseReturnRate(tier)).toBeLessThanOrEqual(0.7); + expect(caseExpectedValue(tier)).toBeLessThan(CASE_PRICES[tier]); + } + }); + + it("garde les gros lots rares et réservés aux caisses chères", () => { + const lot = (tier: (typeof CASE_TIERS)[number], n: number) => + CASE_ODDS[tier].filter((o) => o.lot === n).reduce((s, o) => s + o.weight, 0); + expect(lot("bronze", 3) + lot("bronze", 5) + lot("bronze", 10)).toBe(0); + expect(lot("legendary", 10)).toBe(100); // 1 % + expect(lot("gold", 10)).toBe(0); + // Assez de légendaires différentes pour le plus gros lot de chaque niveau. + for (const tier of CASE_TIERS) { + const biggest = Math.max(...CASE_ODDS[tier].map((o) => o.lot)); + expect(CASE_CONTENT[tier].L ?? 0).toBeGreaterThanOrEqual(biggest > 1 ? biggest : 1); + } + }); + + it("tire une issue selon les poids", () => { + expect(rollCase("bronze", () => 0)).toBe(0); + expect(rollCase("bronze", () => 9_999)).toBe(3); + expect(rollCase("legendary", () => 9_950)).toBe(4); + }); + + it("donne des légendaires différentes dans un lot tant que la caisse en a assez", () => { + expect(new Set(pickLot([1, 2, 3, 4, 5], 5, () => 0)).size).toBe(5); + expect(pickLot([1, 2], 5, () => 0)).toHaveLength(5); + expect(() => pickLot([], 1, () => 0)).toThrow(); + }); + + it("place la case gagnante au bon endroit dans une bande de la bonne longueur", () => { + const reel = buildReel("gold", { UR: [0, 1], L: [2, 3] }, { kind: "lot", lot: 5 }, seq([3, 9_000, 1, 7])); + expect(reel).toHaveLength(REEL_LENGTH); + expect(reel[REEL_WIN_INDEX]).toEqual({ kind: "lot", lot: 5 }); + for (const t of reel) if (t.kind === "card") expect([0, 1, 2, 3]).toContain(t.index); + }); +}); diff --git a/packages/game/src/cases.ts b/packages/game/src/cases.ts new file mode 100644 index 0000000..6ca952d --- /dev/null +++ b/packages/game/src/cases.ts @@ -0,0 +1,154 @@ +import { ECONOMY } from "./economy.js"; +import type { RandomInt } from "./packs.js"; +import type { Rarity } from "./rarity.js"; + +// --------------------------------------------------------------------------- +// Caisses (suggestion n° 126, « case opening » à la CS) : une caisse au contenu visible, achetée en PW (ou +// gagnée dans les roues et le passe), donne UNE issue : une carte, ou pour les caisses les plus chères un lot +// de 3, 5 ou 10 légendaires d'un coup. Deux thèmes par semaine, chacun en quatre niveaux. +// +// Équilibrage (données de la prod du 2026-11-04) : un booster à thème à 200 PW rend en moyenne ~126 PW au +// recyclage (63 %), et le recyclage a rendu 60 % des PW dépensés en boosters sur 14 jours. Chaque caisse rend +// en moyenne 55 à 70 % de son prix en valeur de recyclage : jamais plus que son prix, sinon acheter puis +// recycler fabriquerait des PW sans fin (les achats ne sont pas limités, choix de Palawi). +// --------------------------------------------------------------------------- + +export const CASE_TIERS = ["bronze", "silver", "gold", "legendary"] as const; +export type CaseTier = (typeof CASE_TIERS)[number]; + +export const CASE_TIER_LABELS: Record = { + bronze: "Bronze", + silver: "Argent", + gold: "Or", + legendary: "Légendaire", +}; + +/** Prix d'ouverture en PW (choix de Palawi). */ +export const CASE_PRICES: Record = { + bronze: 100, + silver: 300, + gold: 750, + legendary: 2_000, +}; + +/** Issue d'une caisse : une carte de cette rareté (`lot` = 1), ou un lot de `lot` légendaires. */ +export interface CaseOutcome { + rarity: Rarity; + lot: number; + /** Poids sur `CASE_TOTAL`. */ + weight: number; +} + +export const CASE_TOTAL = 10_000; + +/** Issues de chaque niveau, des plus fréquentes aux plus rares. */ +export const CASE_ODDS: Record = { + bronze: [ + { rarity: "R", lot: 1, weight: 4_500 }, + { rarity: "SR", lot: 1, weight: 3_750 }, + { rarity: "UR", lot: 1, weight: 1_500 }, + { rarity: "L", lot: 1, weight: 250 }, + ], + silver: [ + { rarity: "SR", lot: 1, weight: 4_500 }, + { rarity: "UR", lot: 1, weight: 4_500 }, + { rarity: "L", lot: 1, weight: 900 }, + { rarity: "L", lot: 3, weight: 100 }, + ], + gold: [ + { rarity: "UR", lot: 1, weight: 7_150 }, + { rarity: "L", lot: 1, weight: 2_400 }, + { rarity: "L", lot: 3, weight: 300 }, + { rarity: "L", lot: 5, weight: 150 }, + ], + legendary: [ + { rarity: "UR", lot: 1, weight: 3_600 }, + { rarity: "L", lot: 1, weight: 5_100 }, + { rarity: "L", lot: 3, weight: 800 }, + { rarity: "L", lot: 5, weight: 400 }, + { rarity: "L", lot: 10, weight: 100 }, + ], +}; + +/** Nombre de cartes de chaque rareté dans le contenu d'une caisse (choisies par l'atelier des boosters). */ +export const CASE_CONTENT: Record>> = { + bronze: { R: 12, SR: 10, UR: 6, L: 2 }, + silver: { SR: 10, UR: 12, L: 5 }, + gold: { UR: 12, L: 10 }, + legendary: { UR: 10, L: 15 }, +}; + +/** Valeur de recyclage d'une issue (PW, sans brillance). */ +export const outcomeValue = (o: Pick) => ECONOMY.recycleValue[o.rarity] * o.lot; + +/** Valeur de recyclage moyenne d'une ouverture (PW). */ +export function caseExpectedValue(tier: CaseTier): number { + return CASE_ODDS[tier].reduce((s, o) => s + (o.weight * outcomeValue(o)) / CASE_TOTAL, 0); +} + +/** Part du prix rendue en moyenne au recyclage. */ +export const caseReturnRate = (tier: CaseTier) => caseExpectedValue(tier) / CASE_PRICES[tier]; + +/** Index de l'issue tirée. */ +export function rollCase(tier: CaseTier, randomInt: RandomInt): number { + const odds = CASE_ODDS[tier]; + let roll = randomInt(CASE_TOTAL); + for (let i = 0; i < odds.length; i++) { + roll -= odds[i]!.weight; + if (roll < 0) return i; + } + throw new Error("caisse invalide (somme < 10 000)"); +} + +/** + * Cartes d'un lot de `count` légendaires parmi celles de la caisse : toutes différentes tant que la caisse + * en a assez, puis on recommence la liste (tirage sans remise par passes). + */ +export function pickLot(pool: T[], count: number, randomInt: RandomInt): T[] { + if (!pool.length) throw new Error("caisse sans légendaire"); + const out: T[] = []; + while (out.length < count) { + const left = [...pool]; + while (left.length && out.length < count) out.push(left.splice(randomInt(left.length), 1)[0]!); + } + return out; +} + +/** Une case de la bande qui défile : une carte de la caisse, ou un lot de légendaires. */ +export type ReelTile = { kind: "card"; index: number } | { kind: "lot"; lot: number }; + +/** Cases de la bande, la gagnante comprise (à `REEL_WIN_INDEX`). */ +export const REEL_LENGTH = 48; +/** Position de la case gagnante : assez loin pour une longue course, avec quelques cases après elle. */ +export const REEL_WIN_INDEX = 42; + +/** + * Bande honnête : chaque case est tirée selon les vraies chances de la caisse, sans placer exprès de gros lots + * autour de la case gagnante (le « presque gagné » répété est ce qui frustre le plus les joueurs de CS). + * `cards` : indices des cartes de la caisse par rareté. + */ +export function buildReel( + tier: CaseTier, + cards: Partial>, + win: ReelTile, + randomInt: RandomInt, +): ReelTile[] { + const tiles: ReelTile[] = []; + for (let i = 0; i < REEL_LENGTH; i++) { + if (i === REEL_WIN_INDEX) { + tiles.push(win); + continue; + } + const o = CASE_ODDS[tier][rollCase(tier, randomInt)]!; + if (o.lot > 1) { + tiles.push({ kind: "lot", lot: o.lot }); + continue; + } + const pool = cards[o.rarity]?.length ? cards[o.rarity]! : Object.values(cards).flat(); + tiles.push({ kind: "card", index: pool[randomInt(pool.length)]! }); + } + return tiles; +} + +/** Ouvertures d'un coup (bouton « Ouvrir ×5 »). */ +export const CASE_MULTI_OPEN = 5; diff --git a/packages/game/src/index.ts b/packages/game/src/index.ts index ec438e7..caa54c6 100644 --- a/packages/game/src/index.ts +++ b/packages/game/src/index.ts @@ -24,3 +24,4 @@ export * from "./shop.js"; export * from "./higher-lower.js"; export * from "./image-filters.js"; export * from "./portfolios.js"; +export * from "./cases.js"; diff --git a/packages/game/src/pass.ts b/packages/game/src/pass.ts index 1b51f28..52e16e3 100644 --- a/packages/game/src/pass.ts +++ b/packages/game/src/pass.ts @@ -1,4 +1,5 @@ import type { GameEvent } from "./achievements.js"; +import type { CaseTier } from "./cases.js"; import { GUILD_OBJECTIVE_REWARD } from "./guild.js"; // --------------------------------------------------------------------------- @@ -64,18 +65,28 @@ export function passProgress(xp: number): PassProgress { export interface PassReward { pw: number; packs: number; + /** Caisse gratuite (suggestion n° 126), à ouvrir dans n'importe quel thème en cours de ce niveau. */ + case?: CaseTier; } +/** Au-delà du niveau 200 : une caisse Bronze tous les `PASS_OVERFLOW_CASE_EVERY` niveaux (à la place des PW). */ +export const PASS_OVERFLOW_CASE_EVERY = 10; + /** * Récompense d'un niveau : en alternance des paquets bonus (niveaux impairs) et des PW (niveaux pairs), - * 2 paquets tous les 5 niveaux, 3 paquets et 150 PW tous les 10, les niveaux 100 et 200 donnent 5 paquets - * et 1 000 PW. Total d'une carte complète : 184 paquets bonus et 12 700 PW. Au-delà du niveau 200, - * chaque niveau donne `PASS_OVERFLOW_PW`. + * 2 paquets tous les 5 niveaux ; tous les 10, 3 paquets, 50 PW et une caisse Bronze ; aux niveaux 50 et 150, + * 3 paquets et une caisse Argent ; aux niveaux 100 et 200, 5 paquets, 250 PW et une caisse Or. Les caisses + * (2026-11-04) remplacent des PW à valeur à peu près égale (caisse = son prix). Au-delà du niveau 200, chaque + * niveau donne `PASS_OVERFLOW_PW`, et une caisse Bronze tous les `PASS_OVERFLOW_CASE_EVERY` niveaux. */ export function passReward(level: number): PassReward { - if (level > PASS_MAX_LEVEL) return { pw: PASS_OVERFLOW_PW, packs: 0 }; - if (level % PASS_HALF_LEVEL === 0) return { pw: 1_000, packs: 5 }; - if (level % 10 === 0) return { pw: 150, packs: 3 }; + if (level > PASS_MAX_LEVEL) + return level % PASS_OVERFLOW_CASE_EVERY === 0 + ? { pw: 0, packs: 0, case: "bronze" } + : { pw: PASS_OVERFLOW_PW, packs: 0 }; + if (level % PASS_HALF_LEVEL === 0) return { pw: 250, packs: 5, case: "gold" }; + if (level % 50 === 0) return { pw: 0, packs: 3, case: "silver" }; + if (level % 10 === 0) return { pw: 50, packs: 3, case: "bronze" }; if (level % 5 === 0) return { pw: 0, packs: 2 }; if (level % 2 === 0) return { pw: 50 + Math.floor(level / 2), packs: 0 }; return { pw: 0, packs: 1 }; @@ -96,6 +107,8 @@ export const XP = { /** Par phase du boss dont on touche la récompense (deux phases : l'ancienne chute, 300 XP). */ bossPhase: 150, upgrade: 10, + /** Par caisse ouverte. */ + caseOpened: 15, trade: 20, sale: 20, guildObjective: GUILD_OBJECTIVE_REWARD.xp, @@ -119,6 +132,8 @@ export function xpForEvent(e: GameEvent): number { return XP.bossPhase; case "upgrade": return XP.upgrade; + case "case_opened": + return XP.caseOpened * e.count; case "trade_done": return XP.trade; case "sale": diff --git a/packages/game/src/progression.test.ts b/packages/game/src/progression.test.ts index f866451..50315d5 100644 --- a/packages/game/src/progression.test.ts +++ b/packages/game/src/progression.test.ts @@ -32,27 +32,34 @@ describe("passe de saison", () => { expect(passProgress(xpToReach(200) + 1_250)).toEqual({ level: 202, into: 250, need: 500 }); expect(passProgress(10_000_000).level).toBe(100 + Math.floor((10_000_000 - 40_100) / 500)); expect(passReward(201)).toEqual({ pw: PASS_OVERFLOW_PW, packs: 0 }); - expect(passReward(1_000)).toEqual({ pw: PASS_OVERFLOW_PW, packs: 0 }); + expect(passReward(1_001)).toEqual({ pw: PASS_OVERFLOW_PW, packs: 0 }); + // Une caisse Bronze tous les 10 niveaux au-delà de 200, à la place des PW. + expect(passReward(210)).toEqual({ pw: 0, packs: 0, case: "bronze" }); + expect(passReward(1_000)).toEqual({ pw: 0, packs: 0, case: "bronze" }); }); it("alterne paquets et PW, gros paliers tous les 10 niveaux", () => { let packs = 0; let pw = 0; + const cases: Record = {}; for (let n = 1; n <= PASS_MAX_LEVEL; n++) { const r = passReward(n); expect(r.pw + r.packs).toBeGreaterThan(0); packs += r.packs; pw += r.pw; + if (r.case) cases[r.case] = (cases[r.case] ?? 0) + 1; } expect(packs).toBe(184); - expect(pw).toBe(12_700); + expect(pw).toBe(9_300); + expect(cases).toEqual({ bronze: 16, silver: 2, gold: 2 }); expect(passReward(1)).toEqual({ pw: 0, packs: 1 }); expect(passReward(2)).toEqual({ pw: 51, packs: 0 }); - expect(passReward(10)).toEqual({ pw: 150, packs: 3 }); - expect(passReward(100)).toEqual({ pw: 1_000, packs: 5 }); + expect(passReward(10)).toEqual({ pw: 50, packs: 3, case: "bronze" }); + expect(passReward(50)).toEqual({ pw: 0, packs: 3, case: "silver" }); + expect(passReward(100)).toEqual({ pw: 250, packs: 5, case: "gold" }); expect(passReward(102)).toEqual({ pw: 101, packs: 0 }); - expect(passReward(110)).toEqual({ pw: 150, packs: 3 }); - expect(passReward(200)).toEqual({ pw: 1_000, packs: 5 }); + expect(passReward(110)).toEqual({ pw: 50, packs: 3, case: "bronze" }); + expect(passReward(200)).toEqual({ pw: 250, packs: 5, case: "gold" }); }); it("donne de l'XP pour chaque paquet ouvert, plus pour un booster à thème", () => { diff --git a/packages/game/src/shiny.ts b/packages/game/src/shiny.ts index 7369de4..6deef93 100644 --- a/packages/game/src/shiny.ts +++ b/packages/game/src/shiny.ts @@ -17,7 +17,7 @@ export function rollShiny(randomInt: RandomInt, chance = SHINY_CHANCE): boolean } /** Systèmes hors paquets qui donnent une carte : upgrader (réussite), roues du jour (lot carte), boutique (achat). */ -export type ShinySource = "upgrade" | "wheel" | "shop"; +export type ShinySource = "upgrade" | "wheel" | "shop" | "case"; /** * Chance de brillance (sur `SHINY_TOTAL`) d'une carte donnée par chaque système hors paquets : 0 = jamais @@ -27,6 +27,7 @@ export const SHINY_SOURCE_CHANCE: Record = { upgrade: SHINY_CHANCE, wheel: SHINY_CHANCE, shop: SHINY_CHANCE, + case: SHINY_CHANCE, }; /** @@ -37,6 +38,7 @@ export const SHINY_SOURCE_COUNTS: Record = { upgrade: true, wheel: true, shop: true, + case: true, }; /** Tirage de brillance d'une carte donnée par un système hors paquets (aucun tirage si le système n'en donne pas). */ diff --git a/packages/game/src/wheel.test.ts b/packages/game/src/wheel.test.ts index 8e9ab8e..c52afb7 100644 --- a/packages/game/src/wheel.test.ts +++ b/packages/game/src/wheel.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { CASE_PRICES } from "./cases.js"; import { rollWheel, WHEEL_GAP_MS, @@ -10,7 +11,7 @@ import { type WheelTier, } from "./wheel.js"; -/** Valeur indicative d'un lot (paquet 150 PW, booster 200, UR 250, légendaire 1 500). */ +/** Valeur indicative d'un lot (paquet 150 PW, booster 200, UR 250, légendaire 1 500, caisse = son prix). */ const value = (r: WheelReward) => r.kind === "pw" ? r.amount @@ -18,9 +19,11 @@ const value = (r: WheelReward) => ? r.amount * 150 : r.kind === "theme" ? r.amount * 200 - : r.rarity === "UR" - ? 250 - : 1_500; + : r.kind === "case" + ? CASE_PRICES[r.tier] + : r.rarity === "UR" + ? 250 + : 1_500; const expected = (tier: WheelTier) => WHEELS[tier].reduce((s, x) => s + (value(x.reward) * x.weight) / WHEEL_TOTAL, 0); describe("roues du jour", () => { @@ -44,6 +47,16 @@ describe("roues du jour", () => { expect(Math.min(...WHEELS[tier].map((s) => value(s.reward)))).toBeGreaterThanOrEqual(100); }); + it("donne une caisse gratuite par roue, de plus en plus chère, sans changer la valeur de la roue", () => { + const caseOf = (tier: WheelTier) => WHEELS[tier].find((s) => s.reward.kind === "case")!; + expect(caseOf("small").reward).toEqual({ kind: "case", tier: "bronze" }); + expect(caseOf("medium").reward).toEqual({ kind: "case", tier: "silver" }); + expect(caseOf("large").reward).toEqual({ kind: "case", tier: "gold" }); + expect(expected("small")).toBeCloseTo(230, -1); + expect(expected("medium")).toBeGreaterThan(340); + expect(expected("large")).toBeGreaterThan(700); + }); + it("tire chaque case selon son poids (bornes des intervalles)", () => { for (const tier of WHEEL_TIERS) { const segments = WHEELS[tier]; diff --git a/packages/game/src/wheel.ts b/packages/game/src/wheel.ts index 71f9df0..660bfdf 100644 --- a/packages/game/src/wheel.ts +++ b/packages/game/src/wheel.ts @@ -1,3 +1,4 @@ +import type { CaseTier } from "./cases.js"; import type { RandomInt } from "./packs.js"; /** @@ -19,7 +20,9 @@ export type WheelReward = | { kind: "pw"; amount: number } | { kind: "packs"; amount: number } | { kind: "theme"; amount: number } - | { kind: "card"; rarity: "UR" | "L" }; + | { kind: "card"; rarity: "UR" | "L" } + /** Caisse gratuite de ce niveau, à ouvrir dans n'importe quel thème en cours (suggestion n° 126). */ + | { kind: "case"; tier: CaseTier }; /** Paquets bonus donnés à la place d'un booster à thème quand aucun n'est en vente. */ export const WHEEL_THEME_FALLBACK_PACKS = 2; @@ -34,12 +37,15 @@ export const WHEEL_TOTAL = 10_000; /** * Cases de chaque roue, dans l'ordre d'affichage (petits et gros lots alternés). Valeur moyenne (paquet = - * 150 PW, booster = 200 PW, UR = 250, légendaire = 1 500) : petite ≈ 230, moyenne ≈ 360, grande ≈ 730. + * 150 PW, booster = 200 PW, UR = 250, légendaire = 1 500, caisse = son prix) : petite ≈ 230, moyenne ≈ 350, + * grande ≈ 720. Les caisses gratuites (Bronze 10 %, Argent 8 %, Or 5 %) remplacent une partie des PW + * (choix de Palawi du 2026-11-04 : valeur constante). */ export const WHEELS: Record = { small: [ - { reward: { kind: "pw", amount: 100 }, weight: 2_500 }, + { reward: { kind: "pw", amount: 100 }, weight: 1_500 }, { reward: { kind: "packs", amount: 1 }, weight: 2_000 }, + { reward: { kind: "case", tier: "bronze" }, weight: 1_000 }, { reward: { kind: "pw", amount: 200 }, weight: 2_000 }, { reward: { kind: "card", rarity: "UR" }, weight: 500 }, { reward: { kind: "packs", amount: 2 }, weight: 2_000 }, @@ -49,7 +55,8 @@ export const WHEELS: Record = { medium: [ { reward: { kind: "pw", amount: 200 }, weight: 2_500 }, { reward: { kind: "packs", amount: 2 }, weight: 2_000 }, - { reward: { kind: "pw", amount: 400 }, weight: 1_800 }, + { reward: { kind: "pw", amount: 400 }, weight: 1_000 }, + { reward: { kind: "case", tier: "silver" }, weight: 800 }, { reward: { kind: "card", rarity: "UR" }, weight: 700 }, { reward: { kind: "packs", amount: 4 }, weight: 1_500 }, { reward: { kind: "theme", amount: 1 }, weight: 1_200 }, @@ -58,7 +65,8 @@ export const WHEELS: Record = { large: [ { reward: { kind: "pw", amount: 500 }, weight: 2_200 }, { reward: { kind: "packs", amount: 5 }, weight: 2_000 }, - { reward: { kind: "pw", amount: 1_000 }, weight: 1_000 }, + { reward: { kind: "pw", amount: 1_000 }, weight: 500 }, + { reward: { kind: "case", tier: "gold" }, weight: 500 }, { reward: { kind: "card", rarity: "UR" }, weight: 1_500 }, { reward: { kind: "theme", amount: 2 }, weight: 1_500 }, { reward: { kind: "packs", amount: 10 }, weight: 1_000 }, diff --git a/packages/shared/src/dto.ts b/packages/shared/src/dto.ts index e3aee24..ac01fd4 100644 --- a/packages/shared/src/dto.ts +++ b/packages/shared/src/dto.ts @@ -1,4 +1,4 @@ -import type { AutoRecycleRarity, DailyLoginRates, Rarity, WheelReward, WheelTier } from "@palacards/game"; +import type { AutoRecycleRarity, CaseTier, DailyLoginRates, Rarity, WheelReward, WheelTier } from "@palacards/game"; import type { PackState, Wallet } from "./events.js"; import type { SuggestionKind, SuggestionStatus } from "./schemas.js"; @@ -356,7 +356,7 @@ export interface QuestsDTO { export interface PassLevelDTO { level: number; - reward: { pw: number; packs: number }; + reward: { pw: number; packs: number; case?: CaseTier }; } export interface PassDTO { @@ -893,3 +893,80 @@ export interface PortfolioDesignItemDTO { condition: string | null; owned: boolean; } + +// --------------------------------------------------------------------------- +// Caisses (suggestion n° 126) +// --------------------------------------------------------------------------- + +/** Carte du contenu d'une caisse, avec sa chance de sortir seule (sur 1, lots non compris). */ +export type CaseCardDTO = CardDTO & { chance: number }; + +/** Issue possible : une carte de cette rareté, ou un lot de `lot` légendaires ; `chance` sur 1. */ +export interface CaseOutcomeDTO { + rarity: Rarity; + lot: number; + chance: number; +} + +export interface CaseDTO { + id: number; + collection: string; + tier: CaseTier; + price: number; + startsAt: string; + endsAt: string; + cards: CaseCardDTO[]; + outcomes: CaseOutcomeDTO[]; + /** Valeur de recyclage moyenne d'une ouverture (PW). */ + expectedValue: number; +} + +/** Thème de la semaine et ses caisses, du Bronze à la Légendaire. */ +export interface CaseCollectionDTO { + name: string; + endsAt: string; + cases: CaseDTO[]; +} + +/** Gros tirage récent (une légendaire ou mieux), pour la page. */ +export interface CaseWinDTO { + id: number; + player: { username: string; displayName: string; avatar: string | null }; + collection: string; + tier: CaseTier; + rarity: Rarity; + lot: number; + value: number; + /** Première carte sortie (la plus lue pour un lot). */ + card: CardDTO | null; + createdAt: string; +} + +export interface CasesDTO { + collections: CaseCollectionDTO[]; + /** Caisses gratuites du joueur par niveau (roues, passe). */ + free: Record; + recent: CaseWinDTO[]; +} + +/** Case de la bande qui défile : une carte du contenu (par `cardId`) ou un lot de légendaires. */ +export type CaseTileDTO = { kind: "card"; cardId: number } | { kind: "lot"; lot: number }; + +export interface CaseResultDTO { + tiles: CaseTileDTO[]; + /** Position de la case gagnante dans `tiles`. */ + winIndex: number; + rarity: Rarity; + lot: number; + /** Exemplaires gagnés (un, ou le lot). */ + cards: CardDTO[]; + value: number; +} + +export interface CaseOpenDTO { + results: CaseResultDTO[]; + free: Record; + wallet: Wallet; + /** Gratuite utilisée plutôt que des PW. */ + usedFree: boolean; +} From 0000ae61ead50c8d072e283024285771732b07c9 Mon Sep 17 00:00:00 2001 From: Palawi Date: Wed, 4 Nov 2026 12:10:49 +0100 Subject: [PATCH 2/2] =?UTF-8?q?feat(cases):=20page=20des=20caisses,=20band?= =?UTF-8?q?e=20qui=20d=C3=A9file,=20caisses=20dans=20les=20roues=20et=20le?= =?UTF-8?q?=20passe,=20commande=20case=20de=20l'atelier=20(suggestion=20n?= =?UTF-8?q?=C2=B0=20126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/routes/core.ts | 2 + apps/api/src/routes/test.ts | 59 +- apps/api/src/services/cases.ts | 2 - apps/api/test/features.test.ts | 1 + apps/e2e/tests/18-caisses.spec.ts | 43 ++ apps/web/src/app/(game)/cases/page.tsx | 727 ++++++++++++++++++++++++ apps/web/src/app/(game)/feed/page.tsx | 14 +- apps/web/src/app/(game)/quests/page.tsx | 14 +- apps/web/src/app/(game)/wheel/page.tsx | 48 +- apps/web/src/components/CaseBox.tsx | 83 +++ apps/web/src/components/Shell.tsx | 4 +- apps/web/src/components/cases.css | 338 +++++++++++ apps/web/src/components/content.css | 7 + apps/web/src/lib/format.ts | 2 + apps/web/src/lib/nav.ts | 4 +- apps/web/src/lib/notifications.ts | 8 +- apps/web/src/lib/sfx.ts | 7 + packages/db/src/schema.ts | 5 +- packages/shared/src/dto.ts | 2 + tools/themes/README.md | 16 + tools/themes/palacards_themes/cases.py | 102 ++++ tools/themes/palacards_themes/cli.py | 61 +- tools/themes/tests/test_cases.py | 20 + 23 files changed, 1535 insertions(+), 34 deletions(-) create mode 100644 apps/e2e/tests/18-caisses.spec.ts create mode 100644 apps/web/src/app/(game)/cases/page.tsx create mode 100644 apps/web/src/components/CaseBox.tsx create mode 100644 apps/web/src/components/cases.css create mode 100644 tools/themes/palacards_themes/cases.py create mode 100644 tools/themes/tests/test_cases.py diff --git a/apps/api/src/routes/core.ts b/apps/api/src/routes/core.ts index 9f938ae..4935298 100644 --- a/apps/api/src/routes/core.ts +++ b/apps/api/src/routes/core.ts @@ -8,6 +8,7 @@ import { conflict, notFound, parse } from "../errors.js"; import { deleteAvatarImage, getAvatarImage, saveAvatarImage } from "../services/avatars.js"; import { clearBannerCard, deleteBanner, getBannerImage, saveBannerImage, setBannerCard } from "../services/banners.js"; import { answeringQuestion, queueWaiting } from "../services/battles.js"; +import { freeCases } from "../services/free-cases.js"; import { arthropodFlags, cardSheet, catalog, imageFlags } from "../services/cards.js"; import { bulkTag, @@ -151,6 +152,7 @@ export async function me( quests: { done: quests.filter((q) => q.completedAt).length, total: quests.length }, hlTriesLeft: await hlTriesLeft(ctx, user.id), boss: await bossSummary(ctx, user.id), + freeCases: Object.values(await freeCases(ctx.db, user.id)).reduce((a, b) => a + b, 0), }; } diff --git a/apps/api/src/routes/test.ts b/apps/api/src/routes/test.ts index cff04ce..89003c6 100644 --- a/apps/api/src/routes/test.ts +++ b/apps/api/src/routes/test.ts @@ -1,5 +1,5 @@ import { and, eq, schema, sql } from "@palacards/db"; -import { TITLE_BOARDS, TITLE_MAX_RANK } from "@palacards/game"; +import { CASE_CONTENT, CASE_TIERS, TITLE_BOARDS, TITLE_MAX_RANK } from "@palacards/game"; import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { requireUser, type Ctx } from "../context.js"; @@ -84,6 +84,63 @@ export function testRoutes(api: FastifyInstance, ctx: Ctx) { return { balance: p.balance }; }); + /** + * Caisses en E2E : les quatre niveaux d'un thème, remplis des cartes les plus lues de la saison (comme + * l'atelier), en vente une semaine, et des caisses gratuites pour le joueur connecté. + */ + api.post("/test/case-seed", auth, async (req) => { + const body = parse( + z.object({ collection: z.string().min(1).max(60), free: z.number().int().min(0).max(20).default(0) }), + req.body, + ); + const season = await activeSeason(ctx.db); + const ids: number[] = []; + for (const tier of CASE_TIERS) { + const [box] = await ctx.db + .insert(schema.caseBoxes) + .values({ + collection: body.collection, + tier, + season, + startsAt: new Date(ctx.now().getTime() - 60_000), + endsAt: new Date(ctx.now().getTime() + 7 * 86_400_000), + }) + .returning({ id: schema.caseBoxes.id }); + let pos = 0; + for (const [rarity, n] of Object.entries(CASE_CONTENT[tier])) { + const rows = await ctx.db.execute<{ id: string }>(sql` + select id from cards where season = ${season} and rarity = ${rarity}::rarity + order by views_12m desc, id limit ${n} + `); + if (rows.length) + await ctx.db + .insert(schema.caseCards) + .values(rows.map((r) => ({ caseId: box!.id, cardId: Number(r.id), rarity: rarity as "UR", pos: pos++ }))); + } + ids.push(box!.id); + } + if (body.free) + await ctx.db + .insert(schema.playerFreeCases) + .values(CASE_TIERS.map((tier) => ({ userId: req.user.id, tier, count: body.free }))) + .onConflictDoUpdate({ + target: [schema.playerFreeCases.userId, schema.playerFreeCases.tier], + set: { count: body.free }, + }); + return { ids }; + }); + + /** Prochaine ouverture de caisse forcée sur la dernière issue (le plus gros lot) : captures et parcours E2E. */ + api.post("/test/case-jackpot", auth, async () => { + const random = ctx.random; + ctx.random = (max) => { + if (max !== 10_000) return random(max); + ctx.random = random; + return 9_999; + }; + return { ok: true }; + }); + /** Donne un titre de fin de saison au joueur connecté (sans l'afficher : c'est à lui de le choisir). */ api.post("/test/grant-title", auth, async (req) => { const body = parse( diff --git a/apps/api/src/services/cases.ts b/apps/api/src/services/cases.ts index ce06868..f9fe868 100644 --- a/apps/api/src/services/cases.ts +++ b/apps/api/src/services/cases.ts @@ -45,11 +45,9 @@ import { freeCases } from "./free-cases.js"; import { emit } from "./progression.js"; import { articleUrl } from "./wiki.js"; -type Tx = Parameters[0]>[0]; type CaseRow = typeof schema.caseBoxes.$inferSelect; const cb = schema.caseBoxes; -const cc = schema.caseCards; const fc = schema.playerFreeCases; /** Ouvertures montrées dans « Gros tirages » : une légendaire ou mieux. */ diff --git a/apps/api/test/features.test.ts b/apps/api/test/features.test.ts index ef54181..0692469 100644 --- a/apps/api/test/features.test.ts +++ b/apps/api/test/features.test.ts @@ -15,6 +15,7 @@ describe("menu : nouveautés et paquets à ouvrir", () => { "suggestions", "updates", "wheels", + "cases", "battle-decks", "boss-v2", "leaderboard-achievements", diff --git a/apps/e2e/tests/18-caisses.spec.ts b/apps/e2e/tests/18-caisses.spec.ts new file mode 100644 index 0000000..d136a71 --- /dev/null +++ b/apps/e2e/tests/18-caisses.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from "@playwright/test"; +import { apiCall, newPlayer } from "./helpers"; + +test("caisses : contenu visible, ouverture gratuite, ×5 avec « Passer », puis plus assez de PW", async ({ + browser, +}) => { + const { page } = await newPlayer(browser, "caisse"); + await apiCall(page, "POST", "/test/case-seed", { collection: "Essai e2e", free: 1 }); + await page.goto("cases"); + await expect(page.getByRole("heading", { name: "Caisses", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Essai e2e" })).toHaveAttribute("aria-pressed", "true"); + + // Quatre niveaux, chacun avec son prix ; le contenu affiche une chance par carte. + const tiers = page.getByRole("list", { name: "Niveaux de caisse" }).getByRole("button"); + await expect(tiers).toHaveCount(4); + await tiers.filter({ hasText: "Or" }).click(); + await expect(page.getByRole("heading", { name: "Contenu de la caisse Or" })).toBeVisible(); + await expect(page.getByText(/Lots de légendaires/)).toBeVisible(); + await expect(page.getByRole("heading", { name: "Chances" })).toBeVisible(); + + // Caisse gratuite : la bande défile, « Passer » l'arrête, la carte rejoint la collection, sans toucher aux PW. + await page.getByRole("button", { name: /Ouvrir une caisse gratuite/ }).click(); + const skip = page.getByRole("button", { name: "Passer", exact: true }); + if (await skip.isVisible().catch(() => false)) await skip.click(); + await expect(page.getByText(/rejoin(t|nent) ta/)).toBeVisible(); + await expect(page.getByRole("button", { name: /Ouvrir une caisse gratuite/ })).toHaveCount(0); + + // ×5 contre des PW : cinq bandes, puis au moins cinq cartes gagnées (le prix est vérifié par l'API). + await apiCall(page, "POST", "/test/grant-pw", { amount: 5 * 750 }); + await page.getByRole("button", { name: /^Ouvrir ×5/ }).click(); + await expect(page.locator(".pc-reel")).toHaveCount(5); + await page.getByRole("button", { name: "Passer", exact: true }).click(); + await expect(page.getByText(/cartes rejoignent ta/)).toBeVisible(); + const after = (await apiCall<{ wallet: { available: number } }>(page, "GET", "/me")).wallet.available; + + // Pas assez de PW pour une Légendaire (solde de départ) : bouton grisé et somme manquante. + await tiers.filter({ hasText: "Légendaire" }).click(); + const open = page.getByRole("button", { name: /^Ouvrir 2\s000 PW/ }); + if (after < 2_000) { + await expect(open).toBeDisabled(); + await expect(page.getByText(/Il te manque .* PW pour cette caisse/)).toBeVisible(); + } else await expect(open).toBeEnabled(); +}); diff --git a/apps/web/src/app/(game)/cases/page.tsx b/apps/web/src/app/(game)/cases/page.tsx new file mode 100644 index 0000000..84f2847 --- /dev/null +++ b/apps/web/src/app/(game)/cases/page.tsx @@ -0,0 +1,727 @@ +"use client"; + +import { + CASE_MULTI_OPEN, + CASE_TIER_LABELS, + CASE_TIERS, + RARITY_LABELS, + type CaseTier, + type Rarity, +} from "@palacards/game"; +import type { + CaseCardDTO, + CaseDTO, + CaseOpenDTO, + CaseResultDTO, + CasesDTO, + CaseTileDTO, + CaseWinDTO, +} from "@palacards/shared"; +import { animate, motion, useMotionValue, useReducedMotion, type AnimationPlaybackControls } from "motion/react"; +import Link from "next/link"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import useSWR from "swr"; +import { Avatar } from "@/components/Avatar"; +import { Card, RaritySigil } from "@/components/Card"; +import { CaseBox } from "@/components/CaseBox"; +import { FlipCard } from "@/components/PackOpener"; +import { Empty, ErrorBox } from "@/components/ui"; +import { api, ApiError, thumbSrc } from "@/lib/api"; +import { fmt, relative, timeLeft } from "@/lib/format"; +import { useSeenFeature } from "@/lib/features"; +import { useHoldNews, useMe } from "@/lib/game"; +import { useCardMedia } from "@/lib/media"; +import { play } from "@/lib/sfx"; +import { useNow } from "@/lib/use-now"; +import "@/components/content.css"; +import "@/components/cases.css"; + +const RARITY_ORDER: Rarity[] = ["L", "UR", "SR", "R", "PC", "C"]; + +const pct = (chance: number) => + `${(chance * 100).toLocaleString("fr-FR", { maximumFractionDigits: chance < 0.01 ? 2 : 1 })} %`; + +const lotText = (lot: number) => `${lot} légendaires`; + +/** Ce que le joueur a eu, en une ligne (fil des gros tirages, annonce du résultat). */ +function outcomeText(o: { rarity: Rarity; lot: number }) { + return o.lot > 1 ? `un lot de ${lotText(o.lot)}` : `une ${RARITY_LABELS[o.rarity].toLowerCase()}`; +} + +type Speed = "normal" | "fast" | "instant"; + +/** Durée du défilement d'une bande (s) : la plus longue pour une seule caisse, en cascade pour ×5. */ +function rollDuration(speed: Speed, reel: number, many: boolean) { + if (speed === "instant") return 0.5; + if (speed === "fast") return (many ? 2.4 : 3) + reel * 0.2; + return (many ? 4.2 : 5.4) + reel * 0.3; +} + +/** Bande d'attente : le contenu de la caisse mélangé, tiré selon les vraies chances (comme la vraie bande). */ +function previewTiles(box: CaseDTO): CaseTileDTO[] { + const singles = box.cards.map((c) => ({ w: c.chance, t: { kind: "card", cardId: c.cardId } as CaseTileDTO })); + const lots = box.outcomes + .filter((o) => o.lot > 1) + .map((o) => ({ w: o.chance, t: { kind: "lot", lot: o.lot } as CaseTileDTO })); + const pool = [...singles, ...lots]; + const total = pool.reduce((s, p) => s + p.w, 0); + return Array.from({ length: 21 }, () => { + let r = Math.random() * total; + for (const p of pool) if ((r -= p.w) < 0) return p.t; + return pool[0]!.t; + }); +} + +/** Une case de la bande : vignette réduite (image, bande de rareté et titre) ou lot de légendaires. */ +function Tile({ tile, card, win }: { tile: CaseTileDTO; card?: CaseCardDTO; win: boolean }) { + const live = useCardMedia(tile.kind === "card" ? tile.cardId : -1); + const [broken, setBroken] = useState(false); + if (tile.kind === "lot") + return ( +
+ + ×{tile.lot} + légendaires + +
+ ); + if (!card) return
; + const url = live?.thumbUrl ?? card.thumbUrl; + return ( +
+ {url && !broken ? ( + // eslint-disable-next-line @next/next/no-img-element -- vignette déjà réduite par l'API + setBroken(true)} /> + ) : ( + {card.title.slice(0, 1)} + )} + + {card.title} + +
+ ); +} + +/** + * Une bande qui défile puis ralentit jusqu'à poser la case gagnante sous la languette, avec un cliquet à + * chaque case qui passe. Sans `roll`, elle attend immobile (contenu de la caisse). + */ +function Reel({ + tiles, + cards, + roll, + compact = false, + onStop, +}: { + tiles: CaseTileDTO[]; + cards: Map; + /** Tirage à jouer : case gagnante, durée (s), départ et mouvement réduit ; `skip` l'arrête net. */ + roll: { winIndex: number; duration: number; reduce: boolean; skip: boolean } | null; + compact?: boolean; + onStop?: () => void; +}) { + const track = useRef(null); + const x = useMotionValue(0); + const [stopped, setStopped] = useState(false); + const controls = useRef(null); + const finish = useRef<(() => void) | null>(null); + const onStopRef = useRef(onStop); + useEffect(() => { + onStopRef.current = onStop; + }); + + const winIndex = roll?.winIndex ?? -1; + const duration = roll?.duration ?? 0; + const reduce = !!roll?.reduce; + const skip = !!roll?.skip; + + // Pas d'une case (largeur + espace), mesuré : il suit la taille de la bande (téléphone, ×5). + function geometry() { + const el = track.current; + const a = el?.children[0] as HTMLElement | undefined; + const b = el?.children[1] as HTMLElement | undefined; + const tile = a?.offsetWidth ?? 112; + return { tile, step: b && a ? b.offsetLeft - a.offsetLeft : tile + 8 }; + } + + // Au repos : une case au centre, sous le repère. + useLayoutEffect(() => { + if (roll) return; + const { tile, step } = geometry(); + x.set(-(Math.floor(tiles.length / 2) * step + tile / 2)); + }, [roll, tiles, x]); + + // Chaque tirage a sa propre bande (montée à neuf) : on la place au départ avant le premier affichage. + useLayoutEffect(() => { + if (winIndex < 0) return; + const { tile, step } = geometry(); + const center = -(winIndex * step + tile / 2); + // Arrêt quelque part dans la case gagnante, pas pile au milieu : on ne sait qu'à la fin de quel côté. + const land = reduce ? center : center + (Math.random() - 0.5) * tile * 0.76; + const from = reduce ? winIndex - 2 : 3; + x.set(-(from * step + tile / 2)); + let last = Math.floor(-x.get() / step); + const unsub = x.on("change", (v) => { + const n = Math.floor(-v / step); + if (n !== last) { + last = n; + play("tick"); + } + }); + let done = false; + const end = () => { + if (done) return; + done = true; + unsub(); + x.set(center); + setStopped(true); + onStopRef.current?.(); + }; + finish.current = () => { + controls.current?.stop(); + end(); + }; + void (async () => { + controls.current = animate(x, land, { + duration, + ease: reduce ? [0.23, 1, 0.32, 1] : [0.1, 0.72, 0.16, 1], + }); + await controls.current; + if (done) return; + unsub(); + // Petit recentrage : la vignette gagnante se cale sous la languette. + if (!reduce) { + controls.current = animate(x, center, { duration: 0.32, ease: [0.77, 0, 0.175, 1] }); + await controls.current; + } + end(); + })(); + return () => { + done = true; + unsub(); + controls.current?.stop(); + }; + }, [winIndex, duration, reduce, tiles, x]); + + useEffect(() => { + if (skip) finish.current?.(); + }, [skip]); + + return ( +
+ + {tiles.map((t, i) => ( + + ))} + + +
+ ); +} + +/** Résultat d'une ouverture : la vignette retournée, ou le tampon du lot et ses légendaires. */ +function Winnings({ results, speed, onShown }: { results: CaseResultDTO[]; speed: Speed; onShown: () => void }) { + const reduce = useReducedMotion(); + const cards = results.flatMap((r) => r.cards.map((c, i) => ({ card: c, lot: r.lot, first: i === 0 }))); + const lot = results.find((r) => r.lot > 1); + const value = results.reduce((s, r) => s + r.value, 0); + const [revealed, setRevealed] = useState(0); + const flips = cards.filter((c) => c.first).length; + const many = results.length > 1; + const shownRef = useRef(onShown); + useEffect(() => { + shownRef.current = onShown; + }); + + // Les vignettes se retournent l'une après l'autre ; les autres légendaires d'un lot suivent, déjà face visible. + useEffect(() => { + if (revealed >= flips) { + const t = setTimeout(() => shownRef.current(), speed === "instant" ? 0 : 500); + return () => clearTimeout(t); + } + const t = setTimeout( + () => setRevealed((n) => n + 1), + revealed === 0 ? (speed === "instant" ? 0 : 280) : speed === "normal" ? 260 : 140, + ); + return () => clearTimeout(t); + }, [revealed, flips, speed]); + + useEffect(() => { + if (lot && revealed >= 1) play("coin", 500); + }, [lot, revealed]); + + let flipIndex = 0; + return ( +
+ {lot && ( + + ×{lot.lot} + légendaires d'un coup + + )} +
+ {cards.map(({ card, first }, i) => { + if (!first) { + const shown = revealed >= 1; + return ( + + + + ); + } + const n = flipIndex++; + return ( + n} + onReveal={() => setRevealed((r) => Math.max(r, n + 1))} + index={n} + speed={speed === "instant" ? "instant" : many ? "fast" : "normal"} + stagger={false} + /> + ); + })} +
+

+ {cards.length > 1 ? `${cards.length} cartes rejoignent` : "La carte rejoint"} ta{" "} + + collection + + . Valeur au recyclage : {fmt(value)} PW. +

+
+ ); +} + +/** Contenu d'une caisse, groupé par rareté : chaque vignette avec sa chance, puis les lots. */ +function Contents({ box }: { box: CaseDTO }) { + const groups = RARITY_ORDER.map((r) => ({ + rarity: r, + cards: box.cards.filter((c) => c.rarity === r).sort((a, b) => b.chance - a.chance), + chance: box.outcomes.filter((o) => o.rarity === r && o.lot === 1).reduce((s, o) => s + o.chance, 0), + })).filter((g) => g.cards.length); + const lots = box.outcomes.filter((o) => o.lot > 1); + return ( +
+

+ Contenu de la caisse {CASE_TIER_LABELS[box.tier]} +

+
+ {lots.length > 0 && ( +
+

+ Lots de légendaires, toutes différentes +

+
    + {lots.map((o) => ( +
  • + + ×{o.lot} + + {pct(o.chance)} +
  • + ))} +
+
+ )} + {groups.map((g) => ( +
+

+ + · {pct(g.chance)} au total +

+
    + {g.cards.map((c) => ( +
  • + +

    {pct(c.chance)}

    +
  • + ))} +
+
+ ))} +
+
+ ); +} + +function RecentWins({ wins, now }: { wins: CaseWinDTO[]; now: number }) { + return ( +
+

+ Gros tirages +

+ {wins.length ? ( +
    + {wins.map((w) => ( +
  • + +

    + + {w.player.displayName} + {" "} + + a eu {outcomeText(w)} + {w.card && w.lot === 1 ? ` (${w.card.title})` : ""} · {CASE_TIER_LABELS[w.tier]}, {w.collection} + +

    + {relative(w.createdAt, now)} +
  • + ))} +
+ ) : ( +

Aucune légendaire sortie d'une caisse pour l'instant.

+ )} +
+ ); +} + +interface Run { + results: CaseResultDTO[]; + box: CaseDTO; + reduce: boolean; + speed: Speed; + skip: boolean; + /** Bandes arrêtées. */ + stopped: number; +} + +export default function CasesPage() { + useSeenFeature("cases"); + const { me, mutateMe } = useMe(); + const holdNews = useHoldNews(); + const cases = useSWR("/cases"); + const reduceMotion = useReducedMotion(); + const now = useNow(30_000); + const [pickedCollection, setPickedCollection] = useState(null); + const [pickedTier, setPickedTier] = useState(null); + const [busy, setBusy] = useState(false); + const [run, setRun] = useState(null); + const [shown, setShown] = useState(false); + const release = useRef<(() => void) | null>(null); + const stage = useRef(null); + + const data = cases.data; + const collection = data?.collections.find((c) => c.name === pickedCollection) ?? data?.collections[0]; + const free = data?.free ?? { bronze: 0, silver: 0, gold: 0, legendary: 0 }; + // Niveau affiché : celui choisi, sinon le premier pour lequel le joueur a une caisse gratuite. + const tier: CaseTier = pickedTier ?? CASE_TIERS.find((t) => free[t] > 0) ?? "bronze"; + const box = collection?.cases.find((c) => c.tier === tier) ?? collection?.cases[0]; + const cardMap = useMemo(() => new Map((box?.cards ?? []).map((c) => [c.cardId, c])), [box]); + const preview = useMemo(() => (box ? previewTiles(box) : []), [box]); + const rolling = !!run && run.stopped < run.results.length; + const speed: Speed = me?.animationSpeed ?? "normal"; + const available = me?.wallet.available ?? 0; + const runBox = run?.box; + const runCards = useMemo(() => new Map((runBox?.cards ?? []).map((c) => [c.cardId, c])), [runBox]); + + // Changer de caisse efface le dernier tirage. + function choose(next: { collection?: string; tier?: CaseTier }) { + if (rolling || busy) return; + if (next.collection) setPickedCollection(next.collection); + if (next.tier) setPickedTier(next.tier); + setRun(null); + setShown(false); + } + + async function open(count: number, useFree: boolean) { + if (!box || busy || rolling) return; + setBusy(true); + setShown(false); + release.current?.(); + // Solde, succès et fil n'annoncent le tirage qu'une fois la bande arrêtée et la carte retournée. + release.current = holdNews(); + try { + const res = await api(`/cases/${box.id}/open`, { body: { count, free: useFree } }); + setRun({ + results: res.results, + box, + reduce: !!reduceMotion || speed === "instant", + speed, + skip: false, + stopped: 0, + }); + requestAnimationFrame(() => { + const r = stage.current?.getBoundingClientRect(); + if (r && (r.top < 0 || r.bottom > window.innerHeight)) + stage.current?.scrollIntoView({ block: "start", behavior: reduceMotion ? "auto" : "smooth" }); + }); + void cases.mutate((d) => (d ? { ...d, free: res.free } : d), { revalidate: false }); + void mutateMe( + (m) => (m ? { ...m, wallet: res.wallet, freeCases: Object.values(res.free).reduce((a, b) => a + b, 0) } : m), + { revalidate: false }, + ); + } catch (err) { + release.current?.(); + release.current = null; + toast.error(err instanceof ApiError ? err.message : "La caisse ne s'ouvre pas, réessaie."); + void cases.mutate(); + } finally { + setBusy(false); + } + } + + function revealDone() { + release.current?.(); + release.current = null; + void cases.mutate(); + } + + useEffect(() => () => release.current?.(), []); + + const many = (run?.results.length ?? 0) > 1; + const allStopped = !!run && run.stopped >= run.results.length; + const price = box?.price ?? 0; + const freeHere = free[tier]; + + return ( +
+
+

Caisses

+

+ Une caisse au contenu visible donne une seule carte, tirée selon les chances affichées. Les plus chères + peuvent lâcher d'un coup un lot de 3, 5 ou 10 légendaires toutes différentes. Deux thèmes chaque semaine, + renouvelés le lundi soir ; les roues du jour et le passe de saison donnent des caisses gratuites. +

+
+ + {cases.error && cases.mutate()} />} + + {data && !collection && ( + + Les caisses de la semaine arrivent le lundi soir. Tes caisses gratuites t'attendent jusque-là. + + )} + + {collection && box && ( + <> +
+
+ {data!.collections.map((c) => ( + + ))} +
+

+ En vente encore {timeLeft(new Date(collection.endsAt).getTime() - now)} +

+
+ +
    + {CASE_TIERS.map((t) => { + const c = collection.cases.find((x) => x.tier === t); + if (!c) return null; + return ( +
  1. + +
  2. + ); + })} +
+ +
+ {run ? ( +
+ {run.results.map((r, i) => ( + setRun((cur) => (cur ? { ...cur, stopped: cur.stopped + 1 } : cur))} + /> + ))} +
+ ) : ( + + )} + +
+ {rolling ? ( + + ) : ( + <> + {freeHere > 0 && ( + + )} + + + + )} +
+ {!rolling && me && available < price && freeHere === 0 && ( +

+ Il te manque {fmt(price - available)} PW pour cette caisse. +

+ )} + + {run && allStopped && ( + r.cards[0]?.instanceId).join("-")} + results={run.results} + speed={run.reduce ? "instant" : run.speed} + onShown={() => { + if (!shown) { + setShown(true); + revealDone(); + } + }} + /> + )} +
+ +
+ +
+
+

+ Chances +

+ + + {box.outcomes.map((o) => ( + + + + + ))} + +
+ + + {o.lot > 1 ? `Lot de ${lotText(o.lot)}` : RARITY_LABELS[o.rarity]} + + {pct(o.chance)}
+

+ En moyenne, une caisse {CASE_TIER_LABELS[box.tier]} rend {fmt(Math.round(box.expectedValue))} PW au + recyclage, pour {fmt(box.price)} PW. Une caisse gratuite s'ouvre dans n'importe quel thème + de son niveau. +

+
+ +
+
+ + )} + + {!data && !cases.error && ( +
+
+ {CASE_TIERS.map((t) => ( +
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(game)/feed/page.tsx b/apps/web/src/app/(game)/feed/page.tsx index ae6927e..da6a437 100644 --- a/apps/web/src/app/(game)/feed/page.tsx +++ b/apps/web/src/app/(game)/feed/page.tsx @@ -33,12 +33,14 @@ const genreOf = (key: string) => GENRES.find((g) => g.key === key); */ const FEED_REFRESH_MS = 60_000; +/** Où la carte a été tirée, avec son article (« une roue », « un paquet »). */ const SOURCE: Record = { - pack: "paquet", - theme: "booster à thème", - wheel: "roue du jour", - upgrade: "upgrader", - boss: "boss du jour", + pack: "un paquet", + theme: "un booster à thème", + wheel: "une roue du jour", + upgrade: "l'upgrader", + boss: "le boss du jour", + case: "une caisse", }; /** Un tirage du fil : la vignette, qui l'a tirée et quand, et les réactions de la bande. */ @@ -88,7 +90,7 @@ function FeedItem({ item, fresh, now }: { item: FeedItemDTO; fresh: boolean; now {item.source === "shop" && Achetée en boutique · } {relative(item.createdAt, now)} diff --git a/apps/web/src/app/(game)/quests/page.tsx b/apps/web/src/app/(game)/quests/page.tsx index f283c3e..64e7edd 100644 --- a/apps/web/src/app/(game)/quests/page.tsx +++ b/apps/web/src/app/(game)/quests/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { CASE_TIER_LABELS, PASS_OVERFLOW_CASE_EVERY, passReward, type CaseTier } from "@palacards/game"; import type { PassDTO, QuestDTO, QuestsDTO } from "@palacards/shared"; import { ArrowRight, Check, Package, Shuffle } from "lucide-react"; import Link from "next/link"; @@ -22,8 +23,12 @@ const TIER_LABEL: Record = { weekly: "Semaine", }; -const rewardText = (r: { pw: number; packs: number }) => - [r.pw ? `${fmt(r.pw)} PW` : "", r.packs ? `${r.packs} paquet${r.packs > 1 ? "s" : ""}` : ""] +const rewardText = (r: { pw: number; packs: number; case?: CaseTier }) => + [ + r.pw ? `${fmt(r.pw)} PW` : "", + r.packs ? `${r.packs} paquet${r.packs > 1 ? "s" : ""}` : "", + r.case ? `une caisse ${CASE_TIER_LABELS[r.case]}` : "", + ] .filter(Boolean) .join(" + "); @@ -137,7 +142,7 @@ function LoyaltyCard({ pass }: { pass: PassDTO }) { const last = face === "recto" ? pass.halfLevel : pass.maxLevel; const full = pass.level >= pass.maxLevel; const extra = Math.max(0, pass.level - pass.maxLevel); - const nextReward = pass.levels[pass.level]?.reward ?? { pw: pass.overflowPw, packs: 0 }; + const nextReward = pass.levels[pass.level]?.reward ?? passReward(pass.level + 1); const pct = pass.need ? Math.round((pass.into / pass.need) * 100) : 100; return ( @@ -152,7 +157,8 @@ function LoyaltyCard({ pass }: { pass: PassDTO }) {

Un tampon par niveau : cent cases au recto, cent au verso. Carte pleine, chaque niveau en plus rapporte{" "} - {fmt(pass.overflowPw)} PW. Tout repart à zéro à chaque nouvelle saison. + {fmt(pass.overflowPw)} PW, et une caisse Bronze tous les {PASS_OVERFLOW_CASE_EVERY} niveaux. Tout + repart à zéro à chaque nouvelle saison.

diff --git a/apps/web/src/app/(game)/wheel/page.tsx b/apps/web/src/app/(game)/wheel/page.tsx index f9811bd..2ecbd64 100644 --- a/apps/web/src/app/(game)/wheel/page.tsx +++ b/apps/web/src/app/(game)/wheel/page.tsx @@ -1,6 +1,6 @@ "use client"; -import type { WheelTier } from "@palacards/game"; +import { CASE_TIER_LABELS, type WheelTier } from "@palacards/game"; import type { WheelDTO, WheelFaceDTO, WheelPrizeDTO, WheelRewardDTO, WheelSpinDTO } from "@palacards/shared"; import { Check, Clock, Lock, Moon } from "lucide-react"; import { animate, AnimatePresence, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react"; @@ -9,6 +9,7 @@ import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import useSWR from "swr"; import { RaritySigil } from "@/components/Card"; +import { CaseBox } from "@/components/CaseBox"; import { FlipCard } from "@/components/PackOpener"; import { ErrorBox } from "@/components/ui"; import { api, ApiError } from "@/lib/api"; @@ -51,6 +52,12 @@ function label( long: name ? `${n} « ${name} »` : `${n} à thème`, }; } + if (r.kind === "case") + return { + big: CASE_TIER_LABELS[r.tier], + small: "caisse", + long: `Une caisse ${CASE_TIER_LABELS[r.tier]} gratuite`, + }; return { big: r.rarity, small: "carte", @@ -101,12 +108,15 @@ function WheelFace({ tier, segments }: { tier: WheelTier; segments: WheelFaceDTO const card = reward.kind === "card"; const fill = card ? `var(--color-rarity-${reward.rarity.toLowerCase()})` - : i % 2 - ? "var(--wheel-b)" - : "var(--wheel-a)"; + : reward.kind === "case" + ? `var(--wheel-case-${reward.tier})` + : i % 2 + ? "var(--wheel-b)" + : "var(--wheel-a)"; const mid = i * seg + seg / 2; const l = label(reward); const long = l.big.length > 3; + const box = reward.kind === "case"; return ( @@ -116,9 +126,9 @@ function WheelFace({ tier, segments }: { tier: WheelTier; segments: WheelFaceDTO y={C - R + 44} textAnchor="middle" className="font-display" - style={{ fontStretch: "62%", fontWeight: 800 }} - fontSize={card ? 34 : long ? 28 : 36} - fill={card ? (reward.rarity === "L" ? "#1f1600" : "#fff") : "var(--wheel-ink)"} + style={{ fontStretch: "62%", fontWeight: 800, textTransform: box ? "uppercase" : undefined }} + fontSize={card ? 34 : box ? 22 : long ? 28 : 36} + fill={card ? (reward.rarity === "L" ? "#1f1600" : "#fff") : box ? "#fff" : "var(--wheel-ink)"} > {l.big} @@ -441,6 +451,17 @@ export default function WheelPage() { )}

+ ) : shown.prize.kind === "case" ? ( +
+ +

+ {label(shown.prize).long}, à ouvrir dans le thème de ton + choix.{" "} + + L'ouvrir + +

+
) : (
@@ -474,10 +495,10 @@ export default function WheelPage() { : face?.status === "missed" ? "Elle ne serait prête qu’après minuit : reviens demain dès minuit pour recommencer par la petite." : tier === "small" - ? "Des points wiki, des paquets bonus, et parfois une ultra rare ou une légendaire." + ? "Des points wiki, des paquets bonus, une caisse Bronze, et parfois une ultra rare ou une légendaire." : tier === "medium" - ? "Plus de points, plus de paquets, un booster à thème, et une chance de légendaire plus grande." - : "Le gros lot de la journée : jusqu’à 1 000 PW, 10 paquets, deux boosters à thème ou une légendaire."} + ? "Plus de points, plus de paquets, un booster à thème, une caisse Argent, et une chance de légendaire plus grande." + : "Le gros lot de la journée : jusqu’à 1 000 PW, 10 paquets, deux boosters à thème, une caisse Or ou une légendaire."}

)} {!data?.next && data && ( @@ -497,7 +518,12 @@ export default function WheelPage() { .map((s, i) => ( - {s.reward.kind === "card" ? ( + {s.reward.kind === "case" ? ( + + + {label(s.reward).long} + + ) : s.reward.kind === "card" ? ( {label(s.reward).long} diff --git a/apps/web/src/components/CaseBox.tsx b/apps/web/src/components/CaseBox.tsx new file mode 100644 index 0000000..456b733 --- /dev/null +++ b/apps/web/src/components/CaseBox.tsx @@ -0,0 +1,83 @@ +import type { CaseTier } from "@palacards/game"; +import "./cases.css"; + +/** + * Une caisse dessinée : coffret d'album vu de face, couvercle plus clair, deux sangles et le sceau jaune + * « P » des vignettes posé sur la fermeture. La couleur dit le niveau (bronze, argent, or, nuit étoilée + * pour la légendaire). Purement décorative : le niveau est toujours écrit à côté. + */ +export function CaseBox({ tier, className = "" }: { tier: CaseTier; className?: string }) { + return ( + + {/* Ombre portée. */} + + {/* Corps. */} + + + {/* Couvercle. */} + + + {/* Sangles. */} + + + {/* Rivets. */} + {[ + [17, 46], + [103, 46], + [17, 84], + [103, 84], + ].map(([x, y]) => ( + + ))} + {tier === "legendary" && + [ + [48, 70, 3.4], + [73, 80, 2.4], + [66, 58, 1.8], + [50, 24, 2], + ].map(([x, y, s]) => ( + + ))} + {/* Reflet du couvercle. */} + + {/* Sceau. */} + + + + P + + + + + + + + + ); +} diff --git a/apps/web/src/components/Shell.tsx b/apps/web/src/components/Shell.tsx index 34f60eb..e347770 100644 --- a/apps/web/src/components/Shell.tsx +++ b/apps/web/src/components/Shell.tsx @@ -76,7 +76,9 @@ function Portal({ onNavigate }: { onNavigate?: () => void }) { ? me.quests.total - me.quests.done : item.badge === "suggestions" ? (me?.newSuggestions ?? 0) - : 0; + : item.badge === "cases" + ? (me?.freeCases ?? 0) + : 0; // Joueurs dans la file de matchmaking : pastille verte, distincte des compteurs jaunes. const queue = item.badge === "battle" ? (me?.battleQueue ?? 0) : 0; const isNew = !!item.feature && !!me?.newFeatures.includes(item.feature); diff --git a/apps/web/src/components/cases.css b/apps/web/src/components/cases.css new file mode 100644 index 0000000..47a4de9 --- /dev/null +++ b/apps/web/src/components/cases.css @@ -0,0 +1,338 @@ +/* + * Caisses : quatre coffrets d'album (bronze, argent, or, nuit étoilée) et la bande de vignettes qui défile + * sous la languette jaune. Les couleurs du niveau ne servent qu'au coffret et à son ticket ; les vignettes + * gardent celles de leur rareté. + */ +[data-case="bronze"] { + --case-body: #93552b; + --case-lid: #b8743f; + --case-strap: #643515; + --case-rivet: #f0c08e; + --case-seal-ring: #643515; + --case-accent: #c98446; +} +[data-case="silver"] { + --case-body: #7d8699; + --case-lid: #aeb7c9; + --case-strap: #545c70; + --case-rivet: #eef1f7; + --case-seal-ring: #545c70; + --case-accent: #aeb7c9; +} +[data-case="gold"] { + --case-body: #b8870f; + --case-lid: #e2b433; + --case-strap: #7c5806; + --case-rivet: #fff1b8; + --case-seal-ring: #7c5806; + --case-accent: #e2b433; +} +[data-case="legendary"] { + --case-body: #2a2168; + --case-lid: #40348f; + --case-edge: rgb(255 226 122 / 0.7); + --case-strap: var(--color-rarity-l); + --case-rivet: #fff3b0; + --case-seal-ring: #a86b00; + --case-accent: var(--color-rarity-l); +} + +.pc-case-box { + --case-edge: rgb(0 0 0 / 0.22); + display: block; + overflow: visible; +} +[data-case="legendary"] .pc-case-box, +.pc-case-box[data-case="legendary"] { + --case-edge: rgb(255 226 122 / 0.7); +} + +/* Les quatre niveaux : des tickets, celui affiché est en relief (même grammaire que les roues du jour). */ +.pc-case-tiers { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.75rem; +} +@media (max-width: 720px) { + .pc-case-tiers { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; + } +} +.pc-case-tier { + position: relative; + display: grid; + grid-template-columns: 3.4rem minmax(0, 1fr); + align-items: center; + gap: 0.65rem; + width: 100%; + min-height: 4.5rem; + padding: 0.55rem 0.75rem 0.55rem 0.55rem; + border: 2px dashed var(--color-line-strong); + border-radius: 12px; + background: color-mix(in oklab, var(--color-panel) 60%, transparent); + color: var(--color-muted); + text-align: left; + transition: + border-color 160ms ease, + background-color 160ms ease, + color 160ms ease, + transform 160ms var(--ease-out); +} +.pc-case-tier:active:not(:disabled) { + transform: scale(0.97); +} +.pc-case-tier:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; +} +.pc-case-tier[aria-pressed="true"] { + border-style: solid; + border-color: var(--case-accent); + background: var(--color-panel); + color: var(--color-text); + box-shadow: var(--shadow-lift); +} +@media (hover: hover) and (pointer: fine) { + .pc-case-tier:hover:not(:disabled):not([aria-pressed="true"]) { + border-color: var(--case-accent); + color: var(--color-text); + } +} +.pc-case-tier .pc-case-box { + width: 100%; + transition: transform 220ms var(--ease-out); +} +.pc-case-tier[aria-pressed="true"] .pc-case-box { + transform: translateY(-2px) rotate(-4deg); +} +/* Caisses gratuites : la pastille jaune des compteurs du menu. */ +.pc-case-free { + position: absolute; + top: -0.55rem; + right: -0.4rem; + min-width: 1.45rem; + height: 1.45rem; + padding: 0 0.4rem; + border-radius: 999px; + background: var(--color-accent); + color: var(--color-accent-ink); + font-size: 0.75rem; + font-weight: 800; + line-height: 1.45rem; + text-align: center; + box-shadow: 0 0 0 3px var(--color-bg); +} + +/* + * La bande : un ruban de vignettes sous une languette jaune. `--tile` et `--gap` fixent le pas, le JS les + * reprend pour placer la case gagnante sous le repère. + */ +.pc-reel { + --tile: 7rem; + --gap: 0.5rem; + position: relative; + overflow: hidden; + padding-block: 0.9rem; + border-radius: 14px; + background: + repeating-linear-gradient(-45deg, rgb(255 255 255 / 0.035) 0 2px, transparent 2px 12px), + linear-gradient(180deg, var(--color-cover-2), color-mix(in oklab, var(--color-cover-2) 70%, #000)); + box-shadow: + inset 0 2px 10px rgb(0 0 0 / 0.45), + 0 0 0 1px rgb(0 0 0 / 0.2); + /* Les vignettes s'effacent sur les bords : l'œil reste au centre. */ + -webkit-mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent); + mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent); +} +.pc-reel[data-compact] { + --tile: 4.6rem; + --gap: 0.35rem; + padding-block: 0.55rem; + border-radius: 10px; +} +@media (max-width: 640px) { + .pc-reel { + --tile: 5.6rem; + --gap: 0.4rem; + } + .pc-reel[data-compact] { + --tile: 3.9rem; + --gap: 0.3rem; + } +} +.pc-reel-track { + position: relative; + left: 50%; + display: flex; + gap: var(--gap); + width: max-content; + will-change: transform; +} +/* Repère : un fil jaune et deux languettes, au-dessus des masques. */ +.pc-reel-marker { + pointer-events: none; + position: absolute; + inset-block: 0; + left: 50%; + z-index: 2; + width: 3px; + margin-left: -1.5px; + background: var(--color-accent); + box-shadow: 0 0 10px rgb(255 210 63 / 0.55); +} +.pc-reel-marker::before, +.pc-reel-marker::after { + content: ""; + position: absolute; + left: 50%; + width: 18px; + height: 12px; + margin-left: -9px; + background: var(--color-accent); +} +.pc-reel-marker::before { + top: 0; + clip-path: polygon(0 0, 100% 0, 50% 100%); +} +.pc-reel-marker::after { + bottom: 0; + clip-path: polygon(50% 0, 100% 100%, 0 100%); +} + +/* Une case de la bande : vignette réduite, bande de rareté en pied. */ +.pc-reel-tile { + position: relative; + flex-shrink: 0; + width: var(--tile); + aspect-ratio: 5 / 6.4; + overflow: hidden; + border-radius: 9px; + background: var(--color-sticker); + box-shadow: 0 1px 2px rgb(0 0 0 / 0.35); + transition: + opacity 320ms ease, + filter 320ms ease, + transform 420ms var(--ease-out), + box-shadow 420ms var(--ease-out); +} +.pc-reel-tile img { + position: absolute; + inset: 0 0 26% 0; + width: 100%; + height: 74%; + object-fit: cover; +} +.pc-reel-tile-letter { + position: absolute; + inset: 0 0 26% 0; + display: grid; + place-items: center; + font-size: 2.4em; + color: color-mix(in oklab, var(--r) 70%, var(--color-sticker-ink)); + background: color-mix(in oklab, var(--r) 16%, var(--color-sticker)); +} +.pc-reel-tile-band { + position: absolute; + inset: auto 0 0 0; + height: 26%; + display: flex; + align-items: center; + padding-inline: 0.35rem; + background: var(--r); + color: #fff; + font-size: 0.72em; + line-height: 1; +} +.pc-reel-tile[data-rarity="L"] .pc-reel-tile-band { + color: #1f1600; +} +.pc-reel-tile-band span { + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.pc-reel[data-compact] .pc-reel-tile-band { + font-size: 0.6em; +} +/* Lot de légendaires : une liasse de vignettes ambrées et le multiplicateur. */ +.pc-reel-tile[data-lot] { + display: grid; + place-items: center; + background: + repeating-linear-gradient(-45deg, rgb(255 255 255 / 0.12) 0 2px, transparent 2px 9px), + linear-gradient(160deg, #ffd56b, var(--color-rarity-l) 55%, #a86b00); + color: #1f1600; +} +.pc-reel-tile-lot { + display: grid; + justify-items: center; + gap: 0.1em; + font-size: 2.1em; + line-height: 0.85; + text-align: center; +} +.pc-reel-tile-lot small { + font-size: 0.32em; + letter-spacing: 0.06em; +} +.pc-reel[data-compact] .pc-reel-tile-lot { + font-size: 1.45em; +} + +/* Arrêt : la case gagnante se soulève dans la couleur de sa rareté, les autres s'éteignent. */ +.pc-reel[data-stopped] .pc-reel-tile:not([data-win]) { + opacity: 0.32; + filter: saturate(0.4); +} +.pc-reel[data-stopped] .pc-reel-tile[data-win] { + z-index: 1; + transform: scale(1.06); + box-shadow: + 0 0 0 3px var(--r), + 0 0 22px 2px color-mix(in oklab, var(--r) 70%, transparent); +} +.pc-reel[data-stopped] .pc-reel-tile[data-win][data-lot] { + box-shadow: + 0 0 0 3px var(--color-accent), + 0 0 30px 6px rgb(245 158 11 / 0.65); +} + +/* Bandeau d'un lot gagné : le tampon « ×10 légendaires ». */ +.pc-case-stamp { + display: inline-grid; + place-items: center; + padding: 0.45rem 1.1rem 0.35rem; + border: 3px solid currentColor; + border-radius: 10px; + color: var(--color-rarity-l); + transform: rotate(-4deg); + font-size: clamp(1.7rem, 5vw, 2.6rem); + line-height: 0.9; + text-transform: uppercase; +} +.pc-case-stamp small { + font-size: 0.42em; + letter-spacing: 0.06em; +} + +/* Contenu : chance de chaque vignette sous elle, en chiffres alignés. */ +.pc-case-chance { + margin-top: 0.35rem; + text-align: center; + font-size: 0.75rem; + font-weight: 700; + color: var(--color-muted); +} + +@media (prefers-reduced-motion: reduce) { + .pc-reel-tile, + .pc-case-tier .pc-case-box { + transition-property: opacity, filter, box-shadow; + } + .pc-reel[data-stopped] .pc-reel-tile[data-win], + .pc-case-tier[aria-pressed="true"] .pc-case-box { + transform: none; + } +} diff --git a/apps/web/src/components/content.css b/apps/web/src/components/content.css index 25ecaf1..43a5fd2 100644 --- a/apps/web/src/components/content.css +++ b/apps/web/src/components/content.css @@ -1520,6 +1520,13 @@ --wheel-accent: #e0a526; } +/* Cases « caisse » de la roue : la couleur du coffret (voir cases.css). */ +.pc-wheel-stage { + --wheel-case-bronze: #93552b; + --wheel-case-silver: #7d8699; + --wheel-case-gold: #b8870f; +} + /* La roue grandit avec son rang (la grande prend toute la place). */ .pc-wheel-stage { position: relative; diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index b0455d1..67c0936 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -56,6 +56,8 @@ const REASONS: Record = { guild_objective: "Objectif de guilde", upgrade: "Upgrader", wheel: "Roues du jour", + case_purchase: "Caisse", + case_open: "Caisse gratuite", promo_code: "Code promo", theme_pack: "Booster à thème", quest: "Quête", diff --git a/apps/web/src/lib/nav.ts b/apps/web/src/lib/nav.ts index 0b29f11..fae0202 100644 --- a/apps/web/src/lib/nav.ts +++ b/apps/web/src/lib/nav.ts @@ -3,6 +3,7 @@ import { ArrowBigUpDash, ChartNoAxesColumn, Award, + Boxes, CircleDot, Gavel, Heart, @@ -33,7 +34,7 @@ export interface NavItem { label: string; icon: LucideIcon; /** Badge alimenté par /me : non-lus, roue prête, paquets à ouvrir, quêtes, article, boss, file des duels. */ - badge?: "messages" | "wheel" | "packs" | "quests" | "hl" | "boss" | "suggestions" | "battle"; + badge?: "messages" | "wheel" | "packs" | "quests" | "hl" | "boss" | "suggestions" | "battle" | "cases"; /** Clé de nouveauté (`/me.newFeatures`) : pastille « Nouveau » jusqu'à la première visite. */ feature?: string; admin?: boolean; @@ -52,6 +53,7 @@ export const NAV: NavGroup[] = [ { href: "/pulls", label: "Paquets", icon: Package, badge: "packs" }, { href: "/quests", label: "Quêtes et passe", icon: Stamp, badge: "quests", feature: "quests" }, { href: "/wheel", label: "Roues du jour", icon: CircleDot, badge: "wheel", feature: "wheels" }, + { href: "/cases", label: "Caisses", icon: Boxes, badge: "cases", feature: "cases" }, { href: "/plus-ou-moins", label: "Plus ou moins vu", diff --git a/apps/web/src/lib/notifications.ts b/apps/web/src/lib/notifications.ts index d544885..6011872 100644 --- a/apps/web/src/lib/notifications.ts +++ b/apps/web/src/lib/notifications.ts @@ -103,16 +103,18 @@ export function describe(notif: Pick): { te }; } case "pass_level": { - const r = (p.reward ?? {}) as { pw?: unknown; packs?: unknown }; + const r = (p.reward ?? {}) as { pw?: unknown; packs?: unknown; cases?: unknown }; const gain = [ n(r.packs) ? `${n(r.packs)} paquet${n(r.packs) > 1 ? "s" : ""}` : "", n(r.pw) ? `${fmt(n(r.pw))} PW` : "", + n(r.cases) ? `${n(r.cases)} caisse${n(r.cases) > 1 ? "s" : ""} gratuite${n(r.cases) > 1 ? "s" : ""}` : "", ] .filter(Boolean) - .join(" et "); + .join(", ") + .replace(/, ([^,]*)$/, " et $1"); return { text: `Passe de saison : niveau ${fmt(n(p.level))} atteint${gain ? `, +${gain}` : ""} !`, - href: "/quests", + href: n(r.cases) ? "/cases" : "/quests", }; } case "boss_killed": { diff --git a/apps/web/src/lib/sfx.ts b/apps/web/src/lib/sfx.ts index 82462a6..22d566b 100644 --- a/apps/web/src/lib/sfx.ts +++ b/apps/web/src/lib/sfx.ts @@ -12,6 +12,7 @@ import { useSyncExternalStore } from "react"; export type Sfx = | "tear" | "deal" + | "tick" | "flip" | "sr" | "ur" @@ -333,6 +334,12 @@ const RECIPES: Record = { deal(c, out, t) { hiss(c, out, t, { a: 0.012, d: 0.13, peak: 0.7 }, { type: "bandpass", from: 3200, to: 900, q: 1.1 }); }, + // Bande des caisses : le cliquet sec d'une case qui passe sous le repère (joué des dizaines de fois, très + // court et discret). + tick(c, out, t) { + hiss(c, out, t, { a: 0.001, d: 0.018, peak: 0.28 }, { type: "bandpass", from: 4200, q: 3 }); + tone(c, out, t, 1450, { a: 0.001, d: 0.022, peak: 0.07 }, "triangle", 900); + }, // Retournement : claquement sec du carton, petit corps grave. flip(c, out, t) { hiss(c, out, t, { a: 0.001, d: 0.045, peak: 0.45 }, { type: "highpass", from: 2200 }); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 3273d02..366d4d3 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -673,10 +673,7 @@ export const caseBoxes = pgTable( createdBy: text("created_by"), createdAt: tstz("created_at").notNull().defaultNow(), }, - (t) => [ - index("cases_ends_idx").on(t.endsAt), - check("cases_dates_ok", sql`${t.endsAt} > ${t.startsAt}`), - ], + (t) => [index("cases_ends_idx").on(t.endsAt), check("cases_dates_ok", sql`${t.endsAt} > ${t.startsAt}`)], ); /** Contenu visible d'une caisse : chaque carte et sa rareté (figée à la création), dans l'ordre d'affichage. */ diff --git a/packages/shared/src/dto.ts b/packages/shared/src/dto.ts index ac01fd4..dc43e29 100644 --- a/packages/shared/src/dto.ts +++ b/packages/shared/src/dto.ts @@ -86,6 +86,8 @@ export interface MeDTO { hlTriesLeft: number; /** Boss du jour : assauts restants pour le joueur, phase en cours. */ boss: { assaultsLeft: number; phase: number }; + /** Caisses gratuites pas encore ouvertes, tous niveaux confondus (pastille « Caisses » du menu). */ + freeCases: number; } /** Booster à thème temporaire (page Paquets). */ diff --git a/tools/themes/README.md b/tools/themes/README.md index 344a965..c06991e 100644 --- a/tools/themes/README.md +++ b/tools/themes/README.md @@ -28,4 +28,20 @@ cd /srv/nas/palacards-themes && nohup ./run index > data/index.log 2>&1 & 4. `./run create data/themes/.yaml --go` : sauvegarde (`palacards-backup.service`), puis création en une transaction (thème, cartes avec rareté et rang, index de tirage), en quelques secondes. +## Les caisses de la semaine + +Deux thèmes par semaine, chacun en quatre niveaux (Bronze, Argent, Or, Légendaire), renouvelés le lundi soir. +Le contenu de chaque niveau = les cartes les plus lues du thème dans chaque rareté (`CASE_CONTENT` de +`packages/game/src/cases.ts`, recopié dans `palacards_themes/cases.py`). + +```sh +./run case --theme 24 # essai à partir d'un booster en base (transaction annulée) +./run case --theme 24 --go # sauvegarde puis création, jusqu'au lundi 23 h 59 suivant +./run case data/themes/.yaml --go # ou à partir d'une fiche, sans créer de booster +``` + +`--name` change le nom affiché, `--starts` / `--ends` les dates (heure de Paris si sans fuseau). Refusé si une +rareté manque à un niveau (une Bronze sans rare, une Or sans légendaire) ou si des caisses du même nom sont +déjà en vente sur ces dates. + Règles de prix (doc 22 du projet Infra Palawi) : 200 PW avec assez de légendaires, sinon 150 PW ; jamais sous 150. diff --git a/tools/themes/palacards_themes/cases.py b/tools/themes/palacards_themes/cases.py new file mode 100644 index 0000000..4193387 --- /dev/null +++ b/tools/themes/palacards_themes/cases.py @@ -0,0 +1,102 @@ +"""Caisses de la semaine (suggestion n° 126) : les quatre niveaux d'un thème, en une transaction. + +Le contenu de chaque niveau = les cartes les plus lues du thème dans chaque rareté, en nombre fixé par +`CASE_CONTENT` (copie de `packages/game/src/cases.ts`, à garder identique). Le thème vient soit d'un booster +déjà en base (`--theme ` : ses cartes), soit d'une fiche YAML de booster (sélection de l'index local). +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from zoneinfo import ZoneInfo + +TIERS = ["bronze", "silver", "gold", "legendary"] +# packages/game/src/cases.ts › CASE_CONTENT +CASE_CONTENT: dict[str, dict[str, int]] = { + "bronze": {"R": 12, "SR": 10, "UR": 6, "L": 2}, + "silver": {"SR": 10, "UR": 12, "L": 5}, + "gold": {"UR": 12, "L": 10}, + "legendary": {"UR": 10, "L": 15}, +} +PARIS = ZoneInfo("Europe/Paris") + + +def next_monday_evening(after: datetime) -> datetime: + """Prochain lundi 23 h 59 (heure de Paris), en UTC : l'heure de renouvellement des caisses.""" + local = after.astimezone(PARIS) + days = (0 - local.weekday()) % 7 + target = (local + timedelta(days=days)).replace(hour=23, minute=59, second=0, microsecond=0) + if target <= local: + target += timedelta(days=7) + return target.astimezone(UTC) + + +def _lit(s: str) -> str: + return "'" + s.replace("'", "''") + "'" + + +def script( + collection: str, + source_sql: str, + season: int, + starts: datetime, + ends: datetime, + by: str, + commit: bool, + copy_ids: list[int] | None = None, +) -> str: + """Script psql : `source_sql` remplit `_ids` (ou `copy_ids` par COPY), puis quatre caisses et leur contenu.""" + fill = ( + "COPY _ids FROM STDIN;\n" + "\n".join(str(i) for i in sorted(copy_ids)) + "\n\\.\n" + if copy_ids is not None + else f"INSERT INTO _ids {source_sql} ON CONFLICT DO NOTHING;\n" + ) + key = f"k.collection = {_lit(collection)} AND k.starts_at = {_lit(starts.isoformat())}" + tiers = [] + checks = [] + for tier in TIERS: + content = CASE_CONTENT[tier] + wanted = " OR ".join(f"(c.rarity = '{r}' AND c.n <= {n})" for r, n in content.items()) + tiers.append(f""" +INSERT INTO cases (collection, tier, season, starts_at, ends_at, created_by) +VALUES ({_lit(collection)}, '{tier}', {season}, {_lit(starts.isoformat())}, {_lit(ends.isoformat())}, + (SELECT id FROM "user" WHERE username = {_lit(by)})) +RETURNING id AS {tier}_id \\gset +INSERT INTO case_cards (case_id, card_id, rarity, pos) +SELECT :{tier}_id, c.id, c.rarity, + (row_number() OVER (ORDER BY c.rarity DESC, c.views_12m DESC, c.id) - 1)::smallint +FROM _ranked c WHERE {wanted};""") + # Chaque rareté qu'une issue du niveau peut donner doit avoir au moins une carte. + checks += [ + f""" IF NOT EXISTS (SELECT 1 FROM cases k JOIN case_cards cc ON cc.case_id = k.id + WHERE {key} AND k.tier = '{tier}' AND cc.rarity = '{r}') THEN + RAISE EXCEPTION 'Caisse {tier} : aucune carte {r} dans le thème.'; + END IF;""" + for r in content + ] + return f""" +\\set ON_ERROR_STOP on +BEGIN; +DO $$ BEGIN + IF (SELECT id FROM seasons WHERE status = 'active') <> {season} THEN + RAISE EXCEPTION 'La saison active n''est plus la {season}.'; + END IF; + IF EXISTS (SELECT 1 FROM cases WHERE collection = {_lit(collection)} AND ends_at > {_lit(starts.isoformat())}) THEN + RAISE EXCEPTION 'Des caisses « % » sont déjà en vente sur ces dates.', {_lit(collection)}; + END IF; +END $$; +CREATE TEMP TABLE _ids (id bigint PRIMARY KEY) ON COMMIT DROP; +{fill}CREATE TEMP TABLE _ranked ON COMMIT DROP AS +SELECT c.id, c.rarity, c.views_12m, + row_number() OVER (PARTITION BY c.rarity ORDER BY c.views_12m DESC, c.id) AS n +FROM cards c JOIN _ids i ON i.id = c.id WHERE c.season = {season}; +{"".join(tiers)} +DO $$ BEGIN +{chr(10).join(checks)} +END $$; +SELECT 'case', k.tier, k.id, count(cc.card_id), string_agg(DISTINCT cc.rarity::text, '/') +FROM cases k LEFT JOIN case_cards cc ON cc.case_id = k.id +WHERE {key} +GROUP BY k.tier, k.id ORDER BY k.id; +{"COMMIT;" if commit else "ROLLBACK;"} +""" diff --git a/tools/themes/palacards_themes/cli.py b/tools/themes/palacards_themes/cli.py index 6e50f30..77e6005 100644 --- a/tools/themes/palacards_themes/cli.py +++ b/tools/themes/palacards_themes/cli.py @@ -1,10 +1,12 @@ -"""palacards-themes : index | preview | create | find-cat | find-class | card. +"""palacards-themes : index | preview | create | case | find-cat | find-class | card. Exemples (sur PalaServer, depuis /srv/nas/palacards-themes) : ./run index # une fois par saison, ~15 à 25 min, en arrière-plan ./run preview themes/jeux-video.yaml ./run create themes/jeux-video.yaml # essai (transaction annulée) ./run create themes/jeux-video.yaml --go # sauvegarde puis création + ./run case --theme 24 # caisses de la semaine d'un booster existant (essai) + ./run case themes/jeux-video.yaml --go # caisses d'une fiche, jusqu'au lundi soir suivant ./run find-cat "jeu vidéo" ./run find-class "console de jeux" ./run card "Tetris" @@ -15,7 +17,9 @@ import argparse import sys import time +from datetime import UTC, datetime +from . import cases as cases_mod from . import create as create_mod from . import index, paths, prod, report, spec from .select import select @@ -73,6 +77,52 @@ def cmd_create(a) -> None: f"{s.name}, {len(sel.ids):,} cartes, {price} PW, du {s.starts:%Y-%m-%d %H:%M} au {s.ends:%Y-%m-%d %H:%M} UTC") +def _when(v: str | None, default: datetime) -> datetime: + if not v: + return default + d = datetime.fromisoformat(v) + return d if d.tzinfo else d.replace(tzinfo=cases_mod.PARIS) + + +def cmd_case(a) -> None: + """Quatre caisses (Bronze à Légendaire) d'un thème, du `--starts` (maintenant) au lundi 23 h 59 suivant.""" + starts = _when(a.starts, datetime.now(UTC)).astimezone(UTC) + ends = _when(a.ends, cases_mod.next_monday_evening(starts)).astimezone(UTC) + if ends <= starts: + raise SystemExit("Fin avant le début.") + if a.theme: + if a.spec: + raise SystemExit("Une fiche ou --theme, pas les deux.") + season = prod.active_season() + name = a.name or prod.psql(f"select name from themes where id = {int(a.theme)};").strip() + if not name: + raise SystemExit(f"Booster {a.theme} introuvable.") + sql = cases_mod.script(name, f"SELECT card_id FROM theme_cards WHERE theme_id = {int(a.theme)}", season, + starts, ends, a.by, commit=a.go) + slug = f"caisses-theme-{a.theme}" + elif a.spec: + s, _con, meta, sel, _ = _run(a) + season = int(meta["season"]) + name = a.name or s.name + sql = cases_mod.script(name, "", season, starts, ends, a.by, commit=a.go, copy_ids=sorted(sel.ids)) + slug = f"caisses-{s.slug}" + else: + raise SystemExit("Donne une fiche YAML ou --theme .") + out = paths.THEMES / slug + out.mkdir(parents=True, exist_ok=True) + (out / ("case.sql" if a.go else "case-dry-run.sql")).write_text(sql, encoding="utf-8") + if a.go: + print("Sauvegarde de la base…") + prod.backup() + t = time.time() + res = prod.psql(sql) + print("niveau · id · cartes · raretés") + for line in res.strip().splitlines(): + print(" " + line.replace("|", " · ").removeprefix("case · ")) + print(f"{'Créées' if a.go else 'Essai annulé (rien d’écrit)'} en {time.time() - t:.1f} s : caisses « {name} », " + f"du {starts:%Y-%m-%d %H:%M} au {ends:%Y-%m-%d %H:%M} UTC") + + def cmd_find_cat(a) -> None: con = index.connect() rows = con.execute( @@ -139,6 +189,15 @@ def main(argv: list[str] | None = None) -> None: s.add_argument("--price", type=int) s.add_argument("--by", default="palawi", help="pseudo de l'admin créateur") s.set_defaults(fn=cmd_create) + s = sub.add_parser("case", help="caisses de la semaine d'un thème (essai sans --go)") + s.add_argument("spec", nargs="?", help="fiche YAML de booster") + s.add_argument("--theme", type=int, help="booster déjà en base : ses cartes") + s.add_argument("--name", help="nom affiché (par défaut : celui du booster ou de la fiche)") + s.add_argument("--starts", help="début (ISO, heure de Paris si sans fuseau ; par défaut maintenant)") + s.add_argument("--ends", help="fin (par défaut le lundi 23 h 59 suivant, heure de Paris)") + s.add_argument("--go", action="store_true", help="sauvegarde puis création pour de vrai") + s.add_argument("--by", default="palawi", help="pseudo de l'admin créateur") + s.set_defaults(fn=cmd_case) for name, fn in [("find-cat", cmd_find_cat), ("find-class", cmd_find_class)]: s = sub.add_parser(name) s.add_argument("text") diff --git a/tools/themes/tests/test_cases.py b/tools/themes/tests/test_cases.py new file mode 100644 index 0000000..6286bcb --- /dev/null +++ b/tools/themes/tests/test_cases.py @@ -0,0 +1,20 @@ +from datetime import UTC, datetime + +from palacards_themes.cases import CASE_CONTENT, next_monday_evening, script + + +def test_monday_evening_paris(): + # Mercredi 4 novembre 2026, heure d'hiver : lundi 9 à 23 h 59 à Paris = 22 h 59 UTC. + assert next_monday_evening(datetime(2026, 11, 4, 12, tzinfo=UTC)) == datetime(2026, 11, 9, 22, 59, tzinfo=UTC) + # Lundi après 23 h 59 : la semaine suivante. + assert next_monday_evening(datetime(2026, 11, 9, 23, 30, tzinfo=UTC)) == datetime(2026, 11, 16, 22, 59, tzinfo=UTC) + + +def test_script_four_tiers_and_counts(): + sql = script("Jeux vidéo", "", 1, datetime(2026, 11, 4, tzinfo=UTC), datetime(2026, 11, 9, tzinfo=UTC), "palawi", + False, copy_ids=[3, 1, 2]) + assert sql.count("INSERT INTO cases") == 4 + assert "COPY _ids FROM STDIN;\n1\n2\n3\n\\." in sql + assert "(c.rarity = 'L' AND c.n <= 15)" in sql + assert sql.strip().endswith("ROLLBACK;") + assert sum(CASE_CONTENT["bronze"].values()) == 30