diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..3e5b820 --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,7 @@ +LIVE_POKER_ALLOWED_ORIGINS=http://localhost:3000 +LIVE_POKER_JWT_SECRET=replace-with-the-value-used-by-nextjs +LIVE_POKER_CONTROL_SECRET=replace-with-the-control-value-used-by-nextjs +LIVE_POKER_WEBHOOK_SECRET=replace-with-the-callback-value-used-by-nextjs +LIVE_POKER_WEBHOOK_URL=http://localhost:3000/api/live-poker/record-hand +LIVE_POKER_SETTLEMENT_URL=http://localhost:3000/api/live-poker/settle-player +LIVE_POKER_TURN_TIMEOUT_SECONDS=20 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..583456b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + verify: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.32.1 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run type-check + - run: pnpm run test:live-poker + - run: pnpm exec wrangler deploy --dry-run + - run: pnpm build + env: + AUTH_TRUST_HOST: "true" + GOOGLE_CLIENT_ID: ci-google-client + GOOGLE_CLIENT_SECRET: ci-google-secret + LIVE_POKER_CONTROL_SECRET: ci-control-secret + LIVE_POKER_CONVEX_SECRET: ci-convex-secret + LIVE_POKER_JWT_SECRET: ci-jwt-secret + LIVE_POKER_WEBHOOK_SECRET: ci-webhook-secret + LIVE_POKER_WORKER_URL: https://worker.example.com + NEXTAUTH_SECRET: ci-nextauth-secret + NEXTAUTH_URL: http://localhost:3000 + NEXT_PUBLIC_CONVEX_URL: https://example.convex.cloud + NEXT_PUBLIC_LIVE_POKER_WORKER_URL: wss://worker.example.com diff --git a/.gitignore b/.gitignore index c8f026d..c702f8c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,11 @@ # next.js /.next/ /out/ +/.partykit/ +/.wrangler/ +.dev.vars +.dev.vars.* +!.dev.vars.example # production /build @@ -39,4 +44,3 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts - diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..fefc648 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,139 @@ +# Deployment + +The Next.js application runs on Vercel. Live table WebSockets and authoritative poker state run in a separate Cloudflare Worker backed by a Durable Object. Vercel does not need a Durable Object binding. + +## Prerequisites + +- Node.js 22+ and pnpm 10 +- A production Convex deployment +- A **dedicated Cloudflare Workers Free account** with Durable Objects enabled, no paid Workers subscription, and no payment method needed for this project +- A stable, unprotected Vercel production domain for Worker callbacks +- Google OAuth configured with `https:///api/auth/callback/google` + +Generate four independent random secrets: + +- `LIVE_POKER_JWT_SECRET`: Vercel and Cloudflare token signing +- `LIVE_POKER_CONTROL_SECRET`: Vercel-to-Cloudflare claim/close/recovery calls +- `LIVE_POKER_WEBHOOK_SECRET`: Cloudflare-to-Vercel hand/settlement callbacks +- `LIVE_POKER_CONVEX_SECRET`: Vercel and Convex + +Do not expose any of these as `NEXT_PUBLIC_*` variables. + +## 1. Deploy Convex + +Set `LIVE_POKER_CONVEX_SECRET` in the production Convex deployment, then deploy: + +```bash +pnpm deploy:convex +``` + +Set `NEXT_PUBLIC_CONVEX_URL` on Vercel to that production deployment URL. + +## 2. Configure and deploy Cloudflare + +### Zero-cost requirement + +This application is designed for the Workers Free plan. Do not deploy it into an account with Workers Paid enabled: Workers Paid has a monthly minimum and usage overages. Free-plan limits fail closed when exhausted instead of creating usage charges. Budget alerts are notifications only and are **not** spending caps. + +Use a dedicated Free account so another project cannot upgrade the shared account or consume this application's allowance. Before every deployment, verify **Workers & Pages > Plans** still shows Free. Never enable Workers Paid for this account. + +The guarded deployment command requires the selected account ID to match the approved Free account: + +```bash +export CLOUDFLARE_ACCOUNT_ID= +export CLOUDFLARE_ZERO_COST_ACCOUNT_ID= +export CLOUDFLARE_ZERO_COST_ACK=workers-free-hard-limits +export CLOUDFLARE_BILLING_READ_TOKEN= +``` + +Create the billing token with only **Account > Billing > Read** for this account. `pnpm deploy:worker` fails unless the account IDs match and Cloudflare's subscriptions API reports no positive-price or non-Free Workers subscription. Do not bypass the guard with a direct `wrangler deploy`. This checks deployment-time state; it cannot prevent someone from upgrading the account later in the dashboard, so the dedicated-account rule still matters. + +Current Cloudflare references: + +- Workers pricing and Free limits: +- Durable Object pricing and Free limits: +- Budget alerts do not cap usage: +- Account subscriptions API used by the deploy guard: + +### Worker configuration + +`wrangler.jsonc` binds `LIVE_POKER_TABLE` to `LivePokerTableDurableObject`, includes the SQLite Durable Object migration, caps CPU at the Free-plan limit, disables persisted observability, and configures IP/user rate-limit bindings. Configure these Worker secrets/variables in the Cloudflare dashboard or with `pnpm exec wrangler secret put `: + +```text +LIVE_POKER_ALLOWED_ORIGINS=https:// +LIVE_POKER_JWT_SECRET= +LIVE_POKER_CONTROL_SECRET= +LIVE_POKER_WEBHOOK_SECRET= +LIVE_POKER_WEBHOOK_URL=https:///api/live-poker/record-hand +LIVE_POKER_SETTLEMENT_URL=https:///api/live-poker/settle-player +LIVE_POKER_TURN_TIMEOUT_SECONDS=20 +``` + +Multiple allowed origins are comma-separated. Do not include a trailing slash. The guarded deployment retains dashboard-managed runtime variables; do not remove its `--keep-vars` protection. Deploy: + +```bash +pnpm deploy:worker +``` + +Verify `https:///health` returns `{ "ok": true, ... }`. The public browser URL uses `wss://`; the server URL uses `https://`. + +The code also limits connections and WebSocket messages, authenticates before Durable Object dispatch, stops unattended tables, bounds webhook retries, and deletes closed-object storage after successful delivery. A callback that reaches a permanent error or exhausts retries is dead-lettered and pauses the table instead of retrying forever. After fixing the callback, retry a table manually: + +```bash +curl -X POST \ + -H "x-live-poker-secret: " \ + "https:///live-poker//retry-dead-letters" +``` + +Treat this secret and command as an operator-only recovery mechanism. + +## 3. Configure and deploy Vercel + +Configure these Production environment variables before the final Vercel build: + +```text +AUTH_TRUST_HOST=true +NEXTAUTH_SECRET=... +NEXTAUTH_URL=https:// +GOOGLE_CLIENT_ID=... +GOOGLE_CLIENT_SECRET=... +NEXT_PUBLIC_CONVEX_URL=https://.convex.cloud +NEXT_PUBLIC_LIVE_POKER_WORKER_URL=wss:// +LIVE_POKER_WORKER_URL=https:// +LIVE_POKER_JWT_SECRET= +LIVE_POKER_CONTROL_SECRET= +LIVE_POKER_WEBHOOK_SECRET= +LIVE_POKER_CONVEX_SECRET= +``` + +`NEXT_PUBLIC_LIVE_POKER_WORKER_URL` is embedded at build time, so redeploy Vercel after changing it. + +The Worker must be able to call the hand and settlement API routes. Point callbacks at an unprotected production domain; Vercel Deployment Protection on a preview URL will block them. + +## Preview isolation + +Do not connect Vercel previews to the production Worker. A preview must use a separately named Worker/Durable Object deployment, separate secrets, a preview Convex deployment, and callback URLs that Cloudflare can access. Otherwise preview table IDs and state can mix with production. + +## Operational limits and emergency stop + +- The deployment can have at most 10 open tables. One user can have at most 3 open tables, hold active access grants for at most 3 tables, and create at most 10 tables in a rolling 24-hour window. +- One table accepts at most 36 WebSockets and 3 WebSockets per user. +- The Worker asks Cloudflare's approximate rate-limit binding to admit 120 requests/minute per source IP and 12 WebSocket handshakes/minute per authenticated user. These counters are per-location, permissive, and eventually consistent—not strict quotas—and rejected requests still count as Worker invocations. The Durable Object separately enforces hard per-instance connection and message limits. +- A table does not start another hand unless at least two eligible players are connected. It auto-pauses after 6 consecutive timed-out actions and has a hard 250-hand lifetime; close it and create a new table to continue. +- Webhook requests time out after 10 seconds. Transient retries use minute-to-hour backoff, stop after 12 attempts, and write only the changed outbox item. +- Closed Durable Object storage is deleted after a 10-minute token-expiry buffer once every settlement is delivered. Dead letters intentionally retain state for manual recovery without scheduling more alarms. + +If abuse or unexpected quota consumption appears, use **Workers & Pages > buyin-live-poker > Settings > Disable** (or remove its route/domain) immediately. Free-plan exhaustion can make live poker unavailable until quotas reset, but it should not create an overage bill. + +This repository cannot determine whether real-money poker is lawful in a deployment jurisdiction. If tables represent actual wagering rather than a private/play-money ledger, get jurisdiction-specific legal review before deployment; platform usage controls do not remove account-suspension risk from unlawful use. + +## Release smoke test + +1. Sign in through Google. +2. Create a table and connect two browser sessions. +3. Approve an initial buy-in and play a complete hand. +4. Confirm the hand appears in Convex. +5. Leave seats or close the table between hands and confirm settlements appear. +6. Reconnect a browser and confirm the table state is restored from the Durable Object. + +Local development uses `.env.local` for Next.js and `.dev.vars` for Wrangler. Start from `env.example` and `.dev.vars.example`; neither real secrets file is committed. diff --git a/biome.jsonc b/biome.jsonc index a39804b..96776e8 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,4 +1,16 @@ { "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", - "extends": ["ultracite/biome/core"] + "extends": ["ultracite/biome/core"], + "overrides": [ + { + "includes": ["convex/live_poker.ts"], + "linter": { + "rules": { + "style": { + "useFilenamingConvention": "off" + } + } + } + } + ] } diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 821e055..be53742 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -12,6 +12,7 @@ import type * as auth from "../auth.js"; import type * as games from "../games.js"; import type * as groups from "../groups.js"; import type * as helpers from "../helpers.js"; +import type * as live_poker from "../live_poker.js"; import type * as player_claims from "../player_claims.js"; import type * as players from "../players.js"; import type * as poker_now_imports from "../poker_now_imports.js"; @@ -29,6 +30,7 @@ declare const fullApi: ApiFromModules<{ games: typeof games; groups: typeof groups; helpers: typeof helpers; + live_poker: typeof live_poker; player_claims: typeof player_claims; players: typeof players; poker_now_imports: typeof poker_now_imports; diff --git a/convex/games.ts b/convex/games.ts index 0d83c7c..5570c2b 100644 --- a/convex/games.ts +++ b/convex/games.ts @@ -6,6 +6,7 @@ import { deleteGameCascade, getPlayerDisplaySummary, getUserDisplaySummary, + isUserGroupMember, requireGameManager, } from "./helpers"; @@ -177,9 +178,16 @@ export const createGame = mutation({ location: v.optional(v.string()), notes: v.optional(v.string()), gameType: v.optional(v.union(v.literal("cash"), v.literal("tournament"))), + livePokerEnabled: v.optional(v.boolean()), + smallBlind: v.optional(v.number()), + bigBlind: v.optional(v.number()), + minBuyIn: v.optional(v.number()), + maxBuyIn: v.optional(v.number()), + seatCount: v.optional(v.number()), groupId: v.id("groups"), createdById: v.id("users"), }, + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Creation validates both legacy sessions and optional live-table settings. handler: async (ctx, args) => { // Verify user is a member of the group const membership = await ctx.db @@ -193,11 +201,46 @@ export const createGame = mutation({ throw new Error("Only group members can create sessions"); } + if (args.livePokerEnabled && args.gameType === "tournament") { + throw new Error("Live poker is currently available for cash games only"); + } + + if (args.livePokerEnabled) { + if (!(args.smallBlind && args.bigBlind) || args.smallBlind <= 0) { + throw new Error("Live poker requires positive blinds"); + } + if (args.bigBlind < args.smallBlind) { + throw new Error("Big blind must be at least the small blind"); + } + if ( + !args.seatCount || + args.seatCount < 2 || + args.seatCount > 9 || + !Number.isInteger(args.seatCount) + ) { + throw new Error("Live poker tables must have 2 to 9 seats"); + } + if ( + args.minBuyIn !== undefined && + args.maxBuyIn !== undefined && + args.maxBuyIn < args.minBuyIn + ) { + throw new Error("Max buy-in must be at least the min buy-in"); + } + } + return await ctx.db.insert("games", { date: args.date, location: args.location, notes: args.notes, gameType: args.gameType, + livePokerEnabled: args.livePokerEnabled, + liveStatus: args.livePokerEnabled ? "WAITING" : undefined, + smallBlind: args.livePokerEnabled ? args.smallBlind : undefined, + bigBlind: args.livePokerEnabled ? args.bigBlind : undefined, + minBuyIn: args.livePokerEnabled ? args.minBuyIn : undefined, + maxBuyIn: args.livePokerEnabled ? args.maxBuyIn : undefined, + seatCount: args.livePokerEnabled ? args.seatCount : undefined, groupId: args.groupId, createdById: args.createdById, status: "ACTIVE", @@ -243,6 +286,12 @@ export const updateGame = mutation({ notes: v.optional(v.string()), date: v.optional(v.number()), gameType: v.optional(v.union(v.literal("cash"), v.literal("tournament"))), + livePokerEnabled: v.optional(v.boolean()), + smallBlind: v.optional(v.number()), + bigBlind: v.optional(v.number()), + minBuyIn: v.optional(v.number()), + maxBuyIn: v.optional(v.number()), + seatCount: v.optional(v.number()), }, handler: async (ctx, args) => { const game = await ctx.db.get(args.gameId); @@ -258,6 +307,38 @@ export const updateGame = mutation({ ); const { gameId, userId, ...updates } = args; + if (updates.livePokerEnabled && updates.gameType === "tournament") { + throw new Error("Live poker is currently available for cash games only"); + } + if (updates.livePokerEnabled) { + const smallBlind = updates.smallBlind ?? game.smallBlind; + const bigBlind = updates.bigBlind ?? game.bigBlind; + const seatCount = updates.seatCount ?? game.seatCount; + + if ( + !(smallBlind && bigBlind) || + smallBlind <= 0 || + bigBlind < smallBlind + ) { + throw new Error("Live poker requires valid blinds"); + } + if ( + !seatCount || + seatCount < 2 || + seatCount > 9 || + !Number.isInteger(seatCount) + ) { + throw new Error("Live poker tables must have 2 to 9 seats"); + } + if ( + updates.minBuyIn !== undefined && + updates.maxBuyIn !== undefined && + updates.maxBuyIn < updates.minBuyIn + ) { + throw new Error("Max buy-in must be at least the min buy-in"); + } + } + await ctx.db.patch(gameId, updates); return await ctx.db.get(gameId); }, @@ -308,6 +389,58 @@ export const joinGame = mutation({ }, }); +export const getLivePokerAccess = query({ + args: { gameId: v.id("games"), userId: v.id("users") }, + handler: async (ctx, args) => { + const game = await ctx.db.get(args.gameId); + if (!(game?.livePokerEnabled && game.status === "ACTIVE")) { + return null; + } + + const isMember = await isUserGroupMember(ctx, game.groupId, args.userId); + if (!isMember) { + return null; + } + + const group = await ctx.db.get(game.groupId); + const player = await ctx.db + .query("players") + .withIndex("by_userId", (q) => q.eq("userId", args.userId)) + .first(); + + return { + game: { + id: game._id, + createdById: game.createdById, + liveStatus: game.liveStatus ?? "WAITING", + smallBlind: game.smallBlind ?? 1, + bigBlind: game.bigBlind ?? 2, + minBuyIn: game.minBuyIn ?? game.bigBlind ?? 2, + maxBuyIn: game.maxBuyIn ?? (game.bigBlind ?? 2) * 200, + seatCount: game.seatCount ?? 6, + }, + group: group ? { id: group._id, name: group.name } : null, + player: player ? { id: player._id, name: player.name } : null, + }; + }, +}); + +export const updateLivePokerStatus = mutation({ + args: { + gameId: v.id("games"), + liveStatus: v.union( + v.literal("WAITING"), + v.literal("PLAYING"), + v.literal("PAUSED"), + v.literal("CLOSED") + ), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.gameId, { liveStatus: args.liveStatus }); + return await ctx.db.get(args.gameId); + }, +}); + // Get game player export const getGamePlayer = query({ args: { gameId: v.id("games"), playerId: v.id("players") }, diff --git a/convex/helpers.ts b/convex/helpers.ts index c4fe8d4..69f8cfc 100644 --- a/convex/helpers.ts +++ b/convex/helpers.ts @@ -51,6 +51,21 @@ export async function canUserManageGame( return group?.ownerId === userId; } +export async function isUserGroupMember( + ctx: DbCtx, + groupId: Id<"groups">, + userId: Id<"users"> +) { + const membership = await ctx.db + .query("groupMembers") + .withIndex("by_groupId_userId", (q) => + q.eq("groupId", groupId).eq("userId", userId) + ) + .first(); + + return Boolean(membership); +} + export async function requireGameManager( ctx: DbCtx, game: Doc<"games">, @@ -90,5 +105,14 @@ export async function deleteGameCascade(ctx: MutationCtx, gameId: Id<"games">) { await ctx.db.delete(transaction._id); } + const liveHands = await ctx.db + .query("livePokerHands") + .withIndex("by_gameId", (q) => q.eq("gameId", gameId)) + .collect(); + + for (const liveHand of liveHands) { + await ctx.db.delete(liveHand._id); + } + await ctx.db.delete(gameId); } diff --git a/convex/live_poker.ts b/convex/live_poker.ts new file mode 100644 index 0000000..fb1d320 --- /dev/null +++ b/convex/live_poker.ts @@ -0,0 +1,1178 @@ +import { v } from "convex/values"; +import { internal } from "./_generated/api"; +import type { Doc, Id } from "./_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { + action, + internalMutation, + internalQuery, + query, +} from "./_generated/server"; + +const winnerValidator = v.object({ + playerId: v.id("players"), + userId: v.id("users"), + seatIndex: v.number(), + amount: v.number(), + description: v.optional(v.string()), +}); + +const buyInRequestTypeValidator = v.union( + v.literal("INITIAL"), + v.literal("ADD_ON") +); +const LIVE_POKER_ACCESS_GRANT_MS = 15 * 60 * 1000; +const LIVE_POKER_ACCESS_GRANT_RENEWAL_MS = 6 * 60 * 1000; +const MAX_ACTIVE_LIVE_POKER_TABLES_PER_USER = 3; +const MAX_LIVE_POKER_TABLES_CREATED_PER_DAY = 10; +const MAX_OPEN_LIVE_POKER_TABLES_GLOBAL = 10; +const MAX_OPEN_LIVE_POKER_TABLES_PER_USER = 3; + +type LivePokerBuyInRequestRow = Doc<"livePokerBuyInRequests"> & { + id: Id<"livePokerBuyInRequests">; + player: { id: Id<"players">; name: string }; + respondedBy: { id: Id<"users"> | undefined; name: string } | null; +}; + +interface LivePokerWorkerTable { + bigBlind: number; + createdById: Id<"users">; + id: Id<"livePokerTables">; + maxBuyIn: number; + minBuyIn: number; + seatCount: number; + smallBlind: number; +} + +type LivePokerClaimRequest = Doc<"livePokerBuyInRequests"> & { + id: Id<"livePokerBuyInRequests">; + table: LivePokerWorkerTable; +}; + +type LivePokerApprovalRequest = LivePokerClaimRequest & { + playerName: string; +}; + +export const recordCompletedHandInternal = internalMutation({ + args: { + gameId: v.optional(v.id("games")), + tableId: v.optional(v.id("livePokerTables")), + handNumber: v.number(), + dealerSeat: v.number(), + smallBlind: v.number(), + bigBlind: v.number(), + communityCards: v.array(v.string()), + winners: v.array(winnerValidator), + pot: v.number(), + actionLog: v.array(v.string()), + completedAt: v.number(), + }, + handler: async (ctx, args) => { + if (!(args.gameId || args.tableId)) { + throw new Error("A gameId or tableId is required"); + } + + const existing = await ctx.db + .query("livePokerHands") + .withIndex( + args.tableId ? "by_tableId_handNumber" : "by_gameId_handNumber", + (q) => + args.tableId + ? q.eq("tableId", args.tableId).eq("handNumber", args.handNumber) + : q.eq("gameId", args.gameId).eq("handNumber", args.handNumber) + ) + .first(); + + if (existing) { + return existing._id; + } + + return await ctx.db.insert("livePokerHands", args); + }, +}); + +function isValidChipAmount(value: number, allowZero = false) { + const units = Math.round(value * 100); + return ( + Number.isFinite(value) && + Number.isSafeInteger(units) && + Math.abs(value * 100 - units) <= 1e-7 && + (allowZero ? units >= 0 : units > 0) + ); +} + +function validateTableSettings(args: { + bigBlind: number; + maxBuyIn: number; + minBuyIn: number; + seatCount: number; + smallBlind: number; +}) { + const hasValidPositiveAmounts = [ + args.smallBlind, + args.bigBlind, + args.maxBuyIn, + ].every((amount) => isValidChipAmount(amount)); + const hasValidAmounts = + hasValidPositiveAmounts && isValidChipAmount(args.minBuyIn, true); + if (!hasValidAmounts) { + throw new Error( + "Live poker amounts must be safe, non-negative 0.01 increments" + ); + } + if (args.bigBlind < args.smallBlind) { + throw new Error("Live poker requires valid blinds"); + } + if ( + args.seatCount < 2 || + args.seatCount > 9 || + !Number.isInteger(args.seatCount) + ) { + throw new Error("Live poker tables must have 2 to 9 seats"); + } + if (args.maxBuyIn < args.minBuyIn) { + throw new Error("Max buy-in must be at least the min buy-in"); + } +} + +async function getRequestDetails( + ctx: QueryCtx, + request: { + playerId: Id<"players">; + respondedById?: Id<"users">; + userId: Id<"users">; + } +) { + const player = await ctx.db.get(request.playerId); + const user = await ctx.db.get(request.userId); + const respondedBy = request.respondedById + ? await ctx.db.get(request.respondedById) + : null; + + return { + player: { + id: request.playerId, + name: player?.name ?? user?.name ?? user?.email ?? "Player", + }, + respondedBy: respondedBy + ? { + id: request.respondedById, + name: respondedBy.name ?? respondedBy.email ?? "Host", + } + : null, + }; +} + +async function handleExistingPendingBuyInRequest( + ctx: MutationCtx, + args: { + amount: number; + pendingRequestId?: Id<"livePokerBuyInRequests">; + seatIndex?: number; + type: "ADD_ON" | "INITIAL"; + } +) { + if (!args.pendingRequestId) { + return null; + } + + await ctx.db.patch(args.pendingRequestId, { + amount: args.amount, + requestedAt: Date.now(), + seatIndex: args.type === "INITIAL" ? args.seatIndex : undefined, + }); + + return args.pendingRequestId; +} + +export const createLivePokerTableInternal = internalMutation({ + args: { + title: v.string(), + createdById: v.id("users"), + smallBlind: v.number(), + bigBlind: v.number(), + minBuyIn: v.number(), + maxBuyIn: v.number(), + seatCount: v.number(), + }, + handler: async (ctx, args) => { + validateTableSettings(args); + + const now = Date.now(); + const globallyOpenTables = await ctx.db + .query("livePokerTables") + .withIndex("by_status", (q) => q.eq("status", "OPEN")) + .take(MAX_OPEN_LIVE_POKER_TABLES_GLOBAL); + if (globallyOpenTables.length >= MAX_OPEN_LIVE_POKER_TABLES_GLOBAL) { + throw new Error( + `Live poker is limited to ${MAX_OPEN_LIVE_POKER_TABLES_GLOBAL} open tables across this deployment` + ); + } + const openTables = await ctx.db + .query("livePokerTables") + .withIndex("by_createdById_status", (q) => + q.eq("createdById", args.createdById).eq("status", "OPEN") + ) + .take(MAX_OPEN_LIVE_POKER_TABLES_PER_USER); + if (openTables.length >= MAX_OPEN_LIVE_POKER_TABLES_PER_USER) { + throw new Error( + `Close an existing table before creating more than ${MAX_OPEN_LIVE_POKER_TABLES_PER_USER} open tables` + ); + } + const recentlyCreatedTables = await ctx.db + .query("livePokerTables") + .withIndex("by_createdById_createdAt", (q) => + q + .eq("createdById", args.createdById) + .gte("createdAt", now - 24 * 60 * 60 * 1000) + ) + .take(MAX_LIVE_POKER_TABLES_CREATED_PER_DAY); + if ( + recentlyCreatedTables.length >= MAX_LIVE_POKER_TABLES_CREATED_PER_DAY + ) { + throw new Error( + `Live poker table creation is limited to ${MAX_LIVE_POKER_TABLES_CREATED_PER_DAY} per day` + ); + } + + return await ctx.db.insert("livePokerTables", { + title: args.title.trim() || "Untitled Table", + status: "OPEN", + liveStatus: "WAITING", + smallBlind: args.smallBlind, + bigBlind: args.bigBlind, + minBuyIn: args.minBuyIn, + maxBuyIn: args.maxBuyIn, + seatCount: args.seatCount, + createdById: args.createdById, + createdAt: now, + updatedAt: now, + }); + }, +}); + +export const reserveLivePokerAccessInternal = internalMutation({ + args: { + tableId: v.id("livePokerTables"), + userId: v.id("users"), + }, + handler: async (ctx, args) => { + const now = Date.now(); + const table = await ctx.db.get(args.tableId); + if (!table || table.status !== "OPEN") { + throw new Error("Live poker table is not open"); + } + + const existing = await ctx.db + .query("livePokerAccessGrants") + .withIndex("by_userId_tableId", (q) => + q.eq("userId", args.userId).eq("tableId", args.tableId) + ) + .first(); + if (existing && existing.expiresAt > now) { + if (existing.expiresAt - now <= LIVE_POKER_ACCESS_GRANT_RENEWAL_MS) { + await ctx.db.patch(existing._id, { + expiresAt: now + LIVE_POKER_ACCESS_GRANT_MS, + updatedAt: now, + }); + } + return; + } + + const activeGrants = await ctx.db + .query("livePokerAccessGrants") + .withIndex("by_userId_expiresAt", (q) => + q.eq("userId", args.userId).gt("expiresAt", now) + ) + .take(MAX_ACTIVE_LIVE_POKER_TABLES_PER_USER); + if (activeGrants.length >= MAX_ACTIVE_LIVE_POKER_TABLES_PER_USER) { + throw new Error( + `Live poker access is limited to ${MAX_ACTIVE_LIVE_POKER_TABLES_PER_USER} active tables per user` + ); + } + + const expiredGrants = await ctx.db + .query("livePokerAccessGrants") + .withIndex("by_userId_expiresAt", (q) => + q.eq("userId", args.userId).lte("expiresAt", now) + ) + .take(10); + for (const grant of expiredGrants) { + if (grant._id !== existing?._id) { + await ctx.db.delete(grant._id); + } + } + + if (existing) { + await ctx.db.patch(existing._id, { + expiresAt: now + LIVE_POKER_ACCESS_GRANT_MS, + updatedAt: now, + }); + return; + } + await ctx.db.insert("livePokerAccessGrants", { + expiresAt: now + LIVE_POKER_ACCESS_GRANT_MS, + tableId: args.tableId, + updatedAt: now, + userId: args.userId, + }); + }, +}); + +export const listLivePokerTables = query({ + args: { + currentUserId: v.optional(v.id("users")), + includeClosed: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const tables = args.includeClosed + ? await ctx.db.query("livePokerTables").collect() + : await ctx.db + .query("livePokerTables") + .withIndex("by_status", (q) => q.eq("status", "OPEN")) + .collect(); + + const rows = await Promise.all( + tables.map(async (table) => { + const createdBy = await ctx.db.get(table.createdById); + const player = await ctx.db + .query("players") + .withIndex("by_userId", (q) => q.eq("userId", table.createdById)) + .first(); + const tablePlayers = await ctx.db + .query("livePokerTablePlayers") + .withIndex("by_tableId", (q) => q.eq("tableId", table._id)) + .collect(); + const isCurrentUserSettled = args.currentUserId + ? tablePlayers.some( + (tablePlayer) => tablePlayer.userId === args.currentUserId + ) + : false; + + return { + ...table, + id: table._id, + createdBy: { + id: table.createdById, + name: player?.name ?? createdBy?.name ?? createdBy?.email ?? "Host", + }, + isCreatedByCurrentUser: table.createdById === args.currentUserId, + isCurrentUserSettled, + settledPlayerCount: tablePlayers.length, + }; + }) + ); + + return rows.sort((a, b) => b.updatedAt - a.updatedAt); + }, +}); + +export const getLivePokerTable = query({ + args: { tableId: v.id("livePokerTables") }, + handler: async (ctx, args) => { + const table = await ctx.db.get(args.tableId); + if (!table) { + return null; + } + + const createdBy = await ctx.db.get(table.createdById); + const player = await ctx.db + .query("players") + .withIndex("by_userId", (q) => q.eq("userId", table.createdById)) + .first(); + + return { + ...table, + id: table._id, + createdBy: { + id: table.createdById, + name: player?.name ?? createdBy?.name ?? createdBy?.email ?? "Host", + }, + }; + }, +}); + +export const getLivePokerAccess = query({ + args: { tableId: v.id("livePokerTables"), userId: v.id("users") }, + handler: async (ctx, args) => { + const table = await ctx.db.get(args.tableId); + if (!table || table.status !== "OPEN") { + return null; + } + + const player = await ctx.db + .query("players") + .withIndex("by_userId", (q) => q.eq("userId", args.userId)) + .first(); + + return { + table: { + id: table._id, + title: table.title, + createdById: table.createdById, + liveStatus: table.liveStatus, + smallBlind: table.smallBlind, + bigBlind: table.bigBlind, + minBuyIn: table.minBuyIn, + maxBuyIn: table.maxBuyIn, + seatCount: table.seatCount, + }, + player: player ? { id: player._id, name: player.name } : null, + }; + }, +}); + +export const createLivePokerBuyInRequestInternal = internalMutation({ + args: { + tableId: v.id("livePokerTables"), + userId: v.id("users"), + playerId: v.id("players"), + seatIndex: v.optional(v.number()), + amount: v.number(), + type: buyInRequestTypeValidator, + }, + handler: async (ctx, args) => { + const table = await ctx.db.get(args.tableId); + if (!table || table.status !== "OPEN") { + throw new Error("Table not found"); + } + + const player = await ctx.db.get(args.playerId); + if (!player || player.userId !== args.userId) { + throw new Error("Player profile does not match this user"); + } + + if (!Number.isFinite(args.amount) || args.amount <= 0) { + throw new Error("Buy-in amount must be a positive finite number"); + } + + if (args.type === "INITIAL") { + if ( + args.seatIndex === undefined || + args.seatIndex < 0 || + args.seatIndex >= table.seatCount || + !Number.isInteger(args.seatIndex) + ) { + throw new Error("Choose a valid seat"); + } + if (args.amount < table.minBuyIn || args.amount > table.maxBuyIn) { + throw new Error( + `Buy-in must be between ${table.minBuyIn} and ${table.maxBuyIn}` + ); + } + } + + if (args.type === "ADD_ON" && args.amount > table.maxBuyIn) { + throw new Error(`Add-on cannot exceed ${table.maxBuyIn}`); + } + + const pendingRequests = await ctx.db + .query("livePokerBuyInRequests") + .withIndex("by_tableId_userId", (q) => + q.eq("tableId", args.tableId).eq("userId", args.userId) + ) + .filter((q) => + q.and( + q.eq(q.field("type"), args.type), + q.eq(q.field("status"), "PENDING") + ) + ) + .collect(); + + const existingRequestId = await handleExistingPendingBuyInRequest(ctx, { + amount: args.amount, + pendingRequestId: pendingRequests[0]?._id, + seatIndex: args.seatIndex, + type: args.type, + }); + if (existingRequestId) { + return existingRequestId; + } + + const now = Date.now(); + + return await ctx.db.insert("livePokerBuyInRequests", { + tableId: args.tableId, + userId: args.userId, + playerId: args.playerId, + seatIndex: args.type === "INITIAL" ? args.seatIndex : undefined, + amount: args.amount, + type: args.type, + status: "PENDING", + requestedAt: now, + }); + }, +}); + +export const getPendingLivePokerBuyInRequests = internalQuery({ + args: { + tableId: v.id("livePokerTables"), + userId: v.id("users"), + }, + handler: async (ctx, args) => { + const table = await ctx.db.get(args.tableId); + if (!table || table.createdById !== args.userId) { + return []; + } + + const requests = await ctx.db + .query("livePokerBuyInRequests") + .withIndex("by_tableId_status", (q) => + q.eq("tableId", args.tableId).eq("status", "PENDING") + ) + .collect(); + + const rows = await Promise.all( + requests.map(async (request) => ({ + ...request, + id: request._id, + ...(await getRequestDetails(ctx, request)), + })) + ); + + return rows.sort((a, b) => a.requestedAt - b.requestedAt); + }, +}); + +export const getUserLivePokerBuyInRequests = internalQuery({ + args: { + tableId: v.id("livePokerTables"), + userId: v.id("users"), + }, + handler: async (ctx, args) => { + const requests = await ctx.db + .query("livePokerBuyInRequests") + .withIndex("by_tableId_userId", (q) => + q.eq("tableId", args.tableId).eq("userId", args.userId) + ) + .collect(); + + const visibleRequests = requests.filter((request) => + ["PENDING", "APPROVED", "REJECTED"].includes(request.status) + ); + const rows = await Promise.all( + visibleRequests.map(async (request) => ({ + ...request, + id: request._id, + ...(await getRequestDetails(ctx, request)), + })) + ); + + return rows.sort((a, b) => b.requestedAt - a.requestedAt); + }, +}); + +export const getLivePokerBuyInRequestForClaim = internalQuery({ + args: { + requestId: v.id("livePokerBuyInRequests"), + userId: v.id("users"), + }, + handler: async (ctx, args) => { + const request = await ctx.db.get(args.requestId); + if ( + !request || + request.userId !== args.userId || + !["APPROVED", "CLAIMED"].includes(request.status) + ) { + return null; + } + + const table = await ctx.db.get(request.tableId); + if (!table || table.status !== "OPEN") { + return null; + } + + return { + ...request, + id: request._id, + table: { + id: table._id, + bigBlind: table.bigBlind, + createdById: table.createdById, + maxBuyIn: table.maxBuyIn, + minBuyIn: table.minBuyIn, + seatCount: table.seatCount, + smallBlind: table.smallBlind, + }, + }; + }, +}); + +export const getLivePokerBuyInRequestForApproval = internalQuery({ + args: { + requestId: v.id("livePokerBuyInRequests"), + userId: v.id("users"), + }, + handler: async (ctx, args) => { + const request = await ctx.db.get(args.requestId); + if (!(request && ["PENDING", "CLAIMED"].includes(request.status))) { + return null; + } + + const table = await ctx.db.get(request.tableId); + if ( + !table || + table.status !== "OPEN" || + table.createdById !== args.userId + ) { + return null; + } + + const player = await ctx.db.get(request.playerId); + const user = await ctx.db.get(request.userId); + + return { + ...request, + id: request._id, + playerName: player?.name ?? user?.name ?? user?.email ?? "Player", + table: { + id: table._id, + bigBlind: table.bigBlind, + createdById: table.createdById, + maxBuyIn: table.maxBuyIn, + minBuyIn: table.minBuyIn, + seatCount: table.seatCount, + smallBlind: table.smallBlind, + }, + }; + }, +}); + +export const respondToLivePokerBuyInRequestInternal = internalMutation({ + args: { + requestId: v.id("livePokerBuyInRequests"), + userId: v.id("users"), + status: v.literal("REJECTED"), + }, + handler: async (ctx, args) => { + const request = await ctx.db.get(args.requestId); + if (!request) { + throw new Error("Request not found"); + } + + const table = await ctx.db.get(request.tableId); + if (!table) { + throw new Error("Table not found"); + } + if (table.createdById !== args.userId) { + throw new Error("Only the table creator can respond to buy-ins"); + } + if (request.status !== "PENDING") { + throw new Error("Request is not pending"); + } + + await ctx.db.patch(args.requestId, { + status: args.status, + respondedAt: Date.now(), + respondedById: args.userId, + }); + + return await ctx.db.get(args.requestId); + }, +}); + +export const markLivePokerBuyInRequestClaimedInternal = internalMutation({ + args: { + requestId: v.id("livePokerBuyInRequests"), + userId: v.id("users"), + }, + handler: async (ctx, args) => { + const request = await ctx.db.get(args.requestId); + if (!request) { + throw new Error("Request not found"); + } + if (request.userId !== args.userId) { + throw new Error("Only the requester can claim this buy-in"); + } + if (request.status === "CLAIMED") { + return request; + } + if (request.status !== "APPROVED") { + throw new Error("Request is not approved"); + } + + await ctx.db.patch(args.requestId, { + status: "CLAIMED", + claimedAt: Date.now(), + claimedById: args.userId, + }); + + return await ctx.db.get(args.requestId); + }, +}); + +export const approveAndMarkLivePokerBuyInRequestClaimedInternal = + internalMutation({ + args: { + requestId: v.id("livePokerBuyInRequests"), + userId: v.id("users"), + }, + handler: async (ctx, args) => { + const request = await ctx.db.get(args.requestId); + if (!request) { + throw new Error("Request not found"); + } + + const table = await ctx.db.get(request.tableId); + if (!table) { + throw new Error("Table not found"); + } + if (table.createdById !== args.userId) { + throw new Error("Only the table creator can approve buy-ins"); + } + if (request.status === "CLAIMED") { + return request; + } + if (request.status !== "PENDING") { + throw new Error("Request is not pending"); + } + + const now = Date.now(); + await ctx.db.patch(args.requestId, { + status: "CLAIMED", + respondedAt: now, + respondedById: args.userId, + claimedAt: now, + claimedById: request.userId, + }); + + return await ctx.db.get(args.requestId); + }, + }); + +export const updateLivePokerStatusInternal = internalMutation({ + args: { + tableId: v.id("livePokerTables"), + liveStatus: v.union( + v.literal("WAITING"), + v.literal("PLAYING"), + v.literal("PAUSED"), + v.literal("CLOSED") + ), + }, + handler: async (ctx, args) => { + const updates = + args.liveStatus === "CLOSED" + ? { + liveStatus: args.liveStatus, + status: "CLOSED" as const, + updatedAt: Date.now(), + } + : { + liveStatus: args.liveStatus, + updatedAt: Date.now(), + }; + + await ctx.db.patch(args.tableId, updates); + return await ctx.db.get(args.tableId); + }, +}); + +export const deleteLivePokerTableInternal = internalMutation({ + args: { + tableId: v.id("livePokerTables"), + userId: v.id("users"), + }, + handler: async (ctx, args) => { + const table = await ctx.db.get(args.tableId); + if (!table) { + return; + } + + if (table.createdById !== args.userId) { + throw new Error("Only the table creator can delete this table"); + } + + // Preserve the ledger and hand history. The worker has already closed + // admissions and durably queued every seat settlement before this runs. + await ctx.db.patch(args.tableId, { + liveStatus: "CLOSED", + status: "CANCELLED", + updatedAt: Date.now(), + }); + }, +}); + +export const settlePlayerStackInternal = internalMutation({ + args: { + gameId: v.optional(v.id("games")), + tableId: v.optional(v.id("livePokerTables")), + playerId: v.id("players"), + userId: v.id("users"), + buyIn: v.number(), + cashOut: v.number(), + settledAt: v.optional(v.number()), + settlementId: v.optional(v.string()), + }, + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Game and live-table settlement paths share one atomic mutation, including event-based idempotency for out-of-order retries. + handler: async (ctx, args) => { + if ( + !(Number.isFinite(args.buyIn) && Number.isFinite(args.cashOut)) || + args.buyIn < 0 || + args.cashOut < 0 + ) { + throw new Error("Amounts must be finite and non-negative"); + } + + if (args.tableId) { + if (!args.settlementId) { + throw new Error("A settlementId is required for live poker tables"); + } + const settlementId = args.settlementId; + const tableId = args.tableId; + const table = await ctx.db.get(tableId); + if (!table) { + throw new Error("Table not found"); + } + + const existing = await ctx.db + .query("livePokerTablePlayers") + .withIndex("by_tableId_playerId", (q) => + q.eq("tableId", tableId).eq("playerId", args.playerId) + ) + .first(); + + const appliedSettlement = await ctx.db + .query("livePokerSettlementEvents") + .withIndex("by_settlementId", (q) => + q.eq("settlementId", settlementId) + ) + .first(); + if (appliedSettlement) { + return existing; + } + + if (existing?.lastSettlementId === settlementId) { + await ctx.db.insert("livePokerSettlementEvents", { + appliedAt: Date.now(), + playerId: args.playerId, + settledAt: args.settledAt, + settlementId, + tableId, + userId: args.userId, + }); + return existing; + } + + const settlementMetadata = { + lastSettledAt: + args.settledAt === undefined + ? existing?.lastSettledAt + : Math.max(existing?.lastSettledAt ?? 0, args.settledAt), + lastSettlementId: settlementId, + updatedAt: Date.now(), + }; + let tablePlayerId: Id<"livePokerTablePlayers">; + + if (existing) { + const buyIn = existing.buyIn + args.buyIn; + const cashOut = (existing.cashOut ?? 0) + args.cashOut; + await ctx.db.patch(existing._id, { + ...settlementMetadata, + buyIn, + cashOut, + profit: cashOut - buyIn, + }); + tablePlayerId = existing._id; + } else { + tablePlayerId = await ctx.db.insert("livePokerTablePlayers", { + tableId, + playerId: args.playerId, + userId: args.userId, + ...settlementMetadata, + buyIn: args.buyIn, + cashOut: args.cashOut, + profit: args.cashOut - args.buyIn, + }); + } + + await ctx.db.insert("livePokerSettlementEvents", { + appliedAt: Date.now(), + playerId: args.playerId, + settledAt: args.settledAt, + settlementId, + tableId, + userId: args.userId, + }); + return await ctx.db.get(tablePlayerId); + } + + if (!args.gameId) { + throw new Error("A gameId or tableId is required"); + } + + const gameId = args.gameId; + const game = await ctx.db.get(gameId); + if (!game) { + throw new Error("Game not found"); + } + + let gamePlayer = await ctx.db + .query("gamePlayers") + .withIndex("by_gameId_playerId", (q) => + q.eq("gameId", gameId).eq("playerId", args.playerId) + ) + .first(); + + if (!gamePlayer) { + const gamePlayerId = await ctx.db.insert("gamePlayers", { + gameId, + playerId: args.playerId, + buyIn: 0, + }); + gamePlayer = await ctx.db.get(gamePlayerId); + } + + if (!gamePlayer) { + throw new Error("Unable to create player record for game"); + } + + await ctx.db.patch(gamePlayer._id, { + buyIn: args.buyIn, + cashOut: args.cashOut, + profit: args.cashOut - args.buyIn, + }); + + return await ctx.db.get(gamePlayer._id); + }, +}); + +function assertLivePokerServerSecret(secret: string) { + const expected = process.env.LIVE_POKER_CONVEX_SECRET; + if (!expected || secret !== expected) { + throw new Error("Unauthorized live poker server operation"); + } +} + +export const serverGetLivePokerBuyInRequestForClaim = action({ + args: { + requestId: v.id("livePokerBuyInRequests"), + secret: v.string(), + userId: v.id("users"), + }, + handler: async ( + ctx, + { requestId, secret, userId } + ): Promise => { + assertLivePokerServerSecret(secret); + return await ctx.runQuery( + internal.live_poker.getLivePokerBuyInRequestForClaim, + { requestId, userId } + ); + }, +}); + +export const serverGetLivePokerBuyInRequestForApproval = action({ + args: { + requestId: v.id("livePokerBuyInRequests"), + secret: v.string(), + userId: v.id("users"), + }, + handler: async ( + ctx, + { requestId, secret, userId } + ): Promise => { + assertLivePokerServerSecret(secret); + return await ctx.runQuery( + internal.live_poker.getLivePokerBuyInRequestForApproval, + { requestId, userId } + ); + }, +}); + +export const serverGetLivePokerBuyInRequests = action({ + args: { + secret: v.string(), + tableId: v.id("livePokerTables"), + userId: v.id("users"), + }, + handler: async ( + ctx, + { secret, tableId, userId } + ): Promise<{ + pending: LivePokerBuyInRequestRow[]; + user: LivePokerBuyInRequestRow[]; + }> => { + assertLivePokerServerSecret(secret); + const pending: LivePokerBuyInRequestRow[] = await ctx.runQuery( + internal.live_poker.getPendingLivePokerBuyInRequests, + { tableId, userId } + ); + const user: LivePokerBuyInRequestRow[] = await ctx.runQuery( + internal.live_poker.getUserLivePokerBuyInRequests, + { tableId, userId } + ); + return { pending, user }; + }, +}); + +export const serverReserveLivePokerAccess = action({ + args: { + secret: v.string(), + tableId: v.id("livePokerTables"), + userId: v.id("users"), + }, + handler: async (ctx, { secret, ...args }): Promise => { + assertLivePokerServerSecret(secret); + await ctx.runMutation( + internal.live_poker.reserveLivePokerAccessInternal, + args + ); + }, +}); + +export const serverCreateLivePokerTable = action({ + args: { + bigBlind: v.number(), + createdById: v.id("users"), + maxBuyIn: v.number(), + minBuyIn: v.number(), + seatCount: v.number(), + secret: v.string(), + smallBlind: v.number(), + title: v.string(), + }, + handler: async (ctx, { secret, ...args }): Promise> => { + assertLivePokerServerSecret(secret); + return await ctx.runMutation( + internal.live_poker.createLivePokerTableInternal, + args + ); + }, +}); + +export const serverCreateLivePokerBuyInRequest = action({ + args: { + amount: v.number(), + playerId: v.id("players"), + seatIndex: v.optional(v.number()), + secret: v.string(), + tableId: v.id("livePokerTables"), + type: buyInRequestTypeValidator, + userId: v.id("users"), + }, + handler: async ( + ctx, + { secret, ...args } + ): Promise> => { + assertLivePokerServerSecret(secret); + return await ctx.runMutation( + internal.live_poker.createLivePokerBuyInRequestInternal, + args + ); + }, +}); + +export const serverRejectLivePokerBuyInRequest = action({ + args: { + requestId: v.id("livePokerBuyInRequests"), + secret: v.string(), + userId: v.id("users"), + }, + handler: async ( + ctx, + { secret, ...args } + ): Promise | null> => { + assertLivePokerServerSecret(secret); + return await ctx.runMutation( + internal.live_poker.respondToLivePokerBuyInRequestInternal, + { ...args, status: "REJECTED" } + ); + }, +}); + +export const serverMarkLivePokerBuyInRequestClaimed = action({ + args: { + requestId: v.id("livePokerBuyInRequests"), + secret: v.string(), + userId: v.id("users"), + }, + handler: async ( + ctx, + { secret, ...args } + ): Promise | null> => { + assertLivePokerServerSecret(secret); + return await ctx.runMutation( + internal.live_poker.markLivePokerBuyInRequestClaimedInternal, + args + ); + }, +}); + +export const serverApproveLivePokerBuyInRequest = action({ + args: { + requestId: v.id("livePokerBuyInRequests"), + secret: v.string(), + userId: v.id("users"), + }, + handler: async ( + ctx, + { secret, ...args } + ): Promise | null> => { + assertLivePokerServerSecret(secret); + return await ctx.runMutation( + internal.live_poker.approveAndMarkLivePokerBuyInRequestClaimedInternal, + args + ); + }, +}); + +export const serverDeleteLivePokerTable = action({ + args: { + secret: v.string(), + tableId: v.id("livePokerTables"), + userId: v.id("users"), + }, + handler: async (ctx, { secret, ...args }): Promise => { + assertLivePokerServerSecret(secret); + await ctx.runMutation( + internal.live_poker.deleteLivePokerTableInternal, + args + ); + }, +}); + +export const serverRecordCompletedHand = action({ + args: { + actionLog: v.array(v.string()), + bigBlind: v.number(), + communityCards: v.array(v.string()), + completedAt: v.number(), + dealerSeat: v.number(), + gameId: v.optional(v.id("games")), + handNumber: v.number(), + pot: v.number(), + secret: v.string(), + smallBlind: v.number(), + tableId: v.optional(v.id("livePokerTables")), + winners: v.array(winnerValidator), + }, + handler: async (ctx, { secret, ...args }): Promise> => { + assertLivePokerServerSecret(secret); + return await ctx.runMutation( + internal.live_poker.recordCompletedHandInternal, + args + ); + }, +}); + +export const serverSettlePlayerStack = action({ + args: { + buyIn: v.number(), + cashOut: v.number(), + gameId: v.optional(v.id("games")), + playerId: v.id("players"), + secret: v.string(), + settledAt: v.optional(v.number()), + settlementId: v.optional(v.string()), + tableId: v.optional(v.id("livePokerTables")), + userId: v.id("users"), + }, + handler: async (ctx, { secret, ...args }): Promise => { + assertLivePokerServerSecret(secret); + return await ctx.runMutation( + internal.live_poker.settlePlayerStackInternal, + args + ); + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index 6ff5521..a5a72a9 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -159,12 +159,24 @@ export default defineSchema({ v.literal("CANCELLED") ), gameType: v.optional(v.union(v.literal("cash"), v.literal("tournament"))), + livePokerEnabled: v.optional(v.boolean()), + liveStatus: v.optional( + v.union( + v.literal("WAITING"), + v.literal("PLAYING"), + v.literal("PAUSED"), + v.literal("CLOSED") + ) + ), + smallBlind: v.optional(v.number()), + bigBlind: v.optional(v.number()), + minBuyIn: v.optional(v.number()), + maxBuyIn: v.optional(v.number()), + seatCount: v.optional(v.number()), groupId: v.id("groups"), createdById: v.id("users"), importSource: v.optional(v.literal("POKER_NOW")), importSourceId: v.optional(v.string()), - smallBlind: v.optional(v.number()), - bigBlind: v.optional(v.number()), }) .index("by_groupId", ["groupId"]) .index("by_groupId_status", ["groupId", "status"]) @@ -201,4 +213,116 @@ export default defineSchema({ .index("by_playerId", ["playerId"]) .index("by_createdById", ["createdById"]) .index("by_gameId_status", ["gameId", "status"]), + + livePokerAccessGrants: defineTable({ + expiresAt: v.number(), + tableId: v.id("livePokerTables"), + updatedAt: v.number(), + userId: v.id("users"), + }) + .index("by_userId_expiresAt", ["userId", "expiresAt"]) + .index("by_userId_tableId", ["userId", "tableId"]), + + livePokerTables: defineTable({ + title: v.string(), + status: v.union( + v.literal("OPEN"), + v.literal("CLOSED"), + v.literal("CANCELLED") + ), + liveStatus: v.union( + v.literal("WAITING"), + v.literal("PLAYING"), + v.literal("PAUSED"), + v.literal("CLOSED") + ), + smallBlind: v.number(), + bigBlind: v.number(), + minBuyIn: v.number(), + maxBuyIn: v.number(), + seatCount: v.number(), + createdById: v.id("users"), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_status", ["status"]) + .index("by_createdById", ["createdById"]) + .index("by_createdById_status", ["createdById", "status"]) + .index("by_createdById_createdAt", ["createdById", "createdAt"]), + + livePokerTablePlayers: defineTable({ + tableId: v.id("livePokerTables"), + playerId: v.id("players"), + userId: v.id("users"), + buyIn: v.number(), + cashOut: v.optional(v.number()), + profit: v.optional(v.number()), + lastSettledAt: v.optional(v.number()), + lastSettlementId: v.optional(v.string()), + updatedAt: v.number(), + }) + .index("by_tableId", ["tableId"]) + .index("by_playerId", ["playerId"]) + .index("by_tableId_playerId", ["tableId", "playerId"]), + + livePokerSettlementEvents: defineTable({ + appliedAt: v.number(), + playerId: v.id("players"), + settledAt: v.optional(v.number()), + settlementId: v.string(), + tableId: v.id("livePokerTables"), + userId: v.id("users"), + }) + .index("by_settlementId", ["settlementId"]) + .index("by_tableId", ["tableId"]), + + livePokerBuyInRequests: defineTable({ + tableId: v.id("livePokerTables"), + userId: v.id("users"), + playerId: v.id("players"), + seatIndex: v.optional(v.number()), + amount: v.number(), + type: v.union(v.literal("INITIAL"), v.literal("ADD_ON")), + status: v.union( + v.literal("PENDING"), + v.literal("APPROVED"), + v.literal("REJECTED"), + v.literal("CLAIMED") + ), + requestedAt: v.number(), + respondedAt: v.optional(v.number()), + respondedById: v.optional(v.id("users")), + claimedAt: v.optional(v.number()), + claimedById: v.optional(v.id("users")), + }) + .index("by_tableId", ["tableId"]) + .index("by_tableId_status", ["tableId", "status"]) + .index("by_tableId_userId", ["tableId", "userId"]) + .index("by_userId_status", ["userId", "status"]), + + livePokerHands: defineTable({ + gameId: v.optional(v.id("games")), + tableId: v.optional(v.id("livePokerTables")), + handNumber: v.number(), + dealerSeat: v.number(), + smallBlind: v.number(), + bigBlind: v.number(), + communityCards: v.array(v.string()), + winners: v.array( + v.object({ + playerId: v.id("players"), + userId: v.id("users"), + seatIndex: v.number(), + amount: v.number(), + description: v.optional(v.string()), + }) + ), + pot: v.number(), + actionLog: v.array(v.string()), + completedAt: v.number(), + }) + .index("by_gameId", ["gameId"]) + .index("by_gameId_handNumber", ["gameId", "handNumber"]) + .index("by_tableId", ["tableId"]) + .index("by_tableId_handNumber", ["tableId", "handNumber"]), }); diff --git a/env.example b/env.example index 29fd6d5..3b21e8b 100644 --- a/env.example +++ b/env.example @@ -1,16 +1,38 @@ -# Shared local defaults +# Shared secrets (configure the same values on every platform that uses them) +LIVE_POKER_JWT_SECRET=replace-with-a-random-secret +# Vercel-to-Worker control calls only. +LIVE_POKER_CONTROL_SECRET=replace-with-a-separate-random-secret +# Worker-to-Vercel callbacks only. +LIVE_POKER_WEBHOOK_SECRET=replace-with-another-random-secret +# Shared by Vercel and the Convex deployment only. +LIVE_POKER_CONVEX_SECRET=replace-with-a-separate-random-secret + +# Vercel / local Next.js NEXTAUTH_URL=http://localhost:3000 AUTH_TRUST_HOST=true - -# Auth / NextAuth NEXTAUTH_SECRET=replace-with-a-random-secret GOOGLE_CLIENT_ID=replace-with-google-client-id GOOGLE_CLIENT_SECRET=replace-with-google-client-secret - -# Convex local development -CONVEX_DEPLOYMENT=replace-with-convex-deployment-name NEXT_PUBLIC_CONVEX_URL=https://replace-with-your-convex-url.convex.cloud +# Browser WebSocket endpoint. Use wss:// for Vercel production. +NEXT_PUBLIC_LIVE_POKER_WORKER_URL=ws://localhost:8787 +# Server-to-server endpoint. Use https:// for Vercel production. +LIVE_POKER_WORKER_URL=http://localhost:8787 -# Optional production/preview deploy key for Convex deploys +# Convex CLI / production deployment +CONVEX_DEPLOYMENT=replace-with-convex-deployment-name CONVEX_DEPLOY_KEY=replace-with-convex-deploy-key +# Cloudflare Worker (copy runtime names to .dev.vars for local development; +# use Wrangler or the Cloudflare dashboard in production). Deployment is blocked +# unless these identify a dedicated Workers Free account and acknowledge its hard limits. +CLOUDFLARE_ACCOUNT_ID=replace-with-dedicated-workers-free-account-id +CLOUDFLARE_ZERO_COST_ACCOUNT_ID=replace-with-the-same-account-id +CLOUDFLARE_ZERO_COST_ACK=workers-free-hard-limits +# Account-scoped API token with only Account Billing Read permission. +CLOUDFLARE_BILLING_READ_TOKEN=replace-with-billing-read-token +LIVE_POKER_ALLOWED_ORIGINS=http://localhost:3000 +LIVE_POKER_WEBHOOK_URL=http://localhost:3000/api/live-poker/record-hand +LIVE_POKER_SETTLEMENT_URL=http://localhost:3000/api/live-poker/settle-player +# Regular action clock; each player has one consumable 10-second time bank. +LIVE_POKER_TURN_TIMEOUT_SECONDS=20 diff --git a/next.config.ts b/next.config.ts index 4b77356..cbc76a3 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + turbopack: { + root: process.cwd(), + }, typescript: { ignoreBuildErrors: true, }, diff --git a/package.json b/package.json index 7df8433..26a02c9 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,11 @@ "private": true, "scripts": { "dev": "next dev", + "dev:all": "concurrently \"pnpm dev\" \"pnpm dev:convex\" \"pnpm dev:worker\"", "dev:convex": "convex dev", + "dev:worker": "wrangler dev", + "deploy:convex": "convex deploy", + "deploy:worker": "node scripts/verify-cloudflare-zero-cost.mjs && wrangler deploy --keep-vars", "build": "pnpm run type-check && next build", "start": "next start", "lint": "biome check", @@ -12,9 +16,15 @@ "format": "biome format --write", "type-check": "tsgo --project tsconfig.json --noEmit --diagnostics", "test": "playwright test", + "test:live-poker": "playwright test -c playwright.live-poker.config.ts", "test:ui": "playwright test --ui" }, + "engines": { + "node": ">=22" + }, + "packageManager": "pnpm@10.32.1", "dependencies": { + "@heruka_urgyen/react-playing-cards": "^0.5.0", "@hookform/resolvers": "^5.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", @@ -25,11 +35,14 @@ "@typescript/native-preview": "^7.0.0-dev.20260116.1", "@vercel/analytics": "^1.6.1", "@vercel/speed-insights": "^1.3.1", + "@xpressit/winning-poker-hand-rank": "^0.2.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "concurrently": "^9.2.1", "convex": "^1.31.5", "date-fns": "^4.1.0", + "jose": "^6.2.3", "lucide-react": "^0.562.0", "next": "^16.1.3", "next-auth": "^5.0.0-beta.30", @@ -44,6 +57,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.3.11", + "@cloudflare/workers-types": "^4.20260506.1", "@playwright/test": "^1.50.0", "@types/node": "^20.14.0", "@types/react": "^19.2.8", @@ -52,6 +66,7 @@ "postcss": "^8.4.0", "tailwindcss": "^3.4.0", "typescript": "^5.5.0", - "ultracite": "^7.0.11" + "ultracite": "^7.0.11", + "wrangler": "4.88.0" } } diff --git a/playwright.live-poker.config.ts b/playwright.live-poker.config.ts new file mode 100644 index 0000000..168729e --- /dev/null +++ b/playwright.live-poker.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + fullyParallel: false, + reporter: "list", + testDir: "./tests", + testMatch: [ + "live-poker-engine.spec.ts", + "live-poker-resilience.spec.ts", + "live-poker-safety.spec.ts", + ], + workers: 1, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24c5b28..c9c91ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@heruka_urgyen/react-playing-cards': + specifier: ^0.5.0 + version: 0.5.0(react@19.2.3) '@hookform/resolvers': specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.71.1(react@19.2.3)) @@ -38,6 +41,9 @@ importers: '@vercel/speed-insights': specifier: ^1.3.1 version: 1.3.1(next@16.1.4(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + '@xpressit/winning-poker-hand-rank': + specifier: ^0.2.3 + version: 0.2.3 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -47,12 +53,18 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + concurrently: + specifier: ^9.2.1 + version: 9.2.4 convex: specifier: ^1.31.5 version: 1.31.6(react@19.2.3) date-fns: specifier: ^4.1.0 version: 4.1.0 + jose: + specifier: ^6.2.3 + version: 6.2.8 lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.3) @@ -90,6 +102,9 @@ importers: '@biomejs/biome': specifier: ^2.3.11 version: 2.3.12 + '@cloudflare/workers-types': + specifier: ^4.20260506.1 + version: 4.20260702.1 '@playwright/test': specifier: ^1.50.0 version: 1.58.0 @@ -117,6 +132,9 @@ importers: ultracite: specifier: ^7.0.11 version: 7.0.12(typescript@5.9.3) + wrangler: + specifier: 4.88.0 + version: 4.88.0(@cloudflare/workers-types@4.20260702.1) packages: @@ -160,24 +178,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.3.12': resolution: {integrity: sha512-nbOsuQROa3DLla5vvsTZg+T5WVPGi9/vYxETm9BOuLHBJN3oWQIg3MIkE2OfL18df1ZtNkqXkH6Yg9mdTPem7A==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.3.12': resolution: {integrity: sha512-kVGWtupRRsOjvw47YFkk5mLiAdpCPMWBo1jOwAzh+juDpUb2sWarIp+iq+CPL1Wt0LLZnYtP7hH5kD6fskcxmg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.3.12': resolution: {integrity: sha512-CQtqrJ+qEEI8tgRSTjjzk6wJAwfH3wQlkIGsM5dlecfRZaoT+XCms/mf7G4kWNexrke6mnkRzNy6w8ebV177ow==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.3.12': resolution: {integrity: sha512-Re4I7UnOoyE4kHMqpgtG6UvSBGBbbtvsOvBROgCCoH7EgANN6plSQhvo2W7OCITvTp7gD6oZOyZy72lUdXjqZg==} @@ -197,6 +219,56 @@ packages: '@clack/prompts@0.11.0': resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260504.1': + resolution: {integrity: sha512-IOMjYoftNRXabFt+QzY2Bo2mR2TNl8xsGvE0HnQ+K0S2c61VOUGUkr9gpJjnwrJ65yA9Qed4xfg0RRqXHO+nfA==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260504.1': + resolution: {integrity: sha512-7iMXxIU0N5KklZpQm2kuwTm0XtrpHXNqhejJyGquky8gSTnm31zBdutjMekH8VRr6ckbvZIl6lvqXzXdfOEojg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260504.1': + resolution: {integrity: sha512-YLB0EH5FQV++oWlalFgPF3p2Bp3dn/D6RWNMw0ukEC8gKnNX6o61A+dlFUl8hRD35ja1zKRxGFUojs4U2+MoJA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260504.1': + resolution: {integrity: sha512-FAh/82jDXDArfn9xDih6f/IJfF2SHXBb4nFeQAyHyvXrn18zM6Q3yl2Vj0U7LybbNbmu7TNGghwaM2NoSQS+0A==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260504.1': + resolution: {integrity: sha512-QUg/B3dfrK/KHHHhiJzdkLkTg5mG7lA3t8iplbBoUa3XKCLOHOOXhbU4WSYlLqg8YnsQ6XLZ1HVA99fmZhJh7A==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@4.20260702.1': + resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} @@ -206,156 +278,312 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.27.0': resolution: {integrity: sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.27.0': resolution: {integrity: sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.27.0': resolution: {integrity: sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.27.0': resolution: {integrity: sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.27.0': resolution: {integrity: sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.27.0': resolution: {integrity: sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.0': resolution: {integrity: sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.27.0': resolution: {integrity: sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.27.0': resolution: {integrity: sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.27.0': resolution: {integrity: sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.27.0': resolution: {integrity: sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.27.0': resolution: {integrity: sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.27.0': resolution: {integrity: sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.27.0': resolution: {integrity: sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.27.0': resolution: {integrity: sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.27.0': resolution: {integrity: sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.27.0': resolution: {integrity: sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.0': resolution: {integrity: sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.27.0': resolution: {integrity: sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.0': resolution: {integrity: sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.27.0': resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.27.0': resolution: {integrity: sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.27.0': resolution: {integrity: sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.27.0': resolution: {integrity: sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.27.0': resolution: {integrity: sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@floating-ui/core@1.7.3': resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} @@ -371,6 +599,11 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@heruka_urgyen/react-playing-cards@0.5.0': + resolution: {integrity: sha512-o/tSxBIIdGJQYCwARHMzddjY0hYdxxYZXiyCVzg8FuQNCrnVl8n1moKjzjkRVK7qMQGR3FCpromg2kpw85pQ+A==} + peerDependencies: + react: ^16.13.1 + '@hookform/resolvers@5.2.2': resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} peerDependencies: @@ -406,89 +639,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -534,6 +783,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@next/env@16.1.4': resolution: {integrity: sha512-gkrXnZyxPUy0Gg6SrPQPccbNVLSP3vmW8LU5dwEttEEC1RwDivk8w4O+sZIjFvPrSICXyhQDCG+y3VmjlJf+9A==} @@ -554,24 +806,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.4': resolution: {integrity: sha512-3Wm0zGYVCs6qDFAiSSDL+Z+r46EdtCv/2l+UlIdMbAq9hPJBvGu/rZOeuvCaIUjbArkmXac8HnTyQPJFzFWA0Q==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.4': resolution: {integrity: sha512-lWAYAezFinaJiD5Gv8HDidtsZdT3CDaCeqoPoJjeB57OqzvMajpIhlZFce5sCAH6VuX4mdkxCRqecCJFwfm2nQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.4': resolution: {integrity: sha512-fHaIpT7x4gA6VQbdEpYUXRGyge/YbRrkG6DXM60XiBqDM2g2NcrsQaIuj375egnGFkJow4RHacgBOEsHfGbiUw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.4': resolution: {integrity: sha512-MCrXxrTSE7jPN1NyXJr39E+aNFBrQZtO154LoCz7n99FuKqJDekgxipoodLNWdQP7/DZ5tKMc/efybx1l159hw==} @@ -605,6 +861,15 @@ packages: engines: {node: '>=18'} hasBin: true + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1151,6 +1416,13 @@ packages: react-redux: optional: true + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1294,6 +1566,17 @@ packages: vue-router: optional: true + '@xpressit/winning-poker-hand-rank@0.2.3': + resolution: {integrity: sha512-9wyNzcTF2ybuDw4gggbPDd12bWtYkCi3+kPPQrLIQ6C1jvDa1pS/ve+XxI0NKJ2j4c8DmtK0GWUpOTTQY6/maQ==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1323,6 +1606,9 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1339,6 +1625,10 @@ packages: caniuse-lite@1.0.30001766: resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -1352,6 +1642,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1362,6 +1656,13 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@14.0.2: resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} engines: {node: '>=20'} @@ -1370,6 +1671,11 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + concurrently@9.2.4: + resolution: {integrity: sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==} + engines: {node: '>=18'} + hasBin: true + convex@1.31.6: resolution: {integrity: sha512-9cIsOzepa3s9DURRF+fZHxbNuzLgilg9XGQCc45v0Xx4FemqeIezpPFSJF9WHC9ckk43TDUUXLecvLVt9djPkw==} engines: {node: '>=18.0.0', npm: '>=7.0.0'} @@ -1386,6 +1692,10 @@ packages: react: optional: true + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -1464,6 +1774,12 @@ packages: electron-to-chromium@1.5.278: resolution: {integrity: sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-toolkit@1.45.1: resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==} @@ -1472,6 +1788,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1515,6 +1836,10 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} @@ -1531,6 +1856,10 @@ packages: resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} engines: {node: 20 || >=22} + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -1557,6 +1886,10 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1569,12 +1902,16 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true - jose@6.1.3: - resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + jose@6.2.8: + resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -1599,6 +1936,11 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + miniflare@4.20260504.0: + resolution: {integrity: sha512-HeI/HLx+rbeo/UB4qb6NsNcFdUVD7xDzyCexZJTVtFMlfpfexUKEDmdeTRRpzeHrJseZFGua+v9JO1kfPublUw==} + engines: {node: '>=22.0.0'} + hasBin: true + minimatch@10.1.1: resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} engines: {node: 20 || >=22} @@ -1688,6 +2030,9 @@ packages: resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} engines: {node: 20 || >=22} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1870,6 +2215,10 @@ packages: redux@5.0.1: resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} @@ -1885,6 +2234,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1897,6 +2249,10 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shell-quote@1.9.0: + resolution: {integrity: sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==} + engines: {node: '>= 0.4'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -1904,6 +2260,14 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -1922,6 +2286,18 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -1961,6 +2337,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + trpc-cli@0.12.2: resolution: {integrity: sha512-kGNCiyOimGlfcZFImbWzFF2Nn3TMnenwUdyuckiN5SEaceJbIac7+Iau3WsVHjQpoNgugFruZMDOKf8GNQNtJw==} engines: {node: '>=18'} @@ -2009,6 +2389,13 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.24.8: + resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -2046,6 +2433,55 @@ packages: victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + workerd@1.20260504.1: + resolution: {integrity: sha512-AQTXSHbYNP9tLPgJNn0TmizyE4aDh2VuZZXlTAL0uu4fbCY436NAnQSJIzZbaFHM3DnAtVs9G8tkiJztSdYqDg==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.88.0: + resolution: {integrity: sha512-f470QwbeT/JM1S0duq+sLtkss7UBxIFDtYHgujv9tdQUyA/dLGDq51am0rqrsuFtCi97lTM1P5sqtt8xra1AlA==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20260504.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -2056,7 +2492,7 @@ snapshots: '@auth/core@0.41.0': dependencies: '@panva/hkdf': 1.2.1 - jose: 6.1.3 + jose: 6.2.8 oauth4webapi: 3.8.3 preact: 10.24.3 preact-render-to-string: 6.5.11(preact@10.24.3) @@ -2107,6 +2543,35 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260504.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260504.1 + + '@cloudflare/workerd-darwin-64@1.20260504.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260504.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260504.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260504.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260504.1': + optional: true + + '@cloudflare/workers-types@4.20260702.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 @@ -2115,81 +2580,159 @@ snapshots: '@esbuild/aix-ppc64@0.27.0': optional: true + '@esbuild/aix-ppc64@0.27.3': + optional: true + '@esbuild/android-arm64@0.27.0': optional: true + '@esbuild/android-arm64@0.27.3': + optional: true + '@esbuild/android-arm@0.27.0': optional: true + '@esbuild/android-arm@0.27.3': + optional: true + '@esbuild/android-x64@0.27.0': optional: true + '@esbuild/android-x64@0.27.3': + optional: true + '@esbuild/darwin-arm64@0.27.0': optional: true + '@esbuild/darwin-arm64@0.27.3': + optional: true + '@esbuild/darwin-x64@0.27.0': optional: true + '@esbuild/darwin-x64@0.27.3': + optional: true + '@esbuild/freebsd-arm64@0.27.0': optional: true + '@esbuild/freebsd-arm64@0.27.3': + optional: true + '@esbuild/freebsd-x64@0.27.0': optional: true + '@esbuild/freebsd-x64@0.27.3': + optional: true + '@esbuild/linux-arm64@0.27.0': optional: true + '@esbuild/linux-arm64@0.27.3': + optional: true + '@esbuild/linux-arm@0.27.0': optional: true + '@esbuild/linux-arm@0.27.3': + optional: true + '@esbuild/linux-ia32@0.27.0': optional: true + '@esbuild/linux-ia32@0.27.3': + optional: true + '@esbuild/linux-loong64@0.27.0': optional: true + '@esbuild/linux-loong64@0.27.3': + optional: true + '@esbuild/linux-mips64el@0.27.0': optional: true + '@esbuild/linux-mips64el@0.27.3': + optional: true + '@esbuild/linux-ppc64@0.27.0': optional: true + '@esbuild/linux-ppc64@0.27.3': + optional: true + '@esbuild/linux-riscv64@0.27.0': optional: true + '@esbuild/linux-riscv64@0.27.3': + optional: true + '@esbuild/linux-s390x@0.27.0': optional: true + '@esbuild/linux-s390x@0.27.3': + optional: true + '@esbuild/linux-x64@0.27.0': optional: true + '@esbuild/linux-x64@0.27.3': + optional: true + '@esbuild/netbsd-arm64@0.27.0': optional: true + '@esbuild/netbsd-arm64@0.27.3': + optional: true + '@esbuild/netbsd-x64@0.27.0': optional: true + '@esbuild/netbsd-x64@0.27.3': + optional: true + '@esbuild/openbsd-arm64@0.27.0': optional: true + '@esbuild/openbsd-arm64@0.27.3': + optional: true + '@esbuild/openbsd-x64@0.27.0': optional: true + '@esbuild/openbsd-x64@0.27.3': + optional: true + '@esbuild/openharmony-arm64@0.27.0': optional: true + '@esbuild/openharmony-arm64@0.27.3': + optional: true + '@esbuild/sunos-x64@0.27.0': optional: true + '@esbuild/sunos-x64@0.27.3': + optional: true + '@esbuild/win32-arm64@0.27.0': optional: true + '@esbuild/win32-arm64@0.27.3': + optional: true + '@esbuild/win32-ia32@0.27.0': optional: true + '@esbuild/win32-ia32@0.27.3': + optional: true + '@esbuild/win32-x64@0.27.0': optional: true + '@esbuild/win32-x64@0.27.3': + optional: true + '@floating-ui/core@1.7.3': dependencies: '@floating-ui/utils': 0.2.10 @@ -2207,13 +2750,16 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@heruka_urgyen/react-playing-cards@0.5.0(react@19.2.3)': + dependencies: + react: 19.2.3 + '@hookform/resolvers@5.2.2(react-hook-form@7.71.1(react@19.2.3))': dependencies: '@standard-schema/utils': 0.3.0 react-hook-form: 7.71.1(react@19.2.3) - '@img/colour@1.0.0': - optional: true + '@img/colour@1.0.0': {} '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: @@ -2329,6 +2875,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@next/env@16.1.4': {} '@next/swc-darwin-arm64@16.1.4': @@ -2373,6 +2924,18 @@ snapshots: dependencies: playwright: 1.58.0 + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -2854,6 +3417,10 @@ snapshots: react: 19.2.3 react-redux: 9.2.0(@types/react@19.2.9)(react@19.2.3)(redux@5.0.1) + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.24': {} + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -2945,6 +3512,14 @@ snapshots: next: 16.1.4(@playwright/test@1.58.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 + '@xpressit/winning-poker-hand-rank@0.2.3': {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + any-promise@1.3.0: {} anymatch@3.1.3: @@ -2971,6 +3546,8 @@ snapshots: binary-extensions@2.3.0: {} + blake3-wasm@2.1.5: {} + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -2987,6 +3564,11 @@ snapshots: caniuse-lite@1.0.30001766: {} + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -3007,6 +3589,12 @@ snapshots: client-only@0.0.1: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clsx@2.1.1: {} cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): @@ -3021,10 +3609,25 @@ snapshots: - '@types/react' - '@types/react-dom' + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + commander@14.0.2: {} commander@4.1.1: {} + concurrently@9.2.4: + dependencies: + chalk: 4.1.2 + rxjs: 7.8.2 + shell-quote: 1.9.0 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + convex@1.31.6(react@19.2.3): dependencies: esbuild: 0.27.0 @@ -3032,6 +3635,8 @@ snapshots: optionalDependencies: react: 19.2.3 + cookie@1.1.1: {} + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -3080,8 +3685,7 @@ snapshots: deepmerge@4.3.1: {} - detect-libc@2.1.2: - optional: true + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -3091,6 +3695,10 @@ snapshots: electron-to-chromium@1.5.278: {} + emoji-regex@8.0.0: {} + + error-stack-parser-es@1.0.5: {} + es-toolkit@1.45.1: {} esbuild@0.27.0: @@ -3122,6 +3730,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.0 '@esbuild/win32-x64': 0.27.0 + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + escalade@3.2.0: {} eventemitter3@5.0.4: {} @@ -3156,6 +3793,8 @@ snapshots: function-bind@1.1.2: {} + get-caller-file@2.0.5: {} + get-nonce@1.0.1: {} glob-parent@5.1.2: @@ -3172,6 +3811,8 @@ snapshots: minipass: 7.1.2 path-scurry: 2.0.1 + has-flag@4.0.0: {} + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -3192,6 +3833,8 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -3200,10 +3843,12 @@ snapshots: jiti@1.21.7: {} - jose@6.1.3: {} + jose@6.2.8: {} jsonc-parser@3.3.1: {} + kleur@4.1.5: {} + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -3221,6 +3866,18 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + miniflare@4.20260504.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.24.8 + workerd: 1.20260504.1 + ws: 8.18.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + minimatch@10.1.1: dependencies: '@isaacs/brace-expansion': 5.0.0 @@ -3294,6 +3951,8 @@ snapshots: lru-cache: 11.2.4 minipass: 7.1.2 + path-to-regexp@6.3.0: {} + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -3450,6 +4109,8 @@ snapshots: redux@5.0.1: {} + require-directory@2.1.1: {} + reselect@5.1.1: {} resolve@1.22.11: @@ -3464,10 +4125,13 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + scheduler@0.27.0: {} - semver@7.7.3: - optional: true + semver@7.7.3: {} sharp@0.34.5: dependencies: @@ -3499,12 +4163,23 @@ snapshots: '@img/sharp-win32-arm64': 0.34.5 '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 - optional: true + + shell-quote@1.9.0: {} sisteransi@1.0.5: {} source-map-js@1.2.1: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + styled-jsx@5.1.6(react@19.2.3): dependencies: client-only: 0.0.1 @@ -3520,6 +4195,16 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 + supports-color@10.2.2: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + supports-preserve-symlinks-flag@1.0.0: {} tailwind-merge@3.4.0: {} @@ -3577,6 +4262,8 @@ snapshots: dependencies: is-number: 7.0.0 + tree-kill@1.2.2: {} + trpc-cli@0.12.2(@trpc/server@11.8.1(typescript@5.9.3))(zod@4.3.6): dependencies: commander: 14.0.2 @@ -3609,6 +4296,12 @@ snapshots: undici-types@6.21.0: {} + undici@7.24.8: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -3653,4 +4346,64 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + workerd@1.20260504.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260504.1 + '@cloudflare/workerd-darwin-arm64': 1.20260504.1 + '@cloudflare/workerd-linux-64': 1.20260504.1 + '@cloudflare/workerd-linux-arm64': 1.20260504.1 + '@cloudflare/workerd-windows-64': 1.20260504.1 + + wrangler@4.88.0(@cloudflare/workers-types@4.20260702.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260504.1) + blake3-wasm: 2.1.5 + esbuild: 0.27.3 + miniflare: 4.20260504.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260504.1 + optionalDependencies: + '@cloudflare/workers-types': 4.20260702.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + ws@8.18.0: {} + + y18n@5.0.8: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 + zod@4.3.6: {} diff --git a/scripts/verify-cloudflare-zero-cost.mjs b/scripts/verify-cloudflare-zero-cost.mjs new file mode 100644 index 0000000..7f91822 --- /dev/null +++ b/scripts/verify-cloudflare-zero-cost.mjs @@ -0,0 +1,117 @@ +const accountId = process.env.CLOUDFLARE_ACCOUNT_ID; +const zeroCostAccountId = process.env.CLOUDFLARE_ZERO_COST_ACCOUNT_ID; +const acknowledgement = process.env.CLOUDFLARE_ZERO_COST_ACK; +const billingReadToken = process.env.CLOUDFLARE_BILLING_READ_TOKEN; + +const failures = []; +if (!accountId) { + failures.push("CLOUDFLARE_ACCOUNT_ID must select the deployment account explicitly"); +} +if (!zeroCostAccountId) { + failures.push( + "CLOUDFLARE_ZERO_COST_ACCOUNT_ID must identify the dedicated Workers Free account" + ); +} +if (accountId && zeroCostAccountId && accountId !== zeroCostAccountId) { + failures.push("the selected account is not the approved zero-cost account"); +} +if (acknowledgement !== "workers-free-hard-limits") { + failures.push( + "CLOUDFLARE_ZERO_COST_ACK must equal workers-free-hard-limits after verifying the account is still on Workers Free" + ); +} +if (!billingReadToken) { + failures.push( + "CLOUDFLARE_BILLING_READ_TOKEN must be an account-scoped token with Billing Read permission" + ); +} + +if (failures.length > 0) { + console.error(`Cloudflare deployment blocked:\n- ${failures.join("\n- ")}`); + process.exit(1); +} + +function blockDeployment(message) { + console.error(`Cloudflare deployment blocked: ${message}`); + process.exit(1); +} + +async function getAllSubscriptions() { + const subscriptions = []; + let expectedTotal = null; + for (let page = 1; page <= 100; page += 1) { + const url = new URL( + `https://api.cloudflare.com/client/v4/accounts/${accountId}/subscriptions` + ); + url.searchParams.set("page", String(page)); + url.searchParams.set("per_page", "50"); + const response = await fetch(url, { + headers: { Authorization: `Bearer ${billingReadToken}` }, + }); + if (!response.ok) { + blockDeployment( + `unable to verify subscriptions (HTTP ${response.status})` + ); + } + + const body = await response.json(); + if (!(body.success && Array.isArray(body.result))) { + blockDeployment("the subscriptions API returned an invalid response"); + } + const totalCount = Number(body.result_info?.total_count); + if (!(Number.isSafeInteger(totalCount) && totalCount >= 0)) { + blockDeployment("the subscriptions API omitted its total result count"); + } + if (expectedTotal === null) { + expectedTotal = totalCount; + } else if (expectedTotal !== totalCount) { + blockDeployment("the subscription count changed during verification"); + } + subscriptions.push(...body.result); + if (subscriptions.length >= expectedTotal) { + if (subscriptions.length !== expectedTotal) { + blockDeployment("the subscriptions API returned inconsistent paging"); + } + return subscriptions; + } + if (body.result.length === 0) { + blockDeployment("the subscriptions API returned an incomplete page"); + } + } + blockDeployment("the subscriptions API exceeded 100 pages"); +} + +const subscriptions = await getAllSubscriptions(); +const inactiveStates = new Set(["cancelled", "expired", "inactive"]); +const paidSubscriptions = subscriptions.filter((subscription) => { + const state = String(subscription.state ?? "").toLowerCase(); + if (inactiveStates.has(state)) { + return false; + } + const plan = `${subscription.rate_plan?.id ?? ""} ${ + subscription.rate_plan?.public_name ?? "" + }`.toLowerCase(); + const isExplicitlyFree = + Number(subscription.price ?? 0) === 0 && plan.includes("free"); + return !isExplicitlyFree; +}); + +if (paidSubscriptions.length > 0) { + const plans = paidSubscriptions + .map( + (subscription) => + subscription.rate_plan?.public_name ?? + subscription.rate_plan?.id ?? + subscription.id ?? + "unknown plan" + ) + .join(", "); + console.error( + `Cloudflare deployment blocked: paid or non-Free account subscription detected (${plans}).` + ); + process.exit(1); +} + +console.log( + "Cloudflare zero-cost deployment guard passed: account IDs match and no paid subscription was reported." +); diff --git a/src/app/api/live-poker/buy-in-requests/route.ts b/src/app/api/live-poker/buy-in-requests/route.ts new file mode 100644 index 0000000..f72a0e4 --- /dev/null +++ b/src/app/api/live-poker/buy-in-requests/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from "next/server"; +import { + getAuthenticatedLivePokerUser, + getLivePokerConvexSecret, + livePokerConvex, +} from "@/lib/live-poker/server-auth"; +import { api } from "../../../../../convex/_generated/api"; +import type { Id } from "../../../../../convex/_generated/dataModel"; + +export async function GET(request: Request) { + const user = await getAuthenticatedLivePokerUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const tableId = new URL(request.url).searchParams.get("tableId"); + if (!tableId) { + return NextResponse.json( + { error: "A tableId is required" }, + { status: 400 } + ); + } + + try { + const requests = await livePokerConvex.action( + api.live_poker.serverGetLivePokerBuyInRequests, + { + secret: getLivePokerConvexSecret(), + tableId: tableId as Id<"livePokerTables">, + userId: user._id, + } + ); + return NextResponse.json(requests); + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Unable to load buy-in requests", + }, + { status: 400 } + ); + } +} + +export async function POST(request: Request) { + const user = await getAuthenticatedLivePokerUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await request.json().catch(() => null)) as { + amount?: number; + seatIndex?: number; + tableId?: string; + type?: "ADD_ON" | "INITIAL"; + } | null; + if (!(body?.tableId && body.type)) { + return NextResponse.json({ error: "Invalid request" }, { status: 400 }); + } + + const player = await livePokerConvex.query(api.players.getPlayerByUserId, { + userId: user._id, + }); + if (!player) { + return NextResponse.json( + { error: "A player profile is required" }, + { status: 403 } + ); + } + + try { + const requestId = await livePokerConvex.action( + api.live_poker.serverCreateLivePokerBuyInRequest, + { + amount: Number(body.amount), + playerId: player._id, + seatIndex: body.seatIndex, + secret: getLivePokerConvexSecret(), + tableId: body.tableId as Id<"livePokerTables">, + type: body.type, + userId: user._id, + } + ); + return NextResponse.json({ requestId }); + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Unable to request buy-in", + }, + { status: 400 } + ); + } +} diff --git a/src/app/api/live-poker/claim/route.ts b/src/app/api/live-poker/claim/route.ts new file mode 100644 index 0000000..c96b5f8 --- /dev/null +++ b/src/app/api/live-poker/claim/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; +import { applyLivePokerBuyInRequest } from "@/lib/live-poker/apply-buy-in-request"; +import { + getAuthenticatedLivePokerUser, + getLivePokerConvexSecret, + livePokerConvex, +} from "@/lib/live-poker/server-auth"; +import { api } from "../../../../../convex/_generated/api"; +import type { Id } from "../../../../../convex/_generated/dataModel"; + +export async function POST(request: Request) { + const user = await getAuthenticatedLivePokerUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await request.json().catch(() => null)) as { + requestId?: string; + } | null; + if (!body?.requestId) { + return NextResponse.json( + { error: "Request ID is required" }, + { status: 400 } + ); + } + const requestId = body.requestId as Id<"livePokerBuyInRequests">; + + const buyInRequest = await livePokerConvex.action( + api.live_poker.serverGetLivePokerBuyInRequestForClaim, + { + requestId, + secret: getLivePokerConvexSecret(), + userId: user._id, + } + ); + if (!buyInRequest) { + return NextResponse.json( + { error: "Approved buy-in request not found" }, + { status: 404 } + ); + } + if (buyInRequest.status === "CLAIMED") { + return NextResponse.json({ idempotent: true, ok: true }); + } + + const player = await livePokerConvex.query(api.players.getPlayerByUserId, { + userId: user._id, + }); + if (!player || player._id !== buyInRequest.playerId) { + return NextResponse.json( + { error: "Player profile does not match this request" }, + { status: 403 } + ); + } + + const workerResult = await applyLivePokerBuyInRequest({ + ...buyInRequest, + playerName: player.name, + }); + if (!("ok" in workerResult)) { + return NextResponse.json( + { error: workerResult.error }, + { status: workerResult.status } + ); + } + + await livePokerConvex.action( + api.live_poker.serverMarkLivePokerBuyInRequestClaimed, + { + requestId, + secret: getLivePokerConvexSecret(), + userId: user._id, + } + ); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/live-poker/record-hand/route.ts b/src/app/api/live-poker/record-hand/route.ts new file mode 100644 index 0000000..2f9947d --- /dev/null +++ b/src/app/api/live-poker/record-hand/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { + getLivePokerConvexSecret, + livePokerConvex, +} from "@/lib/live-poker/server-auth"; +import { api } from "../../../../../convex/_generated/api"; +import type { Id } from "../../../../../convex/_generated/dataModel"; + +export async function POST(request: Request) { + const webhookSecret = process.env.LIVE_POKER_WEBHOOK_SECRET; + if ( + !webhookSecret || + request.headers.get("x-live-poker-secret") !== webhookSecret + ) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await request.json()) as { + actionLog: string[]; + bigBlind: number; + communityCards: string[]; + completedAt: number; + dealerSeat: number; + gameId?: string; + handNumber: number; + pot: number; + smallBlind: number; + tableId?: string; + winners: Array<{ + amount: number; + description?: string; + playerId: string; + seatIndex: number; + userId: string; + }>; + }; + + await livePokerConvex.action(api.live_poker.serverRecordCompletedHand, { + actionLog: body.actionLog, + bigBlind: body.bigBlind, + communityCards: body.communityCards, + completedAt: body.completedAt, + dealerSeat: body.dealerSeat, + gameId: body.gameId as Id<"games"> | undefined, + handNumber: body.handNumber, + pot: body.pot, + secret: getLivePokerConvexSecret(), + smallBlind: body.smallBlind, + tableId: body.tableId as Id<"livePokerTables"> | undefined, + winners: body.winners.map((winner) => ({ + ...winner, + playerId: winner.playerId as Id<"players">, + userId: winner.userId as Id<"users">, + })), + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/live-poker/respond-buy-in/route.ts b/src/app/api/live-poker/respond-buy-in/route.ts new file mode 100644 index 0000000..aa75112 --- /dev/null +++ b/src/app/api/live-poker/respond-buy-in/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from "next/server"; +import { applyLivePokerBuyInRequest } from "@/lib/live-poker/apply-buy-in-request"; +import { + getAuthenticatedLivePokerUser, + getLivePokerConvexSecret, + livePokerConvex, +} from "@/lib/live-poker/server-auth"; +import { api } from "../../../../../convex/_generated/api"; +import type { Id } from "../../../../../convex/_generated/dataModel"; + +export async function POST(request: Request) { + const user = await getAuthenticatedLivePokerUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await request.json().catch(() => null)) as { + requestId?: string; + status?: "REJECTED"; + } | null; + if (!body?.requestId) { + return NextResponse.json( + { error: "Request ID is required" }, + { status: 400 } + ); + } + const requestId = body.requestId as Id<"livePokerBuyInRequests">; + const secret = getLivePokerConvexSecret(); + + if (body.status === "REJECTED") { + await livePokerConvex.action( + api.live_poker.serverRejectLivePokerBuyInRequest, + { requestId, secret, userId: user._id } + ); + return NextResponse.json({ ok: true }); + } + + const buyInRequest = await livePokerConvex.action( + api.live_poker.serverGetLivePokerBuyInRequestForApproval, + { requestId, secret, userId: user._id } + ); + if (!buyInRequest) { + return NextResponse.json( + { error: "Pending buy-in request not found" }, + { status: 404 } + ); + } + if (buyInRequest.status === "CLAIMED") { + return NextResponse.json({ idempotent: true, ok: true }); + } + + const workerResult = await applyLivePokerBuyInRequest({ + ...buyInRequest, + playerName: buyInRequest.playerName, + }); + if (!("ok" in workerResult)) { + return NextResponse.json( + { error: workerResult.error }, + { status: workerResult.status } + ); + } + + await livePokerConvex.action( + api.live_poker.serverApproveLivePokerBuyInRequest, + { requestId, secret, userId: user._id } + ); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/live-poker/settle-player/route.ts b/src/app/api/live-poker/settle-player/route.ts new file mode 100644 index 0000000..be63916 --- /dev/null +++ b/src/app/api/live-poker/settle-player/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { + getLivePokerConvexSecret, + livePokerConvex, +} from "@/lib/live-poker/server-auth"; +import { api } from "../../../../../convex/_generated/api"; +import type { Id } from "../../../../../convex/_generated/dataModel"; + +export async function POST(request: Request) { + const webhookSecret = process.env.LIVE_POKER_WEBHOOK_SECRET; + if ( + !webhookSecret || + request.headers.get("x-live-poker-secret") !== webhookSecret + ) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await request.json()) as { + buyIn: number; + cashOut: number; + gameId?: string; + playerId: string; + settledAt?: number; + settlementId?: string; + tableId?: string; + userId: string; + }; + if (!(body.settlementId && body.settledAt)) { + return NextResponse.json( + { error: "Settlement idempotency fields are required" }, + { status: 400 } + ); + } + + const gamePlayer = await livePokerConvex.action( + api.live_poker.serverSettlePlayerStack, + { + buyIn: body.buyIn, + cashOut: body.cashOut, + gameId: body.gameId as Id<"games"> | undefined, + playerId: body.playerId as Id<"players">, + secret: getLivePokerConvexSecret(), + settledAt: body.settledAt, + settlementId: body.settlementId, + tableId: body.tableId as Id<"livePokerTables"> | undefined, + userId: body.userId as Id<"users">, + } + ); + + return NextResponse.json({ gamePlayer }); +} diff --git a/src/app/api/live-poker/tables/[tableId]/route.ts b/src/app/api/live-poker/tables/[tableId]/route.ts new file mode 100644 index 0000000..e638885 --- /dev/null +++ b/src/app/api/live-poker/tables/[tableId]/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import { closeLivePokerTable } from "@/lib/live-poker/apply-buy-in-request"; +import { + getAuthenticatedLivePokerUser, + getLivePokerConvexSecret, + livePokerConvex, +} from "@/lib/live-poker/server-auth"; +import { api } from "../../../../../../convex/_generated/api"; +import type { Id } from "../../../../../../convex/_generated/dataModel"; + +export async function DELETE( + _request: Request, + context: { params: Promise<{ tableId: string }> } +) { + const user = await getAuthenticatedLivePokerUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { tableId: rawTableId } = await context.params; + const tableId = rawTableId as Id<"livePokerTables">; + const table = await livePokerConvex.query(api.live_poker.getLivePokerTable, { + tableId, + }); + if (!table) { + return NextResponse.json({ error: "Table not found" }, { status: 404 }); + } + if (table.createdById !== user._id) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const workerResult = await closeLivePokerTable(tableId, table); + if (!("ok" in workerResult)) { + return NextResponse.json( + { error: workerResult.error }, + { status: workerResult.status } + ); + } + + await livePokerConvex.action(api.live_poker.serverDeleteLivePokerTable, { + secret: getLivePokerConvexSecret(), + tableId, + userId: user._id, + }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/live-poker/tables/route.ts b/src/app/api/live-poker/tables/route.ts new file mode 100644 index 0000000..6edfec7 --- /dev/null +++ b/src/app/api/live-poker/tables/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { + getAuthenticatedLivePokerUser, + getLivePokerConvexSecret, + livePokerConvex, +} from "@/lib/live-poker/server-auth"; +import { api } from "../../../../../convex/_generated/api"; + +export async function POST(request: Request) { + const user = await getAuthenticatedLivePokerUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await request.json().catch(() => null)) as { + bigBlind?: number; + maxBuyIn?: number; + minBuyIn?: number; + seatCount?: number; + smallBlind?: number; + title?: string; + } | null; + if (!body) { + return NextResponse.json({ error: "Invalid request" }, { status: 400 }); + } + + try { + const tableId = await livePokerConvex.action( + api.live_poker.serverCreateLivePokerTable, + { + bigBlind: Number(body.bigBlind), + createdById: user._id, + maxBuyIn: Number(body.maxBuyIn), + minBuyIn: Number(body.minBuyIn), + seatCount: Number(body.seatCount), + secret: getLivePokerConvexSecret(), + smallBlind: Number(body.smallBlind), + title: body.title ?? "", + } + ); + return NextResponse.json({ tableId }); + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Unable to create table", + }, + { status: 400 } + ); + } +} diff --git a/src/app/api/live-poker/token/route.ts b/src/app/api/live-poker/token/route.ts new file mode 100644 index 0000000..49bb74b --- /dev/null +++ b/src/app/api/live-poker/token/route.ts @@ -0,0 +1,125 @@ +import { ConvexHttpClient } from "convex/browser"; +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { signLivePokerToken } from "@/lib/live-poker/auth"; +import { api } from "../../../../../convex/_generated/api"; +import type { Id } from "../../../../../convex/_generated/dataModel"; + +const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; +if (!convexUrl) { + throw new Error("NEXT_PUBLIC_CONVEX_URL is not defined"); +} + +const convex = new ConvexHttpClient(convexUrl); + +export async function POST(request: Request) { + const session = await auth(); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await request.json().catch(() => null)) as { + tableId?: string; + } | null; + + if (!body?.tableId) { + return NextResponse.json( + { error: "Table ID is required" }, + { status: 400 } + ); + } + + const user = await convex.query(api.auth.getUserByEmail, { + email: session.user.email, + }); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + let access = await convex.query(api.live_poker.getLivePokerAccess, { + tableId: body.tableId as Id<"livePokerTables">, + userId: user._id, + }); + + if (!access) { + return NextResponse.json( + { error: "Live poker table is not available" }, + { status: 403 } + ); + } + + const convexSecret = process.env.LIVE_POKER_CONVEX_SECRET; + if (!convexSecret) { + return NextResponse.json( + { error: "Live poker server authorization is not configured" }, + { status: 500 } + ); + } + try { + await convex.action(api.live_poker.serverReserveLivePokerAccess, { + secret: convexSecret, + tableId: body.tableId as Id<"livePokerTables">, + userId: user._id, + }); + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Unable to reserve live poker access", + }, + { status: 429 } + ); + } + + if (!access.player) { + const playerId = await convex.mutation( + api.players.getOrCreatePlayerForUser, + { + userId: user._id, + name: user.name ?? user.email ?? "Player", + } + ); + const player = await convex.query(api.players.getPlayerByUserId, { + userId: user._id, + }); + access = { + ...access, + player: player + ? { id: player._id, name: player.name } + : { id: playerId, name: user.name ?? user.email ?? "Player" }, + }; + } + + if (!access.player) { + return NextResponse.json( + { error: "You must be signed in with a player profile to play" }, + { status: 403 } + ); + } + + const exp = Math.floor(Date.now() / 1000) + 5 * 60; + const token = await signLivePokerToken({ + exp, + tableConfig: { + bigBlind: access.table.bigBlind, + hostUserId: access.table.createdById, + maxBuyIn: access.table.maxBuyIn, + minBuyIn: access.table.minBuyIn, + seatCount: access.table.seatCount, + smallBlind: access.table.smallBlind, + }, + tableId: body.tableId, + playerId: access.player.id, + playerName: access.player.name, + userId: user._id, + }); + + return NextResponse.json({ + config: access.table, + expiresAt: exp * 1000, + player: access.player, + token, + }); +} diff --git a/src/app/globals.css b/src/app/globals.css index f175773..3acdf2d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -75,6 +75,69 @@ } } +@keyframes live-poker-card-deal { + 0% { + opacity: 0; + scale: 0.96; + translate: 0 -5px; + } + 100% { + opacity: 1; + scale: 1; + translate: 0 0; + } +} + +@keyframes live-poker-winner-pop { + 0% { + opacity: 0; + scale: 0.72; + translate: 0 14px; + } + 65% { + opacity: 1; + scale: 1.08; + translate: 0 -2px; + } + 100% { + opacity: 1; + scale: 1; + translate: 0 0; + } +} + +@keyframes live-poker-winner-glow { + 0%, 100% { + box-shadow: 0 0 14px rgb(251 191 36 / 35%); + } + 50% { + box-shadow: 0 0 32px rgb(251 191 36 / 75%); + } +} + +.live-poker-card-deal { + animation: live-poker-card-deal 160ms ease-out both; + will-change: opacity, scale, translate; +} + +.live-poker-winner-announcement, +.live-poker-winner-badge { + animation: live-poker-winner-pop 520ms cubic-bezier(0.16, 0.84, 0.24, 1) both; +} + +.live-poker-winner-seat { + animation: live-poker-winner-glow 1.1s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .live-poker-card-deal, + .live-poker-winner-announcement, + .live-poker-winner-badge, + .live-poker-winner-seat { + animation: none; + } +} + @layer utilities { .pb-safe { padding-bottom: env(safe-area-inset-bottom, 0px); diff --git a/src/app/live-poker/[id]/page.tsx b/src/app/live-poker/[id]/page.tsx new file mode 100644 index 0000000..1638ad1 --- /dev/null +++ b/src/app/live-poker/[id]/page.tsx @@ -0,0 +1,37 @@ +import { Loader2 } from "lucide-react"; +import { redirect } from "next/navigation"; +import { Suspense } from "react"; +import Navbar from "@/components/layout/navbar"; +import { LivePokerTableClient } from "@/features/live-poker/live-poker-table-client"; +import { auth } from "@/lib/auth"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default async function PublicLivePokerTablePage({ params }: PageProps) { + const session = await auth(); + + if (!session?.user) { + redirect("/login"); + } + + const { id } = await params; + + return ( +
+ +
+ + +
+ } + > + + + + + ); +} diff --git a/src/app/live-poker/new/page.tsx b/src/app/live-poker/new/page.tsx new file mode 100644 index 0000000..74948f5 --- /dev/null +++ b/src/app/live-poker/new/page.tsx @@ -0,0 +1,37 @@ +import { Loader2 } from "lucide-react"; +import { redirect } from "next/navigation"; +import { Suspense } from "react"; +import Navbar from "@/components/layout/navbar"; +import { NewLivePokerTableClient } from "@/features/live-poker/new-live-poker-table-client"; +import { auth } from "@/lib/auth"; + +export default async function NewLivePokerTablePage() { + const session = await auth(); + + if (!session?.user) { + redirect("/login"); + } + + return ( +
+ +
+
+

Create Live Poker Table

+

+ Anyone signed in can join this table. +

+
+ + +
+ } + > + + + + + ); +} diff --git a/src/app/live-poker/page.tsx b/src/app/live-poker/page.tsx new file mode 100644 index 0000000..ec8c802 --- /dev/null +++ b/src/app/live-poker/page.tsx @@ -0,0 +1,38 @@ +import { Plus } from "lucide-react"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import Navbar from "@/components/layout/navbar"; +import { Button } from "@/components/ui/button"; +import { LivePokerLobbyClient } from "@/features/live-poker/live-poker-lobby-client"; +import { auth } from "@/lib/auth"; + +export default async function LivePokerLobbyPage() { + const session = await auth(); + + if (!session?.user) { + redirect("/login"); + } + + return ( +
+ +
+
+
+

Live Poker

+

+ Public tables for signed-in players. +

+
+ + + +
+ +
+
+ ); +} diff --git a/src/components/game/game-form.tsx b/src/components/game/game-form.tsx index 3b8becd..98ea71d 100644 --- a/src/components/game/game-form.tsx +++ b/src/components/game/game-form.tsx @@ -27,6 +27,12 @@ const gameFormSchema = z.object({ date: z.string().min(1, "Date is required"), location: z.string().optional(), gameType: z.enum(["cash", "tournament"]).optional(), + livePokerEnabled: z.boolean().optional(), + smallBlind: z.string().optional(), + bigBlind: z.string().optional(), + minBuyIn: z.string().optional(), + maxBuyIn: z.string().optional(), + seatCount: z.string().optional(), }); type GameFormValues = z.infer; @@ -36,6 +42,7 @@ interface GameFormProps { defaultValues?: Partial; groups: Array<{ id: string; name: string }>; defaultGroupId?: string; + showLivePokerFields?: boolean; } export function GameForm({ @@ -43,6 +50,7 @@ export function GameForm({ defaultValues, groups, defaultGroupId, + showLivePokerFields = false, }: GameFormProps) { const form = useForm({ resolver: zodResolver(gameFormSchema), @@ -51,6 +59,12 @@ export function GameForm({ date: new Date().toISOString().split("T")[0], location: "", gameType: "cash", + livePokerEnabled: false, + smallBlind: "1", + bigBlind: "2", + minBuyIn: "20", + maxBuyIn: "400", + seatCount: "6", }, }); @@ -61,10 +75,19 @@ export function GameForm({ date: String(data.date), location: data.location ? String(data.location) : undefined, gameType: data.gameType, + livePokerEnabled: showLivePokerFields && Boolean(data.livePokerEnabled), + smallBlind: data.smallBlind, + bigBlind: data.bigBlind, + minBuyIn: data.minBuyIn, + maxBuyIn: data.maxBuyIn, + seatCount: data.seatCount, }); }; const isLoading = form.formState.isSubmitting; + const gameType = form.watch("gameType") || "cash"; + const livePokerEnabled = + showLivePokerFields && Boolean(form.watch("livePokerEnabled")); return ( @@ -153,6 +176,108 @@ export function GameForm({ + {showLivePokerFields ? ( +
+ + {gameType === "tournament" ? ( +

+ Live poker is available for cash games in this version. +

+ ) : null} +
+ ) : null} + + {livePokerEnabled && gameType === "cash" ? ( +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ ) : null} +
+ + + + + ) : null} + {status === "ACTIVE" && !isJoined ? ( + + + + ) : null} + +
+ {tables.map((table) => { + const tableActionLabel = getTableActionLabel(table); + + return ( + + +
+
+

+ {table.title} +

+

+ Hosted by {table.createdBy.name} +

+
+ + OPEN + +
+ +
+
+

Blinds

+

+ {chip(table.smallBlind)} / {chip(table.bigBlind)} +

+
+
+

Buy-in

+

+ {chip(table.minBuyIn)}-{chip(table.maxBuyIn)} +

+
+
+

Seats

+

+ + {table.seatCount} +

+
+
+

Updated

+

+ {formatDistanceToNow(new Date(table.updatedAt), { + addSuffix: true, + })} +

+
+
+ +
+ + + + {table.isCreatedByCurrentUser ? ( + + ) : null} +
+
+
+ ); + })} +
+ + { + if (!open) { + setPendingDeleteTableId(null); + } + }} + open={pendingDeleteTableId !== null} + > + + + Delete live poker table? + + This closes the table, settles every occupied seat, and prevents + anyone from reconnecting. Ledger and hand history are preserved. + + + + Cancel + + Delete Table + + + + +
+ ); +} diff --git a/src/features/live-poker/live-poker-table-client.tsx b/src/features/live-poker/live-poker-table-client.tsx new file mode 100644 index 0000000..ed7cfc7 --- /dev/null +++ b/src/features/live-poker/live-poker-table-client.tsx @@ -0,0 +1,1983 @@ +"use client"; + +import { + Check, + CircleDollarSign, + Loader2, + LogOut, + Minus, + Pause, + Play, + Plus, + Power, + RefreshCw, + Trophy, + UserX, + WifiOff, + X, +} from "lucide-react"; +import dynamic from "next/dynamic"; +import type { CSSProperties } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useConvexUser } from "@/lib/convex-hooks"; +import { + getJitteredLivePokerReconnectDelay, + LIVE_POKER_MAX_RECONNECT_ATTEMPTS, +} from "@/lib/live-poker/reconnect"; +import { + LIVE_POKER_ACTION_SETTLE_MS, + LIVE_POKER_INITIAL_DEAL_MS, + LIVE_POKER_RUNOUT_STAGE_MS, +} from "@/lib/live-poker/timing"; +import type { + LivePokerClientMessage, + LivePokerServerMessage, + PublicLivePokerSeat, + PublicLivePokerState, +} from "@/lib/live-poker/types"; +import { + useCreateLivePokerBuyInRequest, + useLivePokerBuyInRequests, + useRespondToLivePokerBuyInRequest, +} from "@/lib/live-poker-hooks"; +import type { Id } from "../../../convex/_generated/dataModel"; + +interface LivePokerTableClientProps { + tableId: string; +} + +type ConnectionStatus = + | "connected" + | "connecting" + | "disconnected" + | "reconnecting"; + +const Card = dynamic( + () => import("@heruka_urgyen/react-playing-cards/lib/FcB"), + { ssr: false } +); + +const tableStageStyle = { + background: + "radial-gradient(circle at 50% 35%, rgba(255, 255, 255, 0.12), transparent 34%), linear-gradient(145deg, #18181b, #050505)", +} satisfies CSSProperties; + +const tableVignetteStyle = { + background: + "radial-gradient(circle at center, transparent 42%, rgba(0, 0, 0, 0.45) 72%)", +} satisfies CSSProperties; + +const tableFeltStyle = { + backgroundColor: "#047857", + boxShadow: + "0 0 0 1px rgba(255, 255, 255, 0.14), 0 24px 70px rgba(0, 0, 0, 0.55), inset 0 0 0 8px rgba(255, 255, 255, 0.07), inset 0 0 45px rgba(0, 0, 0, 0.36)", +} satisfies CSSProperties; + +const tableFeltSurfaceStyle = { + background: + "radial-gradient(circle at 50% 42%, rgba(255, 255, 255, 0.18), transparent 34%), linear-gradient(135deg, #10b981, #047857 62%, #065f46)", + boxShadow: "inset 0 0 30px rgba(0, 0, 0, 0.26)", +} satisfies CSSProperties; + +const tableCenterStyle = { + maxWidth: "34rem", + width: "78%", +} satisfies CSSProperties; + +const SEAT_POSITIONS: Record> = { + 2: [ + { x: 50, y: 87 }, + { x: 50, y: 13 }, + ], + 3: [ + { x: 50, y: 87 }, + { x: 17, y: 34 }, + { x: 83, y: 34 }, + ], + 4: [ + { x: 50, y: 87 }, + { x: 16, y: 50 }, + { x: 50, y: 13 }, + { x: 84, y: 50 }, + ], + 5: [ + { x: 50, y: 87 }, + { x: 16, y: 66 }, + { x: 24, y: 16 }, + { x: 76, y: 16 }, + { x: 84, y: 66 }, + ], + 6: [ + { x: 50, y: 87 }, + { x: 16, y: 72 }, + { x: 16, y: 30 }, + { x: 50, y: 13 }, + { x: 84, y: 30 }, + { x: 84, y: 72 }, + ], + 7: [ + { x: 50, y: 87 }, + { x: 23, y: 82 }, + { x: 16, y: 45 }, + { x: 28, y: 16 }, + { x: 72, y: 16 }, + { x: 84, y: 45 }, + { x: 77, y: 82 }, + ], + 8: [ + { x: 50, y: 87 }, + { x: 25, y: 82 }, + { x: 16, y: 64 }, + { x: 18, y: 28 }, + { x: 50, y: 13 }, + { x: 82, y: 28 }, + { x: 84, y: 64 }, + { x: 75, y: 82 }, + ], + 9: [ + { x: 50, y: 87 }, + { x: 25, y: 82 }, + { x: 16, y: 70 }, + { x: 16, y: 40 }, + { x: 31, y: 16 }, + { x: 69, y: 16 }, + { x: 84, y: 40 }, + { x: 84, y: 70 }, + { x: 75, y: 82 }, + ], +}; + +type SeatZone = "bottom" | "left" | "right" | "top"; + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function getSeatZone(position: { x: number; y: number }): SeatZone { + if (position.y >= 78) { + return "bottom"; + } + if (position.y <= 24) { + return "top"; + } + return position.x < 50 ? "left" : "right"; +} + +function towardCenter( + position: { x: number; y: number }, + distance: number, + xNudge = 0, + yNudge = 0 +) { + return { + x: clamp(position.x + (50 - position.x) * distance + xNudge, 8, 92), + y: clamp(position.y + (50 - position.y) * distance + yNudge, 8, 92), + }; +} + +function getMarkerLayout(position: { x: number; y: number }) { + const zone = getSeatZone(position); + let sideNudge = 0; + if (position.x < 50) { + sideNudge = 2; + } else if (position.x > 50) { + sideNudge = -2; + } + + if (zone === "top") { + return { + bet: towardCenter(position, 0.48, 0, 1), + blind: towardCenter(position, 0.2, sideNudge, 1), + cards: { + x: clamp(position.x, 8, 92), + y: clamp(position.y - 8, 7, 92), + }, + cardsZIndex: 18, + zone, + }; + } + + if (zone === "bottom") { + return { + bet: towardCenter(position, 0.5, 0, -3), + blind: towardCenter(position, 0.2, sideNudge, -1), + cards: { + x: clamp(position.x, 8, 92), + y: clamp(position.y - 8, 7, 92), + }, + cardsZIndex: 18, + zone, + }; + } + + return { + bet: towardCenter(position, 0.5, sideNudge, 0), + blind: towardCenter(position, 0.2, sideNudge, 0), + cards: { + x: clamp(position.x, 8, 92), + y: clamp(position.y - 8, 7, 92), + }, + cardsZIndex: 18, + zone, + }; +} + +function getDealerButtonPosition(position: { x: number; y: number }) { + const zone = getSeatZone(position); + let horizontalSide = 1; + if (position.x < 50) { + horizontalSide = -1; + } else if (position.x > 50) { + horizontalSide = 1; + } + + if (zone === "top") { + return { + x: clamp(position.x + horizontalSide * 10, 14, 86), + y: clamp(position.y + 9, 16, 28), + }; + } + + if (zone === "bottom") { + return { + x: clamp(position.x + horizontalSide * 10, 14, 86), + y: clamp(position.y - 6, 74, 84), + }; + } + + return { + x: clamp(position.x + (50 - position.x) * 0.18, 10, 90), + y: clamp(position.y + (position.y < 50 ? -8 : 8), 14, 86), + }; +} + +function chip(value: number) { + return value.toLocaleString(undefined, { + maximumFractionDigits: 2, + minimumFractionDigits: value % 1 === 0 ? 0 : 2, + }); +} + +function tableControlLabel(state: PublicLivePokerState) { + if (!state.gamePaused) { + return state.phase === "waiting" ? "Pause table" : "Pause after hand"; + } + return state.handNumber === 0 ? "Start table" : "Resume table"; +} + +function connectionLabel( + status: ConnectionStatus, + attempt: number, + detailed = false +) { + switch (status) { + case "connected": + return "Connected"; + case "connecting": + return detailed ? "Connecting to the live table…" : "Connecting"; + case "disconnected": + return detailed ? "Live table disconnected" : "Disconnected"; + case "reconnecting": + return detailed + ? `Reconnecting to the live table (attempt ${attempt} of ${LIVE_POKER_MAX_RECONNECT_ATTEMPTS})…` + : `Reconnecting (${attempt}/${LIVE_POKER_MAX_RECONNECT_ATTEMPTS})`; + default: + return "Disconnected"; + } +} + +function getBetTargetBounds( + state: PublicLivePokerState, + seat: PublicLivePokerSeat +) { + const maxTarget = seat.bet + seat.stack; + const minRaiseTarget = + state.currentBet === 0 ? state.bigBlind : state.currentBet + state.minRaise; + + return { + maxTarget, + minTarget: Math.min(minRaiseTarget, maxTarget), + }; +} + +function getBetPresets(state: PublicLivePokerState, seat: PublicLivePokerSeat) { + const { maxTarget, minTarget } = getBetTargetBounds(state, seat); + const toTarget = (value: number) => + clamp(Math.round(value * 100) / 100, minTarget, maxTarget); + if (state.phase === "preflop") { + return [2, 2.5, 3].map((blinds) => ({ + label: `${blinds} BB`, + target: toTarget(state.bigBlind * blinds), + })); + } + + const callAmount = Math.max(0, state.currentBet - seat.bet); + const potAfterCall = state.pot + callAmount; + return [ + { fraction: 1 / 3, label: "⅓ Pot" }, + { fraction: 1 / 2, label: "½ Pot" }, + { fraction: 3 / 4, label: "¾ Pot" }, + { fraction: 1, label: "Pot" }, + ].map(({ fraction, label }) => ({ + label, + target: toTarget(seat.bet + callAmount + potAfterCall * fraction), + })); +} + +function getSeatPosition(index: number, seatCount: number) { + const positions = SEAT_POSITIONS[seatCount]; + if (positions?.[index]) { + return positions[index]; + } + + const angle = Math.PI / 2 - (2 * Math.PI * index) / Math.max(seatCount, 1); + return { + x: 50 + 46 * Math.cos(angle), + y: 50 + 52 * Math.sin(angle), + }; +} + +function getViewerSeatPosition( + seatIndex: number, + seatCount: number, + viewerSeatIndex?: number +) { + if (viewerSeatIndex === undefined) { + return getSeatPosition(seatIndex, seatCount); + } + + const displayIndex = (seatIndex - viewerSeatIndex + seatCount) % seatCount; + return getSeatPosition(displayIndex, seatCount); +} + +function visibleCommunityCardCount( + state: PublicLivePokerState, + serverNow: number +) { + const total = state.communityCards.length; + const revealStart = state.communityCardRevealStartIndex; + if (revealStart === null || !state.transitionDeadlineAt) { + return total; + } + + const transitionDuration = + state.transition === "runout" + ? LIVE_POKER_RUNOUT_STAGE_MS + : LIVE_POKER_ACTION_SETTLE_MS; + if (state.transition !== "actionSettle" && state.transition !== "runout") { + return total; + } + + const elapsed = transitionDuration - (state.transitionDeadlineAt - serverNow); + const revealCount = total - revealStart; + if (elapsed < 100) { + return revealStart; + } + if (revealCount === 3) { + return revealStart + Math.min(3, 1 + Math.floor((elapsed - 100) / 190)); + } + return elapsed < 140 ? revealStart : total; +} + +function PlayingCard({ + animationDelayMs, + card, + className = "", + hidden, + rotate = 0, +}: { + animationDelayMs?: number; + card?: string; + className?: string; + hidden?: boolean; + rotate?: number; +}) { + const packageCard = card + ? `${card.slice(0, -1)}${card.at(-1)?.toLowerCase()}` + : undefined; + + const cardHeight = "80px"; + + return ( + + + + ); +} + +function streetActionLabel( + action: NonNullable["type"] +) { + const labels = { + allIn: "All in", + bet: "Bet", + bigBlind: "BB", + call: "Call", + check: "Check", + fold: "Fold", + raise: "Raise", + smallBlind: "SB", + } satisfies Record; + return labels[action]; +} + +function CurrentBetBadge({ + action, + amount, +}: { + action: NonNullable; + amount: number; +}) { + return ( + + {streetActionLabel(action.type)} + {amount > 0 ? chip(amount) : null} + + ); +} + +function DealerButton({ position }: { position: { x: number; y: number } }) { + return ( +
+ D +
+ ); +} + +function CenterPot({ + amount, + phase, +}: { + amount: number; + phase: PublicLivePokerState["phase"] | "connecting"; +}) { + return ( + <> +
+ POT: + + {chip(amount)} +
+
+ {phase} +
+ + ); +} + +function NextHandIntermission({ + serverNow, + state, +}: { + serverNow: number; + state: PublicLivePokerState; +}) { + if (state.transition !== "nextHand" || !state.transitionDeadlineAt) { + return null; + } + + const seconds = Math.max( + 1, + Math.ceil((state.transitionDeadlineAt - serverNow) / 1000) + ); + return ( + + + {state.handNumber === 0 ? "Game starts in" : "Next hand"} + + {seconds} + + ); +} + +function WinnerAnnouncement({ state }: { state: PublicLivePokerState }) { + if (state.phase !== "showdown" || state.lastWinners.length === 0) { + return null; + } + + const seatIndexes = [ + ...new Set(state.lastWinners.map((winner) => winner.seatIndex)), + ]; + const names = seatIndexes.map( + (seatIndex) => state.seats[seatIndex]?.name ?? `Seat ${seatIndex + 1}` + ); + const amount = state.lastWinners.reduce( + (total, winner) => total + winner.amount, + 0 + ); + const descriptions = [ + ...new Set( + state.lastWinners + .map((winner) => winner.description) + .filter((description): description is string => Boolean(description)) + ), + ]; + + return ( + + + + + + {names.join(" & ")} {names.length > 1 ? "split the pot" : "wins"} + + + {chip(amount)} + + {descriptions.length > 0 ? ( + + {descriptions.join(" · ")} + + ) : null} + + + ); +} + +function timerBarColor(isTimeBank: boolean, progress: number) { + if (isTimeBank) { + return "bg-amber-400"; + } + return progress <= 25 ? "bg-red-500" : "bg-emerald-500"; +} + +function Seat({ + isActive, + isTimeBank, + seat, + turnProgress, + winAmount, +}: { + isActive?: boolean; + isTimeBank?: boolean; + seat: PublicLivePokerSeat | null; + turnProgress?: number; + winAmount?: number; +}) { + if (!seat) { + return ( +
+ Open Seat +
+ ); + } + + return ( +
+

{seat.name}

+

+ {chip(seat.stack)} +

+ {seat.connected ? null : ( +

+

+ )} + {winAmount !== undefined ? ( +

+ Winner · +{chip(winAmount)} +

+ ) : null} + {turnProgress !== undefined ? ( +
+
+
+ ) : null} +
+ ); +} + +function TableSeatMarkers({ + dealElapsedMs, + dealOrder, + dealtSeatCount, + phase, + position, + seat, + settledAction, +}: { + dealElapsedMs?: number; + dealOrder?: number; + dealtSeatCount: number; + phase: PublicLivePokerState["phase"]; + position: { x: number; y: number }; + seat: PublicLivePokerSeat | null; + settledAction: PublicLivePokerState["settledAction"]; +}) { + if (!seat) { + return null; + } + + const markerLayout = getMarkerLayout(position); + const settlingAction = + settledAction?.seatIndex === seat.seatIndex ? settledAction : null; + const currentStreetAction = settlingAction ?? seat.streetAction; + const displayedAmount = settlingAction?.amount ?? seat.bet; + const showCurrentStreetBet = + Boolean(settlingAction) || + (phase !== "waiting" && + phase !== "showdown" && + seat.bet > 0 && + Boolean(currentStreetAction)); + const isBlind = + currentStreetAction?.type === "smallBlind" || + currentStreetAction?.type === "bigBlind"; + const badgePosition = isBlind ? markerLayout.blind : markerLayout.bet; + + return ( + <> + {showCurrentStreetBet && currentStreetAction ? ( +
+ +
+ ) : null} +
+ {seat.cards?.map((card, cardIndex) => ( + + ))} + {!seat.cards && seat.hasCards + ? [0, 1].map((cardIndex) => ( +
+ + ); +} + +function BettingControlsOverlay({ + amount, + callAmount, + currentSeat, + disabled, + onAmountChange, + onSend, + state, +}: { + amount: number; + callAmount: number; + currentSeat: PublicLivePokerSeat; + disabled: boolean; + onAmountChange: (amount: number) => void; + onSend: (message: LivePokerClientMessage) => void; + state: PublicLivePokerState; +}) { + const { maxTarget, minTarget } = getBetTargetBounds(state, currentSeat); + const step = Math.max(0.01, state.bigBlind || 0.01); + const hasCallAmount = callAmount > 0; + const clampedAmount = clamp(amount, minTarget, maxTarget); + const actionLabel = state.currentBet > 0 ? "Raise" : "Bet"; + const activeSeat = + state.activeSeatIndex === null ? null : state.seats[state.activeSeatIndex]; + let status = activeSeat ? `Waiting for ${activeSeat.name}` : "Waiting"; + if (!disabled) { + status = "Your turn"; + } else if (state.transition === "deal") { + status = "Dealing cards"; + } else if (state.phase === "showdown") { + status = "Hand complete"; + } + + function setTarget(nextAmount: number) { + onAmountChange( + clamp(Math.round(nextAmount * 100) / 100, minTarget, maxTarget) + ); + } + + function submitBetOrRaise() { + if (clampedAmount >= maxTarget) { + onSend({ type: "allIn" }); + return; + } + + onSend({ + amount: clampedAmount, + type: state.currentBet > 0 ? "raise" : "bet", + }); + } + + const presets = getBetPresets(state, currentSeat); + + return ( +
+
+
+ + Betting controls + + {status} +
+
+
+ + setTarget(Number(event.target.value))} + step={step} + type="range" + value={clampedAmount} + /> + +
+

+ {actionLabel} to +

+

+ {chip(clampedAmount)} +

+
+
+ +
+ {presets.map((preset) => ( + + ))} + {state.phase === "preflop" ? ( + + ) : null} +
+
+ +
+ + + +
+
+
+ ); +} + +interface LivePokerBuyInRequestRow { + _id: string; + amount: number; + id?: string; + player?: { name?: string }; + requestedAt: number; + seatIndex?: number; + status: "APPROVED" | "CLAIMED" | "PENDING" | "REJECTED"; + type: "ADD_ON" | "INITIAL"; +} + +function requestTypeLabel(type: LivePokerBuyInRequestRow["type"]) { + return type === "INITIAL" ? "Buy-in" : "Add-on"; +} + +function PendingBuyInRequestsPanel({ + approvingId, + disabled, + onApprove, + onReject, + rejectingId, + requests, +}: { + approvingId: string | null; + disabled: boolean; + onApprove: (requestId: string) => void; + onReject: (requestId: string) => void; + rejectingId: string | null; + requests: LivePokerBuyInRequestRow[]; +}) { + if (requests.length === 0) { + return null; + } + + return ( +
+
+
+

Join requests

+

Waiting for your approval

+
+ + {requests.length} + +
+
+ {requests.map((request) => ( +
+
+

+ {request.player?.name ?? "Player"} +

+

+ {requestTypeLabel(request.type)} · {chip(request.amount)} +

+
+
+ + +
+
+ ))} +
+
+ ); +} + +function UserBuyInRequestStatus({ + requests, +}: { + requests: LivePokerBuyInRequestRow[]; +}) { + const visibleRequests = requests.slice(0, 3); + if (visibleRequests.length === 0) { + return null; + } + + return ( +
+ {visibleRequests.map((request) => ( +
+ + {requestTypeLabel(request.type)} for {chip(request.amount)} is{" "} + {request.status.toLowerCase()} + {request.seatIndex !== undefined + ? ` for seat ${request.seatIndex + 1}` + : ""} + +
+ ))} +
+ ); +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: This component owns the socket lifecycle and compact v1 table controls. +export function LivePokerTableClient({ tableId }: LivePokerTableClientProps) { + const socketRef = useRef(null); + const socketLifecycleRef = useRef(0); + const approvalInFlightRef = useRef(false); + const tableIdRef = useRef(tableId); + tableIdRef.current = tableId; + const { userId } = useConvexUser(); + const tableConvexId = tableId as Id<"livePokerTables">; + const [playerId, setPlayerId] = useState | null>(null); + const [buyIn, setBuyIn] = useState("100"); + const [betTargetAmount, setBetTargetAmount] = useState(0); + const [approvingRequestId, setApprovingRequestId] = useState( + null + ); + const [connectionAttempt, setConnectionAttempt] = useState(0); + const [connectionStatus, setConnectionStatus] = + useState("connecting"); + const [clockNow, setClockNow] = useState(() => Date.now()); + const [error, setError] = useState(null); + const [isRequestingBuyIn, setIsRequestingBuyIn] = useState(false); + const [retryNonce, setRetryNonce] = useState(0); + const [kickingSeatIndex, setKickingSeatIndex] = useState(null); + const [rejectingRequestId, setRejectingRequestId] = useState( + null + ); + const [stateSnapshot, setStateSnapshot] = useState<{ + receivedAt: number; + state: PublicLivePokerState; + tableId: string; + } | null>(null); + const state = stateSnapshot?.tableId === tableId ? stateSnapshot.state : null; + const createBuyInRequest = useCreateLivePokerBuyInRequest(); + const respondToBuyInRequest = useRespondToLivePokerBuyInRequest(); + const { pending: pendingBuyInRequests, user: userBuyInRequests } = + useLivePokerBuyInRequests(tableConvexId, userId); + + const send = useCallback((message: LivePokerClientMessage) => { + const socket = socketRef.current; + if (!socket || socket.readyState !== WebSocket.OPEN) { + setError( + "The table is disconnected. Wait for it to reconnect and try again." + ); + return false; + } + + socket.send(JSON.stringify(message)); + return true; + }, []); + + useEffect(() => { + const nextTableId = tableId; + setStateSnapshot(null); + setPlayerId(null); + setBetTargetAmount(0); + setApprovingRequestId(null); + setRejectingRequestId(null); + setKickingSeatIndex(null); + setIsRequestingBuyIn(false); + setError(nextTableId ? null : "A table ID is required."); + approvalInFlightRef.current = false; + }, [tableId]); + + useEffect(() => { + let active = true; + let reconnectAttempt = 0; + let reconnectResetTimer: ReturnType | null = null; + let reconnectTimer: ReturnType | null = null; + let tokenController: AbortController | null = null; + let tokenRefreshTimer: ReturnType | null = null; + const lifecycle = socketLifecycleRef.current + retryNonce + 1; + socketLifecycleRef.current = lifecycle; + + function isCurrentLifecycle() { + return active && socketLifecycleRef.current === lifecycle; + } + + function scheduleReconnect(message: string) { + if (!isCurrentLifecycle()) { + return; + } + if (reconnectAttempt >= LIVE_POKER_MAX_RECONNECT_ATTEMPTS) { + setConnectionStatus("disconnected"); + setError( + `${message} Automatic reconnect stopped after ${LIVE_POKER_MAX_RECONNECT_ATTEMPTS} attempts.` + ); + return; + } + + const delay = getJitteredLivePokerReconnectDelay(reconnectAttempt); + reconnectAttempt += 1; + setConnectionAttempt(reconnectAttempt); + setConnectionStatus("reconnecting"); + reconnectTimer = setTimeout(connect, delay); + } + + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: The socket setup keeps fetch, WebSocket, and stale-lifecycle guards together. + async function connect() { + if (!isCurrentLifecycle()) { + return; + } + if (tokenRefreshTimer) { + clearTimeout(tokenRefreshTimer); + tokenRefreshTimer = null; + } + + setConnectionStatus( + reconnectAttempt === 0 ? "connecting" : "reconnecting" + ); + tokenController = new AbortController(); + + try { + const response = await fetch("/api/live-poker/token", { + body: JSON.stringify({ tableId }), + headers: { "content-type": "application/json" }, + method: "POST", + signal: tokenController.signal, + }); + + if (!response.ok) { + const errorBody = (await response.json().catch(() => null)) as { + error?: string; + } | null; + throw new Error(errorBody?.error ?? "Unable to join live table"); + } + + const body = (await response.json()) as { + config: { + bigBlind: number; + createdById: string; + maxBuyIn: number; + minBuyIn: number; + seatCount: number; + smallBlind: number; + }; + expiresAt: number; + player: { + id: Id<"players">; + name: string; + }; + token: string; + }; + + if (!isCurrentLifecycle()) { + return; + } + setPlayerId(body.player.id); + + const workerBaseUrl = process.env.NEXT_PUBLIC_LIVE_POKER_WORKER_URL; + if (!workerBaseUrl) { + throw new Error("The live poker worker URL is not configured"); + } + const workerUrl = new URL( + `/live-poker/${encodeURIComponent(tableId)}`, + workerBaseUrl + ); + + const socket = new WebSocket(workerUrl, [ + "buyin-live-poker", + `buyin-auth-${body.token}`, + ]); + socketRef.current = socket; + + function isCurrentSocket() { + return isCurrentLifecycle() && socketRef.current === socket; + } + + tokenRefreshTimer = setTimeout( + () => { + if (!isCurrentSocket()) { + return; + } + socketRef.current = null; + socket.close(4001, "Refreshing credentials"); + setConnectionStatus("reconnecting"); + connect(); + }, + Math.max(1000, body.expiresAt - Date.now() - 30_000) + ); + + socket.addEventListener("open", () => { + if (!isCurrentSocket()) { + socket.close(); + return; + } + setConnectionAttempt(0); + setConnectionStatus("connected"); + setError(null); + reconnectResetTimer = setTimeout(() => { + if (isCurrentSocket()) { + reconnectAttempt = 0; + } + }, 30_000); + }); + + socket.addEventListener("message", (event) => { + if (!isCurrentSocket()) { + return; + } + try { + const message = JSON.parse( + String(event.data) + ) as LivePokerServerMessage; + if (message.type === "tableState") { + const receivedAt = Date.now(); + setClockNow(receivedAt); + setStateSnapshot({ + receivedAt, + state: message.state, + tableId, + }); + } else if (message.type === "actionRejected") { + setError(message.message); + } + } catch { + setError("The live table sent an unreadable update."); + } + }); + + socket.addEventListener("close", () => { + if (!isCurrentSocket()) { + return; + } + if (reconnectResetTimer) { + clearTimeout(reconnectResetTimer); + reconnectResetTimer = null; + } + socketRef.current = null; + scheduleReconnect("Connection to the live table was lost."); + }); + + socket.addEventListener("error", () => { + if (!isCurrentSocket()) { + return; + } + setError("Unable to connect to the live table. Retrying…"); + socket.close(); + }); + } catch (connectError) { + if ( + !isCurrentLifecycle() || + (connectError instanceof DOMException && + connectError.name === "AbortError") + ) { + return; + } + const message = + connectError instanceof Error + ? connectError.message + : "Unable to connect to the live table."; + setError(message); + scheduleReconnect(message); + } + } + + setConnectionAttempt(0); + setConnectionStatus("connecting"); + connect(); + + return () => { + active = false; + socketLifecycleRef.current += 1; + tokenController?.abort(); + if (reconnectResetTimer) { + clearTimeout(reconnectResetTimer); + } + if (reconnectTimer) { + clearTimeout(reconnectTimer); + } + if (tokenRefreshTimer) { + clearTimeout(tokenRefreshTimer); + } + const socket = socketRef.current; + socketRef.current = null; + socket?.close(); + }; + }, [retryNonce, tableId]); + + const serverClockNow = state + ? state.serverTimeAt + (clockNow - (stateSnapshot?.receivedAt ?? clockNow)) + : clockNow; + const currentSeat = useMemo( + () => state?.seats.find((seat) => seat?.isCurrentUser) ?? null, + [state] + ); + const isConnected = connectionStatus === "connected"; + const numericBuyIn = Number(buyIn); + const hasFiniteBuyIn = Number.isFinite(numericBuyIn); + const callAmount = + currentSeat && state ? state.currentBet - currentSeat.bet : 0; + const canAct = + isConnected && + Boolean(currentSeat && state?.activeSeatIndex === currentSeat.seatIndex) && + state?.phase !== "waiting" && + !state?.transition && + Boolean(state?.turnDeadlineAt && state.turnDeadlineAt > serverClockNow); + const betTargetBounds = + state && currentSeat ? getBetTargetBounds(state, currentSeat) : null; + const betTargetMin = betTargetBounds?.minTarget ?? 0; + const betTargetResetKey = [ + currentSeat?.bet, + currentSeat?.seatIndex, + currentSeat?.stack, + state?.activeSeatIndex, + state?.bigBlind, + state?.currentBet, + state?.handNumber, + state?.minRaise, + state?.phase, + ].join(":"); + const canRequestAddOn = + isConnected && + Boolean(currentSeat && state?.phase === "waiting") && + hasFiniteBuyIn && + numericBuyIn > 0 && + numericBuyIn <= (state?.maxBuyIn ?? Number.POSITIVE_INFINITY) && + !isRequestingBuyIn; + const canRequestInitialBuyIn = + isConnected && + Boolean(!currentSeat && state?.phase === "waiting") && + hasFiniteBuyIn && + numericBuyIn >= (state?.minBuyIn ?? 0) && + numericBuyIn <= (state?.maxBuyIn ?? 0) && + !isRequestingBuyIn; + const disconnectedEligiblePlayerCount = + state?.seats.filter( + (seat) => seat && !seat.connected && !seat.sitOut && seat.stack > 0 + ).length ?? 0; + const kickCandidate = + kickingSeatIndex === null ? null : (state?.seats[kickingSeatIndex] ?? null); + const dealSequence = useMemo(() => { + const orderBySeat = new Map(); + if (!state || state.dealerSeatIndex === null) { + return { count: 0, orderBySeat }; + } + + for (let offset = 1; offset <= state.seatCount; offset += 1) { + const seatIndex = (state.dealerSeatIndex + offset) % state.seatCount; + if (state.seats[seatIndex]?.hasCards) { + orderBySeat.set(seatIndex, orderBySeat.size); + } + } + return { count: orderBySeat.size, orderBySeat }; + }, [state]); + const winnerAmountsBySeat = useMemo(() => { + const amounts = new Map(); + for (const winner of state?.lastWinners ?? []) { + amounts.set( + winner.seatIndex, + (amounts.get(winner.seatIndex) ?? 0) + winner.amount + ); + } + return amounts; + }, [state?.lastWinners]); + const typedPendingBuyInRequests = + pendingBuyInRequests as LivePokerBuyInRequestRow[]; + const typedUserBuyInRequests = + userBuyInRequests as LivePokerBuyInRequestRow[]; + + useEffect(() => { + const deadline = state?.transitionDeadlineAt ?? state?.turnDeadlineAt; + if (!deadline) { + return; + } + setClockNow(Date.now()); + const timer = window.setInterval(() => setClockNow(Date.now()), 250); + return () => window.clearInterval(timer); + }, [state?.transitionDeadlineAt, state?.turnDeadlineAt]); + + useEffect(() => { + if (!(canAct && betTargetResetKey)) { + return; + } + + setBetTargetAmount(betTargetMin); + }, [betTargetMin, betTargetResetKey, canAct]); + + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Validation and stale-table guards keep the request atomic. + async function requestInitialBuyIn(seatIndex: number) { + const requestTableId = tableId; + if (!(state && userId && playerId)) { + setError("Sign in with a player profile before requesting a seat"); + return; + } + const firstOpenSeat = + seatIndex >= 0 ? seatIndex : state.seats.findIndex((seat) => !seat); + if (firstOpenSeat < 0) { + setError("No open seats"); + return; + } + if (!canRequestInitialBuyIn) { + setError( + `Buy-in must be between ${state.minBuyIn} and ${state.maxBuyIn}` + ); + return; + } + + setIsRequestingBuyIn(true); + setError(null); + try { + const requestId = await createBuyInRequest({ + amount: numericBuyIn, + playerId, + seatIndex: firstOpenSeat, + tableId: tableConvexId, + type: "INITIAL", + userId, + }); + if (state.isHost && tableIdRef.current === requestTableId) { + await approveBuyInRequest(String(requestId)); + } + } catch (requestError) { + if (tableIdRef.current === requestTableId) { + setError( + requestError instanceof Error + ? requestError.message + : "Unable to request buy-in" + ); + } + } finally { + if (tableIdRef.current === requestTableId) { + setIsRequestingBuyIn(false); + } + } + } + + async function requestAddOn() { + const requestTableId = tableId; + if (!(userId && playerId && currentSeat)) { + setError("Take a seat before requesting chips"); + return; + } + if (!canRequestAddOn) { + setError("Enter a valid chip amount"); + return; + } + + setIsRequestingBuyIn(true); + setError(null); + try { + const requestId = await createBuyInRequest({ + amount: numericBuyIn, + playerId, + tableId: tableConvexId, + type: "ADD_ON", + userId, + }); + if (state?.isHost && tableIdRef.current === requestTableId) { + await approveBuyInRequest(String(requestId)); + } + } catch (requestError) { + if (tableIdRef.current === requestTableId) { + setError( + requestError instanceof Error + ? requestError.message + : "Unable to request chips" + ); + } + } finally { + if (tableIdRef.current === requestTableId) { + setIsRequestingBuyIn(false); + } + } + } + + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Approval locking and stale-table guards prevent duplicate side effects. + async function approveBuyInRequest(requestId: string) { + const requestTableId = tableId; + if (approvalInFlightRef.current) { + return; + } + if (!isConnected) { + setError("Reconnect before approving a buy-in request."); + return; + } + + approvalInFlightRef.current = true; + setApprovingRequestId(requestId); + setError(null); + try { + const response = await fetch("/api/live-poker/respond-buy-in", { + body: JSON.stringify({ requestId }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { + error?: string; + } | null; + throw new Error(body?.error ?? "Unable to approve buy-in request"); + } + if (tableIdRef.current === requestTableId) { + send({ type: "requestSync" }); + } + } catch (requestError) { + if (tableIdRef.current === requestTableId) { + setError( + requestError instanceof Error + ? requestError.message + : "Unable to approve buy-in request" + ); + } + } finally { + if (tableIdRef.current === requestTableId) { + approvalInFlightRef.current = false; + setApprovingRequestId(null); + } + } + } + + async function rejectBuyInRequest(requestId: string) { + const requestTableId = tableId; + if (!userId) { + return; + } + setRejectingRequestId(requestId); + setError(null); + try { + await respondToBuyInRequest({ + requestId: requestId as Id<"livePokerBuyInRequests">, + status: "REJECTED", + userId, + }); + } catch (requestError) { + if (tableIdRef.current === requestTableId) { + setError( + requestError instanceof Error + ? requestError.message + : "Unable to reject buy-in request" + ); + } + } finally { + if (tableIdRef.current === requestTableId) { + setRejectingRequestId(null); + } + } + } + + function kickSeat(seatIndex: number) { + setKickingSeatIndex(null); + send({ seatIndex, type: "kickSeat" }); + } + + if (!state) { + return ( +
+
+ {connectionStatus !== "disconnected" ? ( + + ) : ( + + )} +

+ {connectionLabel(connectionStatus, connectionAttempt, true)} +

+ {error ?

{error}

: null} + {connectionStatus === "disconnected" ? ( + + ) : null} +
+
+ ); + } + + return ( +
+
+
+
+

Live Poker

+

+ {state.phase} · Blinds {chip(state.smallBlind)} /{" "} + {chip(state.bigBlind)} +

+
+
+ {state.isHost ? ( + + ) : null} + {!state.isHost && state.gamePaused ? ( + + + ) : null} +
+
+
+
+ +
+
+
+
+
+ +
+ + + +
+ {state?.communityCards.length ? ( + state.communityCards + .slice(0, visibleCommunityCardCount(state, serverClockNow)) + .map((card, cardIndex) => ( + = state.communityCardRevealStartIndex + ? 0 + : undefined + } + card={card} + key={card} + /> + )) + ) : ( + + Community cards + + )} +
+
+
+ + {state.isHost && typedPendingBuyInRequests.length > 0 ? ( +
+ +
+ ) : null} + + {state?.seats.map((seat, index) => { + const position = getViewerSeatPosition( + index, + state.seatCount, + currentSeat?.seatIndex + ); + const dealerButtonPosition = getDealerButtonPosition(position); + + return ( +
+ {state.dealerSeatIndex === index ? ( + + ) : null} + + + {seat && state.isHost && !seat.isCurrentUser ? ( + + ) : null} +
+ ); + })} + + {state && + currentSeat && + state.phase !== "waiting" && + betTargetBounds ? ( + + ) : null} +
+ + { + if (!open) { + setKickingSeatIndex(null); + } + }} + open={kickingSeatIndex !== null} + > + + + Remove player from table? + + {kickCandidate + ? `${kickCandidate.name} will be removed from seat ${kickCandidate.seatIndex + 1}. This cannot be undone.` + : "This seat is no longer occupied."} + + + + Cancel + { + if (kickingSeatIndex !== null) { + kickSeat(kickingSeatIndex); + } + }} + > + Remove player + + + + + +
+ {currentSeat ? ( +
+ {state?.phase === "waiting" ? ( + <> + setBuyIn(event.target.value)} + placeholder="Chips" + type="number" + value={buyIn} + /> + + + ) : null} + + +
+ ) : ( +
+ setBuyIn(event.target.value)} + placeholder="Buy-in" + type="number" + value={buyIn} + /> + +
+ )} + + {state.isHost && disconnectedEligiblePlayerCount > 0 ? ( +

+ {disconnectedEligiblePlayerCount} disconnected seated player + {disconnectedEligiblePlayerCount === 1 ? " is" : "s are"} still + eligible to be dealt in, matching table rules. +

+ ) : null} + {error ? ( +
+

+ {error} +

+ {connectionStatus === "disconnected" ? ( + + ) : null} +
+ ) : null} +
+
+
+ ); +} diff --git a/src/features/live-poker/live-poker-table-form.tsx b/src/features/live-poker/live-poker-table-form.tsx new file mode 100644 index 0000000..011e181 --- /dev/null +++ b/src/features/live-poker/live-poker-table-form.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2, RadioTower } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + type LivePokerTableFormInput, + type LivePokerTableFormValues, + livePokerTableFormSchema, +} from "@/lib/live-poker/table-form-schema"; + +export type { LivePokerTableFormValues } from "@/lib/live-poker/table-form-schema"; + +interface LivePokerTableFormProps { + onSubmit: (data: LivePokerTableFormValues) => void | Promise; + submitError?: string | null; +} + +function FieldError({ id, message }: { id: string; message?: string }) { + if (!message) { + return null; + } + + return ( +

+ {message} +

+ ); +} + +export function LivePokerTableForm({ + onSubmit, + submitError, +}: LivePokerTableFormProps) { + const form = useForm< + LivePokerTableFormInput, + unknown, + LivePokerTableFormValues + >({ + resolver: zodResolver(livePokerTableFormSchema), + defaultValues: { + title: "No-Limit Hold'em", + smallBlind: "1", + bigBlind: "2", + minBuyIn: "20", + maxBuyIn: "400", + seatCount: "6", + }, + }); + + const { errors, isSubmitting } = form.formState; + + return ( + + + Live Table + + Create a public signed-in poker table anyone can join. + + + +
+
+ + + +
+ +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + {submitError ? ( +

+ {submitError} +

+ ) : null} + + +
+
+
+ ); +} diff --git a/src/features/live-poker/new-live-poker-table-client.tsx b/src/features/live-poker/new-live-poker-table-client.tsx new file mode 100644 index 0000000..c336279 --- /dev/null +++ b/src/features/live-poker/new-live-poker-table-client.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useNotification } from "@/components/providers/notification-provider"; +import { useConvexUser } from "@/lib/convex-hooks"; +import { useCreateLivePokerTable } from "@/lib/live-poker-hooks"; +import { + LivePokerTableForm, + type LivePokerTableFormValues, +} from "./live-poker-table-form"; + +export function NewLivePokerTableClient() { + const router = useRouter(); + const { showNotification } = useNotification(); + const { userId, isLoading } = useConvexUser(); + const createLivePokerTable = useCreateLivePokerTable(); + const [submitError, setSubmitError] = useState(null); + + async function handleCreateTable(data: LivePokerTableFormValues) { + setSubmitError(null); + if (!userId) { + setSubmitError("Your account is still loading. Please try again."); + return; + } + + try { + const tableId = await createLivePokerTable({ + title: data.title, + smallBlind: data.smallBlind, + bigBlind: data.bigBlind, + minBuyIn: data.minBuyIn, + maxBuyIn: data.maxBuyIn, + seatCount: data.seatCount, + createdById: userId, + }); + router.push(`/live-poker/${tableId}`); + } catch (error) { + console.error("Failed to create live poker table:", error); + const message = "Failed to create live poker table. Please try again."; + setSubmitError(message); + showNotification(message, { type: "error" }); + } + } + + if (isLoading) { + return null; + } + + return ( + + ); +} diff --git a/src/lib/live-poker-hooks.ts b/src/lib/live-poker-hooks.ts new file mode 100644 index 0000000..39aad9a --- /dev/null +++ b/src/lib/live-poker-hooks.ts @@ -0,0 +1,224 @@ +"use client"; + +import { useQuery } from "convex/react"; +import { useEffect, useState } from "react"; +import { api } from "../../convex/_generated/api"; +import type { Id } from "../../convex/_generated/dataModel"; + +export function useLivePokerTables(currentUserId?: Id<"users">) { + const tables = useQuery(api.live_poker.listLivePokerTables, { + currentUserId, + }); + + return { + isLoading: tables === undefined, + tables: tables ?? [], + }; +} + +export function useLivePokerTable(tableId: Id<"livePokerTables"> | undefined) { + const table = useQuery( + api.live_poker.getLivePokerTable, + tableId ? { tableId } : "skip" + ); + + return { + isLoading: table === undefined && tableId !== undefined, + table, + }; +} + +async function requestLivePokerApi(url: string, init: RequestInit) { + const response = await fetch(url, init); + const body = (await response.json().catch(() => null)) as + | (T & { error?: string }) + | null; + if (!response.ok) { + throw new Error(body?.error ?? "Live poker request failed"); + } + if (!body) { + throw new Error("Live poker returned an empty response"); + } + return body; +} + +export function useCreateLivePokerTable() { + return async (args: { + bigBlind: number; + createdById: Id<"users">; + maxBuyIn: number; + minBuyIn: number; + seatCount: number; + smallBlind: number; + title: string; + }) => { + const { createdById: _createdById, ...body } = args; + const result = await requestLivePokerApi<{ tableId: string }>( + "/api/live-poker/tables", + { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method: "POST", + } + ); + return result.tableId; + }; +} + +export function useDeleteLivePokerTable() { + return async (args: { + tableId: Id<"livePokerTables">; + userId: Id<"users">; + }) => { + const result = await requestLivePokerApi<{ ok: boolean }>( + `/api/live-poker/tables/${encodeURIComponent(args.tableId)}`, + { method: "DELETE" } + ); + return result.ok; + }; +} + +export function useCreateLivePokerBuyInRequest() { + return async (args: { + amount: number; + playerId: Id<"players">; + seatIndex?: number; + tableId: Id<"livePokerTables">; + type: "ADD_ON" | "INITIAL"; + userId: Id<"users">; + }) => { + const { playerId: _playerId, userId: _userId, ...body } = args; + const result = await requestLivePokerApi<{ requestId: string }>( + "/api/live-poker/buy-in-requests", + { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method: "POST", + } + ); + return result.requestId; + }; +} + +export function useLivePokerBuyInRequests( + tableId: Id<"livePokerTables"> | undefined, + userId: Id<"users"> | undefined +) { + const [requests, setRequests] = useState<{ + pending: unknown[]; + user: unknown[]; + }>({ pending: [], user: [] }); + const [isLoading, setIsLoading] = useState(Boolean(tableId && userId)); + + useEffect(() => { + if (!(tableId && userId)) { + setRequests({ pending: [], user: [] }); + setIsLoading(false); + return; + } + + setRequests({ pending: [], user: [] }); + setIsLoading(true); + let active = true; + let controller: AbortController | null = null; + let loading = false; + let retryDelay = 5000; + let timer: number | null = null; + + function schedule(delay: number) { + if (active) { + if (timer !== null) { + window.clearTimeout(timer); + } + timer = window.setTimeout(load, delay); + } + } + + async function load() { + if (!active || loading) { + return; + } + if (document.hidden) { + schedule(30_000); + return; + } + loading = true; + controller = new AbortController(); + try { + const body = await requestLivePokerApi<{ + pending: unknown[]; + user: unknown[]; + }>( + `/api/live-poker/buy-in-requests?tableId=${encodeURIComponent(tableId as string)}`, + { + method: "GET", + signal: AbortSignal.any([ + controller.signal, + AbortSignal.timeout(10_000), + ]), + } + ); + if (active) { + setRequests({ pending: body.pending, user: body.user }); + setIsLoading(false); + retryDelay = 5000; + } + } catch (error) { + if ( + active && + !(error instanceof DOMException && error.name === "AbortError") + ) { + setIsLoading(false); + retryDelay = Math.min(60_000, retryDelay * 2); + } + } finally { + loading = false; + schedule(retryDelay); + } + } + + function handleVisibilityChange() { + if (!document.hidden) { + if (timer !== null) { + window.clearTimeout(timer); + timer = null; + } + load(); + } + } + + load(); + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + active = false; + controller?.abort(); + if (timer !== null) { + window.clearTimeout(timer); + } + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [tableId, userId]); + + return { isLoading, pending: requests.pending, user: requests.user }; +} + +export function useRespondToLivePokerBuyInRequest() { + return async (args: { + requestId: Id<"livePokerBuyInRequests">; + status: "REJECTED"; + userId: Id<"users">; + }) => { + const result = await requestLivePokerApi<{ ok: boolean }>( + "/api/live-poker/respond-buy-in", + { + body: JSON.stringify({ + requestId: args.requestId, + status: args.status, + }), + headers: { "content-type": "application/json" }, + method: "POST", + } + ); + return result.ok; + }; +} diff --git a/src/lib/live-poker/apply-buy-in-request.ts b/src/lib/live-poker/apply-buy-in-request.ts new file mode 100644 index 0000000..be255da --- /dev/null +++ b/src/lib/live-poker/apply-buy-in-request.ts @@ -0,0 +1,124 @@ +import type { Id } from "../../../convex/_generated/dataModel"; +import type { LivePokerTableConfig } from "./types"; + +interface LivePokerTableWorkerConfig { + bigBlind: number; + createdById: string; + maxBuyIn: number; + minBuyIn: number; + seatCount: number; + smallBlind: number; +} + +interface LivePokerBuyInRequestForWorker { + amount: number; + id: Id<"livePokerBuyInRequests">; + playerId: Id<"players">; + playerName: string; + seatIndex?: number; + tableId: Id<"livePokerTables">; + table: LivePokerTableWorkerConfig; + type: "ADD_ON" | "INITIAL"; + userId: Id<"users">; +} + +function getWorkerHttpUrl(tableId: string, operation: "claim" | "close") { + const base = + process.env.LIVE_POKER_WORKER_URL || + process.env.NEXT_PUBLIC_LIVE_POKER_WORKER_URL; + if (!base) { + throw new Error("LIVE_POKER_WORKER_URL is not configured"); + } + const url = new URL( + `/live-poker/${encodeURIComponent(tableId)}/${operation}`, + base + ); + if (url.protocol === "ws:") { + url.protocol = "http:"; + } + if (url.protocol === "wss:") { + url.protocol = "https:"; + } + return url; +} + +function getWorkerConfig(table: LivePokerTableWorkerConfig) { + return { + bigBlind: table.bigBlind, + hostUserId: table.createdById, + maxBuyIn: table.maxBuyIn, + minBuyIn: table.minBuyIn, + seatCount: table.seatCount, + smallBlind: table.smallBlind, + } satisfies LivePokerTableConfig; +} + +async function postToWorker( + tableId: string, + operation: "claim" | "close", + body: unknown +) { + const secret = process.env.LIVE_POKER_CONTROL_SECRET; + if (!secret) { + return { + error: "Live poker worker control secret is not configured", + status: 500, + }; + } + + let workerResponse: Response; + try { + workerResponse = await fetch(getWorkerHttpUrl(tableId, operation), { + body: JSON.stringify(body), + headers: { + "content-type": "application/json", + "x-live-poker-secret": secret, + }, + method: "POST", + signal: AbortSignal.timeout(10_000), + }); + } catch (error) { + return { + error: + error instanceof Error + ? `Unable to reach live poker worker: ${error.message}` + : "Unable to reach live poker worker", + status: 502, + }; + } + if (workerResponse.ok) { + return { ok: true as const }; + } + + const responseBody = (await workerResponse.json().catch(() => null)) as { + error?: string; + } | null; + return { + error: responseBody?.error ?? `Unable to ${operation} live poker table`, + status: workerResponse.status, + }; +} + +export async function applyLivePokerBuyInRequest( + request: LivePokerBuyInRequestForWorker +) { + return await postToWorker(request.tableId, "claim", { + amount: request.amount, + config: getWorkerConfig(request.table), + playerId: request.playerId, + playerName: request.playerName, + requestId: request.id, + seatIndex: request.seatIndex, + type: request.type, + userId: request.userId, + }); +} + +export async function closeLivePokerTable( + tableId: Id<"livePokerTables">, + table: LivePokerTableWorkerConfig +) { + return await postToWorker(tableId, "close", { + config: getWorkerConfig(table), + }); +} diff --git a/src/lib/live-poker/auth.ts b/src/lib/live-poker/auth.ts new file mode 100644 index 0000000..aa458b1 --- /dev/null +++ b/src/lib/live-poker/auth.ts @@ -0,0 +1,45 @@ +import { jwtVerify, SignJWT } from "jose"; +import type { LivePokerAuthToken } from "./types"; + +const encoder = new TextEncoder(); + +function getSecret() { + const secret = process.env.LIVE_POKER_JWT_SECRET; + if (!secret) { + throw new Error("LIVE_POKER_JWT_SECRET is not configured"); + } + return encoder.encode(secret); +} + +export async function signLivePokerToken(payload: LivePokerAuthToken) { + return await new SignJWT({ + tableConfig: payload.tableConfig, + tableId: payload.tableId, + playerId: payload.playerId, + playerName: payload.playerName, + userId: payload.userId, + }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime(payload.exp) + .sign(getSecret()); +} + +export async function verifyLivePokerToken(token: string) { + const { payload } = await jwtVerify(token, getSecret()); + + const tableConfig = payload.tableConfig as + | LivePokerAuthToken["tableConfig"] + | undefined; + if (!tableConfig) { + throw new Error("Live poker token is missing table configuration"); + } + + return { + exp: Number(payload.exp), + tableConfig, + tableId: String(payload.tableId), + playerId: String(payload.playerId), + playerName: String(payload.playerName), + userId: String(payload.userId), + } satisfies LivePokerAuthToken; +} diff --git a/src/lib/live-poker/cloudflare-safety.ts b/src/lib/live-poker/cloudflare-safety.ts new file mode 100644 index 0000000..f22610d --- /dev/null +++ b/src/lib/live-poker/cloudflare-safety.ts @@ -0,0 +1,48 @@ +export const LIVE_POKER_CLOSED_OBJECT_RETENTION_MS = 10 * 60 * 1000; +export const LIVE_POKER_MAX_CONNECTIONS_PER_TABLE = 36; +export const LIVE_POKER_MAX_CONNECTIONS_PER_USER = 3; +export const LIVE_POKER_MAX_CONSECUTIVE_TIMEOUTS = 6; +export const LIVE_POKER_MAX_HANDS_PER_TABLE = 250; +export const LIVE_POKER_MAX_MESSAGE_BYTES = 4096; +export const LIVE_POKER_MAX_OUTBOX_ATTEMPTS = 12; +export const LIVE_POKER_MAX_OUTBOX_RETRY_DELAY_MS = 60 * 60 * 1000; + +const LIVE_POKER_INITIAL_OUTBOX_RETRY_DELAY_MS = 60 * 1000; +const LIVE_POKER_TABLE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; + +export function getLivePokerOutboxRetryDelay( + attempts: number, + randomValue = Math.random() +) { + const safeAttempts = Math.max(1, Math.floor(attempts)); + const baseDelay = Math.min( + LIVE_POKER_MAX_OUTBOX_RETRY_DELAY_MS, + LIVE_POKER_INITIAL_OUTBOX_RETRY_DELAY_MS * 2 ** (safeAttempts - 1) + ); + const jitter = 0.75 + Math.min(1, Math.max(0, randomValue)) * 0.25; + return Math.round(baseDelay * jitter); +} + +export function isLivePokerMessageWithinLimit( + message: string | ArrayBuffer +) { + if ( + typeof message === "string" && + message.length > LIVE_POKER_MAX_MESSAGE_BYTES + ) { + return false; + } + const bytes = + typeof message === "string" + ? new TextEncoder().encode(message).byteLength + : message.byteLength; + return bytes <= LIVE_POKER_MAX_MESSAGE_BYTES; +} + +export function isLivePokerTableId(value: string) { + return LIVE_POKER_TABLE_ID_PATTERN.test(value); +} + +export function isRetryableLivePokerWebhookStatus(status: number) { + return status === 408 || status === 429 || status >= 500; +} diff --git a/src/lib/live-poker/engine.ts b/src/lib/live-poker/engine.ts new file mode 100644 index 0000000..f86d922 --- /dev/null +++ b/src/lib/live-poker/engine.ts @@ -0,0 +1,782 @@ +import { rankHands } from "@xpressit/winning-poker-hand-rank"; +import { LIVE_POKER_TIME_BANK_MS } from "./timing"; +import type { LivePokerSeat, LivePokerState, LivePokerWinner } from "./types"; + +const RANKS = ["2", "3", "4", "5", "6", "7", "8", "9", "T", "J", "Q", "K", "A"]; +const SUITS = ["S", "H", "D", "C"]; + +// Live table amounts are currency-denominated (the UI accepts hundredths). +const CHIP_UNITS_PER_AMOUNT = 100; + +interface RankResult { + combination: string; + rank: number; +} + +export function isEligibleForNextHand(seat: LivePokerSeat) { + return seat.connected && !seat.sitOut && seat.stack > 0; +} + +export function getEligibleSeats(state: LivePokerState) { + return state.seats.filter( + (seat): seat is LivePokerSeat => + seat !== null && isEligibleForNextHand(seat) + ); +} + +export function canStartHand(state: LivePokerState) { + return ( + state.phase === "waiting" && + !state.admissionsClosed && + !state.gamePaused && + getEligibleSeats(state).length >= 2 + ); +} + +function handSeats(state: LivePokerState) { + return state.seats.filter((seat): seat is LivePokerSeat => + Boolean(seat?.cards?.length) + ); +} + +function nextSeatIndex( + state: LivePokerState, + from: number, + predicate: (seat: LivePokerSeat) => boolean +) { + for (let offset = 1; offset <= state.seatCount; offset += 1) { + const index = (from + offset) % state.seatCount; + const seat = state.seats[index]; + if (seat && predicate(seat)) { + return index; + } + } + + return null; +} + +function toChipUnits(amount: number, label = "Chip amount") { + const units = Math.round(amount * CHIP_UNITS_PER_AMOUNT); + if ( + !(Number.isFinite(amount) && Number.isSafeInteger(units)) || + Math.abs(amount * CHIP_UNITS_PER_AMOUNT - units) > 1e-7 + ) { + throw new Error(`${label} must use increments of 0.01`); + } + return units; +} + +function fromChipUnits(units: number) { + return units / CHIP_UNITS_PER_AMOUNT; +} + +function validateChipAmount( + amount: number, + label: string, + { allowZero = false }: { allowZero?: boolean } = {} +) { + const units = toChipUnits(amount, label); + if (allowZero ? units < 0 : units <= 0) { + throw new Error( + `${label} must be ${allowZero ? "non-negative" : "positive"}` + ); + } + return fromChipUnits(units); +} + +function postBlind(seat: LivePokerSeat, amount: number) { + const stackUnits = toChipUnits(seat.stack); + const postedUnits = Math.min(stackUnits, toChipUnits(amount)); + const posted = fromChipUnits(postedUnits); + seat.stack = fromChipUnits(stackUnits - postedUnits); + seat.bet = fromChipUnits(toChipUnits(seat.bet) + postedUnits); + seat.committed = fromChipUnits(toChipUnits(seat.committed) + postedUnits); + seat.isAllIn = seat.stack === 0; + return posted; +} + +function resetStreet(state: LivePokerState) { + state.currentBet = 0; + for (const seat of state.seats) { + if (seat) { + seat.bet = 0; + seat.hasActedThisStreet = false; + seat.streetAction = undefined; + } + } +} + +function firstActionSeat(state: LivePokerState, afterSeatIndex: number) { + return nextSeatIndex( + state, + afterSeatIndex, + (seat) => !(seat.folded || seat.isAllIn) && Boolean(seat.cards?.length) + ); +} + +function isBettingRoundComplete(state: LivePokerState) { + const liveSeats = handSeats(state).filter((seat) => !seat.folded); + if (liveSeats.length <= 1) { + return true; + } + + return liveSeats.every( + (seat) => + seat.isAllIn || (seat.bet === state.currentBet && seat.hasActedThisStreet) + ); +} + +function shouldRunoutToShowdown(state: LivePokerState) { + const liveSeats = handSeats(state).filter((seat) => !seat.folded); + const seatsWithAction = liveSeats.filter((seat) => !seat.isAllIn); + + return ( + liveSeats.length > 1 && + seatsWithAction.length <= 1 && + liveSeats.every((seat) => seat.isAllIn || seat.bet === state.currentBet) + ); +} + +function beginRunout(state: LivePokerState) { + state.activeSeatIndex = null; + state.runoutPending = true; +} + +export function revealNextRunoutStage(state: LivePokerState) { + if (!state.runoutPending) { + throw new Error("No board runout is pending"); + } + + if (state.communityCards.length === 0) { + state.phase = "flop"; + state.communityCards.push( + state.deck.pop() as string, + state.deck.pop() as string, + state.deck.pop() as string + ); + state.actionLog.push("FLOP dealt"); + return; + } + + if (state.communityCards.length === 3) { + state.phase = "turn"; + state.communityCards.push(state.deck.pop() as string); + state.actionLog.push("TURN dealt"); + return; + } + + if (state.communityCards.length === 4) { + state.communityCards.push(state.deck.pop() as string); + state.actionLog.push("RIVER dealt"); + } + + state.phase = "showdown"; + state.activeSeatIndex = null; + state.runoutPending = false; +} + +export function createInitialState(config: { + bigBlind: number; + hostUserId: string; + maxBuyIn: number; + minBuyIn: number; + seatCount: number; + smallBlind: number; +}): LivePokerState { + const smallBlind = validateChipAmount(config.smallBlind, "Small blind"); + const bigBlind = validateChipAmount(config.bigBlind, "Big blind"); + const minBuyIn = validateChipAmount(config.minBuyIn, "Minimum buy-in", { + allowZero: true, + }); + const maxBuyIn = validateChipAmount(config.maxBuyIn, "Maximum buy-in"); + if (bigBlind < smallBlind) { + throw new Error("Big blind must be at least the small blind"); + } + if (maxBuyIn < minBuyIn) { + throw new Error("Maximum buy-in must be at least the minimum buy-in"); + } + + return { + actionLog: [], + activeSeatIndex: null, + bigBlind, + bigBlindSeatIndex: null, + communityCardRevealStartIndex: null, + communityCards: [], + consecutiveTimeoutActions: 0, + currentBet: 0, + dealerSeatIndex: null, + deck: [], + gamePaused: true, + handNumber: 0, + hostUserId: config.hostUserId, + lastAggressorSeatIndex: null, + lastWinners: [], + maxBuyIn, + minBuyIn, + minRaise: bigBlind, + phase: "waiting", + seatCount: config.seatCount, + seats: Array.from({ length: config.seatCount }, () => null), + settledAction: null, + showdownPot: null, + showdownSeatIndexes: [], + showdownSettled: false, + smallBlind, + smallBlindSeatIndex: null, + timeBankActive: false, + transition: null, + transitionDeadlineAt: null, + turnDeadlineAt: null, + turnStartedAt: null, + runoutPending: false, + }; +} + +export function createDeck() { + return RANKS.flatMap((rank) => SUITS.map((suit) => `${rank}${suit}`)); +} + +function secureRandomIndex(maxExclusive: number) { + const range = 4_294_967_296; + const limit = Math.floor(range / maxExclusive) * maxExclusive; + const value = new Uint32Array(1); + + do { + crypto.getRandomValues(value); + } while (value[0] >= limit); + + return value[0] % maxExclusive; +} + +export function shuffleDeck(deck = createDeck()) { + const copy = [...deck]; + for (let index = copy.length - 1; index > 0; index -= 1) { + const swapIndex = secureRandomIndex(index + 1); + [copy[index], copy[swapIndex]] = [copy[swapIndex], copy[index]]; + } + return copy; +} + +export function seatPlayer( + state: LivePokerState, + seatIndex: number, + player: { + buyIn: number; + name: string; + playerId: string; + userId: string; + } +) { + const existingSeat = state.seats.find( + (seat) => + seat?.userId === player.userId || seat?.playerId === player.playerId + ); + if (existingSeat) { + throw new Error("You are already seated at this table"); + } + + if (state.seats[seatIndex]) { + throw new Error("Seat is already occupied"); + } + const buyIn = validateChipAmount(player.buyIn, "Buy-in"); + if (buyIn < state.minBuyIn || buyIn > state.maxBuyIn) { + throw new Error( + `Buy-in must be between ${state.minBuyIn} and ${state.maxBuyIn}` + ); + } + + state.seats[seatIndex] = { + bet: 0, + buyIn, + committed: 0, + connected: true, + folded: false, + hasActedThisStreet: false, + isAllIn: false, + name: player.name, + playerId: player.playerId, + seatIndex, + sitOut: false, + stack: buyIn, + streetAction: undefined, + timeBankRemainingMs: LIVE_POKER_TIME_BANK_MS, + userId: player.userId, + }; + state.actionLog.push(`${player.name} sat in seat ${seatIndex + 1}`); +} + +export function addChips( + state: LivePokerState, + userId: string, + amount: number +) { + if (state.phase !== "waiting") { + throw new Error("You can add chips between hands"); + } + + const seat = state.seats.find((candidate) => candidate?.userId === userId); + if (!seat) { + throw new Error("Take a seat before adding chips"); + } + + const addOn = validateChipAmount(amount, "Add-on amount"); + const nextStack = fromChipUnits(toChipUnits(seat.stack) + toChipUnits(addOn)); + if (nextStack < state.minBuyIn || nextStack > state.maxBuyIn) { + throw new Error( + `Stack must be between ${state.minBuyIn} and ${state.maxBuyIn}` + ); + } + + seat.buyIn = fromChipUnits(toChipUnits(seat.buyIn) + toChipUnits(addOn)); + seat.stack = nextStack; + state.actionLog.push(`${seat.name} added ${addOn} chips`); +} + +function requireEligibleSeatsForHand(state: LivePokerState) { + if (state.phase !== "waiting") { + throw new Error("A hand is already in progress"); + } + if (state.admissionsClosed) { + throw new Error("Table is closed"); + } + if (state.gamePaused) { + throw new Error("Table is paused"); + } + const eligible = getEligibleSeats(state); + if (eligible.length < 2) { + throw new Error( + "At least two connected, active players with chips are required" + ); + } + return eligible; +} + +export function startHand(state: LivePokerState) { + const eligible = requireEligibleSeatsForHand(state); + + state.phase = "preflop"; + state.handNumber += 1; + state.communityCardRevealStartIndex = null; + state.communityCards = []; + state.deck = shuffleDeck(); + state.currentBet = 0; + state.bigBlindSeatIndex = null; + state.lastWinners = []; + state.minRaise = state.bigBlind; + state.runoutPending = false; + state.settledAction = null; + state.showdownPot = null; + state.showdownSeatIndexes = []; + state.showdownSettled = false; + state.smallBlindSeatIndex = null; + state.lastAggressorSeatIndex = null; + + for (const seat of state.seats) { + if (seat) { + seat.bet = 0; + seat.cards = undefined; + seat.committed = 0; + seat.folded = false; + seat.hasActedThisStreet = false; + seat.isAllIn = false; + seat.streetAction = undefined; + } + } + + const previousDealer = state.dealerSeatIndex ?? -1; + const dealerSeatIndex = nextSeatIndex( + state, + previousDealer, + isEligibleForNextHand + ); + if (dealerSeatIndex === null) { + throw new Error("No dealer seat available"); + } + + const smallBlindSeatIndex = + eligible.length === 2 + ? dealerSeatIndex + : nextSeatIndex(state, dealerSeatIndex, isEligibleForNextHand); + if (smallBlindSeatIndex === null) { + throw new Error("No small blind seat available"); + } + const bigBlindSeatIndex = nextSeatIndex( + state, + smallBlindSeatIndex, + isEligibleForNextHand + ); + if (bigBlindSeatIndex === null) { + throw new Error("No big blind seat available"); + } + + state.dealerSeatIndex = dealerSeatIndex; + state.smallBlindSeatIndex = smallBlindSeatIndex; + state.bigBlindSeatIndex = bigBlindSeatIndex; + + for (let cardIndex = 0; cardIndex < 2; cardIndex += 1) { + for (let offset = 0; offset < state.seatCount; offset += 1) { + const index = (dealerSeatIndex + 1 + offset) % state.seatCount; + const seat = state.seats[index]; + if (seat && isEligibleForNextHand(seat)) { + seat.cards = [...(seat.cards ?? []), state.deck.pop() as string]; + } + } + } + + const smallBlindSeat = state.seats[smallBlindSeatIndex] as LivePokerSeat; + const bigBlindSeat = state.seats[bigBlindSeatIndex] as LivePokerSeat; + const postedSmallBlind = postBlind(smallBlindSeat, state.smallBlind); + const postedBigBlind = postBlind(bigBlindSeat, state.bigBlind); + smallBlindSeat.streetAction = { + amount: smallBlindSeat.bet, + type: "smallBlind", + }; + bigBlindSeat.streetAction = { + amount: bigBlindSeat.bet, + type: "bigBlind", + }; + // A short big blind does not reduce the nominal preflop bring-in. + state.currentBet = state.bigBlind; + state.activeSeatIndex = firstActionSeat(state, bigBlindSeatIndex); + state.actionLog.push(`Hand ${state.handNumber} started`); + state.actionLog.push(`${smallBlindSeat.name} posted ${postedSmallBlind}`); + state.actionLog.push(`${bigBlindSeat.name} posted ${postedBigBlind}`); + + if (state.activeSeatIndex === null || shouldRunoutToShowdown(state)) { + beginRunout(state); + } +} + +function reopenBettingAfterFullRaise( + state: LivePokerState, + aggressorSeatIndex: number +) { + for (const otherSeat of handSeats(state)) { + otherSeat.hasActedThisStreet = otherSeat.seatIndex === aggressorSeatIndex; + } +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Poker betting rules are clearer kept together at this layer. +export function applyAction( + state: LivePokerState, + userId: string, + action: + | { type: "fold" | "check" | "call" | "allIn" } + | { type: "bet" | "raise"; amount: number } +) { + const seatIndex = state.activeSeatIndex; + if ( + seatIndex === null || + state.phase === "waiting" || + state.phase === "showdown" + ) { + throw new Error("No action is currently available"); + } + + const seat = state.seats[seatIndex]; + if (!seat || seat.userId !== userId) { + throw new Error("It is not your turn"); + } + + const currentBetUnits = toChipUnits(state.currentBet); + const seatBetUnits = toChipUnits(seat.bet); + const maxTargetUnits = seatBetUnits + toChipUnits(seat.stack); + const callAmount = fromChipUnits(Math.max(0, currentBetUnits - seatBetUnits)); + + if (action.type === "fold") { + seat.folded = true; + seat.hasActedThisStreet = true; + seat.streetAction = { amount: seat.bet, type: "fold" }; + state.actionLog.push(`${seat.name} folded`); + } else if (action.type === "check") { + if (callAmount > 0) { + throw new Error("Cannot check while facing a bet"); + } + seat.hasActedThisStreet = true; + seat.streetAction = { amount: seat.bet, type: "check" }; + state.actionLog.push(`${seat.name} checked`); + } else if (action.type === "call") { + const paid = postBlind(seat, callAmount); + seat.hasActedThisStreet = true; + seat.streetAction = { amount: seat.bet, type: "call" }; + state.actionLog.push(`${seat.name} called ${paid}`); + } else if (action.type === "bet" || action.type === "raise") { + const targetBet = validateChipAmount(action.amount, "Bet target"); + const targetBetUnits = toChipUnits(targetBet); + + // Validate the actual stack cap before postBlind can mutate the seat. + if (targetBetUnits > maxTargetUnits) { + throw new Error("Bet target exceeds available stack"); + } + if (targetBetUnits <= currentBetUnits) { + throw new Error("Bet must increase the current bet"); + } + if (seat.hasActedThisStreet) { + throw new Error("Betting has not been reopened"); + } + + const minTargetUnits = + currentBetUnits === 0 + ? toChipUnits(state.bigBlind) + : currentBetUnits + toChipUnits(state.minRaise); + if (targetBetUnits < minTargetUnits && targetBetUnits !== maxTargetUnits) { + throw new Error( + `Minimum ${action.type} is ${fromChipUnits(minTargetUnits)}` + ); + } + + const raiseSizeUnits = targetBetUnits - currentBetUnits; + postBlind(seat, fromChipUnits(targetBetUnits - seatBetUnits)); + state.currentBet = targetBet; + state.lastAggressorSeatIndex = seatIndex; + if (raiseSizeUnits >= toChipUnits(state.minRaise)) { + state.minRaise = fromChipUnits(raiseSizeUnits); + reopenBettingAfterFullRaise(state, seatIndex); + } else { + seat.hasActedThisStreet = true; + } + seat.streetAction = { amount: seat.bet, type: action.type }; + state.actionLog.push( + `${seat.name} ${action.type === "bet" ? "bet" : "raised to"} ${seat.bet}` + ); + } else { + if (maxTargetUnits > currentBetUnits && seat.hasActedThisStreet) { + throw new Error("Betting has not been reopened"); + } + + const paid = postBlind(seat, seat.stack); + if (maxTargetUnits > currentBetUnits) { + const raiseSizeUnits = maxTargetUnits - currentBetUnits; + state.currentBet = fromChipUnits(maxTargetUnits); + state.lastAggressorSeatIndex = seatIndex; + if (raiseSizeUnits >= toChipUnits(state.minRaise)) { + state.minRaise = fromChipUnits(raiseSizeUnits); + reopenBettingAfterFullRaise(state, seatIndex); + } + } + seat.hasActedThisStreet = true; + seat.streetAction = { amount: seat.bet, type: "allIn" }; + state.actionLog.push(`${seat.name} moved all in for ${paid}`); + } + + advanceAfterAction(state, seatIndex); +} + +export function advanceAfterAction( + state: LivePokerState, + actedSeatIndex: number +) { + const liveSeats = handSeats(state).filter((seat) => !seat.folded); + if (liveSeats.length === 1) { + state.phase = "showdown"; + state.activeSeatIndex = null; + return; + } + + if (shouldRunoutToShowdown(state)) { + beginRunout(state); + return; + } + + const nextIndex = firstActionSeat(state, actedSeatIndex); + state.activeSeatIndex = nextIndex; + + if (!isBettingRoundComplete(state) || nextIndex === null) { + return; + } + + advanceStreet(state); +} + +export function advanceStreet(state: LivePokerState) { + resetStreet(state); + + if (state.phase === "preflop") { + state.phase = "flop"; + state.communityCards.push( + state.deck.pop() as string, + state.deck.pop() as string, + state.deck.pop() as string + ); + } else if (state.phase === "flop") { + state.phase = "turn"; + state.communityCards.push(state.deck.pop() as string); + } else if (state.phase === "turn") { + state.phase = "river"; + state.communityCards.push(state.deck.pop() as string); + } else { + state.phase = "showdown"; + state.activeSeatIndex = null; + return; + } + + state.actionLog.push(`${state.phase.toUpperCase()} dealt`); + state.activeSeatIndex = + state.dealerSeatIndex === null + ? null + : firstActionSeat(state, state.dealerSeatIndex); + if (state.activeSeatIndex === null) { + state.phase = "showdown"; + } +} + +function buildSidePots(contenders: LivePokerSeat[]) { + const levels = [ + ...new Set(contenders.map((seat) => toChipUnits(seat.committed))), + ] + .filter((units) => units > 0) + .sort((a, b) => a - b); + let previousUnits = 0; + + return levels + .map((levelUnits) => { + const contributors = contenders.filter( + (seat) => toChipUnits(seat.committed) >= levelUnits + ); + const eligible = contributors.filter((seat) => !seat.folded); + const amountUnits = (levelUnits - previousUnits) * contributors.length; + previousUnits = levelUnits; + return { amountUnits, eligible }; + }) + .filter((pot) => pot.amountUnits > 0); +} + +function payoutOrder(state: LivePokerState, seats: LivePokerSeat[]) { + if (state.dealerSeatIndex === null) { + return [...seats].sort((a, b) => a.seatIndex - b.seatIndex); + } + + const distanceFromDealer = (seat: LivePokerSeat) => { + const distance = + (seat.seatIndex - (state.dealerSeatIndex as number) + state.seatCount) % + state.seatCount; + return distance === 0 ? state.seatCount : distance; + }; + + return [...seats].sort( + (a, b) => distanceFromDealer(a) - distanceFromDealer(b) + ); +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Side-pot ranking and exact-unit payouts are kept together to make conservation auditable. +export function settleShowdown(state: LivePokerState) { + if (state.showdownSettled) { + return state.lastWinners; + } + + const contenders = handSeats(state); + const activeContenders = contenders.filter((seat) => !seat.folded); + const winners: LivePokerWinner[] = []; + state.showdownSeatIndexes = + activeContenders.length > 1 + ? activeContenders.map((seat) => seat.seatIndex) + : []; + + while ( + activeContenders.length > 1 && + state.communityCards.length < 5 && + state.deck.length > 0 + ) { + state.communityCards.push(state.deck.pop() as string); + } + + const potUnits = contenders.reduce( + (sum, seat) => sum + toChipUnits(seat.committed), + 0 + ); + let awardedUnits = 0; + + if (activeContenders.length === 1) { + const winner = activeContenders[0]; + winner.stack = fromChipUnits(toChipUnits(winner.stack) + potUnits); + awardedUnits = potUnits; + const pot = fromChipUnits(potUnits); + winners.push({ + amount: pot, + playerId: winner.playerId, + seatIndex: winner.seatIndex, + userId: winner.userId, + }); + state.actionLog.push(`${winner.name} won ${pot}`); + } else { + const sidePots = buildSidePots(contenders); + for (const sidePot of sidePots) { + // A zero-eligible layer is not reachable through legal betting, but dead + // chips still belong to the remaining live hand rather than disappearing. + const eligible = + sidePot.eligible.length > 0 ? sidePot.eligible : activeContenders; + const ranks = rankHands( + "texas", + state.communityCards as never, + eligible.map((seat) => seat.cards ?? []) as never + ) as RankResult[]; + const bestRank = Math.min(...ranks.map((rank) => rank.rank)); + const tiedWinners = eligible.filter( + (_seat, index) => ranks[index].rank === bestRank + ); + const potWinners = payoutOrder(state, tiedWinners); + const shareUnits = Math.floor(sidePot.amountUnits / potWinners.length); + let remainderUnits = sidePot.amountUnits - shareUnits * potWinners.length; + for (const winner of potWinners) { + const winnerUnits = shareUnits + (remainderUnits > 0 ? 1 : 0); + remainderUnits = Math.max(0, remainderUnits - 1); + const amount = fromChipUnits(winnerUnits); + winner.stack = fromChipUnits(toChipUnits(winner.stack) + winnerUnits); + awardedUnits += winnerUnits; + winners.push({ + amount, + description: ranks[eligible.indexOf(winner)]?.combination, + playerId: winner.playerId, + seatIndex: winner.seatIndex, + userId: winner.userId, + }); + } + } + } + + if (awardedUnits !== potUnits) { + throw new Error("Showdown payouts did not conserve the pot"); + } + + state.lastWinners = winners; + state.showdownPot = fromChipUnits(potUnits); + state.showdownSettled = true; + state.phase = "showdown"; + state.activeSeatIndex = null; + state.currentBet = 0; + state.runoutPending = false; + for (const seat of state.seats) { + if (seat) { + seat.bet = 0; + seat.committed = 0; + seat.hasActedThisStreet = false; + } + } + return winners; +} + +export function cleanupShowdown(state: LivePokerState) { + if (state.phase !== "showdown" || !state.showdownSettled) { + throw new Error("Showdown is not ready to clean up"); + } + + for (const seat of state.seats) { + if (seat) { + seat.cards = undefined; + seat.folded = false; + seat.isAllIn = false; + seat.streetAction = undefined; + } + } + state.activeSeatIndex = null; + state.communityCardRevealStartIndex = null; + state.communityCards = []; + state.deck = []; + state.lastWinners = []; + state.phase = "waiting"; + state.settledAction = null; + state.showdownPot = null; + state.showdownSeatIndexes = []; + state.showdownSettled = false; +} diff --git a/src/lib/live-poker/lifecycle.ts b/src/lib/live-poker/lifecycle.ts new file mode 100644 index 0000000..d14f904 --- /dev/null +++ b/src/lib/live-poker/lifecycle.ts @@ -0,0 +1,50 @@ +import { canStartHand, startHand } from "./engine"; +import { + clearLivePokerTiming, + DEFAULT_LIVE_POKER_TIMING, + type LivePokerTimingConfig, + startLivePokerTransition, +} from "./timing"; +import type { LivePokerState } from "./types"; + +export function startAutomaticHand( + state: LivePokerState, + startedAt: number, + timing: LivePokerTimingConfig = DEFAULT_LIVE_POKER_TIMING +) { + if (!canStartHand(state)) { + return false; + } + + startHand(state); + startLivePokerTransition(state, "deal", startedAt + timing.initialDealMs); + return true; +} + +export function reconcileNextHandTransition( + state: LivePokerState, + now: number, + timing: LivePokerTimingConfig = DEFAULT_LIVE_POKER_TIMING +) { + if (state.phase !== "waiting") { + return false; + } + + if (!canStartHand(state)) { + if (state.transition !== "nextHand") { + return false; + } + clearLivePokerTiming(state); + return true; + } + + if ( + state.transition === "nextHand" && + Number.isFinite(state.transitionDeadlineAt) + ) { + return false; + } + + startLivePokerTransition(state, "nextHand", now + timing.nextHandMs); + return true; +} diff --git a/src/lib/live-poker/reconnect.ts b/src/lib/live-poker/reconnect.ts new file mode 100644 index 0000000..280b517 --- /dev/null +++ b/src/lib/live-poker/reconnect.ts @@ -0,0 +1,19 @@ +export const LIVE_POKER_MAX_RECONNECT_ATTEMPTS = 6; +export const LIVE_POKER_MAX_RECONNECT_DELAY_MS = 16_000; +const LIVE_POKER_INITIAL_RECONNECT_DELAY_MS = 1000; + +export function getLivePokerReconnectDelay(attempt: number) { + const safeAttempt = Math.max(0, Math.floor(attempt)); + return Math.min( + LIVE_POKER_INITIAL_RECONNECT_DELAY_MS * 2 ** safeAttempt, + LIVE_POKER_MAX_RECONNECT_DELAY_MS + ); +} + +export function getJitteredLivePokerReconnectDelay( + attempt: number, + randomValue = Math.random() +) { + const jitter = 0.75 + Math.min(1, Math.max(0, randomValue)) * 0.25; + return Math.round(getLivePokerReconnectDelay(attempt) * jitter); +} diff --git a/src/lib/live-poker/server-auth.ts b/src/lib/live-poker/server-auth.ts new file mode 100644 index 0000000..941084e --- /dev/null +++ b/src/lib/live-poker/server-auth.ts @@ -0,0 +1,28 @@ +import { ConvexHttpClient } from "convex/browser"; +import { auth } from "@/lib/auth"; +import { api } from "../../../convex/_generated/api"; + +const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; +if (!convexUrl) { + throw new Error("NEXT_PUBLIC_CONVEX_URL is not defined"); +} + +export const livePokerConvex = new ConvexHttpClient(convexUrl); + +export function getLivePokerConvexSecret() { + const secret = process.env.LIVE_POKER_CONVEX_SECRET; + if (!secret) { + throw new Error("LIVE_POKER_CONVEX_SECRET is not configured"); + } + return secret; +} + +export async function getAuthenticatedLivePokerUser() { + const session = await auth(); + if (!session?.user?.email) { + return null; + } + return await livePokerConvex.query(api.auth.getUserByEmail, { + email: session.user.email, + }); +} diff --git a/src/lib/live-poker/table-form-schema.ts b/src/lib/live-poker/table-form-schema.ts new file mode 100644 index 0000000..6261724 --- /dev/null +++ b/src/lib/live-poker/table-form-schema.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; + +const DECIMAL_NUMBER_PATTERN = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i; +const MAX_CHIP_AMOUNT = Number.MAX_SAFE_INTEGER / 100; + +function isExactChipAmount(value: number) { + const units = Math.round(value * 100); + return Number.isSafeInteger(units) && Math.abs(value * 100 - units) <= 1e-7; +} + +function chipAmountField(label: string) { + return numericField(label).pipe( + z + .number() + .max(MAX_CHIP_AMOUNT, `${label} is too large`) + .refine(isExactChipAmount, `${label} must use increments of 0.01`) + ); +} + +function numericField(label: string) { + return z + .string() + .trim() + .min(1, `${label} is required`) + .refine( + (value) => + DECIMAL_NUMBER_PATTERN.test(value) && Number.isFinite(Number(value)), + { message: `${label} must be a finite number` } + ) + .transform(Number) + .pipe(z.number().max(Number.MAX_SAFE_INTEGER, `${label} is too large`)); +} + +export const livePokerTableFormSchema = z + .object({ + title: z + .string() + .trim() + .min(1, "Table name is required") + .max(80, "Table name must be 80 characters or less"), + smallBlind: chipAmountField("Small blind").pipe( + z.number().positive("Small blind must be greater than zero") + ), + bigBlind: chipAmountField("Big blind").pipe( + z.number().positive("Big blind must be greater than zero") + ), + minBuyIn: chipAmountField("Min buy-in").pipe( + z.number().nonnegative("Min buy-in cannot be negative") + ), + maxBuyIn: chipAmountField("Max buy-in").pipe( + z.number().positive("Max buy-in must be greater than zero") + ), + seatCount: numericField("Seat count").pipe( + z + .number() + .int("Seat count must be a whole number") + .min(2, "Seat count must be at least 2") + .max(9, "Seat count cannot exceed 9") + ), + }) + .superRefine((values, context) => { + if (values.bigBlind < values.smallBlind) { + context.addIssue({ + code: "custom", + message: "Big blind must be at least the small blind", + path: ["bigBlind"], + }); + } + if (values.maxBuyIn < values.minBuyIn) { + context.addIssue({ + code: "custom", + message: "Max buy-in must be at least the min buy-in", + path: ["maxBuyIn"], + }); + } + }); + +export type LivePokerTableFormInput = z.input; +export type LivePokerTableFormValues = z.output< + typeof livePokerTableFormSchema +>; diff --git a/src/lib/live-poker/timing.ts b/src/lib/live-poker/timing.ts new file mode 100644 index 0000000..da841cc --- /dev/null +++ b/src/lib/live-poker/timing.ts @@ -0,0 +1,229 @@ +import type { LivePokerState, LivePokerTransition } from "./types"; + +export const LIVE_POKER_ACTION_TIME_MS = 20_000; +export const LIVE_POKER_TIME_BANK_MS = 10_000; +export const LIVE_POKER_ACTION_SETTLE_MS = 800; +export const LIVE_POKER_INITIAL_DEAL_MS = 2300; +export const LIVE_POKER_NEXT_HAND_MS = 3000; +export const LIVE_POKER_SHOWDOWN_MS = 6000; +export const LIVE_POKER_RUNOUT_STAGE_MS = 1000; + +export interface LivePokerTimingConfig { + actionTimeMs: number; + actionSettleMs: number; + initialDealMs: number; + nextHandMs: number; + runoutStageMs: number; + showdownMs: number; + timeBankMs: number; +} + +export const DEFAULT_LIVE_POKER_TIMING = { + actionTimeMs: LIVE_POKER_ACTION_TIME_MS, + actionSettleMs: LIVE_POKER_ACTION_SETTLE_MS, + initialDealMs: LIVE_POKER_INITIAL_DEAL_MS, + nextHandMs: LIVE_POKER_NEXT_HAND_MS, + runoutStageMs: LIVE_POKER_RUNOUT_STAGE_MS, + showdownMs: LIVE_POKER_SHOWDOWN_MS, + timeBankMs: LIVE_POKER_TIME_BANK_MS, +} satisfies LivePokerTimingConfig; + +export interface LivePokerTimeoutAction { + type: "check" | "fold"; +} + +function normalizeTimeBankRemainingMs( + value: number, + initialTimeBankMs: number +) { + if (!Number.isFinite(value)) { + return initialTimeBankMs; + } + return Math.min(initialTimeBankMs, Math.max(0, Math.round(value))); +} + +export function getLivePokerSeatTimeBankRemainingMs( + state: LivePokerState, + seatIndex: number, + timing: LivePokerTimingConfig = DEFAULT_LIVE_POKER_TIMING +) { + const seat = state.seats[seatIndex]; + if (!seat) { + return 0; + } + return normalizeTimeBankRemainingMs( + seat.timeBankRemainingMs, + timing.timeBankMs + ); +} + +export function repairLivePokerTimeBanks( + state: LivePokerState, + timing: LivePokerTimingConfig = DEFAULT_LIVE_POKER_TIMING +) { + let changed = false; + for (const seat of state.seats) { + if (!seat) { + continue; + } + const remaining = normalizeTimeBankRemainingMs( + seat.timeBankRemainingMs, + timing.timeBankMs + ); + if (seat.timeBankRemainingMs !== remaining) { + seat.timeBankRemainingMs = remaining; + changed = true; + } + } + return changed; +} + +export type LivePokerTimingEvent = + | { at: number; type: "startTimeBank" } + | { at: number; type: "turnExpired" } + | { at: number; transition: LivePokerTransition; type: "transition" }; + +export function assertLivePokerActionAvailable(state: LivePokerState) { + if (state.transition) { + throw new Error("Actions are paused while the table settles"); + } + if (!state.turnDeadlineAt) { + throw new Error("No action is currently available"); + } +} + +export function getLivePokerTimeoutAction( + state: LivePokerState +): LivePokerTimeoutAction | null { + if (state.activeSeatIndex === null) { + return null; + } + const seat = state.seats[state.activeSeatIndex]; + if (!seat) { + return null; + } + return { type: state.currentBet <= seat.bet ? "check" : "fold" }; +} + +export function clearLivePokerTiming(state: LivePokerState) { + state.timeBankActive = false; + state.transition = null; + state.transitionDeadlineAt = null; + state.turnDeadlineAt = null; + state.turnStartedAt = null; +} + +export function startLivePokerTurn( + state: LivePokerState, + startedAt: number, + timing: LivePokerTimingConfig = DEFAULT_LIVE_POKER_TIMING +) { + state.timeBankActive = false; + state.transition = null; + state.transitionDeadlineAt = null; + state.turnStartedAt = startedAt; + state.turnDeadlineAt = startedAt + timing.actionTimeMs; +} + +export function startLivePokerTimeBank( + state: LivePokerState, + startedAt: number, + timing: LivePokerTimingConfig = DEFAULT_LIVE_POKER_TIMING +) { + const remaining = + state.activeSeatIndex === null + ? 0 + : getLivePokerSeatTimeBankRemainingMs( + state, + state.activeSeatIndex, + timing + ); + state.timeBankActive = remaining > 0; + state.turnStartedAt = startedAt; + state.turnDeadlineAt = startedAt + remaining; +} + +export function consumeLivePokerTimeBank( + state: LivePokerState, + seatIndex: number, + actedAt: number, + timing: LivePokerTimingConfig = DEFAULT_LIVE_POKER_TIMING +) { + if ( + !state.timeBankActive || + state.turnDeadlineAt === null || + state.turnDeadlineAt === undefined + ) { + return 0; + } + + const seat = state.seats[seatIndex]; + if (!seat) { + return 0; + } + const previousRemaining = getLivePokerSeatTimeBankRemainingMs( + state, + seatIndex, + timing + ); + const remainingAtAction = Math.min( + previousRemaining, + Math.max(0, Math.round(state.turnDeadlineAt - actedAt)) + ); + seat.timeBankRemainingMs = remainingAtAction; + return previousRemaining - remainingAtAction; +} + +export function startLivePokerTransition( + state: LivePokerState, + transition: LivePokerTransition, + deadlineAt: number +) { + state.timeBankActive = false; + state.transition = transition; + state.transitionDeadlineAt = deadlineAt; + state.turnDeadlineAt = null; + state.turnStartedAt = null; +} + +export function getDueLivePokerTimingEvent( + state: LivePokerState, + now: number +): LivePokerTimingEvent | null { + if ( + state.transition && + state.transitionDeadlineAt !== null && + state.transitionDeadlineAt !== undefined && + state.transitionDeadlineAt <= now + ) { + return { + at: state.transitionDeadlineAt, + transition: state.transition, + type: "transition", + }; + } + + if ( + state.turnDeadlineAt !== null && + state.turnDeadlineAt !== undefined && + state.turnDeadlineAt <= now + ) { + const hasTimeBank = + state.activeSeatIndex !== null && + getLivePokerSeatTimeBankRemainingMs(state, state.activeSeatIndex) > 0; + return { + at: state.turnDeadlineAt, + type: + state.timeBankActive || !hasTimeBank ? "turnExpired" : "startTimeBank", + }; + } + + return null; +} + +export function getNextLivePokerDeadline(state: LivePokerState) { + return Math.min( + state.transitionDeadlineAt ?? Number.POSITIVE_INFINITY, + state.turnDeadlineAt ?? Number.POSITIVE_INFINITY + ); +} diff --git a/src/lib/live-poker/types.ts b/src/lib/live-poker/types.ts new file mode 100644 index 0000000..e6d2717 --- /dev/null +++ b/src/lib/live-poker/types.ts @@ -0,0 +1,283 @@ +import { z } from "zod"; + +export const cardSchema = z.string().regex(/^[2-9TJQKA][SHDC]$/); + +export const clientMessageSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("joinTable") }), + z.object({ type: z.literal("setTablePaused"), paused: z.boolean() }), + z.object({ + type: z.literal("sit"), + seatIndex: z.number().int().min(0).max(8), + buyIn: z.number().positive(), + }), + z.object({ type: z.literal("addChips"), amount: z.number().positive() }), + z.object({ type: z.literal("leaveSeat") }), + z.object({ + type: z.literal("kickSeat"), + seatIndex: z.number().int().min(0).max(8), + }), + z.object({ type: z.literal("fold") }), + z.object({ type: z.literal("check") }), + z.object({ type: z.literal("call") }), + z.object({ type: z.literal("bet"), amount: z.number().positive() }), + z.object({ type: z.literal("raise"), amount: z.number().positive() }), + z.object({ type: z.literal("allIn") }), + z.object({ type: z.literal("sitOut"), sitOut: z.boolean() }), + z.object({ type: z.literal("requestSync") }), +]); + +export type LivePokerClientMessage = z.infer; + +export type LivePokerPhase = + | "waiting" + | "preflop" + | "flop" + | "turn" + | "river" + | "showdown"; + +export type LivePokerTransition = + | "actionSettle" + | "deal" + | "nextHand" + | "runout" + | "showdown"; + +export type LivePokerStreetActionType = + | "allIn" + | "bet" + | "bigBlind" + | "call" + | "check" + | "fold" + | "raise" + | "smallBlind"; + +export interface LivePokerStreetAction { + amount: number; + type: LivePokerStreetActionType; +} + +export interface LivePokerTableConfig { + bigBlind: number; + hostUserId: string; + maxBuyIn: number; + minBuyIn: number; + seatCount: number; + smallBlind: number; +} + +export interface LivePokerAuthToken { + exp: number; + tableConfig: LivePokerTableConfig; + tableId: string; + playerId: string; + playerName: string; + userId: string; +} + +export interface LivePokerSeat { + bet: number; + buyIn: number; + cards?: string[]; + committed: number; + connected: boolean; + folded: boolean; + hasActedThisStreet: boolean; + isAllIn: boolean; + name: string; + playerId: string; + seatIndex: number; + sitOut: boolean; + stack: number; + streetAction?: LivePokerStreetAction; + timeBankRemainingMs: number; + userId: string; +} + +export interface PublicLivePokerSeat + extends Omit { + cards?: string[]; + hasCards: boolean; + isCurrentUser: boolean; +} + +export interface LivePokerWinner { + amount: number; + description?: string; + playerId: string; + seatIndex: number; + userId: string; +} + +export type PublicLivePokerWinner = Omit; + +export interface LivePokerSettledAction extends LivePokerStreetAction { + seatIndex: number; +} + +export interface LivePokerState { + actionLog: string[]; + admissionsClosed?: boolean; + appliedRequestIds?: string[]; + activeSeatIndex: number | null; + bigBlind: number; + bigBlindSeatIndex: number | null; + communityCardRevealStartIndex?: number | null; + communityCards: string[]; + consecutiveTimeoutActions?: number; + currentBet: number; + dealerSeatIndex: number | null; + deck: string[]; + gamePaused?: boolean; + handNumber: number; + hostUserId: string; + lastAggressorSeatIndex: number | null; + lastWinners: LivePokerWinner[]; + minBuyIn: number; + maxBuyIn: number; + minRaise: number; + phase: LivePokerPhase; + seatCount: number; + seats: Array; + settledAction?: LivePokerSettledAction | null; + showdownPot?: number | null; + showdownSeatIndexes?: number[]; + showdownSettled?: boolean; + smallBlind: number; + smallBlindSeatIndex: number | null; + storageDeleteAt?: number; + timeBankActive?: boolean; + transition?: LivePokerTransition | null; + transitionDeadlineAt?: number | null; + turnDeadlineAt?: number | null; + turnStartedAt?: number | null; + runoutPending?: boolean; +} + +export interface PublicLivePokerState { + actionLog: string[]; + activeSeatIndex: number | null; + bigBlind: number; + bigBlindSeatIndex: number | null; + communityCardRevealStartIndex: number | null; + communityCards: string[]; + currentBet: number; + dealerSeatIndex: number | null; + gamePaused: boolean; + handNumber: number; + isHost: boolean; + lastWinners: PublicLivePokerWinner[]; + maxBuyIn: number; + minBuyIn: number; + minRaise: number; + phase: LivePokerPhase; + pot: number; + seatCount: number; + seats: Array; + serverTimeAt: number; + settledAction: LivePokerSettledAction | null; + showdownSeatIndexes: number[]; + smallBlind: number; + smallBlindSeatIndex: number | null; + timeBankActive: boolean; + transition: LivePokerTransition | null; + transitionDeadlineAt: number | null; + turnDeadlineAt: number | null; + turnStartedAt: number | null; +} + +export type LivePokerServerMessage = + | { type: "tableState"; state: PublicLivePokerState } + | { type: "privateCards"; cards: string[] } + | { type: "actionRejected"; message: string } + | { type: "handStarted"; handNumber: number } + | { type: "handEnded"; winners: PublicLivePokerWinner[] } + | { type: "playerPresence"; userId: string; connected: boolean } + | { type: "ledgerUpdated"; playerId: string; stack: number; buyIn: number }; + +export function calculatePot(state: Pick) { + return state.seats.reduce((sum, seat) => sum + (seat?.committed ?? 0), 0); +} + +export function toPublicState( + state: LivePokerState, + currentUserId: string, + serverTimeAt = Date.now() +): PublicLivePokerState { + const showdownSeatIndexes = state.showdownSeatIndexes ?? []; + + return { + actionLog: state.actionLog.slice(-80), + activeSeatIndex: state.activeSeatIndex, + bigBlind: state.bigBlind, + bigBlindSeatIndex: state.bigBlindSeatIndex ?? null, + communityCardRevealStartIndex: state.communityCardRevealStartIndex ?? null, + communityCards: state.communityCards, + currentBet: state.currentBet, + dealerSeatIndex: state.dealerSeatIndex, + gamePaused: state.gamePaused ?? false, + handNumber: state.handNumber, + isHost: state.hostUserId === currentUserId, + lastWinners: (state.lastWinners ?? []).map( + ({ userId: _userId, ...winner }) => winner + ), + maxBuyIn: state.maxBuyIn, + minBuyIn: state.minBuyIn, + minRaise: state.minRaise, + phase: state.phase, + pot: + state.phase === "showdown" + ? (state.showdownPot ?? calculatePot(state)) + : calculatePot(state), + seatCount: state.seatCount, + seats: state.seats.map((seat) => { + if (!seat) { + return null; + } + + const canShowCards = + (state.phase !== "waiting" && seat.userId === currentUserId) || + showdownSeatIndexes.includes(seat.seatIndex); + + return { + bet: seat.bet, + buyIn: seat.buyIn, + cards: canShowCards ? seat.cards : undefined, + committed: seat.committed, + connected: seat.connected, + folded: seat.folded, + hasActedThisStreet: seat.hasActedThisStreet, + hasCards: Boolean(seat.cards?.length), + isAllIn: seat.isAllIn, + isCurrentUser: seat.userId === currentUserId, + name: seat.name, + playerId: seat.playerId, + seatIndex: seat.seatIndex, + sitOut: seat.sitOut, + stack: seat.stack, + streetAction: seat.streetAction, + timeBankRemainingMs: + state.timeBankActive && + state.activeSeatIndex === seat.seatIndex && + state.turnDeadlineAt !== null && + state.turnDeadlineAt !== undefined + ? Math.min( + seat.timeBankRemainingMs, + Math.max(0, state.turnDeadlineAt - serverTimeAt) + ) + : seat.timeBankRemainingMs, + }; + }), + serverTimeAt, + settledAction: state.settledAction ?? null, + showdownSeatIndexes, + smallBlind: state.smallBlind, + smallBlindSeatIndex: state.smallBlindSeatIndex ?? null, + timeBankActive: state.timeBankActive ?? false, + transition: state.transition ?? null, + transitionDeadlineAt: state.transitionDeadlineAt ?? null, + turnDeadlineAt: state.turnDeadlineAt ?? null, + turnStartedAt: state.turnStartedAt ?? null, + }; +} diff --git a/src/types/react-playing-cards.d.ts b/src/types/react-playing-cards.d.ts new file mode 100644 index 0000000..1ab1e6f --- /dev/null +++ b/src/types/react-playing-cards.d.ts @@ -0,0 +1,14 @@ +declare module "@heruka_urgyen/react-playing-cards/lib/FcB" { + import type { CSSProperties } from "react"; + + interface CardProps { + back?: boolean; + card?: string; + className?: string; + front?: boolean; + height?: string; + style?: CSSProperties; + } + + export default function Card(props: CardProps): React.JSX.Element | null; +} diff --git a/tests/live-poker-engine.spec.ts b/tests/live-poker-engine.spec.ts new file mode 100644 index 0000000..bf5385c --- /dev/null +++ b/tests/live-poker-engine.spec.ts @@ -0,0 +1,730 @@ +import { expect, test } from "@playwright/test"; +import { + applyAction, + canStartHand, + cleanupShowdown, + createInitialState, + getEligibleSeats, + revealNextRunoutStage, + seatPlayer, + settleShowdown, + startHand, +} from "../src/lib/live-poker/engine"; +import { + reconcileNextHandTransition, + startAutomaticHand, +} from "../src/lib/live-poker/lifecycle"; +import { + assertLivePokerActionAvailable, + consumeLivePokerTimeBank, + DEFAULT_LIVE_POKER_TIMING, + getDueLivePokerTimingEvent, + getLivePokerTimeoutAction, + getNextLivePokerDeadline, + LIVE_POKER_ACTION_SETTLE_MS, + LIVE_POKER_ACTION_TIME_MS, + LIVE_POKER_INITIAL_DEAL_MS, + LIVE_POKER_NEXT_HAND_MS, + LIVE_POKER_RUNOUT_STAGE_MS, + LIVE_POKER_SHOWDOWN_MS, + LIVE_POKER_TIME_BANK_MS, + startLivePokerTimeBank, + startLivePokerTransition, + startLivePokerTurn, +} from "../src/lib/live-poker/timing"; +import { + type LivePokerSeat, + type LivePokerState, + toPublicState, +} from "../src/lib/live-poker/types"; + +function makeState(overrides: Partial = {}) { + return { + ...createInitialState({ + bigBlind: 10, + hostUserId: "host", + maxBuyIn: 500, + minBuyIn: 0.01, + seatCount: 6, + smallBlind: 5, + }), + gamePaused: false, + ...overrides, + }; +} + +function addSeat(state: LivePokerState, seatIndex: number, buyIn: number) { + seatPlayer(state, seatIndex, { + buyIn, + name: `Player ${seatIndex}`, + playerId: `player-${seatIndex}`, + userId: `user-${seatIndex}`, + }); + return state.seats[seatIndex] as LivePokerSeat; +} + +function prepareSeat( + state: LivePokerState, + seatIndex: number, + values: Partial +) { + const seat = addSeat(state, seatIndex, values.buyIn ?? 200); + Object.assign(seat, { + cards: [`${seatIndex + 2}C`, `${seatIndex + 2}D`], + ...values, + }); + return seat; +} + +function totalChips(state: LivePokerState) { + return state.seats.reduce( + (total, seat) => total + (seat ? seat.stack + seat.committed : 0), + 0 + ); +} + +test.describe("live poker automatic hand eligibility", () => { + test("new tables wait for the host to press play", () => { + const state = createInitialState({ + bigBlind: 10, + hostUserId: "host", + maxBuyIn: 500, + minBuyIn: 0.01, + seatCount: 2, + smallBlind: 5, + }); + addSeat(state, 0, 100); + addSeat(state, 1, 100); + + expect(state.gamePaused).toBe(true); + expect(canStartHand(state)).toBe(false); + expect(reconcileNextHandTransition(state, 1000)).toBe(false); + expect(state.transition).toBeNull(); + + state.gamePaused = false; + expect(reconcileNextHandTransition(state, 1000)).toBe(true); + expect(state.transition).toBe("nextHand"); + }); + + test("host pause cancels the next hand without interrupting this hand", () => { + const waiting = makeState({ seatCount: 2, seats: [null, null] }); + addSeat(waiting, 0, 100); + addSeat(waiting, 1, 100); + reconcileNextHandTransition(waiting, 1000); + + waiting.gamePaused = true; + expect(reconcileNextHandTransition(waiting, 1500)).toBe(true); + expect(waiting.transition).toBeNull(); + + waiting.gamePaused = false; + reconcileNextHandTransition(waiting, 2000); + expect(startAutomaticHand(waiting, 5000)).toBe(true); + const activeHand = structuredClone(waiting); + waiting.gamePaused = true; + + expect(reconcileNextHandTransition(waiting, 5100)).toBe(false); + expect(waiting.phase).toBe("preflop"); + expect(waiting.handNumber).toBe(activeHand.handNumber); + expect(waiting.seats).toEqual(activeHand.seats); + }); + + test("deals every seated player with chips without a ready flag", () => { + const state = makeState({ seatCount: 3, seats: [null, null, null] }); + const first = addSeat(state, 0, 100); + const second = addSeat(state, 1, 100); + const legacySecond = second as LivePokerSeat & { ready?: boolean }; + legacySecond.ready = false; + + expect(canStartHand(state)).toBe(true); + expect(getEligibleSeats(state).map((seat) => seat.seatIndex)).toEqual([ + 0, 1, + ]); + + startHand(state); + + expect(first.cards).toHaveLength(2); + expect(second.cards).toHaveLength(2); + expect(state.handNumber).toBe(1); + }); + + test("excludes disconnected, sit-out, and busted seats", () => { + const state = makeState({ seatCount: 4, seats: [null, null, null, null] }); + addSeat(state, 0, 100); + const sittingOut = addSeat(state, 1, 100); + const busted = addSeat(state, 2, 100); + const disconnected = addSeat(state, 3, 100); + sittingOut.sitOut = true; + busted.stack = 0; + disconnected.connected = false; + + expect(getEligibleSeats(state).map((seat) => seat.seatIndex)).toEqual([0]); + expect(canStartHand(state)).toBe(false); + + sittingOut.sitOut = false; + expect(canStartHand(state)).toBe(true); + sittingOut.sitOut = true; + busted.stack = 25; + expect(canStartHand(state)).toBe(true); + busted.stack = 0; + disconnected.connected = true; + expect(canStartHand(state)).toBe(true); + }); + + test("keeps a player who sits out in the current hand dealt in", () => { + const state = makeState({ seatCount: 2, seats: [null, null] }); + const first = addSeat(state, 0, 100); + addSeat(state, 1, 100); + startHand(state); + + first.sitOut = true; + + expect(first.cards).toHaveLength(2); + expect(state.activeSeatIndex).toBe(first.seatIndex); + expect(() => + applyAction(state, first.userId, { type: "call" }) + ).not.toThrow(); + expect(first.cards).toHaveLength(2); + }); + + test("prevents a duplicate hand start without mutating the hand", () => { + const state = makeState({ seatCount: 2, seats: [null, null] }); + addSeat(state, 0, 100); + addSeat(state, 1, 100); + startHand(state); + const started = structuredClone(state); + + expect(canStartHand(state)).toBe(false); + expect(() => startHand(state)).toThrow("A hand is already in progress"); + expect(state).toEqual(started); + }); + + test("never starts automatically after admissions close", () => { + const state = makeState({ + admissionsClosed: true, + seatCount: 2, + seats: [null, null], + }); + addSeat(state, 0, 100); + addSeat(state, 1, 100); + + expect(canStartHand(state)).toBe(false); + expect(() => startHand(state)).toThrow("Table is closed"); + }); +}); + +test.describe("live poker betting correctness", () => { + test("rejects a raise target above the player's stack without mutation", () => { + const state = makeState({ + activeSeatIndex: 0, + currentBet: 20, + minRaise: 10, + phase: "flop", + }); + prepareSeat(state, 0, { + bet: 20, + committed: 20, + stack: 30, + }); + prepareSeat(state, 1, { + bet: 20, + committed: 20, + stack: 180, + }); + const before = structuredClone(state); + + expect(() => + applyAction(state, "user-0", { amount: 60, type: "raise" }) + ).toThrow("Bet target exceeds available stack"); + expect(state).toEqual(before); + }); + + test("keeps the nominal big-blind bring-in when the big blind is short", () => { + const state = makeState({ seatCount: 2, seats: [null, null] }); + const smallBlind = addSeat(state, 0, 100); + const bigBlind = addSeat(state, 1, 7); + + startHand(state); + + expect(state.bigBlindSeatIndex).toBe(1); + expect(bigBlind.bet).toBe(7); + expect(bigBlind.isAllIn).toBe(true); + expect(state.currentBet).toBe(10); + expect(state.activeSeatIndex).toBe(0); + + applyAction(state, smallBlind.userId, { type: "call" }); + + expect(smallBlind.bet).toBe(10); + expect(smallBlind.committed).toBe(10); + expect(state.phase).toBe("preflop"); + expect(state.runoutPending).toBe(true); + expect(state.communityCards).toHaveLength(0); + }); + + test("stages an all-in board runout one street at a time", () => { + const state = makeState({ seatCount: 2, seats: [null, null] }); + addSeat(state, 0, 3); + addSeat(state, 1, 7); + + startHand(state); + + expect(state.currentBet).toBe(10); + expect(state.seats[0]?.isAllIn).toBe(true); + expect(state.seats[1]?.isAllIn).toBe(true); + expect(state.activeSeatIndex).toBeNull(); + expect(state.phase).toBe("preflop"); + expect(state.runoutPending).toBe(true); + expect(state.communityCards).toHaveLength(0); + + revealNextRunoutStage(state); + expect(state.phase).toBe("flop"); + expect(state.communityCards).toHaveLength(3); + expect(state.runoutPending).toBe(true); + + revealNextRunoutStage(state); + expect(state.phase).toBe("turn"); + expect(state.communityCards).toHaveLength(4); + + revealNextRunoutStage(state); + expect(state.phase).toBe("showdown"); + expect(state.communityCards).toHaveLength(5); + expect(state.runoutPending).toBe(false); + }); + + test("queues a runout when only a matched big blind still has chips", () => { + const state = makeState({ seatCount: 2, seats: [null, null] }); + addSeat(state, 0, 3); + addSeat(state, 1, 100); + + startHand(state); + + expect(state.seats[0]?.isAllIn).toBe(true); + expect(state.seats[1]?.isAllIn).toBe(false); + expect(state.seats[1]?.bet).toBe(10); + expect(state.activeSeatIndex).toBeNull(); + expect(state.phase).toBe("preflop"); + expect(state.runoutPending).toBe(true); + expect(state.communityCards).toHaveLength(0); + }); + + test("does not reduce the last full raise or reopen action after a short all-in", () => { + const state = makeState({ + activeSeatIndex: 1, + currentBet: 100, + dealerSeatIndex: 2, + minRaise: 40, + phase: "turn", + }); + const alreadyActed = prepareSeat(state, 0, { + bet: 100, + committed: 100, + hasActedThisStreet: true, + stack: 100, + }); + const shortStack = prepareSeat(state, 1, { + bet: 100, + committed: 100, + stack: 15, + }); + const notYetActed = prepareSeat(state, 2, { + bet: 100, + committed: 100, + stack: 100, + }); + + applyAction(state, shortStack.userId, { type: "allIn" }); + + expect(state.currentBet).toBe(115); + expect(state.minRaise).toBe(40); + expect(alreadyActed.hasActedThisStreet).toBe(true); + expect(notYetActed.hasActedThisStreet).toBe(false); + expect(state.activeSeatIndex).toBe(2); + + applyAction(state, notYetActed.userId, { type: "call" }); + expect(state.activeSeatIndex).toBe(0); + + const beforeRejectedRaise = structuredClone(state); + expect(() => + applyAction(state, alreadyActed.userId, { + amount: 155, + type: "raise", + }) + ).toThrow("Betting has not been reopened"); + expect(state).toEqual(beforeRejectedRaise); + expect(() => + applyAction(state, alreadyActed.userId, { type: "allIn" }) + ).toThrow("Betting has not been reopened"); + expect(state).toEqual(beforeRejectedRaise); + + applyAction(state, alreadyActed.userId, { type: "call" }); + expect(alreadyActed.committed).toBe(115); + expect(state.phase).toBe("river"); + }); + + test("a full all-in raise does reopen action and sets the new full raise size", () => { + const state = makeState({ + activeSeatIndex: 1, + currentBet: 100, + minRaise: 40, + phase: "flop", + }); + const alreadyActed = prepareSeat(state, 0, { + bet: 100, + committed: 100, + hasActedThisStreet: true, + stack: 100, + }); + const fullRaiser = prepareSeat(state, 1, { + bet: 100, + committed: 100, + stack: 50, + }); + prepareSeat(state, 2, { + bet: 100, + committed: 100, + hasActedThisStreet: true, + stack: 100, + }); + + applyAction(state, fullRaiser.userId, { type: "allIn" }); + + expect(state.currentBet).toBe(150); + expect(state.minRaise).toBe(50); + expect(alreadyActed.hasActedThisStreet).toBe(false); + }); + + test("enforces the existing one-cent chip denomination at engine boundaries", () => { + expect(() => + createInitialState({ + bigBlind: 0.02, + hostUserId: "host", + maxBuyIn: 10, + minBuyIn: 0.01, + seatCount: 2, + smallBlind: 0.005, + }) + ).toThrow("Small blind must use increments of 0.01"); + + const state = makeState({ + activeSeatIndex: 0, + currentBet: 10, + phase: "flop", + }); + prepareSeat(state, 0, { bet: 10, committed: 10, stack: 100 }); + prepareSeat(state, 1, { bet: 10, committed: 10, stack: 100 }); + const before = structuredClone(state); + + expect(() => + applyAction(state, "user-0", { amount: 20.001, type: "raise" }) + ).toThrow("Bet target must use increments of 0.01"); + expect(state).toEqual(before); + }); +}); + +test.describe("live poker server timing", () => { + test("uses named PokerNow-equivalent pacing defaults", () => { + expect(DEFAULT_LIVE_POKER_TIMING).toEqual({ + actionSettleMs: LIVE_POKER_ACTION_SETTLE_MS, + actionTimeMs: LIVE_POKER_ACTION_TIME_MS, + initialDealMs: LIVE_POKER_INITIAL_DEAL_MS, + nextHandMs: LIVE_POKER_NEXT_HAND_MS, + runoutStageMs: LIVE_POKER_RUNOUT_STAGE_MS, + showdownMs: LIVE_POKER_SHOWDOWN_MS, + timeBankMs: LIVE_POKER_TIME_BANK_MS, + }); + expect(DEFAULT_LIVE_POKER_TIMING).toEqual({ + actionSettleMs: 800, + actionTimeMs: 20_000, + initialDealMs: 2300, + nextHandMs: 3000, + runoutStageMs: 1000, + showdownMs: 6000, + timeBankMs: 10_000, + }); + }); + + test("moves deterministically from the action clock into the visible time bank", () => { + const state = makeState({ activeSeatIndex: 0, phase: "preflop" }); + addSeat(state, 0, 100); + + startLivePokerTurn(state, 1000); + expect(state.turnStartedAt).toBe(1000); + expect(state.turnDeadlineAt).toBe(21_000); + expect(state.timeBankActive).toBe(false); + expect(getDueLivePokerTimingEvent(state, 20_999)).toBeNull(); + expect(getDueLivePokerTimingEvent(state, 21_000)).toEqual({ + at: 21_000, + type: "startTimeBank", + }); + + startLivePokerTimeBank(state, 21_000); + expect(state.timeBankActive).toBe(true); + expect(state.turnStartedAt).toBe(21_000); + expect(state.turnDeadlineAt).toBe(31_000); + expect(getDueLivePokerTimingEvent(state, 31_000)).toEqual({ + at: 31_000, + type: "turnExpired", + }); + }); + + test("persists partial time-bank consumption from authoritative deadlines", () => { + const state = makeState({ activeSeatIndex: 0, phase: "preflop" }); + const seat = addSeat(state, 0, 100); + const otherSeat = addSeat(state, 1, 100); + + startLivePokerTurn(state, 1000); + startLivePokerTimeBank(state, 21_000); + + expect(consumeLivePokerTimeBank(state, 0, 24_500)).toBe(3500); + expect(seat.timeBankRemainingMs).toBe(6500); + expect(otherSeat.timeBankRemainingMs).toBe(10_000); + + startLivePokerTurn(state, 30_000); + expect(getDueLivePokerTimingEvent(state, 50_000)).toEqual({ + at: 50_000, + type: "startTimeBank", + }); + startLivePokerTimeBank(state, 50_000); + expect(state.turnDeadlineAt).toBe(56_500); + expect(toPublicState(state, seat.userId, 52_000).seats[0]).toMatchObject({ + timeBankRemainingMs: 4500, + }); + }); + + test("exhausts a time bank and gives later turns no hidden extension", () => { + const state = makeState({ activeSeatIndex: 0, phase: "preflop" }); + const seat = addSeat(state, 0, 100); + + startLivePokerTurn(state, 1000); + startLivePokerTimeBank(state, 21_000); + expect(consumeLivePokerTimeBank(state, 0, 31_000)).toBe(10_000); + expect(seat.timeBankRemainingMs).toBe(0); + + startLivePokerTurn(state, 40_000); + expect(state.turnDeadlineAt).toBe(60_000); + expect(getDueLivePokerTimingEvent(state, 60_000)).toEqual({ + at: 60_000, + type: "turnExpired", + }); + }); + + test("does not regrant a consumed time bank when a later hand starts", () => { + const state = makeState({ seatCount: 2, seats: [null, null] }); + const seat = addSeat(state, 0, 100); + addSeat(state, 1, 100); + expect(seat.timeBankRemainingMs).toBe(10_000); + seat.timeBankRemainingMs = 3200; + + startHand(state); + + expect(seat.timeBankRemainingMs).toBe(3200); + }); + + test("chooses check or fold deterministically at final expiry", () => { + const state = makeState({ + activeSeatIndex: 0, + currentBet: 20, + phase: "preflop", + }); + const seat = addSeat(state, 0, 100); + seat.bet = 10; + + expect(getLivePokerTimeoutAction(state)).toEqual({ type: "fold" }); + seat.bet = 20; + expect(getLivePokerTimeoutAction(state)).toEqual({ type: "check" }); + state.activeSeatIndex = null; + expect(getLivePokerTimeoutAction(state)).toBeNull(); + }); + + test("rejects actions throughout persisted deal and settle windows", () => { + const state = makeState({ activeSeatIndex: 0, phase: "preflop" }); + + startLivePokerTransition(state, "deal", 3300); + expect(() => assertLivePokerActionAvailable(state)).toThrow( + "Actions are paused while the table settles" + ); + + startLivePokerTransition(state, "actionSettle", 4100); + expect(() => assertLivePokerActionAvailable(state)).toThrow( + "Actions are paused while the table settles" + ); + + startLivePokerTurn(state, 4100); + expect(() => assertLivePokerActionAvailable(state)).not.toThrow(); + }); + + test("persists a next-hand deadline without scheduling duplicates", () => { + const state = makeState({ seatCount: 2, seats: [null, null] }); + addSeat(state, 0, 100); + addSeat(state, 1, 100); + + expect(reconcileNextHandTransition(state, 1000)).toBe(true); + expect(state.transition).toBe("nextHand"); + expect(state.transitionDeadlineAt).toBe(4000); + expect(getNextLivePokerDeadline(structuredClone(state))).toBe(4000); + expect(getDueLivePokerTimingEvent(state, 3999)).toBeNull(); + expect(getDueLivePokerTimingEvent(state, 4000)).toEqual({ + at: 4000, + transition: "nextHand", + type: "transition", + }); + + expect(reconcileNextHandTransition(state, 2000)).toBe(false); + expect(state.transitionDeadlineAt).toBe(4000); + + expect(startAutomaticHand(state, 4000)).toBe(true); + expect(state.handNumber).toBe(1); + expect(state.phase).toBe("preflop"); + expect(state.transition).toBe("deal"); + expect(state.transitionDeadlineAt).toBe(6300); + const started = structuredClone(state); + expect(startAutomaticHand(state, 4000)).toBe(false); + expect(state).toEqual(started); + }); + + test("cancels a pending next hand when eligibility drops", () => { + const state = makeState({ seatCount: 2, seats: [null, null] }); + addSeat(state, 0, 100); + const second = addSeat(state, 1, 100); + reconcileNextHandTransition(state, 1000); + + second.sitOut = true; + + expect(reconcileNextHandTransition(state, 2000)).toBe(true); + expect(state.phase).toBe("waiting"); + expect(state.transition).toBeNull(); + expect(state.transitionDeadlineAt).toBeNull(); + expect(getNextLivePokerDeadline(state)).toBe(Number.POSITIVE_INFINITY); + }); + + test("persists transition kinds and exact alarm deadlines", () => { + const state = makeState({ activeSeatIndex: 0, phase: "preflop" }); + + startLivePokerTransition(state, "deal", 3300); + expect(getNextLivePokerDeadline(state)).toBe(3300); + expect(getDueLivePokerTimingEvent(state, 3299)).toBeNull(); + expect(getDueLivePokerTimingEvent(state, 3300)).toEqual({ + at: 3300, + transition: "deal", + type: "transition", + }); + expect(state.turnDeadlineAt).toBeNull(); + expect(state.transition).toBe("deal"); + }); +}); + +test.describe("live poker payout correctness", () => { + test("conserves a tied pot and awards its odd cent clockwise from the dealer", () => { + const state = makeState({ + communityCards: ["AS", "KS", "QS", "JS", "TS"], + dealerSeatIndex: 0, + deck: [], + phase: "showdown", + }); + const dealer = prepareSeat(state, 0, { + cards: ["2C", "3D"], + committed: 0.05, + stack: 1, + }); + prepareSeat(state, 1, { + cards: ["4C", "5D"], + committed: 0.05, + folded: true, + stack: 1, + }); + const leftOfDealer = prepareSeat(state, 2, { + cards: ["6C", "7D"], + committed: 0.05, + stack: 1, + }); + const before = totalChips(state); + + const winners = settleShowdown(state); + + expect(winners).toEqual([ + expect.objectContaining({ amount: 0.08, seatIndex: 2 }), + expect.objectContaining({ amount: 0.07, seatIndex: 0 }), + ]); + expect(leftOfDealer.stack).toBe(1.08); + expect(dealer.stack).toBe(1.07); + expect(totalChips(state)).toBe(before); + expect(winners.reduce((sum, winner) => sum + winner.amount, 0)).toBeCloseTo( + 0.15 + ); + }); + + test("keeps showdown results visible until explicit cleanup", () => { + const state = makeState({ + communityCards: ["AS", "KS", "QS", "JS", "TS"], + dealerSeatIndex: 0, + deck: [], + phase: "showdown", + }); + const winner = prepareSeat(state, 0, { + cards: ["2C", "3D"], + committed: 20, + stack: 80, + }); + prepareSeat(state, 1, { + cards: ["4C", "5D"], + committed: 20, + folded: true, + stack: 80, + }); + + const winners = settleShowdown(state); + const paidStack = winner.stack; + + expect(state.phase).toBe("showdown"); + expect(state.showdownSettled).toBe(true); + expect(state.showdownPot).toBe(40); + expect(state.communityCards).toHaveLength(5); + expect(state.lastWinners).toEqual(winners); + expect(state.seats[0]?.cards).toHaveLength(2); + expect(settleShowdown(state)).toEqual(winners); + expect(winner.stack).toBe(paidStack); + + cleanupShowdown(state); + + expect(state.phase).toBe("waiting"); + expect(state.communityCards).toEqual([]); + expect(state.lastWinners).toEqual([]); + expect(state.showdownPot).toBeNull(); + expect(state.showdownSeatIndexes).toEqual([]); + expect(state.seats.every((seat) => !seat?.cards)).toBe(true); + }); + + test("conserves every cent across main and side-pot ties", () => { + const state = makeState({ + communityCards: ["AS", "KS", "QS", "JS", "TS"], + dealerSeatIndex: 2, + deck: [], + phase: "showdown", + }); + prepareSeat(state, 0, { + cards: ["2C", "3D"], + committed: 0.03, + stack: 1, + }); + prepareSeat(state, 1, { + cards: ["4C", "5D"], + committed: 0.05, + stack: 1, + }); + prepareSeat(state, 2, { + cards: ["6C", "7D"], + committed: 0.05, + stack: 1, + }); + const before = totalChips(state); + + const winners = settleShowdown(state); + + expect(winners.reduce((sum, winner) => sum + winner.amount, 0)).toBeCloseTo( + 0.13 + ); + expect(totalChips(state)).toBe(before); + expect(state.seats.every((seat) => !seat || seat.committed === 0)).toBe( + true + ); + }); +}); diff --git a/tests/live-poker-resilience.spec.ts b/tests/live-poker-resilience.spec.ts new file mode 100644 index 0000000..73a256a --- /dev/null +++ b/tests/live-poker-resilience.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from "@playwright/test"; +import { + getJitteredLivePokerReconnectDelay, + getLivePokerReconnectDelay, + LIVE_POKER_MAX_RECONNECT_DELAY_MS, +} from "../src/lib/live-poker/reconnect"; +import { livePokerTableFormSchema } from "../src/lib/live-poker/table-form-schema"; + +const validTable = { + title: " Friday Game ", + smallBlind: "1", + bigBlind: "2", + minBuyIn: "20", + maxBuyIn: "400", + seatCount: "6", +}; + +test.describe("live poker resilience helpers", () => { + test("uses capped exponential reconnect delays", () => { + expect([0, 1, 2, 3, 4].map(getLivePokerReconnectDelay)).toEqual([ + 1000, 2000, 4000, 8000, 16_000, + ]); + expect(getLivePokerReconnectDelay(20)).toBe( + LIVE_POKER_MAX_RECONNECT_DELAY_MS + ); + expect(getJitteredLivePokerReconnectDelay(3, 0)).toBe(6000); + expect(getJitteredLivePokerReconnectDelay(3, 1)).toBe(8000); + }); + + test("parses valid table settings into finite numbers", () => { + const result = livePokerTableFormSchema.parse(validTable); + + expect(result).toEqual({ + title: "Friday Game", + smallBlind: 1, + bigBlind: 2, + minBuyIn: 20, + maxBuyIn: 400, + seatCount: 6, + }); + }); + + test("rejects invalid numeric and cross-field table settings", () => { + expect( + livePokerTableFormSchema.safeParse({ + ...validTable, + bigBlind: "0.5", + maxBuyIn: "10", + seatCount: "6.5", + }).success + ).toBe(false); + for (const smallBlind of [ + "1e309", + "0x10", + "not-a-number", + "0.001", + String(Number.MAX_SAFE_INTEGER), + ]) { + expect( + livePokerTableFormSchema.safeParse({ + ...validTable, + smallBlind, + }).success + ).toBe(false); + } + expect( + livePokerTableFormSchema.safeParse({ + ...validTable, + maxBuyIn: "0", + }).success + ).toBe(false); + }); +}); diff --git a/tests/live-poker-safety.spec.ts b/tests/live-poker-safety.spec.ts new file mode 100644 index 0000000..71dfb10 --- /dev/null +++ b/tests/live-poker-safety.spec.ts @@ -0,0 +1,179 @@ +import { expect, test } from "@playwright/test"; +import { + signLivePokerToken, + verifyLivePokerToken, +} from "../src/lib/live-poker/auth"; +import { + getLivePokerOutboxRetryDelay, + isLivePokerMessageWithinLimit, + isLivePokerTableId, + isRetryableLivePokerWebhookStatus, + LIVE_POKER_MAX_CONSECUTIVE_TIMEOUTS, + LIVE_POKER_MAX_HANDS_PER_TABLE, + LIVE_POKER_MAX_MESSAGE_BYTES, + LIVE_POKER_MAX_OUTBOX_RETRY_DELAY_MS, +} from "../src/lib/live-poker/cloudflare-safety"; +import { + createInitialState, + seatPlayer, + shuffleDeck, +} from "../src/lib/live-poker/engine"; +import { + clientMessageSchema, + toPublicState, +} from "../src/lib/live-poker/types"; + +const config = { + bigBlind: 2, + hostUserId: "host-user", + maxBuyIn: 400, + minBuyIn: 20, + seatCount: 6, + smallBlind: 1, +}; + +test("deck shuffling uses the platform cryptographic RNG", () => { + const originalRandom = Math.random; + Math.random = () => { + throw new Error("Math.random must not be used for cards"); + }; + + try { + const shuffled = shuffleDeck(); + expect(shuffled).toHaveLength(52); + expect(new Set(shuffled).size).toBe(52); + } finally { + Math.random = originalRandom; + } +}); + +test("public winner state omits internal user ids", () => { + const state = createInitialState(config); + state.lastWinners = [ + { + amount: 40, + playerId: "player-1", + seatIndex: 0, + userId: "private-user-id", + }, + ]; + + const publicState = toPublicState(state, "viewer"); + expect(publicState.lastWinners).toEqual([ + { amount: 40, playerId: "player-1", seatIndex: 0 }, + ]); + expect(publicState.lastWinners[0]).not.toHaveProperty("userId"); +}); + +test("ready state and manual starts are absent from the public protocol", () => { + const state = createInitialState(config); + seatPlayer(state, 0, { + buyIn: 100, + name: "Legacy Player", + playerId: "player-legacy", + userId: "user-legacy", + }); + const legacySeat = state.seats[0] as NonNullable<(typeof state.seats)[0]> & { + ready?: boolean; + }; + legacySeat.ready = true; + + const publicSeat = toPublicState(state, legacySeat.userId).seats[0]; + expect(publicSeat).not.toHaveProperty("ready"); + expect(clientMessageSchema.safeParse({ type: "startHand" }).success).toBe( + false + ); + expect( + clientMessageSchema.safeParse({ ready: true, type: "ready" }).success + ).toBe(false); + expect( + clientMessageSchema.safeParse({ + paused: true, + type: "setTablePaused", + }).success + ).toBe(true); + expect(toPublicState(state, legacySeat.userId).gamePaused).toBe(true); +}); + +test("Cloudflare request inputs have hard size and identifier bounds", () => { + expect(isLivePokerTableId("j57abc_DEF-123")).toBe(true); + expect(isLivePokerTableId("../table")).toBe(false); + expect(isLivePokerTableId("x".repeat(129))).toBe(false); + expect( + isLivePokerMessageWithinLimit("x".repeat(LIVE_POKER_MAX_MESSAGE_BYTES)) + ).toBe(true); + expect( + isLivePokerMessageWithinLimit( + "x".repeat(LIVE_POKER_MAX_MESSAGE_BYTES + 1) + ) + ).toBe(false); + expect( + isLivePokerMessageWithinLimit( + new Uint8Array(LIVE_POKER_MAX_MESSAGE_BYTES + 1).buffer + ) + ).toBe(false); +}); + +test("unattended and long-running tables have hard work limits", () => { + expect(LIVE_POKER_MAX_CONSECUTIVE_TIMEOUTS).toBe(6); + expect(LIVE_POKER_MAX_HANDS_PER_TABLE).toBe(250); +}); + +test("webhook retries are bounded, slow, and limited to transient failures", () => { + expect(getLivePokerOutboxRetryDelay(1, 1)).toBe(60_000); + expect(getLivePokerOutboxRetryDelay(2, 1)).toBe(120_000); + expect(getLivePokerOutboxRetryDelay(100, 1)).toBe( + LIVE_POKER_MAX_OUTBOX_RETRY_DELAY_MS + ); + expect(isRetryableLivePokerWebhookStatus(400)).toBe(false); + expect(isRetryableLivePokerWebhookStatus(401)).toBe(false); + expect(isRetryableLivePokerWebhookStatus(408)).toBe(true); + expect(isRetryableLivePokerWebhookStatus(429)).toBe(true); + expect(isRetryableLivePokerWebhookStatus(503)).toBe(true); +}); + +test("live poker JWTs require the dedicated configured secret", async () => { + const previousLivePokerSecret = process.env.LIVE_POKER_JWT_SECRET; + const previousNextAuthSecret = process.env.NEXTAUTH_SECRET; + + try { + Reflect.deleteProperty(process.env, "LIVE_POKER_JWT_SECRET"); + process.env.NEXTAUTH_SECRET = "must-not-be-used-as-a-fallback"; + await expect( + signLivePokerToken({ + exp: Math.floor(Date.now() / 1000) + 60, + playerId: "player-1", + playerName: "Player", + tableConfig: config, + tableId: "table-1", + userId: "user-1", + }) + ).rejects.toThrow("LIVE_POKER_JWT_SECRET is not configured"); + + process.env.LIVE_POKER_JWT_SECRET = "test-live-poker-secret"; + const token = await signLivePokerToken({ + exp: Math.floor(Date.now() / 1000) + 60, + playerId: "player-1", + playerName: "Player", + tableConfig: config, + tableId: "table-1", + userId: "user-1", + }); + await expect(verifyLivePokerToken(token)).resolves.toMatchObject({ + tableConfig: config, + tableId: "table-1", + userId: "user-1", + }); + } finally { + if (previousLivePokerSecret === undefined) { + Reflect.deleteProperty(process.env, "LIVE_POKER_JWT_SECRET"); + } else { + process.env.LIVE_POKER_JWT_SECRET = previousLivePokerSecret; + } + if (previousNextAuthSecret === undefined) { + Reflect.deleteProperty(process.env, "NEXTAUTH_SECRET"); + } else { + process.env.NEXTAUTH_SECRET = previousNextAuthSecret; + } + } +}); diff --git a/workers/live-poker.ts b/workers/live-poker.ts new file mode 100644 index 0000000..158099a --- /dev/null +++ b/workers/live-poker.ts @@ -0,0 +1,1664 @@ +/// + +import { DurableObject } from "cloudflare:workers"; +import { jwtVerify } from "jose"; +import { + getLivePokerOutboxRetryDelay, + isLivePokerMessageWithinLimit, + isLivePokerTableId, + isRetryableLivePokerWebhookStatus, + LIVE_POKER_CLOSED_OBJECT_RETENTION_MS, + LIVE_POKER_MAX_CONNECTIONS_PER_TABLE, + LIVE_POKER_MAX_CONNECTIONS_PER_USER, + LIVE_POKER_MAX_CONSECUTIVE_TIMEOUTS, + LIVE_POKER_MAX_HANDS_PER_TABLE, + LIVE_POKER_MAX_OUTBOX_ATTEMPTS, +} from "../src/lib/live-poker/cloudflare-safety"; +import { + addChips, + applyAction, + cleanupShowdown, + createInitialState, + revealNextRunoutStage, + seatPlayer, + settleShowdown, +} from "../src/lib/live-poker/engine"; +import { + reconcileNextHandTransition, + startAutomaticHand, +} from "../src/lib/live-poker/lifecycle"; +import { + assertLivePokerActionAvailable, + clearLivePokerTiming, + consumeLivePokerTimeBank, + DEFAULT_LIVE_POKER_TIMING, + getDueLivePokerTimingEvent, + getLivePokerSeatTimeBankRemainingMs, + getLivePokerTimeoutAction, + getNextLivePokerDeadline, + type LivePokerTimingConfig, + repairLivePokerTimeBanks, + startLivePokerTimeBank, + startLivePokerTransition, + startLivePokerTurn, +} from "../src/lib/live-poker/timing"; +import { + calculatePot, + clientMessageSchema, + type LivePokerAuthToken, + type LivePokerClientMessage, + type LivePokerSeat, + type LivePokerServerMessage, + type LivePokerState, + type LivePokerTableConfig, + type LivePokerWinner, + toPublicState, +} from "../src/lib/live-poker/types"; + +declare const WebSocketPair: { + new (): Record<0 | 1, WebSocket>; +}; + +interface Env { + LIVE_POKER_ALLOWED_ORIGINS?: string; + LIVE_POKER_CONTROL_SECRET?: string; + LIVE_POKER_IP_RATE_LIMITER: RateLimit; + LIVE_POKER_JWT_SECRET?: string; + LIVE_POKER_SETTLEMENT_URL?: string; + LIVE_POKER_TABLE: DurableObjectNamespace; + LIVE_POKER_USER_RATE_LIMITER: RateLimit; + LIVE_POKER_TURN_TIMEOUT_SECONDS?: string; + LIVE_POKER_WEBHOOK_SECRET?: string; + LIVE_POKER_WEBHOOK_URL?: string; +} + +type ConnectionState = LivePokerAuthToken; +type LivePokerActionMessage = Extract< + LivePokerClientMessage, + | { type: "allIn" } + | { type: "bet" } + | { type: "call" } + | { type: "check" } + | { type: "fold" } + | { type: "raise" } +>; + +interface LivePokerClaimRequest { + amount: number; + config: LivePokerTableConfig; + playerId: string; + playerName: string; + requestId: string; + seatIndex?: number; + type: "ADD_ON" | "INITIAL"; + userId: string; +} + +interface OutboxDelivery { + attempts: number; + deadLetteredAt?: number; + id: string; + kind: "hand" | "settlement"; + lastError?: string; + nextAttemptAt: number; + payload: unknown; +} + +interface MessageRateWindow { + count: number; + startedAt: number; +} + +class WebhookDeliveryError extends Error { + readonly retryable: boolean; + + constructor(message: string, retryable: boolean) { + super(message); + this.retryable = retryable; + } +} + +const encoder = new TextEncoder(); +const LIVE_POKER_PATH_REGEX = /^\/live-poker\/([^/]+)$/; +const LIVE_POKER_CLAIM_PATH_REGEX = /^\/live-poker\/([^/]+)\/claim$/; +const LIVE_POKER_CLOSE_PATH_REGEX = /^\/live-poker\/([^/]+)\/close$/; +const LIVE_POKER_RETRY_PATH_REGEX = /^\/live-poker\/([^/]+)\/retry-dead-letters$/; +const MAX_ACTION_LOG_ENTRIES = 200; +const MAX_APPLIED_REQUEST_IDS = 1000; +const MAX_OUTBOX_DELIVERIES_PER_RUN = 10; +const MAX_PENDING_OUTBOX_DELIVERIES = 100; +const MAX_SOCKET_MESSAGES_PER_WINDOW = 20; +const MAX_TABLE_MESSAGES_PER_WINDOW = 60; +const MAX_USER_MESSAGES_PER_WINDOW = 30; +const MESSAGE_RATE_WINDOW_MS = 10_000; +const OUTBOX_FETCH_TIMEOUT_MS = 10_000; +const OUTBOX_STORAGE_PREFIX = "outbox:"; +const SOCKET_PROTOCOL = "buyin-live-poker"; +const TOKEN_PROTOCOL_PREFIX = "buyin-auth-"; + +function envString(env: Env, key: keyof Env) { + const value = env[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function requireEnvString(env: Env, key: keyof Env) { + const value = envString(env, key); + if (!value) { + throw new Error(`${String(key)} is not configured`); + } + return value; +} + +function isValidChipAmount(value: number, allowZero = false) { + const units = Math.round(value * 100); + return ( + Number.isFinite(value) && + Number.isSafeInteger(units) && + Math.abs(value * 100 - units) <= 1e-7 && + (allowZero ? units >= 0 : units > 0) + ); +} + +function validateTableConfig(config: LivePokerTableConfig) { + const hasValidPositiveAmounts = [ + config.smallBlind, + config.bigBlind, + config.maxBuyIn, + ].every((amount) => isValidChipAmount(amount)); + const hasValidIdentityAndAmounts = + Boolean(config.hostUserId) && + hasValidPositiveAmounts && + isValidChipAmount(config.minBuyIn, true); + if ( + !hasValidIdentityAndAmounts || + config.bigBlind < config.smallBlind || + config.maxBuyIn < config.minBuyIn || + !Number.isInteger(config.seatCount) || + config.seatCount < 2 || + config.seatCount > 9 + ) { + throw new Error("Invalid live poker table configuration"); + } + return config; +} + +async function verifyToken(env: Env, token: string) { + const secret = requireEnvString(env, "LIVE_POKER_JWT_SECRET"); + const { payload } = await jwtVerify(token, encoder.encode(secret), { + algorithms: ["HS256"], + }); + const tableConfig = validateTableConfig( + payload.tableConfig as LivePokerTableConfig + ); + const auth = { + exp: Number(payload.exp), + tableConfig, + tableId: String(payload.tableId), + playerId: String(payload.playerId), + playerName: String(payload.playerName), + userId: String(payload.userId), + } satisfies LivePokerAuthToken; + if ( + !( + Number.isFinite(auth.exp) && + auth.tableId && + auth.playerId && + auth.playerName && + auth.userId + ) + ) { + throw new Error("Invalid live poker token"); + } + return auth; +} + +function send(connection: WebSocket, message: LivePokerServerMessage) { + if (connection.readyState !== WebSocket.OPEN) { + return; + } + if (connection.bufferedAmount > 64 * 1024) { + connection.close(1013, "Client is not accepting updates"); + return; + } + connection.send(JSON.stringify(message)); +} + +function isActionMessage( + message: LivePokerClientMessage +): message is LivePokerActionMessage { + return ( + message.type === "allIn" || + message.type === "bet" || + message.type === "call" || + message.type === "check" || + message.type === "fold" || + message.type === "raise" + ); +} + +function getPathTableId(pathname: string, pattern: RegExp) { + const match = pathname.match(pattern); + if (!match?.[1]) { + return null; + } + try { + const tableId = decodeURIComponent(match[1]); + return isLivePokerTableId(tableId) ? tableId : null; + } catch { + return null; + } +} + +function getSocketProtocols(request: Request) { + return (request.headers.get("Sec-WebSocket-Protocol") ?? "") + .split(",") + .map((protocol) => protocol.trim()) + .filter(Boolean); +} + +function getSocketToken(request: Request) { + const protocolToken = getSocketProtocols(request).find((protocol) => + protocol.startsWith(TOKEN_PROTOCOL_PREFIX) + ); + return protocolToken?.slice(TOKEN_PROTOCOL_PREFIX.length) ?? null; +} + +function hasWorkerSecret(request: Request, env: Env) { + const expected = envString(env, "LIVE_POKER_CONTROL_SECRET"); + return Boolean( + expected && request.headers.get("x-live-poker-secret") === expected + ); +} + +function rateLimitedResponse() { + return new Response("Too many requests", { + headers: { "Retry-After": "60" }, + status: 429, + }); +} + +function isAllowedSocketOrigin(request: Request, env: Env) { + const origin = request.headers.get("Origin"); + const allowedOrigins = (envString(env, "LIVE_POKER_ALLOWED_ORIGINS") ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + return Boolean(origin && allowedOrigins.includes(origin)); +} + +function isRuntimeConfigured(env: Env) { + const required: Array = [ + "LIVE_POKER_ALLOWED_ORIGINS", + "LIVE_POKER_CONTROL_SECRET", + "LIVE_POKER_JWT_SECRET", + "LIVE_POKER_SETTLEMENT_URL", + "LIVE_POKER_WEBHOOK_SECRET", + "LIVE_POKER_WEBHOOK_URL", + ]; + return required.every((key) => Boolean(envString(env, key))); +} + +export default { + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Admission checks stay together so no route can reach a Durable Object before authentication and abuse controls. + async fetch(request: Request, env: Env): Promise { + const ip = request.headers.get("CF-Connecting-IP") ?? "unknown"; + const ipLimit = await env.LIVE_POKER_IP_RATE_LIMITER.limit({ key: ip }); + if (!ipLimit.success) { + return rateLimitedResponse(); + } + + const url = new URL(request.url); + if (url.pathname === "/health") { + const configured = isRuntimeConfigured(env); + return Response.json( + { configured, ok: configured, service: "buyin-live-poker" }, + { status: configured ? 200 : 503 } + ); + } + + const tableId = + getPathTableId(url.pathname, LIVE_POKER_CLAIM_PATH_REGEX) ?? + getPathTableId(url.pathname, LIVE_POKER_CLOSE_PATH_REGEX) ?? + getPathTableId(url.pathname, LIVE_POKER_RETRY_PATH_REGEX) ?? + getPathTableId(url.pathname, LIVE_POKER_PATH_REGEX); + if (!tableId) { + return new Response("Not found", { status: 404 }); + } + + const isSocketPath = LIVE_POKER_PATH_REGEX.test(url.pathname); + if (isSocketPath) { + if (request.headers.get("Upgrade") !== "websocket") { + return new Response("Expected WebSocket", { status: 400 }); + } + if (!isAllowedSocketOrigin(request, env)) { + return new Response("WebSocket origin is not allowed", { status: 403 }); + } + const token = getSocketToken(request); + if (!token || token.length > 4096) { + return new Response("Unauthorized", { status: 401 }); + } + try { + const auth = await verifyToken(env, token); + if (auth.tableId !== tableId) { + return new Response("Unauthorized", { status: 401 }); + } + const userLimit = await env.LIVE_POKER_USER_RATE_LIMITER.limit({ + key: auth.userId, + }); + if (!userLimit.success) { + return rateLimitedResponse(); + } + } catch { + return new Response("Unauthorized", { status: 401 }); + } + } else { + if (request.method !== "POST") { + return new Response("Method not allowed", { status: 405 }); + } + if (!hasWorkerSecret(request, env)) { + return new Response("Unauthorized", { status: 401 }); + } + } + + const id = env.LIVE_POKER_TABLE.idFromName(tableId); + return await env.LIVE_POKER_TABLE.get(id).fetch(request); + }, +}; + +export class LivePokerTableDurableObject extends DurableObject { + private readonly bindings: Env; + private flushingOutbox: Promise | null = null; + private outbox: OutboxDelivery[] = []; + private readonly pendingOutboxWrites = new Set(); + private readonly socketMessageWindows = new WeakMap< + WebSocket, + MessageRateWindow + >(); + private state: LivePokerState | null = null; + private readonly tableId: string; + private tableMessageWindow: MessageRateWindow = { count: 0, startedAt: 0 }; + private readonly userMessageWindows = new Map(); + private readonly ready: Promise; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.bindings = env; + this.tableId = ctx.id.name ?? ""; + this.ready = ctx.blockConcurrencyWhile(async () => { + const stored = await ctx.storage.get(["state", "outbox"]); + this.state = (stored.get("state") as LivePokerState | undefined) ?? null; + const legacyOutbox = + (stored.get("outbox") as OutboxDelivery[] | undefined) ?? []; + const storedOutbox = await ctx.storage.list({ + prefix: OUTBOX_STORAGE_PREFIX, + }); + this.outbox = [ + ...storedOutbox.values(), + ...legacyOutbox.filter( + (legacy) => + !Array.from(storedOutbox.values()).some( + (delivery) => delivery.id === legacy.id + ) + ), + ].sort((left, right) => this.compareOutboxDeliveries(left, right)); + const now = Date.now(); + const repairedConnections = this.repairConnectionState(); + const repairedTiming = this.repairTimingState(now); + if (legacyOutbox.length > 0 && this.state) { + await this.persist(this.outbox); + await ctx.storage.delete("outbox"); + } else if (repairedConnections || repairedTiming) { + await this.persist(); + } else { + await this.scheduleAlarm(); + } + }); + } + + async fetch(request: Request): Promise { + await this.ready; + const url = new URL(request.url); + + try { + if (LIVE_POKER_CLAIM_PATH_REGEX.test(url.pathname)) { + return await this.handleClaimRequest(request); + } + if (LIVE_POKER_CLOSE_PATH_REGEX.test(url.pathname)) { + return await this.handleCloseRequest(request); + } + if (LIVE_POKER_RETRY_PATH_REGEX.test(url.pathname)) { + return await this.handleRetryDeadLettersRequest(request); + } + + const token = getSocketToken(request); + if (!token) { + throw new Error("Missing live poker token"); + } + + const auth = await verifyToken(this.bindings, token); + if (auth.tableId !== this.tableId) { + throw new Error("Token does not match this table"); + } + + await this.ensureState(auth.tableConfig); + const now = Date.now(); + const repairedTiming = this.repairTimingState(now); + const advancedTiming = this.advanceDueTiming(now); + if (repairedTiming || advancedTiming) { + await this.persist(); + } + if (this.state?.admissionsClosed) { + return new Response("Table is closed", { status: 410 }); + } + + const connectionCapacityError = this.getConnectionCapacityError(auth); + if (connectionCapacityError) { + return new Response(connectionCapacityError, { + headers: { "Retry-After": "60" }, + status: 429, + }); + } + + const pair = new WebSocketPair(); + const [client, server] = Object.values(pair) as [WebSocket, WebSocket]; + this.ctx.acceptWebSocket(server); + server.serializeAttachment(auth); + + const seat = this.findSeatByUserId(auth.userId); + if (seat && !seat.connected) { + seat.connected = true; + this.reconcileNextHand(now); + await this.persist(); + this.broadcast(); + } else { + this.sendState(server, auth); + } + + const response = new Response(null, { + status: 101, + webSocket: client, + }); + if (getSocketProtocols(request).includes(SOCKET_PROTOCOL)) { + response.headers.set("Sec-WebSocket-Protocol", SOCKET_PROTOCOL); + } + return response; + } catch (error) { + return new Response( + error instanceof Error ? error.message : "Unauthorized", + { status: 401 } + ); + } + } + + async alarm() { + await this.ready; + await this.flushOutbox(); + const now = Date.now(); + if (await this.deleteClosedStorageIfDue(now)) { + return; + } + const repairedTiming = this.repairTimingState(now); + const advancedTiming = this.advanceDueTiming(now); + if (repairedTiming || advancedTiming) { + await this.persist(); + this.broadcast(); + } else { + await this.scheduleAlarm(); + } + } + + async webSocketMessage(connection: WebSocket, message: string | ArrayBuffer) { + await this.ready; + + const auth = connection.deserializeAttachment() as ConnectionState | null; + if ( + !auth || + auth.exp * 1000 <= Date.now() || + this.state?.admissionsClosed + ) { + connection.close(1008, "Unauthorized"); + return; + } + + try { + if (!isLivePokerMessageWithinLimit(message)) { + connection.close(1009, "Message is too large"); + return; + } + const now = Date.now(); + if (!this.consumeMessageRate(connection, auth.userId, now)) { + connection.close(1008, "Message rate limit exceeded"); + return; + } + const rawMessage = + typeof message === "string" + ? message + : new TextDecoder().decode(message); + const parsed = clientMessageSchema.safeParse( + JSON.parse(rawMessage) as unknown + ); + if (!parsed.success) { + send(connection, { + type: "actionRejected", + message: "Invalid table action", + }); + return; + } + if ( + parsed.data.type === "joinTable" || + parsed.data.type === "requestSync" + ) { + this.sendState(connection, auth); + return; + } + + const repairedTiming = this.repairTimingState(now); + const advancedTiming = this.advanceDueTiming(now); + if (repairedTiming || advancedTiming) { + await this.persist(); + this.broadcast(); + } + this.handleMessage(auth, parsed.data, now); + this.reconcileNextHand(now); + await this.persist(); + this.broadcast(); + if (this.hasDueOutbox()) { + this.ctx.waitUntil(this.flushOutboxAndScheduleAlarm()); + } + } catch (error) { + send(connection, { + type: "actionRejected", + message: error instanceof Error ? error.message : "Action rejected", + }); + } + } + + async webSocketClose(connection: WebSocket, code: number, reason: string) { + await this.ready; + this.socketMessageWindows.delete(connection); + const auth = connection.deserializeAttachment() as ConnectionState | null; + const seat = this.findSeatByUserId(auth?.userId); + if (seat && !this.hasOpenConnection(auth?.userId, connection)) { + seat.connected = false; + this.reconcileNextHand(Date.now()); + await this.persist(); + this.broadcast(); + } + connection.close(code, reason); + } + + async webSocketError(connection: WebSocket) { + await this.webSocketClose(connection, 1011, "WebSocket error"); + } + + private async handleClaimRequest(request: Request) { + if (request.method !== "POST") { + return new Response("Method not allowed", { status: 405 }); + } + if (!this.hasWorkerSecret(request)) { + return new Response("Unauthorized", { status: 401 }); + } + + try { + const claim = (await request.json()) as LivePokerClaimRequest; + if (!claim.requestId) { + throw new Error("A requestId is required"); + } + await this.ensureState(validateTableConfig(claim.config)); + if (this.state?.admissionsClosed) { + throw new Error("Table is closed"); + } + if (this.state?.appliedRequestIds?.includes(claim.requestId)) { + return Response.json({ idempotent: true, ok: true }); + } + + this.applyApprovedClaim(claim); + if (this.state) { + this.reconcileNextHand(Date.now()); + this.state.appliedRequestIds = [ + ...(this.state.appliedRequestIds ?? []), + claim.requestId, + ].slice(-MAX_APPLIED_REQUEST_IDS); + } + await this.persist(); + this.broadcast(); + return Response.json({ ok: true }); + } catch (error) { + return Response.json( + { + error: + error instanceof Error ? error.message : "Unable to claim buy-in", + }, + { status: 409 } + ); + } + } + + private async handleCloseRequest(request: Request) { + if (request.method !== "POST") { + return new Response("Method not allowed", { status: 405 }); + } + if (!this.hasWorkerSecret(request)) { + return new Response("Unauthorized", { status: 401 }); + } + + try { + const body = (await request.json()) as { config: LivePokerTableConfig }; + await this.ensureState(validateTableConfig(body.config), true); + if (!this.state) { + throw new Error("Table is not ready"); + } + + if (this.state.phase !== "waiting") { + throw new Error("Finish the current hand before closing the table"); + } + + const occupiedSeats = this.state.seats.filter( + (seat): seat is LivePokerSeat => seat !== null + ); + this.ensureOutboxCapacity(occupiedSeats.length); + this.state.admissionsClosed = true; + this.state.storageDeleteAt = + Date.now() + LIVE_POKER_CLOSED_OBJECT_RETENTION_MS; + for (const seat of occupiedSeats) { + this.queueSettlement(seat); + } + this.state.seats = this.state.seats.map(() => null); + clearLivePokerTiming(this.state); + await this.persist(); + + for (const connection of this.ctx.getWebSockets()) { + send(connection, { + message: "Table closed by host", + type: "actionRejected", + }); + connection.close(1001, "Table closed"); + } + return Response.json({ ok: true, pendingDeliveries: this.outbox.length }); + } catch (error) { + return Response.json( + { error: error instanceof Error ? error.message : "Unable to close" }, + { status: 409 } + ); + } + } + + private async handleRetryDeadLettersRequest(request: Request) { + if (request.method !== "POST") { + return new Response("Method not allowed", { status: 405 }); + } + if (!this.hasWorkerSecret(request)) { + return new Response("Unauthorized", { status: 401 }); + } + + const deadLetters = this.outbox.filter( + (delivery) => delivery.deadLetteredAt + ); + const retriedIds = new Set(deadLetters.map((delivery) => delivery.id)); + const now = Date.now(); + for (const delivery of deadLetters) { + delivery.attempts = 0; + delivery.deadLetteredAt = undefined; + delivery.lastError = undefined; + delivery.nextAttemptAt = now; + } + if (deadLetters.length > 0) { + await this.persist(deadLetters); + await this.flushOutboxAndScheduleAlarm(); + } + const remaining = this.outbox.filter((delivery) => + retriedIds.has(delivery.id) + ); + const stillDeadLettered = remaining.filter( + (delivery) => delivery.deadLetteredAt + ); + let status = 200; + if (stillDeadLettered.length > 0) { + status = 502; + } else if (remaining.length > 0) { + status = 202; + } + return Response.json( + { + deadLettered: stillDeadLettered.length, + delivered: deadLetters.length - remaining.length, + errors: stillDeadLettered.slice(0, 10).map((delivery) => ({ + id: delivery.id, + lastError: delivery.lastError ?? "Delivery failed", + })), + ok: remaining.length === 0, + pending: remaining.length - stillDeadLettered.length, + retried: deadLetters.length, + }, + { status } + ); + } + + private hasWorkerSecret(request: Request) { + return hasWorkerSecret(request, this.bindings); + } + + private async ensureState( + config: LivePokerTableConfig, + allowConfigMismatchForClosure = false + ) { + validateTableConfig(config); + if (!this.state) { + this.state = createInitialState(config); + this.state.admissionsClosed = false; + this.state.appliedRequestIds = []; + this.state.turnDeadlineAt = null; + await this.persist(); + return; + } + + // Signed tokens and secret-authenticated server requests are authoritative. + // This also upgrades legacy seats to persisted per-session time banks. + let repaired = repairLivePokerTimeBanks(this.state, this.timing()); + if (this.state.hostUserId !== config.hostUserId) { + this.state.hostUserId = config.hostUserId; + repaired = true; + } + + const configMismatch = + this.state.bigBlind !== config.bigBlind || + this.state.maxBuyIn !== config.maxBuyIn || + this.state.minBuyIn !== config.minBuyIn || + this.state.seatCount !== config.seatCount || + this.state.smallBlind !== config.smallBlind; + if (configMismatch) { + if (allowConfigMismatchForClosure) { + await this.persist(); + return; + } + if ( + this.state.phase === "waiting" && + this.state.seats.every((seat) => seat === null) + ) { + this.state = createInitialState(config); + this.state.admissionsClosed = false; + this.state.appliedRequestIds = []; + this.state.turnDeadlineAt = null; + await this.persist(); + return; + } + throw new Error("Signed table configuration does not match stored table"); + } + + if (repaired) { + await this.persist(); + } + } + + private handleMessage( + auth: LivePokerAuthToken, + message: LivePokerClientMessage, + now: number + ) { + if (!this.state) { + throw new Error("Table is not ready"); + } + + const seat = this.findSeatByUserId(auth.userId); + if (message.type === "setTablePaused") { + this.requireHost(auth.userId); + this.state.gamePaused = message.paused; + if (!message.paused) { + this.state.consecutiveTimeoutActions = 0; + } + let pauseMessage = "The host resumed the table"; + if (message.paused) { + pauseMessage = + this.state.phase === "waiting" + ? "The host paused the table" + : "The host will pause the table after this hand"; + } + this.state.actionLog.push(pauseMessage); + return; + } + + if (message.type === "kickSeat") { + this.requireHost(auth.userId); + if (this.state.phase !== "waiting") { + throw new Error("Seats can only be kicked between hands"); + } + const kickedSeat = this.state.seats[message.seatIndex]; + if (!kickedSeat) { + throw new Error("Seat is already open"); + } + this.queueSettlement(kickedSeat); + this.state.seats[message.seatIndex] = null; + this.state.actionLog.push( + `${kickedSeat.name} was removed from seat ${message.seatIndex + 1}` + ); + this.disconnectUser(kickedSeat.userId, "Removed by host"); + return; + } + + if (message.type === "sit") { + throw new Error("Request and claim an approved buy-in before sitting"); + } + if (!seat) { + throw new Error("Take a seat before acting"); + } + if (this.handleSeatedControlMessage(seat, message)) { + return; + } + if (!isActionMessage(message)) { + throw new Error("Invalid table action"); + } + assertLivePokerActionAvailable(this.state); + + const actingSeatIndex = this.state.activeSeatIndex; + const previousCommunityCardCount = this.state.communityCards.length; + applyAction(this.state, auth.userId, message); + this.state.consecutiveTimeoutActions = 0; + this.state.communityCardRevealStartIndex = + this.state.communityCards.length > previousCommunityCardCount + ? previousCommunityCardCount + : null; + if (actingSeatIndex !== null) { + consumeLivePokerTimeBank(this.state, actingSeatIndex, now, this.timing()); + } + this.captureSettledAction(actingSeatIndex, message); + startLivePokerTransition( + this.state, + "actionSettle", + now + this.timing().actionSettleMs + ); + } + + private requireHost(userId: string) { + if (userId !== this.state?.hostUserId) { + throw new Error("Only the table creator can perform this action"); + } + } + + private handleSeatedControlMessage( + seat: LivePokerSeat, + message: LivePokerClientMessage + ) { + if (!this.state) { + throw new Error("Table is not ready"); + } + + if (message.type === "addChips") { + throw new Error( + "Request and claim an approved add-on before adding chips" + ); + } + + if (message.type === "leaveSeat") { + if (this.state.phase !== "waiting") { + throw new Error("You can leave after the current hand"); + } + this.queueSettlement(seat); + this.state.seats[seat.seatIndex] = null; + return true; + } + + if (message.type === "sitOut") { + seat.sitOut = message.sitOut; + return true; + } + return false; + } + + private applyApprovedClaim(claim: LivePokerClaimRequest) { + if (!this.state) { + throw new Error("Table is not ready"); + } + if ( + !Number.isFinite(claim.amount) || + claim.amount <= 0 || + !claim.playerId || + !claim.playerName || + !claim.userId || + !(claim.type === "INITIAL" || claim.type === "ADD_ON") + ) { + throw new Error("Invalid approved buy-in payload"); + } + if (this.state.phase !== "waiting") { + throw new Error("Approved buy-ins can be claimed between hands"); + } + + if (claim.type === "INITIAL") { + if ( + claim.seatIndex === undefined || + !Number.isInteger(claim.seatIndex) || + claim.seatIndex < 0 || + claim.seatIndex >= this.state.seatCount + ) { + throw new Error("A valid seat is required for an initial buy-in"); + } + seatPlayer(this.state, claim.seatIndex, { + buyIn: claim.amount, + name: claim.playerName, + playerId: claim.playerId, + userId: claim.userId, + }); + const seat = this.findSeatByUserId(claim.userId); + if (seat) { + seat.connected = this.hasOpenConnection(claim.userId); + } + return; + } + addChips(this.state, claim.userId, claim.amount); + } + + private completeShowdown() { + if ( + !this.state || + this.state.phase !== "showdown" || + this.state.showdownSettled + ) { + return; + } + const deliveryId = `${this.tableId}:hand:${this.state.handNumber}`; + if (!this.outbox.some((delivery) => delivery.id === deliveryId)) { + this.ensureOutboxCapacity(1); + } + const pot = calculatePot(this.state); + const winners = settleShowdown(this.state); + const communityCards = [...this.state.communityCards]; + this.state.actionLog.push( + `Hand ${this.state.handNumber} ended: ${winners + .map((winner) => `${winner.amount} to seat ${winner.seatIndex + 1}`) + .join(", ")}` + ); + this.queueCompletedHand(pot, winners, communityCards); + } + + private outboxStorageKey(id: string) { + return `${OUTBOX_STORAGE_PREFIX}${id}`; + } + + private ensureOutboxCapacity(additionalDeliveries: number) { + if ( + this.outbox.length + additionalDeliveries > + MAX_PENDING_OUTBOX_DELIVERIES + ) { + throw new Error( + "Live poker persistence is temporarily backed up; try again shortly" + ); + } + } + + private queueCompletedHand( + pot: number, + winners: LivePokerWinner[], + communityCards: string[] + ) { + if (!this.state) { + return; + } + const deliveryId = `${this.tableId}:hand:${this.state.handNumber}`; + if (this.outbox.some((delivery) => delivery.id === deliveryId)) { + return; + } + this.outbox.push({ + attempts: 0, + id: deliveryId, + kind: "hand", + nextAttemptAt: Date.now(), + payload: { + actionLog: this.state.actionLog.slice(-80), + bigBlind: this.state.bigBlind, + communityCards, + completedAt: Date.now(), + dealerSeat: this.state.dealerSeatIndex ?? 0, + handNumber: this.state.handNumber, + pot, + smallBlind: this.state.smallBlind, + tableId: this.tableId, + winners, + }, + }); + this.pendingOutboxWrites.add(deliveryId); + } + + private queueSettlement(seat: LivePokerSeat) { + this.ensureOutboxCapacity(1); + const id = `${this.tableId}:settlement:${crypto.randomUUID()}`; + this.outbox.push({ + attempts: 0, + id, + kind: "settlement", + nextAttemptAt: Date.now(), + payload: { + buyIn: seat.buyIn, + cashOut: seat.stack, + playerId: seat.playerId, + settledAt: Date.now(), + settlementId: crypto.randomUUID(), + tableId: this.tableId, + userId: seat.userId, + }, + }); + this.pendingOutboxWrites.add(id); + } + + private flushOutbox() { + if (!this.flushingOutbox) { + this.flushingOutbox = this.drainOutbox().finally(() => { + this.flushingOutbox = null; + }); + } + return this.flushingOutbox; + } + + private async flushOutboxAndScheduleAlarm() { + await this.flushOutbox(); + await this.scheduleAlarm(); + } + + private compareOutboxDeliveries( + left: OutboxDelivery, + right: OutboxDelivery + ) { + if (Boolean(left.deadLetteredAt) !== Boolean(right.deadLetteredAt)) { + return left.deadLetteredAt ? 1 : -1; + } + return ( + left.nextAttemptAt - right.nextAttemptAt || left.id.localeCompare(right.id) + ); + } + + private nextPendingOutboxDelivery() { + this.outbox.sort((left, right) => + this.compareOutboxDeliveries(left, right) + ); + return this.outbox.find((delivery) => !delivery.deadLetteredAt); + } + + private hasDueOutbox(now = Date.now()) { + const delivery = this.nextPendingOutboxDelivery(); + return Boolean(delivery && delivery.nextAttemptAt <= now); + } + + private async drainOutbox() { + let processed = 0; + while (processed < MAX_OUTBOX_DELIVERIES_PER_RUN) { + const delivery = this.nextPendingOutboxDelivery(); + if (!delivery || delivery.nextAttemptAt > Date.now()) { + break; + } + processed += 1; + + try { + await this.deliverOutboxItem(delivery); + this.outbox = this.outbox.filter( + (candidate) => candidate.id !== delivery.id + ); + this.pendingOutboxWrites.delete(delivery.id); + await this.ctx.storage.delete(this.outboxStorageKey(delivery.id)); + } catch (error) { + const now = Date.now(); + delivery.attempts += 1; + delivery.lastError = ( + error instanceof Error ? error.message : "Webhook delivery failed" + ).slice(0, 160); + const retryable = + !(error instanceof WebhookDeliveryError) || error.retryable; + if ( + !retryable || + delivery.attempts >= LIVE_POKER_MAX_OUTBOX_ATTEMPTS + ) { + delivery.deadLetteredAt = now; + } else { + delivery.nextAttemptAt = + now + getLivePokerOutboxRetryDelay(delivery.attempts); + } + await this.ctx.storage.put( + this.outboxStorageKey(delivery.id), + delivery + ); + } + } + + if ( + this.state?.phase === "waiting" && + !this.state.gamePaused && + this.outbox.some((delivery) => delivery.deadLetteredAt) + ) { + this.state.gamePaused = true; + clearLivePokerTiming(this.state); + this.state.actionLog.push( + "Table paused because completed data needs manual delivery recovery" + ); + await this.persist(); + } + } + + private async deliverOutboxItem(delivery: OutboxDelivery) { + const url = requireEnvString( + this.bindings, + delivery.kind === "hand" + ? "LIVE_POKER_WEBHOOK_URL" + : "LIVE_POKER_SETTLEMENT_URL" + ); + const secret = requireEnvString(this.bindings, "LIVE_POKER_WEBHOOK_SECRET"); + const response = await fetch(url, { + body: JSON.stringify(delivery.payload), + headers: { + "content-type": "application/json", + "x-live-poker-secret": secret, + }, + method: "POST", + signal: AbortSignal.timeout(OUTBOX_FETCH_TIMEOUT_MS), + }); + await response.body?.cancel().catch(() => undefined); + if (!response.ok) { + throw new WebhookDeliveryError( + `Webhook failed with status ${response.status}`, + isRetryableLivePokerWebhookStatus(response.status) + ); + } + } + + private captureSettledAction( + seatIndex: number | null, + action: LivePokerActionMessage + ) { + if (!this.state || seatIndex === null) { + return; + } + const seat = this.state.seats[seatIndex]; + this.state.settledAction = { + amount: + action.type === "bet" || action.type === "raise" + ? action.amount + : (seat?.streetAction?.amount ?? 0), + seatIndex, + type: action.type, + }; + } + + private finishShowdown(at: number) { + if (!this.state) { + return; + } + this.completeShowdown(); + startLivePokerTransition( + this.state, + "showdown", + at + this.timing().showdownMs + ); + } + + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Persisted poker timing events are kept in one chronological transition loop so late alarms catch up atomically. + private advanceDueTiming(now: number) { + if (!this.state) { + return false; + } + + let changed = false; + for (let step = 0; step < 20; step += 1) { + const event = getDueLivePokerTimingEvent(this.state, now); + if (!event) { + return changed; + } + changed = true; + + if (event.type === "startTimeBank") { + const seatIndex = this.state.activeSeatIndex; + const remaining = + seatIndex === null + ? 0 + : getLivePokerSeatTimeBankRemainingMs( + this.state, + seatIndex, + this.timing() + ); + startLivePokerTimeBank(this.state, event.at, this.timing()); + const seat = seatIndex === null ? null : this.state.seats[seatIndex]; + if (seat) { + this.state.actionLog.push( + `${seat.name}'s time bank started with ${remaining / 1000} seconds remaining` + ); + } + continue; + } + + if (event.type === "turnExpired") { + const seatIndex = this.state.activeSeatIndex; + const seat = seatIndex === null ? null : this.state.seats[seatIndex]; + if (seatIndex === null || !seat) { + clearLivePokerTiming(this.state); + continue; + } + const action = getLivePokerTimeoutAction(this.state); + if (!action) { + clearLivePokerTiming(this.state); + continue; + } + const canCheck = action.type === "check"; + const timeBankWasActive = this.state.timeBankActive ?? false; + consumeLivePokerTimeBank( + this.state, + seatIndex, + event.at, + this.timing() + ); + applyAction(this.state, seat.userId, action); + this.state.consecutiveTimeoutActions = + (this.state.consecutiveTimeoutActions ?? 0) + 1; + if ( + this.state.consecutiveTimeoutActions >= + LIVE_POKER_MAX_CONSECUTIVE_TIMEOUTS && + !this.state.gamePaused + ) { + this.state.gamePaused = true; + this.state.actionLog.push( + "Table auto-paused after repeated unattended turns" + ); + } + this.captureSettledAction(seatIndex, action); + this.state.actionLog.push( + `${seat.name} automatically ${canCheck ? "checked" : "folded"} ${ + timeBankWasActive + ? "after the time bank expired" + : "with no time bank remaining" + }` + ); + startLivePokerTransition( + this.state, + "actionSettle", + event.at + this.timing().actionSettleMs + ); + continue; + } + + this.state.transition = null; + this.state.transitionDeadlineAt = null; + if (event.transition === "nextHand") { + if (!startAutomaticHand(this.state, event.at, this.timing())) { + clearLivePokerTiming(this.state); + continue; + } + this.broadcastMessage({ + type: "handStarted", + handNumber: this.state.handNumber, + }); + continue; + } + + if (event.transition === "showdown") { + cleanupShowdown(this.state); + clearLivePokerTiming(this.state); + this.reconcileNextHand(event.at); + continue; + } + + if (event.transition === "runout") { + const previousCommunityCardCount = this.state.communityCards.length; + revealNextRunoutStage(this.state); + this.state.communityCardRevealStartIndex = + this.state.communityCards.length > previousCommunityCardCount + ? previousCommunityCardCount + : null; + if (this.state.phase === "showdown") { + this.finishShowdown(event.at); + } else { + startLivePokerTransition( + this.state, + "runout", + event.at + this.timing().runoutStageMs + ); + } + continue; + } + + if (event.transition === "actionSettle") { + this.state.communityCardRevealStartIndex = null; + this.state.settledAction = null; + } + if (this.state.phase === "showdown") { + this.finishShowdown(event.at); + } else if (this.state.runoutPending) { + startLivePokerTransition( + this.state, + "runout", + event.at + this.timing().runoutStageMs + ); + } else if (this.state.activeSeatIndex !== null) { + startLivePokerTurn(this.state, event.at, this.timing()); + } else { + clearLivePokerTiming(this.state); + } + } + + // Persist the bounded batch and let the next alarm continue catch-up. This + // avoids repeatedly failing a table after a long suspension. + return changed; + } + + private repairTimingState(now: number) { + if (!this.state) { + return false; + } + + const timing = this.timing(); + let changed = repairLivePokerTimeBanks(this.state, timing); + if (this.state.phase === "waiting") { + const hasValidNextHand = + this.state.transition === "nextHand" && + Number.isFinite(this.state.transitionDeadlineAt); + if ( + (!hasValidNextHand && + Boolean( + this.state.transition || this.state.transitionDeadlineAt !== null + )) || + Boolean( + this.state.turnDeadlineAt || + this.state.turnStartedAt || + this.state.timeBankActive + ) + ) { + clearLivePokerTiming(this.state); + changed = true; + } + return this.reconcileNextHand(now) || changed; + } + if (this.state.transition) { + return changed; + } + if (this.state.phase === "showdown") { + this.finishShowdown(now); + return true; + } + if (this.state.runoutPending) { + startLivePokerTransition( + this.state, + "runout", + now + timing.runoutStageMs + ); + return true; + } + if (this.state.activeSeatIndex !== null && !this.state.turnDeadlineAt) { + startLivePokerTurn(this.state, now, timing); + return true; + } + return changed; + } + + private reconcileNextHand(now: number) { + if (!this.state) { + return false; + } + if ( + this.state.phase === "waiting" && + !this.state.gamePaused && + this.state.handNumber >= LIVE_POKER_MAX_HANDS_PER_TABLE + ) { + this.state.gamePaused = true; + clearLivePokerTiming(this.state); + this.state.actionLog.push( + `Table reached its ${LIVE_POKER_MAX_HANDS_PER_TABLE}-hand safety limit; close it and create a new table` + ); + return true; + } + if ( + this.state.phase === "waiting" && + !this.state.gamePaused && + (this.outbox.length >= MAX_PENDING_OUTBOX_DELIVERIES || + this.outbox.some((delivery) => delivery.deadLetteredAt)) + ) { + this.state.gamePaused = true; + clearLivePokerTiming(this.state); + this.state.actionLog.push( + "Table paused while completed data waits to be persisted" + ); + return true; + } + return reconcileNextHandTransition(this.state, now, this.timing()); + } + + private timing(): LivePokerTimingConfig { + const configured = Number( + envString(this.bindings, "LIVE_POKER_TURN_TIMEOUT_SECONDS") ?? + DEFAULT_LIVE_POKER_TIMING.actionTimeMs / 1000 + ); + const seconds = Number.isFinite(configured) + ? Math.min(300, Math.max(10, configured)) + : DEFAULT_LIVE_POKER_TIMING.actionTimeMs / 1000; + return { + ...DEFAULT_LIVE_POKER_TIMING, + actionTimeMs: seconds * 1000, + }; + } + + private async persist(additionalOutbox: OutboxDelivery[] = []) { + if (!this.state) { + return; + } + this.state.actionLog = this.state.actionLog.slice(-MAX_ACTION_LOG_ENTRIES); + this.state.appliedRequestIds = (this.state.appliedRequestIds ?? []).slice( + -MAX_APPLIED_REQUEST_IDS + ); + const outboxIds = new Set([ + ...this.pendingOutboxWrites, + ...additionalOutbox.map((delivery) => delivery.id), + ]); + const records: Record = { + state: this.state, + }; + for (const delivery of this.outbox) { + if (outboxIds.has(delivery.id)) { + records[this.outboxStorageKey(delivery.id)] = delivery; + } + } + await this.ctx.storage.put(records); + for (const id of outboxIds) { + this.pendingOutboxWrites.delete(id); + } + await this.scheduleAlarm(); + } + + private async scheduleAlarm() { + const pendingOutbox = this.nextPendingOutboxDelivery(); + const closedStorageDeadline = + this.state?.admissionsClosed && this.outbox.length === 0 + ? (this.state.storageDeleteAt ?? Number.POSITIVE_INFINITY) + : Number.POSITIVE_INFINITY; + const deadlines = [ + this.state + ? getNextLivePokerDeadline(this.state) + : Number.POSITIVE_INFINITY, + pendingOutbox?.nextAttemptAt ?? Number.POSITIVE_INFINITY, + closedStorageDeadline, + ]; + const next = Math.min(...deadlines); + const currentAlarm = await this.ctx.storage.getAlarm(); + if (Number.isFinite(next)) { + const target = Math.max(Date.now() + 1, next); + if (currentAlarm !== target) { + await this.ctx.storage.setAlarm(target); + } + } else if (currentAlarm !== null) { + await this.ctx.storage.deleteAlarm(); + } + } + + private async deleteClosedStorageIfDue(now: number) { + if ( + !this.state?.admissionsClosed || + this.outbox.length > 0 || + !this.state.storageDeleteAt || + this.state.storageDeleteAt > now + ) { + return false; + } + for (const connection of this.ctx.getWebSockets()) { + connection.close(1001, "Table storage expired"); + } + await this.ctx.storage.deleteAlarm(); + await this.ctx.storage.deleteAll(); + this.pendingOutboxWrites.clear(); + this.outbox = []; + this.state = null; + return true; + } + + private repairConnectionState() { + if (!this.state) { + return false; + } + let changed = false; + for (const seat of this.state.seats) { + if (!seat) { + continue; + } + const connected = this.hasOpenConnection(seat.userId); + if (seat.connected !== connected) { + seat.connected = connected; + changed = true; + } + } + return changed; + } + + private getConnectionCapacityError(auth: LivePokerAuthToken) { + const now = Date.now(); + const activeConnections = this.ctx.getWebSockets().filter((connection) => { + const connectionAuth = + connection.deserializeAttachment() as ConnectionState | null; + const active = Boolean( + connection.readyState === WebSocket.OPEN && + connectionAuth && + connectionAuth.exp * 1000 > now + ); + if (!active) { + connection.close(1008, "Credentials expired"); + } + return active; + }); + if (activeConnections.length >= LIVE_POKER_MAX_CONNECTIONS_PER_TABLE) { + return "This table has too many active connections"; + } + const userConnections = activeConnections.filter((connection) => { + const connectionAuth = + connection.deserializeAttachment() as ConnectionState | null; + return connectionAuth?.userId === auth.userId; + }); + return userConnections.length >= LIVE_POKER_MAX_CONNECTIONS_PER_USER + ? "This user has too many active table connections" + : null; + } + + private consumeMessageRate( + connection: WebSocket, + userId: string, + now: number + ) { + const increment = ( + current: MessageRateWindow | undefined, + limit: number + ): [boolean, MessageRateWindow] => { + const window = + !current || now - current.startedAt >= MESSAGE_RATE_WINDOW_MS + ? { count: 0, startedAt: now } + : current; + window.count += 1; + return [window.count <= limit, window]; + }; + + const tableWindowExpired = + now - this.tableMessageWindow.startedAt >= MESSAGE_RATE_WINDOW_MS; + if (tableWindowExpired) { + this.userMessageWindows.clear(); + } + const [tableAllowed, tableWindow] = increment( + this.tableMessageWindow, + MAX_TABLE_MESSAGES_PER_WINDOW + ); + this.tableMessageWindow = tableWindow; + const [userAllowed, userWindow] = increment( + this.userMessageWindows.get(userId), + MAX_USER_MESSAGES_PER_WINDOW + ); + this.userMessageWindows.set(userId, userWindow); + const [socketAllowed, socketWindow] = increment( + this.socketMessageWindows.get(connection), + MAX_SOCKET_MESSAGES_PER_WINDOW + ); + this.socketMessageWindows.set(connection, socketWindow); + return tableAllowed && userAllowed && socketAllowed; + } + + private findSeatByUserId(userId?: string | null) { + return this.state?.seats.find((candidate) => candidate?.userId === userId); + } + + private hasOpenConnection(userId?: string, excluding?: WebSocket) { + if (!userId) { + return false; + } + return this.ctx.getWebSockets().some((connection) => { + if ( + connection === excluding || + connection.readyState !== WebSocket.OPEN + ) { + return false; + } + const auth = connection.deserializeAttachment() as ConnectionState | null; + return Boolean( + auth && auth.exp * 1000 > Date.now() && auth.userId === userId + ); + }); + } + + private disconnectUser(userId: string, reason: string) { + for (const connection of this.ctx.getWebSockets()) { + const auth = connection.deserializeAttachment() as ConnectionState | null; + if (auth?.userId === userId) { + connection.close(1008, reason); + } + } + } + + private sendState(connection: WebSocket, auth: LivePokerAuthToken) { + if (!this.state) { + return; + } + send(connection, { + type: "tableState", + state: toPublicState(this.state, auth.userId), + }); + const seat = this.findSeatByUserId(auth.userId); + if (seat?.cards?.length) { + send(connection, { type: "privateCards", cards: seat.cards }); + } + } + + private broadcast() { + if (!this.state) { + return; + } + + for (const connection of this.ctx.getWebSockets()) { + const auth = connection.deserializeAttachment() as ConnectionState | null; + if (!auth || auth.exp * 1000 <= Date.now()) { + connection.close(1008, "Credentials expired"); + continue; + } + this.sendState(connection, auth); + } + } + + private broadcastMessage(message: LivePokerServerMessage) { + const rawMessage = JSON.stringify(message); + for (const connection of this.ctx.getWebSockets()) { + const auth = connection.deserializeAttachment() as ConnectionState | null; + if (!auth || auth.exp * 1000 <= Date.now()) { + connection.close(1008, "Credentials expired"); + } else if (connection.readyState === WebSocket.OPEN) { + if (connection.bufferedAmount > 64 * 1024) { + connection.close(1013, "Client is not accepting updates"); + } else { + connection.send(rawMessage); + } + } + } + } +} diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..a920910 --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,44 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "buyin-live-poker", + "main": "workers/live-poker.ts", + "compatibility_date": "2026-05-05", + "limits": { + "cpu_ms": 10 + }, + "observability": { + "enabled": false + }, + "ratelimits": [ + { + "name": "LIVE_POKER_IP_RATE_LIMITER", + "namespace_id": "1001", + "simple": { + "limit": 120, + "period": 60 + } + }, + { + "name": "LIVE_POKER_USER_RATE_LIMITER", + "namespace_id": "1002", + "simple": { + "limit": 12, + "period": 60 + } + } + ], + "durable_objects": { + "bindings": [ + { + "name": "LIVE_POKER_TABLE", + "class_name": "LivePokerTableDurableObject" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["LivePokerTableDurableObject"] + } + ] +}