feat(P5B.T1): conversation_games table + game_make_move RPC + RLS + realtime
This commit is contained in:
@@ -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:<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;
|
||||||
Reference in New Issue
Block a user