From abd152cb57e3d202e19e3469559ee23c36ca6015 Mon Sep 17 00:00:00 2001 From: verlyn13 Date: Mon, 14 Sep 2026 09:16:00 -0800 Subject: [PATCH] fix(stats): rebuild player_stats as a projection and persist AI seats player_stats was incremented by both aggregate_game_stats and the aggregate-game-stats Edge Function, and a queue retry reran both. Migration 20260914000001 makes it a projection: rebuild_player_stats(user) recomputes absolute values from completed games and TurnScored events under a per-user advisory lock, refresh_player_stats_for_game rebuilds a game's human seats, aggregate_game_stats keeps its signature and calls the refresh, and complete_game_atomic refreshes in its own transaction. A one-time rebuild replaces existing rows. Execution is service_role only. game_players gains is_ai and ai_profile with a nullable user_id and primary key (game_id, seat_number), so games with AI seats persist; completion matches AI rankings by seat number. The Worker now sends JSON arrays: its hand-built array literals were rejected by PostgREST (22P02), so no game was persisted. The Edge Function, its caller and flags, the unused GamePersistenceService and the unused web stats writers are removed. pgTAP covers repeats, retries, solo, multiplayer, AI seats, incomplete games, missing events, the win rule and the rebuild. The migration applies before or after 20260913000002 with an identical resulting schema. Status and roadmap record the release steps: a stats-count readback before applying, then deleting the deployed Edge Function and rebuilding once after the Worker release. --- docs/architecture/README.md | 11 +- docs/roadmap.md | 8 +- docs/status.md | 8 +- packages/cloudflare-do/src/GameRoom.ts | 119 ++--- .../__tests__/persistence-queue.test.ts | 202 +++++++++ .../__tests__/supabase-rpc.test.ts | 97 +++++ .../persistence/game-persistence.service.ts | 317 -------------- .../src/lib/persistence/index.ts | 5 +- .../src/lib/persistence/persistence-queue.ts | 101 +---- .../src/lib/persistence/schemas.ts | 12 - .../src/lib/persistence/supabase-rpc.ts | 85 +--- .../src/lib/supabase/__tests__/stats.test.ts | 161 ------- packages/web/src/lib/supabase/stats.ts | 82 +--- packages/web/src/lib/types/database.ts | 70 ++- project.yaml | 2 +- .../functions/aggregate-game-stats/index.ts | 277 ------------ ...20260914000001_player_stats_projection.sql | 410 ++++++++++++++++++ supabase/tests/player_stats_projection.sql | 402 +++++++++++++++++ supabase/tests/public_security_hardening.sql | 12 +- supabase/tests/rpc_functions.sql | 12 +- 20 files changed, 1313 insertions(+), 1080 deletions(-) create mode 100644 packages/cloudflare-do/src/lib/persistence/__tests__/persistence-queue.test.ts create mode 100644 packages/cloudflare-do/src/lib/persistence/__tests__/supabase-rpc.test.ts delete mode 100644 packages/cloudflare-do/src/lib/persistence/game-persistence.service.ts delete mode 100644 supabase/functions/aggregate-game-stats/index.ts create mode 100644 supabase/migrations/20260914000001_player_stats_projection.sql create mode 100644 supabase/tests/player_stats_projection.sql diff --git a/docs/architecture/README.md b/docs/architecture/README.md index ce69d68..ba05d81 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -176,15 +176,18 @@ A Durable Object has one native alarm. `AlarmQueue` multiplexes it under the `al `GameRoom` writes game records to Supabase through RPCs. Game start awaits `create_game_atomic`; everything after that goes through a queue. - The bridge runs only when the Worker has the Supabase service-role secret. Without it, persistence is skipped and the reason is logged. -- `SupabaseRpcClient` (`packages/cloudflare-do/src/lib/persistence/supabase-rpc.ts`) posts to the PostgREST RPC endpoint for `create_game_atomic`, `complete_game_atomic`, `persist_domain_events`, `abandon_game_atomic` and `aggregate_game_stats`. Each result carries a retriable flag. -- The functions are SECURITY DEFINER plpgsql. They are defined from `supabase/migrations/20260105000002_rpc_create_game.sql` through `supabase/migrations/20260105000006_rpc_aggregate_stats.sql`, and `supabase/tests/rpc_functions.sql` tests them. +- `SupabaseRpcClient` (`packages/cloudflare-do/src/lib/persistence/supabase-rpc.ts`) posts to the PostgREST RPC endpoint for `create_game_atomic`, `complete_game_atomic`, `persist_domain_events`, `abandon_game_atomic` and `aggregate_game_stats`. Array parameters are JSON arrays of objects, which PostgREST converts to the composite types. Each result carries a retriable flag. No Edge Function is involved. +- The functions are SECURITY DEFINER plpgsql executable only by `service_role`. `supabase/migrations/20260914000001_player_stats_projection.sql` holds their current definitions, and `supabase/tests/rpc_functions.sql` and `supabase/tests/player_stats_projection.sql` test them. +- AI seats are `game_players` rows with `is_ai = true`, a NULL `user_id` and the AI profile in `ai_profile`. Seats are keyed by `(game_id, seat_number)`; completion matches human rankings by user id and AI rankings by seat number. An AI winner is stored as a NULL `winner_id`, and only events of human seats are persisted, because both columns reference profiles. +- `player_stats` is a projection that clients only read. `rebuild_player_stats(user_id)` recomputes a row from completed games and `TurnScored` events, so repeats and retries give the same row. `complete_game_atomic` refreshes the game's human seats in its transaction, and `aggregate_game_stats` refreshes them again after the domain events land. +- Projection rules: a game counts when it is `completed` and the seat has a final score. A win is `final_rank = 1` in a game with more than one seat, AI seats included. Decisions are `TurnScored` events with a boolean `was_optimal`. - `PersistenceQueue` (`packages/cloudflare-do/src/lib/persistence/persistence-queue.ts`) stores tasks in the SQLite table `persistence_queue`. Task types are `PERSIST_GAME_COMPLETION`, `PERSIST_DOMAIN_EVENTS`, `TRIGGER_AGGREGATION` and `ABANDON_GAME`. - At game end, `GameRoom` queues the completion, the domain events and, 500 ms later, the stats aggregation. - The queue shares the native alarm. It moves the alarm only when its own task is due earlier. -- The domain-event sequence number survives hibernation as `event_sequence` in `game_metadata`. Completion rankings carry scorecards and AI flags. +- The domain-event sequence number survives hibernation as `event_sequence` in `game_metadata`. Completion rankings carry scorecards, seat numbers and AI flags. - `packages/cloudflare-do/src/lib/persistence/schema-validation.ts` checks at compile time that persistence records are assignable to the generated Supabase insert types. Regenerating those types is an authenticated operator step, not part of the local gate. -Open defects: array RPC parameters, AI seats, abandonment scheduling and unchecked SQLite reads. They are listed under Supabase obligations in the [roadmap](../roadmap.md). Whether to repair the pipeline or freeze it is the owner's decision. +Open defects: abandonment scheduling and unchecked SQLite reads. They are listed under Supabase obligations in the [roadmap](../roadmap.md). ## Connection UX principles diff --git a/docs/roadmap.md b/docs/roadmap.md index 7218782..148a6fe 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -7,13 +7,13 @@ Ordered next work. State, decisions and deadlines live in [status.md](status.md) The backup, profile-role hardening/audit, two-script Worker URL restrictions and credential containment are recorded as complete in [status](status.md#latest-live-readbacks). Status action numbers are stable references; this is the execution order. Preserve durable facts (Supabase data, migrations, backups), not obsolete runtime topology: fix forward, delete classified legacy surfaces, and make the next production release the architecture we keep. Each production step retains its operator authority and stop points. 1. Read-only discovery (actions 5 and 8): `GameRoom` and `GlobalLobby` owners through the Durable Objects Deployments tab, SQLite and migration tags, production and preview `GAME_WORKER` targets, routes and domains on every Dicee Worker script and Pages project, last deployments of `dicee` and `dicee-production`, secret names on `dicee`, and whether `dicee.games` and `www` sit in a zone on this account and with which record types. Do not measure ephemeral Durable Object state — private readback, then a status decision naming the scripts and projects to delete. -2. Stats correctness (action 7): rebuild `player_stats` as a projection of completed `games`, `game_players` and `domain_events`, so repeats and retries give the same row; store AI seats; remove the Edge Function caller, the ratings/badge flags and unused web stats writers; rebuild existing rows once. The migration must apply alone before or after `000002` — pgTAP for repeats, retries, solo, multiplayer, AI seats, incomplete games and missing events. +2. Fix the self-referencing `game_players` SELECT policy: its subquery's unqualified `game_id` makes signed-in reads fail with infinite recursion, which breaks game history once games persist. Ship a migration that applies alone, like `20260914000001` — pgTAP as a seated player, a spectator and an outsider. 3. Establish the `main` ruleset, Production deployment protections and Dependabot controls (action 10) before the release. Require the exact check `Full repository validation`; inspect bypass behavior and the existing Wrangler/Miniflare ignore policy — authenticated control readbacks. -4. First release (action 3) from a clean checkout of the exact successful CI commit, at a quiet time, with operator commands rather than a CI dispatch: apply the stats migration alone, deploy `dicee-web`, detach `dicee.games` from Pages and attach it to `dicee-web` (short outage), then deploy `dicee` (its protocol gate closes the old Pages client's sockets), run the smoke checks, then delete the Pages project, the deployed Edge Function and the classified scripts (action 8) — sign-in, room/lobby, headers, transcription, non-admin refusal and deletion readbacks. +4. First release (action 3) from a clean checkout of the exact successful CI commit, at a quiet time, with operator commands rather than a CI dispatch: read back the completed `games` count, `player_stats` rows and summed `games_played` (the one-time rebuild resets stats that no completed game backs), apply `20260914000001` and the policy fix alone, deploy `dicee-web`, detach `dicee.games` from Pages and attach it to `dicee-web` (short outage), then deploy `dicee` (its protocol gate closes the old Pages client's sockets), run the smoke checks including one completed game, delete the deployed `aggregate-game-stats` Edge Function and rebuild all `player_stats` once, then delete the Pages project and the classified scripts (action 8) — sign-in, room/lobby, a persisted game with stats, headers, transcription, non-admin refusal and deletion readbacks. 5. Implement the profile visibility opt-in control, initially off for private profiles, writing `profiles.is_public`; explain that visibility is voluntary and cover it with tests. Merge through the new ruleset — successful full validation on the PR. 6. Deploy the opt-in code, verify the control and a test bug report, then take a fresh complete encrypted backup. Recheck the production link and history (`000001` remote, `000002` local-only), apply only `000002`, and verify the schema and two-account privacy behavior (action 4). Invite opt-ins only after verification; the migration clears earlier opt-ins. Fix forward — fresh backup evidence, migration readback and privacy tests. -Do not reapply or reverse `000001`, run a broad database push, release current `main` as it stands, or treat a successful dry run as namespace proof. Hosted multiplayer testing waits for an isolated backend (section 8). +Do not reapply or reverse `000001`, run a broad database push, or treat a successful dry run as namespace proof. Hosted multiplayer testing waits for an isolated backend (section 8). ## 2. Supabase obligations @@ -25,7 +25,7 @@ Date-driven and independent of the organization move. Agents write and test the - Asymmetric JWT signing: decide how to clear audit warning B8 ([open decision](cloudflare.md#open-decisions); default: remove HS256), then pin the JWKS verification algorithms and remove the HS256 fallback, `SUPABASE_JWT_SECRET` and the trailing `packages/cloudflare-do/wrangler.jsonc` comment that asks for it — auth tests; operator confirms the signing-key state first. - Minimize Supabase after a row-count readback and a fresh dump: drop vestigial tables, RPCs and columns (gallery, `solo_leaderboard`, `rooms`, `analysis_events`, `feature_flags`, spectator policies, Glicko and badge columns) with their TypeScript; keep `log_admin_action` as the only admin audit writer; close the `bug_reports` delete-policy gap — pgTAP green; operator applies. - Residual policy fixes for whatever minimization keeps: the open read policies on `admin_permissions` and `feature_flags`, `SET search_path` on the gallery security-definer functions, and spectator policies that match a `playing` status the `games` check never allows — pgTAP green; operator applies. -- Persistence pipeline repair after the section 1 stats fix: jsonb RPC parameters instead of hand-built array literals, scheduled abandonment, schema-validated outbox rows and surfaced permanent failures — tests. +- Persistence follow-ups: the Worker's `TurnScored` events carry `was_optimal` and `ev_difference`, so Decision Quality stops reading 0; scheduled abandonment, schema-validated outbox rows and surfaced permanent failures — tests. ## 3. Worker correctness and security diff --git a/docs/status.md b/docs/status.md index e6c3dff..c27cf3f 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,13 +1,13 @@ # Dicee status -**As of:** 2026-09-14T16:56:19Z +**As of:** 2026-09-14T17:14:24Z **Current phase:** 2026-09 operator safety rollout; discovery (actions 5 and 8) next, then a first release that ships stats correctness and the dicee-web cutover (no deployment) Next work: [roadmap.md](roadmap.md). Cloudflare: [cloudflare.md](cloudflare.md). ## Current state -- `main` carries the reviewed baseline, the database privacy work and current dependency updates (Vitest 5, jsdom 30). Its stats aggregation double-counts (action 7), so current `main` is not released as it stands. +- `main` carries the reviewed baseline, the database privacy work, current dependency updates (Vitest 5, jsdom 30), the `dicee-web` Worker, the protocol handshake and the stats fix (action 7); none of it is deployed. - Migration `20260913000001` is applied to the hosted project and recorded in migration history. Authenticated clients cannot update `profiles.role`; their editable profile fields remain granted. Migration `20260913000002` remains local-only. - The profile-role audit (action 2) is complete: 7 profiles comprise 5 users and 2 super admins, with no moderators or admins. The operator confirmed both elevated assignments as intentional after private record review; no role changes were needed. Audit-log absence cannot establish that the old privilege was never exploited. - The database backup is encrypted and verified on off-machine storage; Storage contained 0 objects. The plaintext exports were removed after verification. @@ -36,11 +36,11 @@ These are stable action identifiers, not execution order. [Roadmap section 1](ro 1. [x] **Backup.** The operator confirmed the Free plan. Five SQL dumps and the Storage inventory were verified in an AES-256 image on off-machine storage; Storage contained 0 objects. Plaintext exports were removed only after the copied image passed verification. 2. [x] **Profile-role audit.** Migration `20260913000001` is applied and recorded; do not reapply it. Role counts and both elevated records were retrieved privately. The operator confirmed both super-admin assignments as intentional; 0 unresolved accounts and 0 corrections. -3. [ ] **First production release.** After actions 5 and 10, with the action 7 fix merged, release the exact validated commit from a clean checkout at a quiet time: apply the stats migration alone, deploy `dicee-web`, move `dicee.games` off Pages, then deploy `dicee` (its protocol gate closes the old Pages client's sockets) and run the [post-deploy smoke checks](cloudflare.md#deploy-path). Do not release current `main` as it stands. +3. [ ] **First production release.** After actions 5 and 10, release the exact validated commit from a clean checkout at a quiet time: read back completed game and `player_stats` counts (the stats rebuild resets stats no completed game backs), apply `20260914000001` and the `game_players` policy fix alone, deploy `dicee-web`, move `dicee.games` off Pages, deploy `dicee` (its protocol gate closes the old Pages client's sockets), run the [post-deploy smoke checks](cloudflare.md#deploy-path), then delete the deployed Edge Function and rebuild all stats once. 4. [ ] **Apply `20260913000002`** only after the missing profile visibility opt-in control is implemented, tested, merged and verified in production. Current source omits the four bug-report fields this migration drops, but deployed compatibility is unverified. Take a fresh complete encrypted backup, recheck the production link and migration history, and apply only this migration. It resets all profiles to private; verify the schema and two-account privacy behavior before inviting opt-ins. Do not improvise a reverse migration; fix forward with the compatible opt-in build. 5. [ ] **Worker namespace and binding check.** Read back which scripts hold `GameRoom` and `GlobalLobby` (the Durable Objects Deployments tab shows backing Worker versions), whether both use SQLite, migration tags, and the production/preview `GAME_WORKER` targets. The result decides between a no-op lifecycle deploy to `dicee` and a cutover to it (decision 1); stop only on ambiguous ownership ([method](cloudflare.md#live-checks-still-needed)). 6. [x] **Worker subdomain URLs.** `workers.dev` and Preview URLs are disabled on `dicee` and `dicee-production`, verified by API. Namespace ownership and other ingress remain for the later reviews in actions 5 and 8. -7. [ ] **Stats aggregation.** Current `main` counts each completed game at least twice: the SQL `aggregate_game_stats` and the `aggregate-game-stats` Edge Function both add it, and a retry reruns both. Games with AI seats likely fail to persist. Replace it with a rebuildable `player_stats` projection and AI-seat storage (roadmap section 1); delete the deployed Edge Function after the release that stops calling it. +7. [ ] **Stats aggregation.** Fixed in source: `20260914000001` makes `player_stats` a rebuildable projection and stores AI seats, and the Worker now sends JSON arrays (its old array literals were rejected, so the deployed Worker has likely never persisted a game). Complete with action 3: delete the deployed `aggregate-game-stats` Edge Function, then rebuild all stats once. 8. [ ] **Delete obsolete surfaces.** After action 5, delete every other Dicee Worker script and Pages project (the `dicee` Pages project after the cutover), with their routes, domains and secrets. Live Durable Object state on them is not preserved. 9. [x] **Credential containment.** Both exposed tokens are replaced, revoked and verified dead by direct HTTP 401 readbacks. Cloudflare: the replacement is canonical in 1Password and GitHub Production, and the repository duplicate is removed. Supabase: a project-scoped token with only Database read-write access is the sole CLI credential; temporary copies, the environment override, the fallback token file and plaintext copies are absent. 10. [ ] **GitHub governance.** A `main` ruleset requiring the check **Full repository validation** (the job display name, not `validate`); a `Production` environment with required reviewers and a main-only deployment branch policy; Dependabot alerts and security updates. diff --git a/packages/cloudflare-do/src/GameRoom.ts b/packages/cloudflare-do/src/GameRoom.ts index 0ec6344..af24edd 100644 --- a/packages/cloudflare-do/src/GameRoom.ts +++ b/packages/cloudflare-do/src/GameRoom.ts @@ -50,6 +50,7 @@ import { getSupabaseGameId, initPersistenceTables, PersistenceQueue, + type QueuedRanking, SupabaseRpcClient, setEventSequence, setSupabaseGameId, @@ -331,23 +332,15 @@ export class GameRoom extends DurableObject { }); // 3. Initialize persistence queue with RPC client - this.persistenceQueue = new PersistenceQueue( - this.ctx, - this.rpcClient, - (task, error) => { - this.logger.error('Persistence task failed permanently', { - operation: 'persistence_task_failed', - type: task.type, - retryCount: task.retryCount, - }); - // Log as handler failure for observability - this.instr?.errorHandlerFailed(`persistence_${task.type}`, error); - }, - { - supabaseUrl: this.env.SUPABASE_URL, - anonKey: this.env.SUPABASE_ANON_KEY, - }, - ); + this.persistenceQueue = new PersistenceQueue(this.ctx, this.rpcClient, (task, error) => { + this.logger.error('Persistence task failed permanently', { + operation: 'persistence_task_failed', + type: task.type, + retryCount: task.retryCount, + }); + // Log as handler failure for observability + this.instr?.errorHandlerFailed(`persistence_${task.type}`, error); + }); // 4. Recover Supabase game ID after hibernation this.supabaseGameId = getSupabaseGameId(this.ctx); @@ -443,6 +436,7 @@ export class GameRoom extends DurableObject { id: string; displayName: string; type?: 'human' | 'ai'; + aiProfileId?: string; isHost: boolean; }>, ): Promise { @@ -466,19 +460,25 @@ export class GameRoom extends DurableObject { humanCount, }); - // Create game and player records atomically via RPC + // Create game and seat records atomically via RPC. Seat numbers follow the + // order of `players`, which is also the key order of gameState.players; + // schedulePersistenceTasks relies on that to address AI seats. const result = await this.rpcClient.createGame({ gameId, roomCode, hostId, gameMode, settings: {}, - players: players.map((player, index) => ({ - user_id: player.id, - seat_number: index, - turn_order: index, - is_ai: player.type === 'ai', - })), + players: players.map((player, index) => { + const isAi = player.type === 'ai'; + return { + user_id: isAi ? null : player.id, + seat_number: index, + turn_order: index, + is_ai: isAi, + ai_profile: isAi ? (player.aiProfileId ?? null) : null, + }; + }), }); if (!result.success) { @@ -548,37 +548,48 @@ export class GameRoom extends DurableObject { // Get game state for player scorecard and type data const gameState = await this.gameStateManager.getState(); const players = gameState?.players ?? {}; + // Seat numbers match persistGameStart: the key order of gameState.players. + const seatNumbers = new Map(Object.keys(players).map((id, seat) => [id, seat])); + const isHuman = (playerId: string) => players[playerId]?.type === 'human'; // 1. Schedule game completion persistence + const queuedRankings: QueuedRanking[] = rankings.map((r) => { + const player = players[r.playerId]; + const isAi = player?.type === 'ai'; + // Convert Scorecard to Record (null values become 0) + const scorecardRecord: Record = {}; + if (player?.scorecard) { + for (const [key, value] of Object.entries(player.scorecard)) { + scorecardRecord[key] = value ?? 0; + } + } + return { + playerId: isAi ? null : r.playerId, + seatNumber: seatNumbers.get(r.playerId) ?? -1, + rank: r.rank, + score: r.score, + scorecard: scorecardRecord, + isAi, + }; + }); + const winner = rankings[0]; + await this.persistenceQueue.schedule({ type: 'PERSIST_GAME_COMPLETION', gameId, payload: { - winnerId: rankings[0]?.playerId ?? null, - rankings: rankings.map((r) => { - const player = players[r.playerId]; - // Convert Scorecard to Record (null values become 0) - const scorecardRecord: Record = {}; - if (player?.scorecard) { - for (const [key, value] of Object.entries(player.scorecard)) { - scorecardRecord[key] = value ?? 0; - } - } - return { - playerId: r.playerId, - rank: r.rank, - score: r.score, - scorecard: scorecardRecord, - isAi: player?.type === 'ai', - }; - }), + // games.winner_id references a profile, so an AI winner is stored as null. + winnerId: winner && isHuman(winner.playerId) ? winner.playerId : null, + rankings: queuedRankings, durationMs, }, }); - // 2. Schedule domain events persistence (if any) - if (pendingEvents.length > 0) { - const eventsForPersistence: DomainEvent[] = pendingEvents.map((e) => ({ + // 2. Schedule domain events persistence. domain_events.player_id references a + // profile, so only events of human seats are persisted. + const humanEvents = pendingEvents.filter((e) => isHuman(e.player_id)); + if (humanEvents.length > 0) { + const eventsForPersistence: DomainEvent[] = humanEvents.map((e) => ({ id: e.id, game_id: e.game_id, player_id: e.player_id, @@ -598,21 +609,11 @@ export class GameRoom extends DurableObject { }); } - // 3. Schedule aggregation (with delay to ensure completion persisted first) - // Only for multiplayer games with human players - const humanPlayers = Object.values(players).filter((p) => p.type !== 'ai'); - const isMultiplayer = humanPlayers.length >= 2; - + // 3. Refresh the player_stats projection after completion and events are + // persisted. The RPC recomputes absolute values, so retries are safe. await this.persistenceQueue.schedule( - { - type: 'TRIGGER_AGGREGATION', - gameId, - payload: { - skipRatings: !isMultiplayer, // Only update ratings for multiplayer - skipBadges: false, - }, - }, - 500, // 500ms delay to ensure completion is persisted + { type: 'TRIGGER_AGGREGATION', gameId, payload: {} }, + 500, // 500ms delay so completion and events are persisted first ); // 4. Clear pending events after scheduling diff --git a/packages/cloudflare-do/src/lib/persistence/__tests__/persistence-queue.test.ts b/packages/cloudflare-do/src/lib/persistence/__tests__/persistence-queue.test.ts new file mode 100644 index 0000000..35e51c5 --- /dev/null +++ b/packages/cloudflare-do/src/lib/persistence/__tests__/persistence-queue.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PersistenceQueue, type QueuedRanking } from '../persistence-queue'; +import type { SupabaseRpcClient } from '../supabase-rpc'; + +interface QueueRow { + rowid: number; + task_type: string; + game_id: string; + payload: string; + retry_count: number; + created_at: number; + scheduled_for: number; +} + +const GAME_ID = '11111111-0000-4000-8000-000000000001'; +const HUMAN_ID = '22222222-0000-4000-8000-000000000001'; + +/** In-memory stand-in for the persistence_queue SQLite table and the alarm. */ +function createFakeCtx() { + const rows: QueueRow[] = []; + let nextRowid = 1; + let alarm: number | null = null; + + const exec = (query: string, ...params: unknown[]): unknown[] => { + const sql = query.replace(/\s+/g, ' ').trim(); + if (sql.startsWith('INSERT INTO persistence_queue')) { + const [task_type, game_id, payload, retry_count, created_at, scheduled_for] = params as [ + string, + string, + string, + number, + number, + number, + ]; + rows.push({ + rowid: nextRowid++, + task_type, + game_id, + payload, + retry_count, + created_at, + scheduled_for, + }); + return []; + } + if (sql.startsWith('SELECT rowid, * FROM persistence_queue')) { + const now = params[0] as number; + return rows + .filter((row) => row.scheduled_for <= now) + .sort((a, b) => a.scheduled_for - b.scheduled_for) + .map((row) => ({ ...row })); + } + if (sql.startsWith('UPDATE persistence_queue')) { + const [retry_count, scheduled_for, rowid] = params as [number, number, number]; + const row = rows.find((candidate) => candidate.rowid === rowid); + if (row) Object.assign(row, { retry_count, scheduled_for }); + return []; + } + if (sql.startsWith('DELETE FROM persistence_queue')) { + const index = rows.findIndex((row) => row.rowid === params[0]); + if (index >= 0) rows.splice(index, 1); + return []; + } + if (sql.startsWith('SELECT MIN(scheduled_for)')) { + return [{ next: rows.length > 0 ? Math.min(...rows.map((row) => row.scheduled_for)) : null }]; + } + throw new Error(`Unexpected SQL: ${sql}`); + }; + + const ctx = { + storage: { + sql: { exec }, + getAlarm: async () => alarm, + setAlarm: async (time: number) => { + alarm = time; + }, + }, + } as unknown as DurableObjectState; + + return { ctx, rows }; +} + +function createFakeRpc() { + const operationOk = { + success: true as const, + data: { success: true, error_code: null, error_message: null, affected_rows: 1 }, + }; + const rpc = { + createGame: vi.fn().mockResolvedValue(operationOk), + completeGame: vi.fn().mockResolvedValue(operationOk), + persistDomainEvents: vi.fn().mockResolvedValue(operationOk), + abandonGame: vi.fn().mockResolvedValue(operationOk), + aggregateStats: vi.fn().mockResolvedValue({ success: true, data: [] }), + }; + return { rpc, client: rpc as unknown as SupabaseRpcClient }; +} + +describe('PersistenceQueue', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(0); + fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('refreshes stats through the aggregate_game_stats RPC only', async () => { + const { ctx, rows } = createFakeCtx(); + const { rpc, client } = createFakeRpc(); + const onError = vi.fn(); + const queue = new PersistenceQueue(ctx, client, onError); + + await queue.schedule({ type: 'TRIGGER_AGGREGATION', gameId: GAME_ID, payload: {} }); + await queue.processDueTasks(); + + expect(rpc.aggregateStats).toHaveBeenCalledOnce(); + expect(rpc.aggregateStats).toHaveBeenCalledWith(GAME_ID); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + expect(rows).toHaveLength(0); + }); + + it('retries a retriable aggregation failure with the same idempotent RPC, then gives up', async () => { + const { ctx, rows } = createFakeCtx(); + const { rpc, client } = createFakeRpc(); + rpc.aggregateStats.mockResolvedValue({ + success: false, + error: 'RPC aggregate_game_stats failed: 503', + retriable: true, + }); + const onError = vi.fn(); + const queue = new PersistenceQueue(ctx, client, onError); + + await queue.schedule({ type: 'TRIGGER_AGGREGATION', gameId: GAME_ID, payload: {} }); + await queue.processDueTasks(); + expect(rows[0]).toMatchObject({ retry_count: 1, scheduled_for: 1000 }); + + for (let attempt = 0; attempt < 3; attempt++) { + vi.setSystemTime(Date.now() + 10_000); + await queue.processDueTasks(); + } + + expect(rpc.aggregateStats).toHaveBeenCalledTimes(4); + expect(rpc.completeGame).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledOnce(); + expect(rows).toHaveLength(0); + }); + + it('sends AI rankings with a null player id and their seat number', async () => { + const { ctx } = createFakeCtx(); + const { rpc, client } = createFakeRpc(); + const queue = new PersistenceQueue(ctx, client, vi.fn()); + const rankings: QueuedRanking[] = [ + { playerId: null, seatNumber: 1, rank: 1, score: 250, scorecard: { ones: 4 }, isAi: true }, + { + playerId: HUMAN_ID, + seatNumber: 0, + rank: 2, + score: 200, + scorecard: { ones: 3 }, + isAi: false, + }, + ]; + + await queue.schedule({ + type: 'PERSIST_GAME_COMPLETION', + gameId: GAME_ID, + payload: { winnerId: null, rankings, durationMs: 1 }, + }); + await queue.processDueTasks(); + + expect(rpc.completeGame).toHaveBeenCalledWith({ + gameId: GAME_ID, + winnerId: null, + rankings: [ + { + player_id: null, + seat_number: 1, + rank: 1, + score: 250, + scorecard: { ones: 4 }, + is_ai: true, + }, + { + player_id: HUMAN_ID, + seat_number: 0, + rank: 2, + score: 200, + scorecard: { ones: 3 }, + is_ai: false, + }, + ], + }); + }); +}); diff --git a/packages/cloudflare-do/src/lib/persistence/__tests__/supabase-rpc.test.ts b/packages/cloudflare-do/src/lib/persistence/__tests__/supabase-rpc.test.ts new file mode 100644 index 0000000..669c37a --- /dev/null +++ b/packages/cloudflare-do/src/lib/persistence/__tests__/supabase-rpc.test.ts @@ -0,0 +1,97 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SupabaseRpcClient } from '../supabase-rpc'; + +const GAME_ID = '11111111-0000-4000-8000-000000000001'; +const HUMAN_ID = '22222222-0000-4000-8000-000000000001'; + +function operationResponse(status = 200) { + return new Response( + JSON.stringify({ success: true, error_code: null, error_message: null, affected_rows: 1 }), + { status }, + ); +} + +describe('SupabaseRpcClient', () => { + let fetchMock: ReturnType; + const client = new SupabaseRpcClient({ + supabaseUrl: 'https://supabase.test', + serviceRoleKey: 'test-service-role', + }); + + const lastCall = () => { + const [url, init] = fetchMock.mock.calls.at(-1) as [string, RequestInit]; + return { url, body: JSON.parse(init.body as string) as Record }; + }; + + beforeEach(() => { + fetchMock = vi.fn(async () => operationResponse()); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('sends seats as a JSON array, with AI seats carrying no user id', async () => { + const result = await client.createGame({ + gameId: GAME_ID, + roomCode: 'ABCDEF', + hostId: HUMAN_ID, + gameMode: 'solo', + settings: {}, + players: [ + { user_id: HUMAN_ID, seat_number: 0, turn_order: 0, is_ai: false, ai_profile: null }, + { user_id: null, seat_number: 1, turn_order: 1, is_ai: true, ai_profile: 'carmen' }, + ], + }); + + expect(result.success).toBe(true); + const { url, body } = lastCall(); + expect(url).toBe('https://supabase.test/rest/v1/rpc/create_game_atomic'); + expect(body.p_players).toEqual([ + { user_id: HUMAN_ID, seat_number: 0, turn_order: 0, is_ai: false, ai_profile: null }, + { user_id: null, seat_number: 1, turn_order: 1, is_ai: true, ai_profile: 'carmen' }, + ]); + }); + + it('sends rankings and domain events as JSON arrays without hand-built literals', async () => { + const scorecard = { ones: 3, chance: 22 }; + await client.completeGame({ + gameId: GAME_ID, + winnerId: null, + rankings: [{ player_id: null, seat_number: 1, rank: 1, score: 250, scorecard, is_ai: true }], + }); + expect(lastCall().body.p_rankings).toEqual([ + { player_id: null, seat_number: 1, rank: 1, score: 250, scorecard, is_ai: true }, + ]); + + const payload = { note: `it's "quoted", (with) {braces}` }; + await client.persistDomainEvents([ + { + id: '33333333-0000-4000-8000-000000000001', + event_type: 'GameStarted', + event_version: '1.0', + sequence_number: 0, + game_id: GAME_ID, + player_id: HUMAN_ID, + turn_number: null, + roll_number: null, + payload, + }, + ]); + expect(lastCall().body.p_events).toEqual([ + expect.objectContaining({ turn_number: null, payload }), + ]); + }); + + it('marks server errors retriable and client errors permanent', async () => { + fetchMock.mockImplementationOnce(async () => new Response('unavailable', { status: 503 })); + expect(await client.aggregateStats(GAME_ID)).toMatchObject({ success: false, retriable: true }); + + fetchMock.mockImplementationOnce(async () => new Response('bad request', { status: 400 })); + expect(await client.aggregateStats(GAME_ID)).toMatchObject({ + success: false, + retriable: false, + }); + }); +}); diff --git a/packages/cloudflare-do/src/lib/persistence/game-persistence.service.ts b/packages/cloudflare-do/src/lib/persistence/game-persistence.service.ts deleted file mode 100644 index 437d030..0000000 --- a/packages/cloudflare-do/src/lib/persistence/game-persistence.service.ts +++ /dev/null @@ -1,317 +0,0 @@ -/** - * Game Persistence Service - * - * Service class for persisting game data to Supabase. - * Uses service role key to bypass RLS for server-to-server writes. - */ - -import { z } from 'zod'; -import { - type DomainEvent, - DomainEventSchema, - type GamePlayerRecord, - GamePlayerRecordSchema, - type GameRecord, - GameRecordSchema, - type PersistenceResult, -} from './schemas'; - -// ============================================================================ -// Configuration -// ============================================================================ - -const MAX_RETRIES = 3; -const BASE_DELAY_MS = 100; - -// ============================================================================ -// Service Class -// ============================================================================ - -export class GamePersistenceService { - readonly #supabaseUrl: string; - readonly #serviceRoleKey: string; - readonly #anonKey: string; - - constructor(env: { - SUPABASE_URL: string; - SUPABASE_SERVICE_ROLE_KEY: string; - SUPABASE_ANON_KEY: string; - }) { - this.#supabaseUrl = env.SUPABASE_URL; - this.#serviceRoleKey = env.SUPABASE_SERVICE_ROLE_KEY; - this.#anonKey = env.SUPABASE_ANON_KEY; - } - - // ========================================================================== - // Game Lifecycle - // ========================================================================== - - /** - * Create a new game record when game starts. - */ - async createGame(game: GameRecord): Promise { - const validated = GameRecordSchema.safeParse(game); - if (!validated.success) { - return { - success: false, - error: `Validation failed: ${z.prettifyError(validated.error)}`, - retriable: false, - }; - } - - return this.#postWithRetry('games', validated.data, validated.data.id); - } - - /** - * Add player records when game starts. - */ - async addGamePlayers(players: GamePlayerRecord[]): Promise { - if (players.length === 0) { - return { success: false, error: 'No players provided', retriable: false }; - } - - const validated = z.array(GamePlayerRecordSchema).safeParse(players); - if (!validated.success) { - return { - success: false, - error: `Validation failed: ${z.prettifyError(validated.error)}`, - retriable: false, - }; - } - - return this.#postWithRetry('game_players', validated.data, players[0].game_id); - } - - /** - * Complete a game: update records, persist events, trigger aggregation. - */ - async completeGame( - gameId: string, - winnerId: string | null, - rankings: Array<{ - playerId: string; - rank: number; - score: number; - scorecard: Record; - isAi: boolean; - }>, - durationMs: number, - ): Promise { - const errors: string[] = []; - - // 1. Update game record (matching games table schema) - // Note: duration_ms and final_rankings columns don't exist in current schema - const gameUpdate = await this.#patchWithRetry( - `games?id=eq.${gameId}`, - { - status: 'completed', - winner_id: winnerId, - completed_at: new Date().toISOString(), - }, - gameId, - ); - - if (!gameUpdate.success) { - errors.push(`Game update: ${gameUpdate.error}`); - } - - // 2. Update each player's record (matching game_players table schema) - for (const ranking of rankings) { - const playerUpdate = await this.#patchWithRetry( - `game_players?game_id=eq.${gameId}&user_id=eq.${ranking.playerId}`, - { - final_score: ranking.score, - final_rank: ranking.rank, - scorecard: ranking.scorecard, - // Note: is_connected remains true - player completed the game successfully - }, - gameId, - ); - - if (!playerUpdate.success) { - errors.push(`Player ${ranking.playerId}: ${playerUpdate.error}`); - // Continue with others - partial success is better than total failure - } - } - - // 3. Return result (aggregation triggered separately via queue) - if (errors.length > 0) { - return { - success: false, - error: errors.join('; '), - retriable: true, - }; - } - - return { success: true, gameId }; - } - - /** - * Persist domain events in batch. - */ - async persistDomainEvents(events: DomainEvent[]): Promise { - if (events.length === 0) { - return { success: true, gameId: '' }; - } - - const validated = z.array(DomainEventSchema).safeParse(events); - if (!validated.success) { - return { - success: false, - error: `Validation failed: ${z.prettifyError(validated.error)}`, - retriable: false, - }; - } - - return this.#postWithRetry('domain_events', validated.data, events[0].game_id); - } - - /** - * Trigger aggregate-game-stats edge function. - */ - async triggerAggregation( - gameId: string, - options: { skipRatings?: boolean; skipBadges?: boolean } = {}, - ): Promise { - const payload = { - gameId, - skipRatings: options.skipRatings ?? false, - skipBadges: options.skipBadges ?? false, - }; - - try { - const response = await fetch(`${this.#supabaseUrl}/functions/v1/aggregate-game-stats`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.#anonKey}`, - }, - body: JSON.stringify(payload), - }); - - if (!response.ok) { - const text = await response.text(); - return { - success: false, - error: `Aggregation failed: ${response.status} ${text}`, - retriable: response.status >= 500, - statusCode: response.status, - }; - } - - return { success: true, gameId }; - } catch (err) { - return { - success: false, - error: `Network error: ${err instanceof Error ? err.message : 'Unknown'}`, - retriable: true, - }; - } - } - - /** - * Mark game as abandoned (player left, timeout, etc.) - */ - async abandonGame(gameId: string, reason: string): Promise { - return this.#patchWithRetry( - `games?id=eq.${gameId}`, - { - status: 'abandoned', - completed_at: new Date().toISOString(), - abandonment_reason: reason, - }, - gameId, - ); - } - - // ========================================================================== - // Private Helpers - // ========================================================================== - - #headers(): HeadersInit { - return { - 'Content-Type': 'application/json', - apikey: this.#serviceRoleKey, - Authorization: `Bearer ${this.#serviceRoleKey}`, - Prefer: 'return=minimal', - }; - } - - async #postWithRetry(table: string, data: unknown, gameId: string): Promise { - return this.#withRetry(async () => { - const response = await fetch(`${this.#supabaseUrl}/rest/v1/${table}`, { - method: 'POST', - headers: this.#headers(), - body: JSON.stringify(data), - }); - - if (!response.ok) { - const text = await response.text(); - return { - success: false as const, - error: `POST ${table} failed: ${response.status} ${text}`, - retriable: response.status >= 500 || response.status === 429, - statusCode: response.status, - }; - } - - return { success: true as const, gameId }; - }); - } - - async #patchWithRetry( - endpoint: string, - data: unknown, - gameId: string, - ): Promise { - return this.#withRetry(async () => { - const response = await fetch(`${this.#supabaseUrl}/rest/v1/${endpoint}`, { - method: 'PATCH', - headers: this.#headers(), - body: JSON.stringify(data), - }); - - if (!response.ok) { - const text = await response.text(); - return { - success: false as const, - error: `PATCH ${endpoint} failed: ${response.status} ${text}`, - retriable: response.status >= 500 || response.status === 429, - statusCode: response.status, - }; - } - - return { success: true as const, gameId }; - }); - } - - async #withRetry(operation: () => Promise): Promise { - let lastResult: PersistenceResult = { - success: false, - error: 'No attempts made', - retriable: false, - }; - - for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { - try { - lastResult = await operation(); - - if (lastResult.success || !lastResult.retriable) { - return lastResult; - } - - // Exponential backoff: 100ms, 200ms, 400ms - const delay = BASE_DELAY_MS * 2 ** attempt; - await new Promise((resolve) => setTimeout(resolve, delay)); - } catch (err) { - lastResult = { - success: false, - error: `Network error: ${err instanceof Error ? err.message : 'Unknown'}`, - retriable: true, - }; - } - } - - return lastResult; - } -} diff --git a/packages/cloudflare-do/src/lib/persistence/index.ts b/packages/cloudflare-do/src/lib/persistence/index.ts index 6842cc5..5cbe1c7 100644 --- a/packages/cloudflare-do/src/lib/persistence/index.ts +++ b/packages/cloudflare-do/src/lib/persistence/index.ts @@ -4,8 +4,6 @@ * DO→Supabase persistence bridge for game data. */ -// Services -export { GamePersistenceService } from './game-persistence.service'; // Migrations and helpers export { clearGameMetadata, @@ -23,13 +21,12 @@ export { PersistenceQueue, type PersistenceTask, type PersistenceTaskType, + type QueuedRanking, } from './persistence-queue'; // Schema validation (compile-time only, ensures no schema drift) export { SCHEMA_MAPPINGS } from './schema-validation'; // Schemas and types export { - type AggregationRequest, - AggregationRequestSchema, DOMAIN_EVENT_TYPES, type DomainEvent, DomainEventSchema, diff --git a/packages/cloudflare-do/src/lib/persistence/persistence-queue.ts b/packages/cloudflare-do/src/lib/persistence/persistence-queue.ts index 24f1cd8..c50bda6 100644 --- a/packages/cloudflare-do/src/lib/persistence/persistence-queue.ts +++ b/packages/cloudflare-do/src/lib/persistence/persistence-queue.ts @@ -3,8 +3,7 @@ * * Reliable async persistence with retry using DO SQLite and alarms. * Tasks are durably stored and processed with exponential backoff. - * - * Phase 4: Uses SupabaseRpcClient for atomic database operations. + * Every task calls one idempotent RPC, so a retry never double-counts. */ import type { DomainEvent, PersistenceResult } from './schemas'; @@ -32,6 +31,16 @@ export interface PersistenceTask { scheduledFor: number; } +/** Completion ranking as queued by GameRoom. AI seats have a null playerId. */ +export interface QueuedRanking { + playerId: string | null; + seatNumber: number; + rank: number; + score: number; + scorecard: Record; + isAi: boolean; +} + // ============================================================================ // Persistence Queue // ============================================================================ @@ -43,21 +52,15 @@ export class PersistenceQueue { readonly #ctx: DurableObjectState; readonly #rpc: SupabaseRpcClient; readonly #onError: (task: PersistenceTask, error: string) => void; - readonly #supabaseUrl: string; - readonly #anonKey: string; constructor( ctx: DurableObjectState, rpcClient: SupabaseRpcClient, onError: (task: PersistenceTask, error: string) => void, - config?: { supabaseUrl: string; anonKey: string }, ) { this.#ctx = ctx; this.#rpc = rpcClient; this.#onError = onError; - // For edge function calls (aggregation) - this.#supabaseUrl = config?.supabaseUrl ?? ''; - this.#anonKey = config?.anonKey ?? ''; } /** @@ -154,19 +157,14 @@ export class PersistenceQueue { async #executeTask(task: PersistenceTask): Promise { switch (task.type) { case 'PERSIST_GAME_COMPLETION': { - const rankings = task.payload.rankings as Array<{ - playerId: string; - rank: number; - score: number; - scorecard: Record; - isAi: boolean; - }>; + const rankings = task.payload.rankings as QueuedRanking[]; const result = await this.#rpc.completeGame({ gameId: task.gameId, winnerId: task.payload.winnerId as string | null, rankings: rankings.map((r) => ({ - player_id: r.playerId, + player_id: r.isAi ? null : r.playerId, + seat_number: r.seatNumber, rank: r.rank, score: r.score, scorecard: r.scorecard, @@ -196,28 +194,9 @@ export class PersistenceQueue { } case 'TRIGGER_AGGREGATION': { - // Call aggregate_game_stats RPC first for core stats - const rpcResult = await this.#rpc.aggregateStats(task.gameId); - - // If RPC failed, return the error - if (!rpcResult.success) { - return this.#rpcToResult(rpcResult, task.gameId); - } - - // Optionally call edge function for Glicko-2 ratings and badges - // (if configured and not skipped) - const skipRatings = task.payload.skipRatings as boolean; - const skipBadges = task.payload.skipBadges as boolean; - - if (!skipRatings || !skipBadges) { - // Edge function handles advanced aggregation - return this.#callAggregationEdgeFunction(task.gameId, { - skipRatings, - skipBadges, - }); - } - - return { success: true, gameId: task.gameId }; + // Rebuilds the player_stats projection from persisted games; safe to retry. + const result = await this.#rpc.aggregateStats(task.gameId); + return this.#rpcToResult(result, task.gameId); } case 'ABANDON_GAME': { @@ -250,50 +229,4 @@ export class PersistenceQueue { retriable: result.retriable, }; } - - /** - * Call the aggregate-game-stats edge function for Glicko-2 and badges. - */ - async #callAggregationEdgeFunction( - gameId: string, - options: { skipRatings: boolean; skipBadges: boolean }, - ): Promise { - if (!this.#supabaseUrl || !this.#anonKey) { - // Edge function not configured, RPC aggregation is sufficient - return { success: true, gameId }; - } - - try { - const response = await fetch(`${this.#supabaseUrl}/functions/v1/aggregate-game-stats`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.#anonKey}`, - }, - body: JSON.stringify({ - gameId, - skipRatings: options.skipRatings, - skipBadges: options.skipBadges, - }), - }); - - if (!response.ok) { - const text = await response.text(); - return { - success: false, - error: `Edge function failed: ${response.status} ${text}`, - retriable: response.status >= 500, - statusCode: response.status, - }; - } - - return { success: true, gameId }; - } catch (err) { - return { - success: false, - error: `Network error: ${err instanceof Error ? err.message : 'Unknown'}`, - retriable: true, - }; - } - } } diff --git a/packages/cloudflare-do/src/lib/persistence/schemas.ts b/packages/cloudflare-do/src/lib/persistence/schemas.ts index b019513..ae6cc21 100644 --- a/packages/cloudflare-do/src/lib/persistence/schemas.ts +++ b/packages/cloudflare-do/src/lib/persistence/schemas.ts @@ -110,15 +110,3 @@ export const PersistenceResultSchema = z.discriminatedUnion('success', [ ]); export type PersistenceResult = z.infer; - -// ============================================================================ -// Aggregation Request Schema -// ============================================================================ - -export const AggregationRequestSchema = z.object({ - gameId: z.uuid(), - skipRatings: z.boolean().default(false), - skipBadges: z.boolean().default(false), -}); - -export type AggregationRequest = z.infer; diff --git a/packages/cloudflare-do/src/lib/persistence/supabase-rpc.ts b/packages/cloudflare-do/src/lib/persistence/supabase-rpc.ts index bed4788..fedb481 100644 --- a/packages/cloudflare-do/src/lib/persistence/supabase-rpc.ts +++ b/packages/cloudflare-do/src/lib/persistence/supabase-rpc.ts @@ -41,13 +41,17 @@ export type StatsUpdateResult = z.infer; // ============================================================================ // RPC Input Types // ============================================================================ +// Array parameters are sent as JSON arrays of objects; PostgREST converts them +// to the composite array types. Omitted attributes arrive as NULL. /** * Player ranking for game completion. * Maps to the `player_ranking` composite type in SQL. + * Human rankings match by player_id; AI rankings (player_id null) match by seat_number. */ export interface PlayerRanking { - player_id: string; + player_id: string | null; + seat_number: number; rank: number; score: number; scorecard: Record; @@ -57,12 +61,14 @@ export interface PlayerRanking { /** * Player input for game creation. * Maps to the `game_player_input` composite type in SQL. + * AI seats have no profile: user_id is null and ai_profile names the AI profile. */ export interface GamePlayerInput { - user_id: string; + user_id: string | null; seat_number: number; turn_order: number; is_ai: boolean; + ai_profile: string | null; } /** @@ -114,18 +120,13 @@ export type RpcResult = * gameId: crypto.randomUUID(), * roomCode: 'ABC123', * hostId: 'host-uuid', - * gameMode: 'multiplayer', + * gameMode: 'solo', * settings: {}, * players: [ - * { user_id: 'host-uuid', seat_number: 0, turn_order: 0, is_ai: false }, + * { user_id: 'host-uuid', seat_number: 0, turn_order: 0, is_ai: false, ai_profile: null }, + * { user_id: null, seat_number: 1, turn_order: 1, is_ai: true, ai_profile: 'carmen' }, * ], * }); - * - * if (result.success) { - * console.log('Game created:', result.data.affected_rows); - * } else { - * console.error('Failed:', result.error, 'Retriable:', result.retriable); - * } * ``` */ export class SupabaseRpcClient { @@ -142,12 +143,8 @@ export class SupabaseRpcClient { // ========================================================================== /** - * Atomically create a game and all player records. - * - * - Creates game record - * - Creates all player records - * - Fully atomic: all succeed or all rollback - * - Idempotent: safe to retry (duplicate game_id returns success) + * Atomically create a game and all seat records, human and AI. + * Idempotent: a duplicate game_id returns success. */ async createGame(params: { gameId: string; @@ -157,44 +154,29 @@ export class SupabaseRpcClient { settings: Record; players: GamePlayerInput[]; }): Promise> { - // Format players as PostgreSQL array of composite type - const playersArray = params.players.map( - (p) => `(${this.#escapeUuid(p.user_id)},${p.seat_number},${p.turn_order},${p.is_ai})`, - ); - return this.#callRpc('create_game_atomic', { p_game_id: params.gameId, p_room_code: params.roomCode, p_host_id: params.hostId, p_game_mode: params.gameMode, p_settings: params.settings, - p_players: `{${playersArray.join(',')}}`, + p_players: params.players, }); } /** - * Atomically complete a game and update all player records. - * - * - Updates game status to 'completed' - * - Updates all player final_score, final_rank, scorecard - * - Fully atomic: all succeed or all rollback - * - Idempotent: safe to retry (already completed returns success) + * Atomically complete a game, record seat results and refresh player stats. + * Idempotent: an already completed game returns success. */ async completeGame(params: { gameId: string; winnerId: string | null; rankings: PlayerRanking[]; }): Promise> { - // Format rankings as PostgreSQL array of composite type - const rankingsArray = params.rankings.map( - (r) => - `(${this.#escapeUuid(r.player_id)},${r.rank},${r.score},'${this.#escapeJson(r.scorecard)}',${r.is_ai})`, - ); - return this.#callRpc('complete_game_atomic', { p_game_id: params.gameId, p_winner_id: params.winnerId, - p_rankings: `{${rankingsArray.join(',')}}`, + p_rankings: params.rankings, }); } @@ -203,7 +185,7 @@ export class SupabaseRpcClient { * * - Inserts all events in a single transaction * - Idempotent: duplicate event IDs are skipped (ON CONFLICT DO NOTHING) - * - All events must belong to the same game + * - All events must belong to the same game and reference a player profile */ async persistDomainEvents(events: DomainEventInput[]): Promise> { if (events.length === 0) { @@ -218,14 +200,8 @@ export class SupabaseRpcClient { }; } - // Format events as PostgreSQL array of composite type - const eventsArray = events.map( - (e) => - `(${this.#escapeUuid(e.id)},'${e.event_type}','${e.event_version}',${e.sequence_number},${this.#escapeUuid(e.game_id)},${this.#escapeUuid(e.player_id)},${e.turn_number ?? 'NULL'},${e.roll_number ?? 'NULL'},'${this.#escapeJson(e.payload)}')`, - ); - return this.#callRpc('persist_domain_events', { - p_events: `{${eventsArray.join(',')}}`, + p_events: events, }); } @@ -248,11 +224,8 @@ export class SupabaseRpcClient { } /** - * Aggregate stats for a completed game. - * - * - Updates player_stats for all human players - * - Calculates games_played, games_won, total_score, best_score, avg_score - * - Updates category_stats if scorecard data available + * Rebuild the player_stats projection for a game's human seats. + * Values are recomputed from completed games, so repeats and retries are safe. */ async aggregateStats(gameId: string): Promise> { return this.#callRpc('aggregate_game_stats', { @@ -334,20 +307,4 @@ export class SupabaseRpcClient { return !nonRetriable.includes(errorCode); } - - /** - * Escape a UUID for PostgreSQL composite type syntax. - */ - #escapeUuid(uuid: string): string { - // UUIDs need no escaping, just ensure proper format - return uuid; - } - - /** - * Escape a JSON object for PostgreSQL JSONB. - */ - #escapeJson(obj: Record): string { - // Escape single quotes by doubling them - return JSON.stringify(obj).replace(/'/g, "''"); - } } diff --git a/packages/web/src/lib/supabase/__tests__/stats.test.ts b/packages/web/src/lib/supabase/__tests__/stats.test.ts index 6d29896..4eda3d8 100644 --- a/packages/web/src/lib/supabase/__tests__/stats.test.ts +++ b/packages/web/src/lib/supabase/__tests__/stats.test.ts @@ -10,12 +10,9 @@ import type { Database } from '$lib/types/database'; import { calculateDecisionQuality, calculateWinRate, - createPlayerStats, - ensurePlayerStats, getGameHistory, getPlayerStats, type PlayerStats, - updatePlayerStats, } from '../stats'; // ============================================================================= @@ -150,164 +147,6 @@ describe('getPlayerStats', () => { }); }); -// ============================================================================= -// createPlayerStats Tests -// ============================================================================= - -describe('createPlayerStats', () => { - let mockSupabase: ReturnType; - - beforeEach(() => { - mockSupabase = createMockSupabase(); - }); - - it('creates new player stats', async () => { - const newStats = { ...mockStats, games_played: 0, games_won: 0 }; - - mockSupabase.__mocks.insert.mockReturnValue({ - select: mockSupabase.__mocks.select.mockReturnValue({ - single: mockSupabase.__mocks.single.mockResolvedValue({ - data: newStats, - error: null, - }), - }), - }); - - const result = await createPlayerStats(mockSupabase, 'test-user-id'); - - expect(result.data).toEqual(newStats); - expect(result.error).toBeNull(); - expect(mockSupabase.__mocks.insert).toHaveBeenCalledWith({ user_id: 'test-user-id' }); - }); - - it('handles creation errors', async () => { - const mockError = { message: 'Creation failed', code: 'ERROR' }; - mockSupabase.__mocks.insert.mockReturnValue({ - select: mockSupabase.__mocks.select.mockReturnValue({ - single: mockSupabase.__mocks.single.mockResolvedValue({ - data: null, - error: mockError, - }), - }), - }); - - const result = await createPlayerStats(mockSupabase, 'test-user-id'); - - expect(result.data).toBeNull(); - expect(result.error).toBeInstanceOf(Error); - }); -}); - -// ============================================================================= -// updatePlayerStats Tests -// ============================================================================= - -describe('updatePlayerStats', () => { - let mockSupabase: ReturnType; - - beforeEach(() => { - mockSupabase = createMockSupabase(); - }); - - it('updates player stats successfully', async () => { - const updates = { games_played: 11, games_won: 5 }; - const updatedStats = { ...mockStats, ...updates }; - - mockSupabase.__mocks.update.mockReturnValue({ - eq: mockSupabase.__mocks.eq.mockReturnValue({ - select: mockSupabase.__mocks.select.mockReturnValue({ - single: mockSupabase.__mocks.single.mockResolvedValue({ - data: updatedStats, - error: null, - }), - }), - }), - }); - - const result = await updatePlayerStats(mockSupabase, 'test-user-id', updates); - - expect(result.data).toEqual(updatedStats); - expect(result.error).toBeNull(); - expect(mockSupabase.__mocks.update).toHaveBeenCalledWith(updates); - }); - - it('handles update errors', async () => { - const mockError = { message: 'Update failed', code: 'ERROR' }; - mockSupabase.__mocks.update.mockReturnValue({ - eq: mockSupabase.__mocks.eq.mockReturnValue({ - select: mockSupabase.__mocks.select.mockReturnValue({ - single: mockSupabase.__mocks.single.mockResolvedValue({ - data: null, - error: mockError, - }), - }), - }), - }); - - const result = await updatePlayerStats(mockSupabase, 'test-user-id', { games_played: 11 }); - - expect(result.data).toBeNull(); - expect(result.error).toBeInstanceOf(Error); - }); -}); - -// ============================================================================= -// ensurePlayerStats Tests -// ============================================================================= - -describe('ensurePlayerStats', () => { - let mockSupabase: ReturnType; - - beforeEach(() => { - mockSupabase = createMockSupabase(); - }); - - it('returns existing stats if they exist', async () => { - mockSupabase.__mocks.select.mockReturnValue({ - eq: mockSupabase.__mocks.eq.mockReturnValue({ - single: mockSupabase.__mocks.single.mockResolvedValue({ - data: mockStats, - error: null, - }), - }), - }); - - const result = await ensurePlayerStats(mockSupabase, 'test-user-id'); - - expect(result.data).toEqual(mockStats); - expect(result.error).toBeNull(); - }); - - it('creates stats if they do not exist', async () => { - const newStats = { ...mockStats, games_played: 0 }; - - // First call (get) returns not found - mockSupabase.__mocks.select.mockReturnValueOnce({ - eq: mockSupabase.__mocks.eq.mockReturnValueOnce({ - single: mockSupabase.__mocks.single.mockResolvedValueOnce({ - data: null, - error: { code: 'PGRST116', message: 'Not found' }, - }), - }), - }); - - // Second call (create) returns new stats - mockSupabase.__mocks.insert.mockReturnValueOnce({ - select: mockSupabase.__mocks.select.mockReturnValueOnce({ - single: mockSupabase.__mocks.single.mockResolvedValueOnce({ - data: newStats, - error: null, - }), - }), - }); - - const result = await ensurePlayerStats(mockSupabase, 'new-user-id'); - - expect(result.data).toEqual(newStats); - expect(result.error).toBeNull(); - }); -}); - // ============================================================================= // getGameHistory Tests // ============================================================================= diff --git a/packages/web/src/lib/supabase/stats.ts b/packages/web/src/lib/supabase/stats.ts index 14b94eb..b04312a 100644 --- a/packages/web/src/lib/supabase/stats.ts +++ b/packages/web/src/lib/supabase/stats.ts @@ -1,8 +1,8 @@ import type { SupabaseClient } from '@supabase/supabase-js'; -import type { Database, Tables, TablesUpdate } from '$lib/types/database'; +import type { Database, Tables } from '$lib/types/database'; +/** Derived projection maintained by the database; clients only read it. */ export type PlayerStats = Tables<'player_stats'>; -export type PlayerStatsUpdate = TablesUpdate<'player_stats'>; export type GamePlayer = Tables<'game_players'>; export type Game = Tables<'games'>; @@ -43,73 +43,6 @@ export async function getPlayerStats( return { data, error: null }; } -/** - * Create or initialize player stats for a user - */ -export async function createPlayerStats( - supabase: SupabaseClient, - userId: string, -): Promise<{ data: PlayerStats | null; error: Error | null }> { - const { data, error } = await supabase - .from('player_stats') - .insert({ user_id: userId }) - .select() - .single(); - - if (error) { - return { data: null, error: new Error(error.message) }; - } - - return { data, error: null }; -} - -/** - * Update player statistics - * Typically called after a game completes to aggregate new results - */ -export async function updatePlayerStats( - supabase: SupabaseClient, - userId: string, - updates: PlayerStatsUpdate, -): Promise<{ data: PlayerStats | null; error: Error | null }> { - const { data, error } = await supabase - .from('player_stats') - .update(updates) - .eq('user_id', userId) - .select() - .single(); - - if (error) { - return { data: null, error: new Error(error.message) }; - } - - return { data, error: null }; -} - -/** - * Get or create player stats (ensures stats record exists) - */ -export async function ensurePlayerStats( - supabase: SupabaseClient, - userId: string, -): Promise<{ data: PlayerStats | null; error: Error | null }> { - // Try to get existing stats - const getResult = await getPlayerStats(supabase, userId); - - // If stats exist, return them - if (getResult.data) { - return getResult; - } - - // If there was an error other than "not found", return it - if (getResult.error) { - return getResult; - } - - // Stats don't exist, create them - return createPlayerStats(supabase, userId); -} - /** * Get a player's game history * Returns games in reverse chronological order (most recent first) @@ -330,7 +263,10 @@ export async function getGameAnalysis( let worstDecision: GameAnalysis['summary']['worstDecision'] = null; for (const player of players) { - const playerEvents = events?.filter((e) => e.player_id === player.user_id) ?? []; + // AI seats have no profile and no decision events. + const userId = player.user_id; + if (!userId) continue; + const playerEvents = events?.filter((e) => e.player_id === userId) ?? []; const decisions: TurnDecision[] = []; let optimalCount = 0; let totalEvLoss = 0; @@ -370,7 +306,7 @@ export async function getGameAnalysis( const evGain = Math.abs(decision.evDifference); if (!bestDecision || evGain > bestDecision.evGain) { bestDecision = { - playerId: player.user_id, + playerId: userId, turn: decision.turn, category: decision.category, evGain, @@ -380,7 +316,7 @@ export async function getGameAnalysis( // EV loss if (!worstDecision || decision.evDifference > worstDecision.evLoss) { worstDecision = { - playerId: player.user_id, + playerId: userId, turn: decision.turn, category: decision.category, evLoss: decision.evDifference, @@ -395,7 +331,7 @@ export async function getGameAnalysis( const profileData = player.profiles as { display_name: string } | null; playerAnalyses.push({ - userId: player.user_id, + userId, displayName: profileData?.display_name ?? 'Unknown', finalScore: player.final_score ?? 0, finalRank: player.final_rank ?? 0, diff --git a/packages/web/src/lib/types/database.ts b/packages/web/src/lib/types/database.ts index df02a1e..b535eba 100644 --- a/packages/web/src/lib/types/database.ts +++ b/packages/web/src/lib/types/database.ts @@ -390,40 +390,46 @@ export type Database = { } game_players: { Row: { + ai_profile: string | null final_rank: number | null final_score: number | null game_id: string + is_ai: boolean is_connected: boolean joined_at: string left_at: string | null scorecard: Json | null seat_number: number turn_order: number - user_id: string + user_id: string | null } Insert: { + ai_profile?: string | null final_rank?: number | null final_score?: number | null game_id: string + is_ai?: boolean is_connected?: boolean joined_at?: string left_at?: string | null scorecard?: Json | null seat_number: number turn_order: number - user_id: string + user_id?: string | null } Update: { + ai_profile?: string | null final_rank?: number | null final_score?: number | null game_id?: string + is_ai?: boolean is_connected?: boolean joined_at?: string left_at?: string | null scorecard?: Json | null seat_number?: number turn_order?: number - user_id?: string + user_id?: string | null } Relationships: [ { @@ -941,6 +947,58 @@ export type Database = { } Returns: boolean } + rebuild_player_stats: { + Args: { p_user_id: string } + Returns: { + avg_ev_loss: number + avg_score: number + best_score: number + bonus_dicees: number + category_stats: Json + dicees_rolled: number + games_completed: number + games_played: number + games_won: number + optimal_decisions: number + total_decisions: number + total_score: number + updated_at: string + upper_bonuses: number + user_id: string + } + SetofOptions: { + from: "*" + to: "player_stats" + isOneToOne: true + isSetofReturn: false + } + } + refresh_player_stats_for_game: { + Args: { p_game_id: string } + Returns: { + avg_ev_loss: number + avg_score: number + best_score: number + bonus_dicees: number + category_stats: Json + dicees_rolled: number + games_completed: number + games_played: number + games_won: number + optimal_decisions: number + total_decisions: number + total_score: number + updated_at: string + upper_bonuses: number + user_id: string + }[] + SetofOptions: { + from: "*" + to: "player_stats" + isOneToOne: false + isSetofReturn: true + } + } unlock_gallery_achievement: { Args: { p_achievement_id: string @@ -957,10 +1015,6 @@ export type Database = { } Returns: undefined } - update_category_stats: { - Args: { p_existing: Json; p_new_scorecard: Json } - Returns: Json - } } Enums: { admin_role: "user" | "moderator" | "admin" | "super_admin" @@ -982,6 +1036,7 @@ export type Database = { seat_number: number | null turn_order: number | null is_ai: boolean | null + ai_profile: string | null } operation_result: { success: boolean | null @@ -995,6 +1050,7 @@ export type Database = { score: number | null scorecard: Json | null is_ai: boolean | null + seat_number: number | null } stats_update_result: { user_id: string | null diff --git a/project.yaml b/project.yaml index bf0eca8..84adcfb 100644 --- a/project.yaml +++ b/project.yaml @@ -11,7 +11,7 @@ peers: [] status: posture: deployed-system local_phase: "2026-09 operator safety rollout; discovery (actions 5 and 8) next, then a first release that ships stats correctness and the dicee-web cutover (no deployment)" - as_of: "2026-09-14T16:56:19Z" + as_of: "2026-09-14T17:14:24Z" authority: status_of_record: docs/status.md presentation: diff --git a/supabase/functions/aggregate-game-stats/index.ts b/supabase/functions/aggregate-game-stats/index.ts deleted file mode 100644 index adc8835..0000000 --- a/supabase/functions/aggregate-game-stats/index.ts +++ /dev/null @@ -1,277 +0,0 @@ -/** - * Aggregate Game Stats Edge Function - * - * Called when a game completes to aggregate statistics for all players. - * Updates player_stats with decision quality metrics and game outcomes. - */ - -import { createClient } from 'jsr:@supabase/supabase-js@2'; - -// CORS headers for cross-origin requests -const corsHeaders = { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', -}; - -interface GamePlayer { - user_id: string; - final_score: number | null; - final_rank: number | null; - scorecard: Record | null; -} - -interface DomainEvent { - event_type: string; - player_id: string; - turn_number: number | null; - roll_number: number | null; - payload: { - category?: string; - score?: number; - optimal_category?: string; - optimal_score?: number; - ev_difference?: number; - was_optimal?: boolean; - dice?: number[]; - kept?: boolean[]; - was_optimal_hold?: boolean; - }; -} - -interface PlayerStats { - user_id: string; - games_played: number; - games_completed: number; - games_won: number; - total_score: number; - best_score: number; - avg_score: number; - optimal_decisions: number; - total_decisions: number; - avg_ev_loss: number; - dicees_rolled: number; - bonus_dicees: number; - upper_bonuses: number; - category_stats: Record; -} - -Deno.serve(async (req) => { - // Handle CORS preflight - if (req.method === 'OPTIONS') { - return new Response('ok', { headers: corsHeaders }); - } - - try { - const { gameId } = await req.json(); - - if (!gameId) { - return new Response(JSON.stringify({ error: 'gameId is required' }), { - status: 400, - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - }); - } - - // Create Supabase client with service role for admin access - const supabaseUrl = Deno.env.get('SUPABASE_URL')!; - const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; - const supabase = createClient(supabaseUrl, supabaseServiceKey); - - // Fetch game players - const { data: players, error: playersError } = await supabase - .from('game_players') - .select('user_id, final_score, final_rank, scorecard') - .eq('game_id', gameId); - - if (playersError) { - throw new Error(`Failed to fetch game players: ${playersError.message}`); - } - - if (!players || players.length === 0) { - return new Response(JSON.stringify({ error: 'No players found for game' }), { - status: 404, - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - }); - } - - // Fetch domain events for decision analysis - const { data: events, error: eventsError } = await supabase - .from('domain_events') - .select('event_type, player_id, turn_number, roll_number, payload') - .eq('game_id', gameId) - .in('event_type', ['TurnScored', 'DiceRolled', 'DiceKept']); - - if (eventsError) { - throw new Error(`Failed to fetch domain events: ${eventsError.message}`); - } - - // Process each player - const updates: Promise[] = []; - - for (const player of players as GamePlayer[]) { - const update = processPlayerStats(supabase, player, events as DomainEvent[], players.length); - updates.push(update); - } - - await Promise.all(updates); - - return new Response( - JSON.stringify({ - success: true, - playersProcessed: players.length, - }), - { - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - }, - ); - } catch (error) { - console.error('Error aggregating game stats:', error); - return new Response( - JSON.stringify({ - error: error instanceof Error ? error.message : 'Unknown error', - }), - { - status: 500, - headers: { ...corsHeaders, 'Content-Type': 'application/json' }, - }, - ); - } -}); - -async function processPlayerStats( - supabase: ReturnType, - player: GamePlayer, - allEvents: DomainEvent[], - totalPlayers: number, -): Promise { - const playerEvents = allEvents.filter((e) => e.player_id === player.user_id); - - // Analyze scoring decisions - const scoringEvents = playerEvents.filter((e) => e.event_type === 'TurnScored'); - let optimalDecisions = 0; - let totalDecisions = 0; - let totalEvLoss = 0; - - for (const event of scoringEvents) { - totalDecisions++; - if (event.payload.was_optimal) { - optimalDecisions++; - } - if (event.payload.ev_difference !== undefined) { - totalEvLoss += Math.max(0, event.payload.ev_difference); - } - } - - // Calculate category stats from scorecard - const categoryStats: Record = {}; - - if (player.scorecard) { - for (const [category, score] of Object.entries(player.scorecard)) { - if (score !== null) { - categoryStats[category] = { - times_scored: 1, - total_score: score, - avg_score: score, - }; - } - } - } - - // Count achievements - let dicees = 0; - let bonusDicees = 0; - let upperBonus = 0; - - if (player.scorecard) { - const scorecard = player.scorecard as Record; - - // Check for Dicee - if (scorecard.Dicee && scorecard.Dicee >= 50) { - dicees = 1; - } - - // Count bonus Dicees (from bonus_dicees field if present) - if ('bonus_dicees' in scorecard && typeof scorecard.bonus_dicees === 'number') { - bonusDicees = scorecard.bonus_dicees; - } - - // Check upper section bonus - const upperCategories = ['Ones', 'Twos', 'Threes', 'Fours', 'Fives', 'Sixes']; - const upperTotal = upperCategories.reduce((sum, cat) => sum + (scorecard[cat] || 0), 0); - if (upperTotal >= 63) { - upperBonus = 1; - } - } - - const finalScore = player.final_score ?? 0; - const won = player.final_rank === 1 && totalPlayers > 1 ? 1 : 0; - - // Fetch existing stats - const { data: existingStats } = await supabase - .from('player_stats') - .select('*') - .eq('user_id', player.user_id) - .single(); - - // Calculate new aggregates - const gamesPlayed = (existingStats?.games_played ?? 0) + 1; - const gamesCompleted = (existingStats?.games_completed ?? 0) + 1; - const gamesWon = (existingStats?.games_won ?? 0) + won; - const totalScoreSum = (existingStats?.total_score ?? 0) + finalScore; - const bestScore = Math.max(existingStats?.best_score ?? 0, finalScore); - const avgScore = totalScoreSum / gamesCompleted; - - const newOptimalDecisions = (existingStats?.optimal_decisions ?? 0) + optimalDecisions; - const newTotalDecisions = (existingStats?.total_decisions ?? 0) + totalDecisions; - const avgEvLoss = - newTotalDecisions > 0 ? ((existingStats?.avg_ev_loss ?? 0) * (existingStats?.total_decisions ?? 0) + totalEvLoss) / newTotalDecisions : 0; - - const newDicees = (existingStats?.dicees_rolled ?? 0) + dicees; - const newBonusDicees = (existingStats?.bonus_dicees ?? 0) + bonusDicees; - const newUpperBonuses = (existingStats?.upper_bonuses ?? 0) + upperBonus; - - // Merge category stats - const existingCategoryStats = (existingStats?.category_stats as typeof categoryStats) ?? {}; - const mergedCategoryStats: typeof categoryStats = { ...existingCategoryStats }; - - for (const [category, stats] of Object.entries(categoryStats)) { - if (mergedCategoryStats[category]) { - const existing = mergedCategoryStats[category]; - const newTimesScored = existing.times_scored + stats.times_scored; - const newTotalScore = existing.total_score + stats.total_score; - mergedCategoryStats[category] = { - times_scored: newTimesScored, - total_score: newTotalScore, - avg_score: newTotalScore / newTimesScored, - }; - } else { - mergedCategoryStats[category] = stats; - } - } - - // Upsert player stats - const { error: upsertError } = await supabase.from('player_stats').upsert( - { - user_id: player.user_id, - games_played: gamesPlayed, - games_completed: gamesCompleted, - games_won: gamesWon, - total_score: totalScoreSum, - best_score: bestScore, - avg_score: avgScore, - optimal_decisions: newOptimalDecisions, - total_decisions: newTotalDecisions, - avg_ev_loss: avgEvLoss, - dicees_rolled: newDicees, - bonus_dicees: newBonusDicees, - upper_bonuses: newUpperBonuses, - category_stats: mergedCategoryStats, - updated_at: new Date().toISOString(), - }, - { onConflict: 'user_id' }, - ); - - if (upsertError) { - console.error(`Failed to upsert stats for ${player.user_id}:`, upsertError); - throw upsertError; - } -} diff --git a/supabase/migrations/20260914000001_player_stats_projection.sql b/supabase/migrations/20260914000001_player_stats_projection.sql new file mode 100644 index 0000000..c77420b --- /dev/null +++ b/supabase/migrations/20260914000001_player_stats_projection.sql @@ -0,0 +1,410 @@ +-- player_stats becomes a derived projection, and games persist AI seats. +-- +-- player_stats was maintained by increments from both aggregate_game_stats and +-- the aggregate-game-stats Edge Function, so every completed game was counted at +-- least twice, and again on each queue retry. The projection below recomputes +-- absolute values from completed games, so any number of runs gives the same row. +-- +-- Game players and domain events are written by the Worker through PostgREST +-- JSON arrays. AI seats have no profile: user_id is NULL, is_ai is true, and +-- ai_profile names the AI profile. Completion matches AI rankings by seat_number. +-- +-- This migration is independent of 20260913000002_public_security_hardening and +-- applies before or after it: it keeps aggregate_game_stats(uuid) and its +-- service_role-only grant, and does not touch the player_stats SELECT policy. + +-- --------------------------------------------------------------------------- +-- AI seats +-- --------------------------------------------------------------------------- + +ALTER TABLE public.game_players + ADD COLUMN is_ai boolean NOT NULL DEFAULT false, + ADD COLUMN ai_profile text; + +ALTER TABLE public.game_players DROP CONSTRAINT game_players_pkey; +ALTER TABLE public.game_players ALTER COLUMN user_id DROP NOT NULL; +ALTER TABLE public.game_players ADD CONSTRAINT game_players_pkey PRIMARY KEY (game_id, seat_number); +-- A human sits once per game; AI rows have NULL user_id and are not constrained. +CREATE UNIQUE INDEX game_players_game_user_key ON public.game_players (game_id, user_id); +ALTER TABLE public.game_players ADD CONSTRAINT game_players_seat_identity CHECK ( + (is_ai AND user_id IS NULL) + OR (NOT is_ai AND user_id IS NOT NULL AND ai_profile IS NULL) +); + +COMMENT ON COLUMN public.game_players.user_id IS 'Human player profile; NULL for AI seats'; +COMMENT ON COLUMN public.game_players.is_ai IS 'True for AI seats, which have no profile'; +COMMENT ON COLUMN public.game_players.ai_profile IS 'AI profile id for AI seats'; + +-- JSON callers that omit these attributes get NULL. +ALTER TYPE public.game_player_input ADD ATTRIBUTE ai_profile text; +ALTER TYPE public.player_ranking ADD ATTRIBUTE seat_number smallint; + +CREATE OR REPLACE FUNCTION public.create_game_atomic( + p_game_id uuid, + p_room_code text, + p_host_id uuid, + p_game_mode text, + p_settings jsonb, + p_players public.game_player_input[] +) +RETURNS public.operation_result +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_player public.game_player_input; + v_is_ai boolean; + v_player_count integer := 0; +BEGIN + IF p_game_id IS NULL THEN + RETURN ROW(false, 'INVALID_INPUT', 'game_id is required', 0)::public.operation_result; + END IF; + + IF p_game_mode IS NULL OR p_game_mode NOT IN ('solo', 'multiplayer', 'tutorial') THEN + RETURN ROW(false, 'INVALID_INPUT', 'game_mode must be solo, multiplayer, or tutorial', 0)::public.operation_result; + END IF; + + IF coalesce(array_length(p_players, 1), 0) < 1 THEN + RETURN ROW(false, 'INVALID_INPUT', 'at least one player is required', 0)::public.operation_result; + END IF; + + IF EXISTS ( + SELECT 1 FROM unnest(p_players) AS p + WHERE p.seat_number IS NULL OR (NOT coalesce(p.is_ai, false) AND p.user_id IS NULL) + ) THEN + RETURN ROW(false, 'INVALID_INPUT', 'every seat needs seat_number, and human seats need user_id', 0)::public.operation_result; + END IF; + + -- Idempotency: a retry of an already created game succeeds without changes. + IF EXISTS (SELECT 1 FROM public.games WHERE id = p_game_id) THEN + RETURN ROW(true, NULL, NULL, 0)::public.operation_result; + END IF; + + INSERT INTO public.games (id, room_code, host_id, status, game_mode, settings, created_at, started_at) + VALUES (p_game_id, p_room_code, p_host_id, 'active', p_game_mode, + coalesce(p_settings, '{}'::jsonb), now(), now()); + + FOREACH v_player IN ARRAY p_players + LOOP + v_is_ai := coalesce(v_player.is_ai, false); + INSERT INTO public.game_players ( + game_id, user_id, is_ai, ai_profile, seat_number, turn_order, is_connected, joined_at + ) VALUES ( + p_game_id, + CASE WHEN v_is_ai THEN NULL ELSE v_player.user_id END, + v_is_ai, + CASE WHEN v_is_ai THEN v_player.ai_profile END, + v_player.seat_number, + coalesce(v_player.turn_order, v_player.seat_number), + true, + now() + ); + v_player_count := v_player_count + 1; + END LOOP; + + RETURN ROW(true, NULL, NULL, v_player_count + 1)::public.operation_result; + +EXCEPTION + WHEN unique_violation THEN + RETURN ROW(false, 'DUPLICATE', 'Game or player record already exists', 0)::public.operation_result; + WHEN foreign_key_violation THEN + RETURN ROW(false, 'INVALID_REFERENCE', 'Referenced user does not exist', 0)::public.operation_result; + WHEN OTHERS THEN + RAISE LOG 'create_game_atomic error: % %', SQLSTATE, SQLERRM; + RETURN ROW(false, SQLSTATE, SQLERRM, 0)::public.operation_result; +END; +$$; + +-- --------------------------------------------------------------------------- +-- player_stats projection +-- --------------------------------------------------------------------------- +-- Counted game: games.status = 'completed' and the user's human seat has a +-- final_score. Abandoned, active and unscored games are ignored. +-- Win: final_rank = 1 in a game with more than one seat, AI seats included. +-- Scorecard keys are the Worker Scorecard fields. A Dicee counts when the +-- dicee category scored 50; each 100 of diceeBonus is one bonus Dicee, and +-- dicees_rolled = Dicee games + bonus Dicees. An upper bonus counts when +-- ones..sixes total at least 63. +-- Decisions are TurnScored domain events of counted games that carry a boolean +-- was_optimal; avg_ev_loss averages max(ev_difference, 0) over them. Games +-- without such events contribute zero decisions. + +CREATE OR REPLACE FUNCTION public.rebuild_player_stats(p_user_id uuid) +RETURNS public.player_stats +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_stats public.player_stats; +BEGIN + IF p_user_id IS NULL OR NOT EXISTS (SELECT 1 FROM public.profiles WHERE id = p_user_id) THEN + RETURN NULL; + END IF; + + -- Serialize rebuilds per user, so the last rebuild reads every earlier commit. + PERFORM pg_advisory_xact_lock(hashtextextended('public.player_stats:' || p_user_id::text, 0)); + + WITH counted AS ( + SELECT + gp.game_id, + gp.final_score, + gp.final_rank, + CASE WHEN jsonb_typeof(gp.scorecard) = 'object' THEN gp.scorecard ELSE '{}'::jsonb END AS scorecard, + (SELECT count(*) FROM public.game_players AS seat WHERE seat.game_id = gp.game_id) AS seat_count + FROM public.game_players AS gp + JOIN public.games AS g ON g.id = gp.game_id + WHERE gp.user_id = p_user_id + AND NOT gp.is_ai + AND g.status = 'completed' + AND gp.final_score IS NOT NULL + ), + numbers AS ( + SELECT c.game_id, e.key, (e.value #>> '{}')::numeric AS value + FROM counted AS c + CROSS JOIN LATERAL jsonb_each(c.scorecard) AS e + WHERE jsonb_typeof(e.value) = 'number' + ), + per_game AS ( + SELECT + c.game_id, + coalesce(max(n.value) FILTER (WHERE n.key = 'dicee'), 0) = 50 AS scored_dicee, + floor(coalesce(max(n.value) FILTER (WHERE n.key = 'diceeBonus'), 0) / 100)::int AS bonus_dicees, + coalesce(sum(n.value) FILTER ( + WHERE n.key IN ('ones', 'twos', 'threes', 'fours', 'fives', 'sixes') + ), 0) >= 63 AS upper_bonus + FROM counted AS c + LEFT JOIN numbers AS n ON n.game_id = c.game_id + GROUP BY c.game_id + ), + totals AS ( + SELECT + count(*)::int AS games, + count(*) FILTER (WHERE c.final_rank = 1 AND c.seat_count > 1)::int AS wins, + coalesce(sum(c.final_score), 0)::bigint AS total_score, + coalesce(max(c.final_score), 0)::int AS best_score, + (SELECT coalesce(sum(pg.bonus_dicees), 0)::int FROM per_game AS pg) AS bonus_dicees, + (SELECT count(*) FILTER (WHERE pg.scored_dicee)::int FROM per_game AS pg) AS dicee_games, + (SELECT count(*) FILTER (WHERE pg.upper_bonus)::int FROM per_game AS pg) AS upper_bonuses + FROM counted AS c + ), + categories AS ( + SELECT coalesce(jsonb_object_agg(cat.key, jsonb_build_object( + 'times_scored', cat.times_scored, + 'total_score', cat.total_score, + 'best_score', cat.best_score, + 'avg_score', cat.avg_score + )), '{}'::jsonb) AS category_stats + FROM ( + SELECT n.key, count(*)::int AS times_scored, sum(n.value) AS total_score, + max(n.value) AS best_score, round(avg(n.value), 2) AS avg_score + FROM numbers AS n + WHERE n.key IN ('ones', 'twos', 'threes', 'fours', 'fives', 'sixes', + 'threeOfAKind', 'fourOfAKind', 'fullHouse', 'smallStraight', + 'largeStraight', 'dicee', 'chance') + GROUP BY n.key + ) AS cat + ), + decisions AS ( + SELECT + count(*)::int AS total, + count(*) FILTER (WHERE (e.payload ->> 'was_optimal')::boolean)::int AS optimal, + coalesce(avg(CASE + WHEN jsonb_typeof(e.payload -> 'ev_difference') = 'number' + THEN greatest((e.payload ->> 'ev_difference')::numeric, 0) + ELSE 0 + END), 0) AS avg_ev_loss + FROM public.domain_events AS e + JOIN counted AS c ON c.game_id = e.game_id + WHERE e.player_id = p_user_id + AND e.event_type = 'TurnScored' + AND jsonb_typeof(e.payload -> 'was_optimal') = 'boolean' + ) + INSERT INTO public.player_stats AS ps ( + user_id, games_played, games_won, games_completed, total_score, best_score, avg_score, + dicees_rolled, bonus_dicees, upper_bonuses, category_stats, + optimal_decisions, total_decisions, avg_ev_loss + ) + SELECT + p_user_id, t.games, t.wins, t.games, t.total_score, t.best_score, + CASE WHEN t.games > 0 THEN round(t.total_score::numeric / t.games, 2) ELSE 0 END, + t.dicee_games + t.bonus_dicees, t.bonus_dicees, t.upper_bonuses, cat.category_stats, + d.optimal, d.total, least(round(d.avg_ev_loss, 2), 999.99) + FROM totals AS t, categories AS cat, decisions AS d + ON CONFLICT (user_id) DO UPDATE SET + games_played = EXCLUDED.games_played, + games_won = EXCLUDED.games_won, + games_completed = EXCLUDED.games_completed, + total_score = EXCLUDED.total_score, + best_score = EXCLUDED.best_score, + avg_score = EXCLUDED.avg_score, + dicees_rolled = EXCLUDED.dicees_rolled, + bonus_dicees = EXCLUDED.bonus_dicees, + upper_bonuses = EXCLUDED.upper_bonuses, + category_stats = EXCLUDED.category_stats, + optimal_decisions = EXCLUDED.optimal_decisions, + total_decisions = EXCLUDED.total_decisions, + avg_ev_loss = EXCLUDED.avg_ev_loss + -- Skip no-op writes, so a repeat run leaves the row, including updated_at, unchanged. + WHERE (ps.games_played, ps.games_won, ps.games_completed, ps.total_score, ps.best_score, + ps.avg_score, ps.dicees_rolled, ps.bonus_dicees, ps.upper_bonuses, ps.category_stats, + ps.optimal_decisions, ps.total_decisions, ps.avg_ev_loss) + IS DISTINCT FROM + (EXCLUDED.games_played, EXCLUDED.games_won, EXCLUDED.games_completed, EXCLUDED.total_score, + EXCLUDED.best_score, EXCLUDED.avg_score, EXCLUDED.dicees_rolled, EXCLUDED.bonus_dicees, + EXCLUDED.upper_bonuses, EXCLUDED.category_stats, EXCLUDED.optimal_decisions, + EXCLUDED.total_decisions, EXCLUDED.avg_ev_loss); + + SELECT * INTO v_stats FROM public.player_stats WHERE user_id = p_user_id; + RETURN v_stats; +END; +$$; + +CREATE OR REPLACE FUNCTION public.refresh_player_stats_for_game(p_game_id uuid) +RETURNS SETOF public.player_stats +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_user_id uuid; + v_stats public.player_stats; +BEGIN + -- Fixed user order keeps advisory-lock acquisition deadlock-free. + FOR v_user_id IN + SELECT gp.user_id FROM public.game_players AS gp + WHERE gp.game_id = p_game_id AND NOT gp.is_ai AND gp.user_id IS NOT NULL + ORDER BY gp.user_id + LOOP + v_stats := public.rebuild_player_stats(v_user_id); + IF v_stats.user_id IS NOT NULL THEN + RETURN NEXT v_stats; + END IF; + END LOOP; +END; +$$; + +-- Kept for the Worker queue and the public-hardening grants; now a projection refresh. +CREATE OR REPLACE FUNCTION public.aggregate_game_stats(p_game_id uuid) +RETURNS SETOF public.stats_update_result +LANGUAGE sql +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT s.user_id, s.games_played, s.games_won, ARRAY[]::text[] + FROM public.refresh_player_stats_for_game(p_game_id) AS s; +$$; + +DROP FUNCTION public.update_category_stats(jsonb, jsonb); + +CREATE OR REPLACE FUNCTION public.complete_game_atomic( + p_game_id uuid, + p_winner_id uuid, + p_rankings public.player_ranking[], + p_completed_at timestamptz DEFAULT now() +) +RETURNS public.operation_result +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_ranking public.player_ranking; + v_current_status text; + v_updated_count integer := 0; +BEGIN + SELECT status INTO v_current_status FROM public.games WHERE id = p_game_id FOR UPDATE; + + IF NOT FOUND THEN + RETURN ROW(false, 'NOT_FOUND', 'Game does not exist', 0)::public.operation_result; + END IF; + + -- Idempotency: a retry of an already completed game succeeds without changes. + IF v_current_status = 'completed' THEN + RETURN ROW(true, NULL, 'Already completed', 0)::public.operation_result; + END IF; + + IF v_current_status <> 'active' THEN + RETURN ROW(false, 'INVALID_STATE', 'Game is not active (status: ' || v_current_status || ')', 0)::public.operation_result; + END IF; + + UPDATE public.games SET + status = 'completed', + winner_id = p_winner_id, + completed_at = coalesce(p_completed_at, now()) + WHERE id = p_game_id; + v_updated_count := 1; + + FOREACH v_ranking IN ARRAY coalesce(p_rankings, ARRAY[]::public.player_ranking[]) + LOOP + UPDATE public.game_players SET + final_score = v_ranking.score, + final_rank = v_ranking.rank, + scorecard = v_ranking.scorecard, + is_connected = false, + left_at = coalesce(left_at, p_completed_at, now()) + WHERE game_id = p_game_id + AND CASE WHEN coalesce(v_ranking.is_ai, false) + THEN is_ai AND seat_number = v_ranking.seat_number + ELSE NOT is_ai AND user_id = v_ranking.player_id + END; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Ranking for % not found in game %', + coalesce(v_ranking.player_id::text, 'AI seat ' || coalesce(v_ranking.seat_number::text, '?')), + p_game_id; + END IF; + + v_updated_count := v_updated_count + 1; + END LOOP; + + -- Refresh the projection in the same transaction, so stats never lag completion. + PERFORM public.refresh_player_stats_for_game(p_game_id); + + RETURN ROW(true, NULL, NULL, v_updated_count)::public.operation_result; + +EXCEPTION + WHEN OTHERS THEN + RAISE LOG 'complete_game_atomic error: % %', SQLSTATE, SQLERRM; + RETURN ROW(false, SQLSTATE, SQLERRM, 0)::public.operation_result; +END; +$$; + +REVOKE ALL ON FUNCTION public.rebuild_player_stats(uuid) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.refresh_player_stats_for_game(uuid) FROM PUBLIC, anon, authenticated; +REVOKE ALL ON FUNCTION public.aggregate_game_stats(uuid) FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION public.rebuild_player_stats(uuid) TO service_role; +GRANT EXECUTE ON FUNCTION public.refresh_player_stats_for_game(uuid) TO service_role; +GRANT EXECUTE ON FUNCTION public.aggregate_game_stats(uuid) TO service_role; + +COMMENT ON TABLE public.player_stats IS + 'Derived projection of completed games; rebuild with public.rebuild_player_stats(user_id)'; +COMMENT ON FUNCTION public.rebuild_player_stats(uuid) IS + 'Recomputes one user''s player_stats from completed games and domain events. Idempotent.'; +COMMENT ON FUNCTION public.refresh_player_stats_for_game(uuid) IS + 'Rebuilds player_stats for every human seat of a game. Idempotent.'; +COMMENT ON FUNCTION public.aggregate_game_stats(uuid) IS + 'Refreshes the player_stats projection for a game''s human seats. Idempotent.'; +COMMENT ON FUNCTION public.create_game_atomic IS + 'Atomically creates a game with human and AI seats. Idempotent - safe to retry.'; +COMMENT ON FUNCTION public.complete_game_atomic IS + 'Atomically completes a game, records seat results and refreshes player_stats. Idempotent.'; + +-- One-time rebuild: replaces previously inflated counts with projected values. +DO $$ +DECLARE + v_user_id uuid; +BEGIN + FOR v_user_id IN + SELECT ps.user_id FROM public.player_stats AS ps + UNION + SELECT gp.user_id FROM public.game_players AS gp + JOIN public.games AS g ON g.id = gp.game_id + WHERE g.status = 'completed' AND NOT gp.is_ai AND gp.user_id IS NOT NULL + ORDER BY 1 + LOOP + PERFORM public.rebuild_player_stats(v_user_id); + END LOOP; +END; +$$; diff --git a/supabase/tests/player_stats_projection.sql b/supabase/tests/player_stats_projection.sql new file mode 100644 index 0000000..9a85fcb --- /dev/null +++ b/supabase/tests/player_stats_projection.sql @@ -0,0 +1,402 @@ +-- player_stats projection, AI seats and projection grants. +-- Run with: supabase test db +-- Fixtures are synthetic, and every change is rolled back. +-- +-- Players: ...01 plays against AI seats, a multiplayer game, a single-seat game, +-- an active game and an abandoned game; ...02 plays the multiplayer game and +-- beats an AI seat; ...03 has an inflated row and no games; ...04 has only an +-- abandoned game. + +begin; +create extension if not exists pgtap with schema extensions; + +select plan(30); + +insert into auth.users (id, email) values + ('f0000000-0000-4000-8000-000000000001', 'projection-1@example.com'), + ('f0000000-0000-4000-8000-000000000002', 'projection-2@example.com'), + ('f0000000-0000-4000-8000-000000000003', 'projection-3@example.com'), + ('f0000000-0000-4000-8000-000000000004', 'projection-4@example.com'); + +-- =========================================================================== +-- AI seats +-- =========================================================================== + +select is( + (select row(r.success, r.affected_rows)::text + from public.create_game_atomic( + 'f1000000-0000-4000-8000-000000000001', 'PROJ01', 'f0000000-0000-4000-8000-000000000001', 'solo', '{}', + array[ + row('f0000000-0000-4000-8000-000000000001'::uuid, 0, 0, false, null)::public.game_player_input, + row(null, 1, 1, true, 'carmen')::public.game_player_input, + row(null, 2, 2, true, 'riley')::public.game_player_input + ]) as r), + '(t,4)', + 'a game with AI seats persists' +); + +select results_eq( + $$select seat_number::int, user_id, is_ai, ai_profile from public.game_players + where game_id = 'f1000000-0000-4000-8000-000000000001' order by seat_number$$, + $$values (0, 'f0000000-0000-4000-8000-000000000001'::uuid, false, null::text), + (1, null::uuid, true, 'carmen'), + (2, null::uuid, true, 'riley')$$, + 'AI seats have no profile and record their AI profile' +); + +select throws_ok( + $$insert into public.game_players (game_id, user_id, is_ai, seat_number, turn_order) + values ('f1000000-0000-4000-8000-000000000001', null, false, 9, 9)$$, + '23514', null, + 'a human seat requires a profile' +); + +select throws_ok( + $$insert into public.game_players (game_id, user_id, is_ai, seat_number, turn_order) + values ('f1000000-0000-4000-8000-000000000001', 'f0000000-0000-4000-8000-000000000004', true, 9, 9)$$, + '23514', null, + 'an AI seat cannot reference a profile' +); + +select is( + (select row(r.success, r.error_code)::text + from public.create_game_atomic( + 'f1000000-0000-4000-8000-0000000000ff', 'PROJFF', 'f0000000-0000-4000-8000-000000000001', 'solo', '{}', + array[row(null, 0, 0, false, null)::public.game_player_input]) as r), + '(f,INVALID_INPUT)', + 'create_game_atomic rejects a human seat without a user id' +); + +select is( + (select row(r.success, r.affected_rows)::text + from public.complete_game_atomic( + 'f1000000-0000-4000-8000-000000000001', null, + array[ + row('f0000000-0000-4000-8000-000000000001'::uuid, 2, 200, + '{"ones":3,"twos":6,"threes":9,"fours":12,"fives":15,"sixes":18,"dicee":50,"diceeBonus":100,"upperBonus":35,"chance":20}'::jsonb, + false, 0)::public.player_ranking, + row(null, 1, 250, '{"ones":4}'::jsonb, true, 1)::public.player_ranking, + row(null, 3, 150, '{"ones":1}'::jsonb, true, 2)::public.player_ranking + ]) as r), + '(t,4)', + 'complete_game_atomic records AI results by seat number' +); + +select results_eq( + $$select seat_number::int, final_rank::int, final_score from public.game_players + where game_id = 'f1000000-0000-4000-8000-000000000001' order by seat_number$$, + $$values (0, 2, 200), (1, 1, 250), (2, 3, 150)$$, + 'every seat of the AI game has its final result' +); + +select is( + (select row(games_played, games_won, total_score, optimal_decisions, total_decisions, avg_ev_loss)::text + from public.player_stats where user_id = 'f0000000-0000-4000-8000-000000000001'), + '(1,0,200,0,0,0.00)', + 'completion refreshes stats; losing to an AI seat is no win; no domain events means zero decisions' +); + +select results_eq( + $$select user_id from public.aggregate_game_stats('f1000000-0000-4000-8000-000000000001')$$, + $$values ('f0000000-0000-4000-8000-000000000001'::uuid)$$, + 'aggregation projects human seats only' +); + +-- =========================================================================== +-- More games and domain events +-- =========================================================================== + +do $$ +declare + r public.operation_result; +begin + -- Decision events for the AI game arrive after its completion, as in the Worker queue. + r := public.persist_domain_events(array[ + row('f2000000-0000-4000-8000-000000000001'::uuid, 'TurnScored', '1.0', 0, + 'f1000000-0000-4000-8000-000000000001'::uuid, 'f0000000-0000-4000-8000-000000000001'::uuid, + 1, null, '{"was_optimal": true, "ev_difference": -2}'::jsonb)::public.domain_event_input + ]); + if not r.success then raise exception 'events G1: %', r; end if; + + -- G2: multiplayer, player 1 wins against player 2. + r := public.create_game_atomic( + 'f1000000-0000-4000-8000-000000000002', 'PROJ02', 'f0000000-0000-4000-8000-000000000001', 'multiplayer', '{}', + array[ + row('f0000000-0000-4000-8000-000000000001'::uuid, 0, 0, false, null)::public.game_player_input, + row('f0000000-0000-4000-8000-000000000002'::uuid, 1, 1, false, null)::public.game_player_input + ]); + if not r.success then raise exception 'create G2: %', r; end if; + r := public.persist_domain_events(array[ + row('f2000000-0000-4000-8000-000000000002'::uuid, 'TurnScored', '1.0', 0, + 'f1000000-0000-4000-8000-000000000002'::uuid, 'f0000000-0000-4000-8000-000000000001'::uuid, + 1, null, '{"was_optimal": true, "ev_difference": 0}'::jsonb)::public.domain_event_input, + row('f2000000-0000-4000-8000-000000000003'::uuid, 'TurnScored', '1.0', 1, + 'f1000000-0000-4000-8000-000000000002'::uuid, 'f0000000-0000-4000-8000-000000000001'::uuid, + 2, null, '{"was_optimal": false, "ev_difference": 4.5}'::jsonb)::public.domain_event_input, + row('f2000000-0000-4000-8000-000000000004'::uuid, 'TurnScored', '1.0', 2, + 'f1000000-0000-4000-8000-000000000002'::uuid, 'f0000000-0000-4000-8000-000000000001'::uuid, + 3, null, '{"category": "chance"}'::jsonb)::public.domain_event_input, + row('f2000000-0000-4000-8000-000000000005'::uuid, 'DiceRolled', '1.0', 3, + 'f1000000-0000-4000-8000-000000000002'::uuid, 'f0000000-0000-4000-8000-000000000001'::uuid, + 3, 1, '{"was_optimal": false, "ev_difference": 9}'::jsonb)::public.domain_event_input + ]); + if not r.success then raise exception 'events G2: %', r; end if; + r := public.complete_game_atomic( + 'f1000000-0000-4000-8000-000000000002', 'f0000000-0000-4000-8000-000000000001', + array[ + row('f0000000-0000-4000-8000-000000000001'::uuid, 1, 300, '{"ones":2,"dicee":0,"chance":25}'::jsonb, false, 0)::public.player_ranking, + row('f0000000-0000-4000-8000-000000000002'::uuid, 2, 180, '{"ones":4}'::jsonb, false, 1)::public.player_ranking + ]); + if not r.success then raise exception 'complete G2: %', r; end if; + + -- G3: a single-seat game, rank 1 but no opponent. + r := public.create_game_atomic( + 'f1000000-0000-4000-8000-000000000003', 'PROJ03', 'f0000000-0000-4000-8000-000000000001', 'solo', '{}', + array[row('f0000000-0000-4000-8000-000000000001'::uuid, 0, 0, false, null)::public.game_player_input]); + if not r.success then raise exception 'create G3: %', r; end if; + r := public.complete_game_atomic( + 'f1000000-0000-4000-8000-000000000003', 'f0000000-0000-4000-8000-000000000001', + array[row('f0000000-0000-4000-8000-000000000001'::uuid, 1, 100, '{}'::jsonb, false, 0)::public.player_ranking]); + if not r.success then raise exception 'complete G3: %', r; end if; + + -- G4: still active, with a stray final score. + r := public.create_game_atomic( + 'f1000000-0000-4000-8000-000000000004', 'PROJ04', 'f0000000-0000-4000-8000-000000000001', 'solo', '{}', + array[ + row('f0000000-0000-4000-8000-000000000001'::uuid, 0, 0, false, null)::public.game_player_input, + row(null, 1, 1, true, 'carmen')::public.game_player_input + ]); + if not r.success then raise exception 'create G4: %', r; end if; + update public.game_players set final_score = 999, final_rank = 1 + where game_id = 'f1000000-0000-4000-8000-000000000004'; + + -- G5: abandoned, with stray final scores and a decision event. + r := public.create_game_atomic( + 'f1000000-0000-4000-8000-000000000005', 'PROJ05', 'f0000000-0000-4000-8000-000000000001', 'multiplayer', '{}', + array[ + row('f0000000-0000-4000-8000-000000000001'::uuid, 0, 0, false, null)::public.game_player_input, + row('f0000000-0000-4000-8000-000000000004'::uuid, 1, 1, false, null)::public.game_player_input + ]); + if not r.success then raise exception 'create G5: %', r; end if; + update public.game_players set final_score = 500, final_rank = 1 + where game_id = 'f1000000-0000-4000-8000-000000000005'; + r := public.persist_domain_events(array[ + row('f2000000-0000-4000-8000-000000000006'::uuid, 'TurnScored', '1.0', 0, + 'f1000000-0000-4000-8000-000000000005'::uuid, 'f0000000-0000-4000-8000-000000000001'::uuid, + 1, null, '{"was_optimal": false, "ev_difference": 10}'::jsonb)::public.domain_event_input + ]); + if not r.success then raise exception 'events G5: %', r; end if; + r := public.abandon_game_atomic('f1000000-0000-4000-8000-000000000005', 'test'); + if not r.success then raise exception 'abandon G5: %', r; end if; + + -- G6: player 2 beats an AI seat. + r := public.create_game_atomic( + 'f1000000-0000-4000-8000-000000000006', 'PROJ06', 'f0000000-0000-4000-8000-000000000002', 'solo', '{}', + array[ + row('f0000000-0000-4000-8000-000000000002'::uuid, 0, 0, false, null)::public.game_player_input, + row(null, 1, 1, true, 'carmen')::public.game_player_input + ]); + if not r.success then raise exception 'create G6: %', r; end if; + r := public.complete_game_atomic( + 'f1000000-0000-4000-8000-000000000006', 'f0000000-0000-4000-8000-000000000002', + array[ + row('f0000000-0000-4000-8000-000000000002'::uuid, 1, 260, '{"ones":5}'::jsonb, false, 0)::public.player_ranking, + row(null, 2, 200, '{"ones":1}'::jsonb, true, 1)::public.player_ranking + ]); + if not r.success then raise exception 'complete G6: %', r; end if; + + -- The queued aggregation for G1 runs once its events are persisted. + perform public.aggregate_game_stats('f1000000-0000-4000-8000-000000000001'); +end; +$$; + +-- =========================================================================== +-- Projection values and win rule +-- =========================================================================== + +select is( + (select row(games_played, games_won, games_completed, total_score, best_score, avg_score, + dicees_rolled, bonus_dicees, upper_bonuses, optimal_decisions, total_decisions, avg_ev_loss)::text + from public.player_stats where user_id = 'f0000000-0000-4000-8000-000000000001'), + '(3,1,3,600,300,200.00,2,1,1,2,3,1.50)', + 'AI, multiplayer and single-seat games project absolute totals; active and abandoned games are ignored' +); + +select is( + (select category_stats from public.player_stats where user_id = 'f0000000-0000-4000-8000-000000000001'), + '{"ones": {"times_scored": 2, "total_score": 5, "best_score": 3, "avg_score": 2.5}, + "twos": {"times_scored": 1, "total_score": 6, "best_score": 6, "avg_score": 6}, + "threes": {"times_scored": 1, "total_score": 9, "best_score": 9, "avg_score": 9}, + "fours": {"times_scored": 1, "total_score": 12, "best_score": 12, "avg_score": 12}, + "fives": {"times_scored": 1, "total_score": 15, "best_score": 15, "avg_score": 15}, + "sixes": {"times_scored": 1, "total_score": 18, "best_score": 18, "avg_score": 18}, + "dicee": {"times_scored": 2, "total_score": 50, "best_score": 50, "avg_score": 25}, + "chance": {"times_scored": 2, "total_score": 45, "best_score": 25, "avg_score": 22.5}}'::jsonb, + 'category stats cover scoring categories of counted games only' +); + +select is( + (select games_won from public.player_stats where user_id = 'f0000000-0000-4000-8000-000000000001'), + 1, + 'win rule: rank 1 with more than one seat; a single-seat game and a loss to an AI seat are not wins' +); + +select is( + (select row(games_played, games_won, total_score, best_score, avg_score, + optimal_decisions, total_decisions, avg_ev_loss)::text + from public.player_stats where user_id = 'f0000000-0000-4000-8000-000000000002'), + '(2,1,440,260,220.00,0,0,0.00)', + 'multiplayer loss plus a win against an AI seat; no decision events means zero decision metrics' +); + +select is( + (select row(r.games_played, r.total_score)::text + from public.rebuild_player_stats('f0000000-0000-4000-8000-000000000004') as r), + '(0,0)', + 'a player with only an abandoned game has no counted games' +); + +-- =========================================================================== +-- Idempotence +-- =========================================================================== + +create temporary table projection_snapshot on commit drop as +select ps.user_id, ps.ctid::text as tid, to_jsonb(ps) as stats +from public.player_stats as ps +where ps.user_id in ('f0000000-0000-4000-8000-000000000001', 'f0000000-0000-4000-8000-000000000002'); + +do $$ +begin + perform public.rebuild_player_stats('f0000000-0000-4000-8000-000000000001'); + perform public.rebuild_player_stats('f0000000-0000-4000-8000-000000000001'); + perform public.refresh_player_stats_for_game('f1000000-0000-4000-8000-000000000006'); +end; +$$; + +select is( + (select jsonb_agg(to_jsonb(ps) order by ps.user_id) from public.player_stats as ps + where ps.user_id in ('f0000000-0000-4000-8000-000000000001', 'f0000000-0000-4000-8000-000000000002')), + (select jsonb_agg(stats order by user_id) from projection_snapshot), + 'running the projection again gives identical rows' +); + +select is( + (select array_agg(ps.ctid::text order by ps.user_id) from public.player_stats as ps + where ps.user_id in ('f0000000-0000-4000-8000-000000000001', 'f0000000-0000-4000-8000-000000000002')), + (select array_agg(tid order by user_id) from projection_snapshot), + 'a repeat run does not rewrite unchanged rows' +); + +-- Simulated queue retry of every G2 task: completion, events, aggregation. +do $$ +declare + r public.operation_result; +begin + perform public.aggregate_game_stats('f1000000-0000-4000-8000-000000000002'); + r := public.complete_game_atomic( + 'f1000000-0000-4000-8000-000000000002', 'f0000000-0000-4000-8000-000000000001', + array[ + row('f0000000-0000-4000-8000-000000000001'::uuid, 1, 300, '{"ones":2,"dicee":0,"chance":25}'::jsonb, false, 0)::public.player_ranking, + row('f0000000-0000-4000-8000-000000000002'::uuid, 2, 180, '{"ones":4}'::jsonb, false, 1)::public.player_ranking + ]); + if not r.success then raise exception 'retry complete G2: %', r; end if; + r := public.persist_domain_events(array[ + row('f2000000-0000-4000-8000-000000000002'::uuid, 'TurnScored', '1.0', 0, + 'f1000000-0000-4000-8000-000000000002'::uuid, 'f0000000-0000-4000-8000-000000000001'::uuid, + 1, null, '{"was_optimal": true, "ev_difference": 0}'::jsonb)::public.domain_event_input + ]); + if not r.success or r.affected_rows <> 0 then raise exception 'retry events G2: %', r; end if; + perform public.aggregate_game_stats('f1000000-0000-4000-8000-000000000002'); + perform public.refresh_player_stats_for_game('f1000000-0000-4000-8000-000000000002'); +end; +$$; + +select is( + (select jsonb_agg(to_jsonb(ps) order by ps.user_id) from public.player_stats as ps + where ps.user_id in ('f0000000-0000-4000-8000-000000000001', 'f0000000-0000-4000-8000-000000000002')), + (select jsonb_agg(stats order by user_id) from projection_snapshot), + 'a retried completion, event batch and aggregation leave rows identical' +); + +-- =========================================================================== +-- One-time rebuild (the statement the migration runs) +-- =========================================================================== + +update public.player_stats +set games_played = 6, games_won = 2, games_completed = 6, total_score = 1200, + optimal_decisions = 4, total_decisions = 6, category_stats = '{"ones": {"attempts": 4}}' +where user_id = 'f0000000-0000-4000-8000-000000000001'; +insert into public.player_stats (user_id, games_played, games_won, total_score, optimal_decisions, total_decisions) +values ('f0000000-0000-4000-8000-000000000003', 9, 9, 999, 5, 10); + +do $$ +declare + v_user_id uuid; +begin + for v_user_id in + select ps.user_id from public.player_stats as ps + union + select gp.user_id from public.game_players as gp + join public.games as g on g.id = gp.game_id + where g.status = 'completed' and not gp.is_ai and gp.user_id is not null + order by 1 + loop + perform public.rebuild_player_stats(v_user_id); + end loop; +end; +$$; + +select is( + (select to_jsonb(ps) - 'updated_at' from public.player_stats as ps + where ps.user_id = 'f0000000-0000-4000-8000-000000000001'), + (select stats - 'updated_at' from projection_snapshot + where user_id = 'f0000000-0000-4000-8000-000000000001'), + 'the one-time rebuild replaces an inflated row with projected values' +); + +select is( + (select row(games_played, games_won, total_score, optimal_decisions, total_decisions, category_stats)::text + from public.player_stats where user_id = 'f0000000-0000-4000-8000-000000000003'), + '(0,0,0,0,0,{})', + 'the one-time rebuild zeroes an inflated row without counted games' +); + +-- =========================================================================== +-- Grants and row security +-- =========================================================================== + +select ok( + not has_function_privilege(role_name, function_signature, 'execute'), + role_name || ' cannot execute ' || function_signature +) +from (values ('anon'), ('authenticated')) as roles(role_name) +cross join (values + ('public.rebuild_player_stats(uuid)'), + ('public.refresh_player_stats_for_game(uuid)'), + ('public.aggregate_game_stats(uuid)') +) as functions(function_signature); + +select ok( + has_function_privilege('service_role', function_signature, 'execute'), + 'service_role can execute ' || function_signature +) +from (values + ('public.rebuild_player_stats(uuid)'), + ('public.refresh_player_stats_for_game(uuid)'), + ('public.aggregate_game_stats(uuid)') +) as functions(function_signature); + +select hasnt_function('public', 'update_category_stats', 'the incremental category helper is removed'); + +set local role authenticated; +set local request.jwt.claims = '{"sub":"f0000000-0000-4000-8000-000000000001","role":"authenticated"}'; +select throws_ok( + $$insert into public.game_players (game_id, user_id, is_ai, ai_profile, seat_number, turn_order) + values ('f1000000-0000-4000-8000-000000000001', null, true, 'forged', 7, 7)$$, + '42501', null, + 'a signed-in player cannot insert an AI seat' +); +reset role; + +select * from finish(); +rollback; diff --git a/supabase/tests/public_security_hardening.sql b/supabase/tests/public_security_hardening.sql index 5f93450..0e3c6b8 100644 --- a/supabase/tests/public_security_hardening.sql +++ b/supabase/tests/public_security_hardening.sql @@ -5,7 +5,7 @@ begin; create extension if not exists pgtap with schema extensions; -select plan(99); +select plan(100); insert into auth.users (id, email, is_anonymous, raw_user_meta_data) values ('dddddddd-0000-4000-8000-000000000001', 'private-owner@example.com', false, '{"is_public":true}'), @@ -317,6 +317,12 @@ select is( 7, 'trusted achievement progress persisted' ); +select is( + (select row(games_played, best_score)::text from public.player_stats + where user_id = 'dddddddd-0000-4000-8000-000000000001'), + '(1,0)', + 'untrusted calls did not aggregate or alter stats before the trusted call' +); select is( (select count(*)::int from public.aggregate_game_stats('eeeeeeee-0000-4000-8000-000000000001')), 1, @@ -325,8 +331,8 @@ select is( select is( (select row(games_played, best_score)::text from public.player_stats where user_id = 'dddddddd-0000-4000-8000-000000000001'), - '(2,200)', - 'untrusted calls did not aggregate or alter stats before the trusted call' + '(1,200)', + 'trusted aggregation projects absolute stats from the completed game' ); select lives_ok( $$insert into public.solo_leaderboard (user_id, score) diff --git a/supabase/tests/rpc_functions.sql b/supabase/tests/rpc_functions.sql index 7a8c044..b0d10d8 100644 --- a/supabase/tests/rpc_functions.sql +++ b/supabase/tests/rpc_functions.sql @@ -37,8 +37,8 @@ select is( 'multiplayer', '{"test": true}'::jsonb, array[ - row('aaaaaaaa-0000-4000-8000-000000000001'::uuid, 0, 0, false)::public.game_player_input, - row('aaaaaaaa-0000-4000-8000-000000000002'::uuid, 1, 1, false)::public.game_player_input + row('aaaaaaaa-0000-4000-8000-000000000001'::uuid, 0, 0, false, null)::public.game_player_input, + row('aaaaaaaa-0000-4000-8000-000000000002'::uuid, 1, 1, false, null)::public.game_player_input ] ) as r), '(t,3)', @@ -69,7 +69,7 @@ select is( 'multiplayer', '{}'::jsonb, array[ - row('aaaaaaaa-0000-4000-8000-000000000001'::uuid, 0, 0, false)::public.game_player_input + row('aaaaaaaa-0000-4000-8000-000000000001'::uuid, 0, 0, false, null)::public.game_player_input ] ) as r), '(t,0)', @@ -86,8 +86,8 @@ select is( 'bbbbbbbb-0000-4000-8000-000000000001'::uuid, 'aaaaaaaa-0000-4000-8000-000000000001'::uuid, array[ - row('aaaaaaaa-0000-4000-8000-000000000001'::uuid, 1, 285, '{"ones": 3, "twos": 6}'::jsonb, false)::public.player_ranking, - row('aaaaaaaa-0000-4000-8000-000000000002'::uuid, 2, 220, '{"ones": 2, "twos": 4}'::jsonb, false)::public.player_ranking + row('aaaaaaaa-0000-4000-8000-000000000001'::uuid, 1, 285, '{"ones": 3, "twos": 6}'::jsonb, false, 0)::public.player_ranking, + row('aaaaaaaa-0000-4000-8000-000000000002'::uuid, 2, 220, '{"ones": 2, "twos": 4}'::jsonb, false, 1)::public.player_ranking ] ) as r), '(t,3)', @@ -123,7 +123,7 @@ select is( 'bbbbbbbb-0000-4000-8000-000000000001'::uuid, 'aaaaaaaa-0000-4000-8000-000000000001'::uuid, array[ - row('aaaaaaaa-0000-4000-8000-000000000001'::uuid, 1, 999, '{}'::jsonb, false)::public.player_ranking + row('aaaaaaaa-0000-4000-8000-000000000001'::uuid, 1, 999, '{}'::jsonb, false, 0)::public.player_ranking ] ) as r), true,