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,