From e1423dba3252aecec02a8b6aba3ac53b4180a9dd Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 21:42:47 +0200 Subject: [PATCH] feat(P5B.T1): conversation_games table + game_make_move RPC + RLS + realtime --- .../migrations/20260516000009_mini_games.sql | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 supabase/migrations/20260516000009_mini_games.sql diff --git a/supabase/migrations/20260516000009_mini_games.sql b/supabase/migrations/20260516000009_mini_games.sql new file mode 100644 index 0000000..e128e83 --- /dev/null +++ b/supabase/migrations/20260516000009_mini_games.sql @@ -0,0 +1,264 @@ +-- Phase 5B: per-conversation mini-games (Tic-Tac-Toe + Vier-Gewinnt / +-- Connect Four). +-- +-- conversation_games: one row per game. The bubble in the chat is a +-- regular `messages` row whose plaintext payload is +-- `{v:1, type:'game', game_id:, game_type:<'ttt'|'c4'>}`. +-- +-- state JSON shape: +-- ttt: { kind: 'ttt', board: [null|0|1 × 9] } (row-major 3×3) +-- c4: { kind: 'c4', board: [null|0|1 × 42] } (row-major 6 rows × 7 cols) +-- +-- players JSON: [user_id_a, user_id_b] — indices 0 and 1 map to board cells. +-- current_turn_user_id alternates; null once finished. +-- winner_user_id null on draw or unfinished. + +create table if not exists public.conversation_games ( + id uuid primary key default gen_random_uuid(), + conversation_id uuid not null references public.conversations(id) on delete cascade, + game_type text not null check (game_type in ('ttt', 'c4')), + state jsonb not null, + players jsonb not null, + current_turn_user_id uuid null references auth.users(id) on delete set null, + winner_user_id uuid null references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + finished_at timestamptz null +); + +create index if not exists conversation_games_conv_idx + on public.conversation_games(conversation_id, created_at desc); + +alter table public.conversation_games enable row level security; + +drop policy if exists conversation_games_select on public.conversation_games; +drop policy if exists conversation_games_insert on public.conversation_games; + +create policy conversation_games_select + on public.conversation_games + for select + using (public.is_conversation_member(conversation_id)); + +create policy conversation_games_insert + on public.conversation_games + for insert + with check ( + public.is_conversation_member(conversation_id) + and current_turn_user_id = auth.uid() + and (players->>0)::uuid = auth.uid() + ); + +alter table public.conversation_games replica identity full; + +do $$ +begin + if not exists ( + select 1 + from pg_publication_tables + where pubname = 'supabase_realtime' + and schemaname = 'public' + and tablename = 'conversation_games' + ) then + execute 'alter publication supabase_realtime add table public.conversation_games'; + end if; +end +$$; + +-- ---------------------------------------------------------------------- +-- Pure winner-check + board-full helpers. +-- ---------------------------------------------------------------------- + +create or replace function public.ttt_check_winner(board jsonb) +returns int +language plpgsql +immutable +as $$ +declare + lines int[][] := array[ + array[0,1,2], array[3,4,5], array[6,7,8], + array[0,3,6], array[1,4,7], array[2,5,8], + array[0,4,8], array[2,4,6] + ]; + ln int[]; + a jsonb; + b jsonb; + c jsonb; +begin + foreach ln slice 1 in array lines loop + a := board->ln[1]; + b := board->ln[2]; + c := board->ln[3]; + if jsonb_typeof(a) = 'number' and a = b and b = c then + return (a)::int; + end if; + end loop; + return null; +end; +$$; + +create or replace function public.c4_check_winner(board jsonb) +returns int +language plpgsql +immutable +as $$ +declare + rows constant int := 6; + cols constant int := 7; + r int; + c int; + i int; + d_r int; + d_c int; + directions int[][] := array[ + array[0, 1], + array[1, 0], + array[1, 1], + array[1, -1] + ]; + dir int[]; + base jsonb; + cell jsonb; + ok boolean; +begin + for r in 0..rows-1 loop + for c in 0..cols-1 loop + base := board->(r * cols + c); + if jsonb_typeof(base) <> 'number' then continue; end if; + foreach dir slice 1 in array directions loop + d_r := dir[1]; + d_c := dir[2]; + if r + d_r * 3 < 0 or r + d_r * 3 >= rows then continue; end if; + if c + d_c * 3 < 0 or c + d_c * 3 >= cols then continue; end if; + ok := true; + for i in 1..3 loop + cell := board->((r + d_r * i) * cols + (c + d_c * i)); + if cell is null or cell <> base then ok := false; exit; end if; + end loop; + if ok then return (base)::int; end if; + end loop; + end loop; + end loop; + return null; +end; +$$; + +create or replace function public.board_is_full(board jsonb) +returns boolean +language sql +immutable +as $$ + select not exists ( + select 1 from jsonb_array_elements(board) elt where jsonb_typeof(elt) is distinct from 'number' + ); +$$; + +-- ---------------------------------------------------------------------- +-- The state-machine RPC. +-- ---------------------------------------------------------------------- + +create or replace function public.game_make_move(p_game_id uuid, p_move jsonb) +returns jsonb +language plpgsql +security definer +set search_path = public +as $$ +declare + v_game record; + v_state jsonb; + v_board jsonb; + v_kind text; + v_player_idx int; + v_other_user uuid; + v_winner_idx int; + v_finished boolean; + v_cell int; + v_col int; + v_row int; + v_cols constant int := 7; + v_rows constant int := 6; + v_target_row int; +begin + if auth.uid() is null then + raise exception 'not_authenticated' using errcode = '28000'; + end if; + + select * into v_game from public.conversation_games where id = p_game_id for update; + if not found then + raise exception 'game_not_found' using errcode = 'P0002'; + end if; + if v_game.finished_at is not null then + raise exception 'game_finished' using errcode = '42501'; + end if; + if v_game.current_turn_user_id is null + or v_game.current_turn_user_id <> auth.uid() then + raise exception 'not_your_turn' using errcode = '42501'; + end if; + + if (v_game.players->>0)::uuid = auth.uid() then + v_player_idx := 0; + v_other_user := (v_game.players->>1)::uuid; + elsif (v_game.players->>1)::uuid = auth.uid() then + v_player_idx := 1; + v_other_user := (v_game.players->>0)::uuid; + else + raise exception 'not_a_player' using errcode = '42501'; + end if; + + v_state := v_game.state; + v_kind := v_state->>'kind'; + v_board := v_state->'board'; + + if v_kind = 'ttt' then + if (p_move->'cell') is null or jsonb_typeof(p_move->'cell') <> 'number' then + raise exception 'bad_move' using errcode = '22023'; + end if; + v_cell := (p_move->>'cell')::int; + if v_cell < 0 or v_cell > 8 then + raise exception 'bad_move' using errcode = '22023'; + end if; + if jsonb_typeof(v_board->v_cell) = 'number' then + raise exception 'cell_taken' using errcode = '42501'; + end if; + v_board := jsonb_set(v_board, array[v_cell::text], to_jsonb(v_player_idx)); + v_winner_idx := public.ttt_check_winner(v_board); + + elsif v_kind = 'c4' then + if (p_move->'column') is null or jsonb_typeof(p_move->'column') <> 'number' then + raise exception 'bad_move' using errcode = '22023'; + end if; + v_col := (p_move->>'column')::int; + if v_col < 0 or v_col >= v_cols then + raise exception 'bad_move' using errcode = '22023'; + end if; + v_target_row := -1; + for v_row in reverse v_rows - 1 .. 0 loop + if jsonb_typeof(v_board->(v_row * v_cols + v_col)) is distinct from 'number' then + v_target_row := v_row; + exit; + end if; + end loop; + if v_target_row < 0 then + raise exception 'column_full' using errcode = '42501'; + end if; + v_board := jsonb_set(v_board, array[(v_target_row * v_cols + v_col)::text], to_jsonb(v_player_idx)); + v_winner_idx := public.c4_check_winner(v_board); + + else + raise exception 'unknown_game_kind' using errcode = '42501'; + end if; + + v_state := jsonb_set(v_state, '{board}', v_board); + v_finished := v_winner_idx is not null or public.board_is_full(v_board); + + update public.conversation_games + set state = v_state, + current_turn_user_id = case when v_finished then null else v_other_user end, + winner_user_id = case when v_winner_idx is not null then (v_game.players->>v_winner_idx)::uuid else null end, + finished_at = case when v_finished then now() else null end + where id = p_game_id; + + return v_state; +end; +$$; + +revoke all on function public.game_make_move(uuid, jsonb) from public; +grant execute on function public.game_make_move(uuid, jsonb) to authenticated;