Skip to content

fix(stats): rebuild player_stats as a projection and persist AI seats - #28

Merged
verlyn13 merged 1 commit into
mainfrom
fix/player-stats-projection
Sep 14, 2026
Merged

verlyn13 merged 1 commit into
mainfrom
fix/player-stats-projection

Conversation

@verlyn13

Copy link
Copy Markdown
Member

Summary

Status action 7: fixes the stats double count and the persistence failures behind it.

  • Root cause found on the way: the Worker sent hand-built Postgres array literals, which PostgREST rejects with 22P02 malformed record literal. create_game_atomic therefore always failed, so the deployed Worker has likely never persisted a game. It now sends JSON arrays of objects. The double counting was real in code (the SQL aggregation plus the Edge Function, and again on each retry) but probably never ran in production.
  • supabase/migrations/20260914000001_player_stats_projection.sql:
    • rebuild_player_stats(user_id) recomputes absolute values from completed games and TurnScored events, under a per-user advisory lock, and skips no-op writes.
    • refresh_player_stats_for_game(game_id) rebuilds every human seat of a game.
    • aggregate_game_stats(uuid) keeps its signature, so the grants in 20260913000002 still apply, and now calls the refresh.
    • complete_game_atomic refreshes stats inside its own transaction. update_category_stats is dropped.
    • AI seats: game_players.is_ai, ai_profile, a nullable user_id with a CHECK tying them together, primary key (game_id, seat_number), and a unique (game_id, user_id).
    • A one-time rebuild runs at the end. Execution of all stats functions is service_role only.
    • It applies before or after 20260913000002, with an identical schema snapshot either way.
  • Worker:
    • AI seats send user_id: null plus their profile, and completion addresses them by seat number.
    • An AI winner is stored as a null winner_id, and only human seats' domain events persist.
    • The aggregation task calls the RPC only; the Edge Function call and the rating and badge flags are gone.
    • GamePersistenceService and AggregationRequestSchema are removed.
  • Deleted: supabase/functions/aggregate-game-stats/ and the unused web createPlayerStats/updatePlayerStats writers.
  • database.ts: the minimal typed change, hand-applied from local generation; the only other difference in the generated file is an unrelated graphql_public block.
  • Docs: architecture persistence bridge; status action 7 and action 3; roadmap step 4 release steps. A new roadmap step 2 covers the self-referencing game_players SELECT policy found here, and section 2 gains a Decision Quality follow-up.

No migration applied, nothing deployed.

Release notes for the operator (status action 3)

  1. Read back the completed games count, player_stats rows and summed games_played. The one-time rebuild resets stats that no completed game backs, so decide before applying.
  2. Apply 20260914000001 alone. It fails and rolls back if existing game_players rows collide on (game_id, seat_number).
  3. Deploy dicee-web, move the domain, then deploy dicee.
  4. Delete the deployed aggregate-game-stats Edge Function, then select public.rebuild_player_stats(user_id) from public.player_stats; once.

Verification

  • Agent (worktree):
    • supabase test db: 177 passed, including 30 new projection tests.
    • Both migration orders produce an identical schema snapshot; the hosted-like order (000001 plus this migration only) was checked too.
    • End-to-end through local PostgREST: a human and AI game created, completed and aggregated twice with identical rows.
    • Worker test:agent: 528 passed. Web test:agent: 1594 passed.
  • Integration: rebased onto feat(multiplayer): close outdated clients with a protocol version handshake #27, Worker typecheck and tests, web stats and proxy tests, pgTAP rerun, docs check, public-safety scan, pre-push pnpm validate:ci, CI and CodeQL.
  • Known limits: Decision Quality stays 0 until TurnScored carries was_optimal/ev_difference; the game_players read policy recursion is pre-existing (roadmap step 2).

player_stats was incremented by both aggregate_game_stats and the aggregate-game-stats Edge Function, and a queue retry reran both. Migration 20260914000001 makes it a projection: rebuild_player_stats(user) recomputes absolute values from completed games and TurnScored events under a per-user advisory lock, refresh_player_stats_for_game rebuilds a game's human seats, aggregate_game_stats keeps its signature and calls the refresh, and complete_game_atomic refreshes in its own transaction. A one-time rebuild replaces existing rows. Execution is service_role only.

game_players gains is_ai and ai_profile with a nullable user_id and primary key (game_id, seat_number), so games with AI seats persist; completion matches AI rankings by seat number. The Worker now sends JSON arrays: its hand-built array literals were rejected by PostgREST (22P02), so no game was persisted. The Edge Function, its caller and flags, the unused GamePersistenceService and the unused web stats writers are removed.

pgTAP covers repeats, retries, solo, multiplayer, AI seats, incomplete games, missing events, the win rule and the rebuild. The migration applies before or after 20260913000002 with an identical resulting schema. Status and roadmap record the release steps: a stats-count readback before applying, then deleting the deployed Edge Function and rebuilding once after the Worker release.
@verlyn13
verlyn13 merged commit a7eb338 into main Sep 14, 2026
8 checks passed
@verlyn13
verlyn13 deleted the fix/player-stats-projection branch September 14, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant