diff --git a/docs/superpowers/plans/2026-05-16-phase5b-mini-games.md b/docs/superpowers/plans/2026-05-16-phase5b-mini-games.md new file mode 100644 index 0000000..8eb762c --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-phase5b-mini-games.md @@ -0,0 +1,1373 @@ +# Phase 5B โ€” Mini-Games (Tic-Tac-Toe + Vier-Gewinnt) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Two server-authoritative turn-based games inside a conversation. Composer โ†’ "๐ŸŽฎ Spielen" โ†’ pick game type โ†’ inline bubble โ†’ click "Spielen" โ†’ fullscreen board; moves are validated and applied by a single PL/pgSQL RPC; winner detection is server-side; realtime postgres_changes UPDATE on the game row keeps both clients in sync. + +**Architecture:** +- One server table `public.conversation_games (id, conversation_id, game_type text, state jsonb, players jsonb, current_turn_user_id, winner_user_id, created_at, finished_at)`. +- One server RPC `game_make_move(p_game_id uuid, p_move jsonb) returns jsonb` โ€” owns the entire state machine. +- Bubble = `messages` row with payload `{v:1, type:'game', game_id, game_type}`. `MessageBubble` dispatches on `parsed.kind === 'game'`. +- Client logic in `packages/shared/src/chat/games.ts` is pure: wrappers around the RPC + tiny render-only helpers (winning-line detection for UI highlighting) โ€” mirrors the server's PL/pgSQL win-check so the UI can highlight without re-querying. +- `useGame(gameId)` hook subscribes to UPDATE on the row + exposes `makeMove(move)`. +- Two board components + a modal that picks the right component from `game_type`. + +**Tech Stack:** PostgreSQL + PL/pgSQL (state-machine RPC) + RLS + Supabase Realtime; React 18; no new build-time deps. + +**Non-goals:** +- No chess / Pong / other games (post-MVP per spec). +- No spectator UI โ€” exactly two players; bubble's second seat fills on opening. +- No move animations beyond CSS transitions. +- No 24h auto-draw cron (post-MVP). +- No rematch button. + +--- + +## Pre-flight + +- [ ] **Verify clean working tree on `main`** + +Run: `cd "D:\Programmieren\ChatApp-Electron\chat-app" && git status` +Expected: clean. + +- [ ] **Confirm tooling is green** + +Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck && pnpm --filter @chat-app/shared test -- --run` +Expected: all green; 60 shared tests pass (post-P5A baseline). + +--- + +## Task 1: SQL migration โ€” table + RPC + RLS + realtime + +**Files:** +- Create: `supabase/migrations/20260516000009_mini_games.sql` + +Idempotent. + +- [ ] **Step 1: Write the SQL migration** + +Create `supabase/migrations/20260516000009_mini_games.sql`: + +```sql +-- 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; +``` + +- [ ] **Step 2: Commit + push to prod** + +```bash +cd "D:\Programmieren\ChatApp-Electron\chat-app" +git add supabase/migrations/20260516000009_mini_games.sql +git commit -m "feat(P5B.T1): conversation_games table + game_make_move RPC + RLS + realtime" +bash scripts/prod/push-migrations.sh mini_games +``` + +--- + +## Task 2: Shared payload + wrappers + game-logic helpers + tests + +**Files:** +- Modify: `packages/db-types/src/index.ts` +- Modify: `packages/shared/src/chat/attachments.ts` +- Create: `packages/shared/src/chat/games.ts` +- Create: `packages/shared/src/chat/games.test.ts` +- Modify: `packages/shared/src/chat/index.ts` + +- [ ] **Step 1: db-types โ€” add table + RPC** + +In `packages/db-types/src/index.ts`, after `conversation_watch_sessions` (P5A.T2 commit `4399d39`), add: + +```ts +conversation_games: { + Row: { + id: string; + conversation_id: string; + game_type: 'ttt' | 'c4'; + state: { kind: 'ttt' | 'c4'; board: Array }; + players: [string, string]; + current_turn_user_id: string | null; + winner_user_id: string | null; + created_at: string; + finished_at: string | null; + }; + Insert: { + id?: string; + conversation_id: string; + game_type: 'ttt' | 'c4'; + state: { kind: 'ttt' | 'c4'; board: Array }; + players: [string, string]; + current_turn_user_id: string | null; + winner_user_id?: string | null; + created_at?: string; + finished_at?: string | null; + }; + Update: { + id?: string; + conversation_id?: string; + game_type?: 'ttt' | 'c4'; + state?: { kind: 'ttt' | 'c4'; board: Array }; + players?: [string, string]; + current_turn_user_id?: string | null; + winner_user_id?: string | null; + finished_at?: string | null; + }; + Relationships: []; +}; +``` + +Find the `Functions` block in the same Database type (it should already have `revoke_device` from P3.T1). Add: +```ts +game_make_move: { + Args: { p_game_id: string; p_move: object }; + Returns: { kind: 'ttt' | 'c4'; board: Array }; +}; +``` +If no `Functions` block exists, add the minimal shape that matches what supabase-js expects (`{ [fn: string]: { Args: ...; Returns: ... } }`). + +- [ ] **Step 2: Extend attachments.ts** + +In `packages/shared/src/chat/attachments.ts`, after `WatchTogetherPayload`, add: + +```ts +export interface GamePayload { + v: 1; + type: 'game'; + game_id: string; + game_type: 'ttt' | 'c4'; +} +``` + +Extend the union: +```ts +export type MessagePayload = + | TextMessagePayload + | CallEventPayload + | PollPayload + | WhiteboardPayload + | WatchTogetherPayload + | GamePayload; +``` + +Extend `ParsedMessagePayload`: +```ts + | { + kind: 'game'; + gameId: string; + gameType: 'ttt' | 'c4'; + }; +``` + +In `parseMessagePayload`, after the `obj.type === 'watch_together'` branch and BEFORE the text fallback: +```ts +if (obj.type === 'game') { + const p = obj as Partial; + const id = typeof p.game_id === 'string' && p.game_id.length > 0 ? p.game_id : ''; + const t = p.game_type === 'ttt' || p.game_type === 'c4' ? p.game_type : 'ttt'; + return { kind: 'game', gameId: id, gameType: t }; +} +``` + +- [ ] **Step 3: Create the wrapper** + +Create `packages/shared/src/chat/games.ts`: + +```ts +import type { AppSupabaseClient } from '../supabase/client'; + +export type GameType = 'ttt' | 'c4'; +export type Cell = 0 | 1 | null; + +export const TTT_CELLS = 9; +export const C4_ROWS = 6; +export const C4_COLS = 7; +export const C4_CELLS = C4_ROWS * C4_COLS; + +export const TTT_LINES: ReadonlyArray = [ + [0, 1, 2], [3, 4, 5], [6, 7, 8], + [0, 3, 6], [1, 4, 7], [2, 5, 8], + [0, 4, 8], [2, 4, 6], +]; + +export interface GameRecord { + id: string; + conversationId: string; + gameType: GameType; + state: { kind: GameType; board: Cell[] }; + players: [string, string]; + currentTurnUserId: string | null; + winnerUserId: string | null; + createdAt: string; + finishedAt: string | null; +} + +export function emptyTttBoard(): Cell[] { + return new Array(TTT_CELLS).fill(null); +} + +export function emptyC4Board(): Cell[] { + return new Array(C4_CELLS).fill(null); +} + +export function tttWinningLine(board: Cell[]): readonly [number, number, number] | null { + for (const line of TTT_LINES) { + const a = board[line[0]]; + const b = board[line[1]]; + const c = board[line[2]]; + if (a !== null && a === b && b === c) return line; + } + return null; +} + +export function c4WinningCells(board: Cell[]): readonly number[] | null { + const directions: Array<[number, number]> = [ + [0, 1], [1, 0], [1, 1], [1, -1], + ]; + for (let r = 0; r < C4_ROWS; r++) { + for (let c = 0; c < C4_COLS; c++) { + const base = board[r * C4_COLS + c]; + if (base === null) continue; + for (const [dr, dc] of directions) { + const rEnd = r + dr * 3; + const cEnd = c + dc * 3; + if (rEnd < 0 || rEnd >= C4_ROWS || cEnd < 0 || cEnd >= C4_COLS) continue; + let ok = true; + const cells: number[] = [r * C4_COLS + c]; + for (let i = 1; i < 4; i++) { + const idx = (r + dr * i) * C4_COLS + (c + dc * i); + if (board[idx] !== base) { ok = false; break; } + cells.push(idx); + } + if (ok) return cells; + } + } + } + return null; +} + +export function isBoardFull(board: Cell[]): boolean { + return board.every((c) => c !== null); +} + +export function c4DropRow(board: Cell[], column: number): number { + for (let r = C4_ROWS - 1; r >= 0; r--) { + if (board[r * C4_COLS + column] === null) return r; + } + return -1; +} + +export async function createGame( + client: AppSupabaseClient, + params: { conversationId: string; gameType: GameType; opponentUserId: string }, +): Promise { + const { data: session } = await client.auth.getUser(); + if (!session.user) throw new Error('not authenticated'); + const board = params.gameType === 'ttt' ? emptyTttBoard() : emptyC4Board(); + const state = { kind: params.gameType, board }; + const players: [string, string] = [session.user.id, params.opponentUserId]; + const { data, error } = await client + .from('conversation_games') + .insert({ + conversation_id: params.conversationId, + game_type: params.gameType, + state, + players, + current_turn_user_id: session.user.id, + }) + .select( + 'id, conversation_id, game_type, state, players, current_turn_user_id, winner_user_id, created_at, finished_at', + ) + .single(); + if (error) throw error; + return mapRow(data); +} + +export async function getGame( + client: AppSupabaseClient, + gameId: string, +): Promise { + const { data, error } = await client + .from('conversation_games') + .select( + 'id, conversation_id, game_type, state, players, current_turn_user_id, winner_user_id, created_at, finished_at', + ) + .eq('id', gameId) + .maybeSingle(); + if (error) throw error; + return data ? mapRow(data) : null; +} + +export async function makeGameMove( + client: AppSupabaseClient, + params: { gameId: string; move: object }, +): Promise { + const { error } = await client.rpc('game_make_move', { + p_game_id: params.gameId, + p_move: params.move, + }); + if (error) throw new Error(error.message); +} + +function mapRow(row: { + id: string; + conversation_id: string; + game_type: string; + state: unknown; + players: unknown; + current_turn_user_id: string | null; + winner_user_id: string | null; + created_at: string; + finished_at: string | null; +}): GameRecord { + const rawState = (row.state ?? {}) as { kind?: string; board?: unknown }; + const kind: GameType = rawState.kind === 'c4' ? 'c4' : 'ttt'; + const rawBoard = Array.isArray(rawState.board) ? rawState.board : []; + const board: Cell[] = rawBoard.map((c) => + typeof c === 'number' && (c === 0 || c === 1) ? (c as Cell) : null, + ); + const players = Array.isArray(row.players) ? row.players : []; + return { + id: row.id, + conversationId: row.conversation_id, + gameType: row.game_type === 'c4' ? 'c4' : 'ttt', + state: { kind, board }, + players: [String(players[0] ?? ''), String(players[1] ?? '')], + currentTurnUserId: row.current_turn_user_id, + winnerUserId: row.winner_user_id, + createdAt: row.created_at, + finishedAt: row.finished_at, + }; +} +``` + +- [ ] **Step 4: Tests** + +Create `packages/shared/src/chat/games.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; + +import { + c4DropRow, + c4WinningCells, + emptyC4Board, + emptyTttBoard, + isBoardFull, + tttWinningLine, + type Cell, +} from './games'; + +describe('tttWinningLine', () => { + it('detects a row win', () => { + const b: Cell[] = [0, 0, 0, null, null, null, null, null, null]; + expect(tttWinningLine(b)).toEqual([0, 1, 2]); + }); + + it('detects a diagonal win', () => { + const b: Cell[] = [1, null, null, null, 1, null, null, null, 1]; + expect(tttWinningLine(b)).toEqual([0, 4, 8]); + }); + + it('returns null when no winner', () => { + expect(tttWinningLine(emptyTttBoard())).toBeNull(); + const mixed: Cell[] = [0, 1, 0, 1, 0, 1, 1, 0, 1]; + expect(tttWinningLine(mixed)).toBeNull(); + }); +}); + +describe('c4WinningCells', () => { + it('detects a horizontal win', () => { + const b = emptyC4Board(); + b[35] = 1; b[36] = 1; b[37] = 1; b[38] = 1; + expect(c4WinningCells(b)).toEqual([35, 36, 37, 38]); + }); + + it('detects a vertical win', () => { + const b = emptyC4Board(); + b[14] = 0; b[21] = 0; b[28] = 0; b[35] = 0; + expect(c4WinningCells(b)).toEqual([14, 21, 28, 35]); + }); + + it('detects a diagonal โ†˜ win', () => { + const b = emptyC4Board(); + b[14] = 1; b[22] = 1; b[30] = 1; b[38] = 1; + expect(c4WinningCells(b)).toEqual([14, 22, 30, 38]); + }); + + it('returns null when no winner', () => { + expect(c4WinningCells(emptyC4Board())).toBeNull(); + }); +}); + +describe('c4DropRow', () => { + it('returns the bottom row on an empty column', () => { + expect(c4DropRow(emptyC4Board(), 0)).toBe(5); + }); + + it('stacks on top of an existing piece', () => { + const b = emptyC4Board(); + b[35] = 0; + expect(c4DropRow(b, 0)).toBe(4); + }); + + it('returns -1 when the column is full', () => { + const b = emptyC4Board(); + for (let r = 0; r < 6; r++) b[r * 7 + 3] = 0; + expect(c4DropRow(b, 3)).toBe(-1); + }); +}); + +describe('isBoardFull', () => { + it('true for a fully filled board, false otherwise', () => { + expect(isBoardFull(emptyTttBoard())).toBe(false); + const filled: Cell[] = [0, 1, 0, 1, 0, 1, 1, 0, 1]; + expect(isBoardFull(filled)).toBe(true); + }); +}); +``` + +- [ ] **Step 5: Re-export** + +Append to `packages/shared/src/chat/index.ts`: +```ts +export * from './games'; +``` + +- [ ] **Step 6: Tests + typecheck** + +``` +pnpm --filter @chat-app/shared test -- --run games +pnpm --filter @chat-app/shared typecheck +pnpm --filter @chat-app/shared test -- --run +``` +Expected: ~13 new tests; full suite ~73/73. + +- [ ] **Step 7: Commit** + +```bash +git add packages/db-types/src/index.ts packages/shared/src/chat/attachments.ts packages/shared/src/chat/games.ts packages/shared/src/chat/games.test.ts packages/shared/src/chat/index.ts +git commit -m "feat(P5B.T2): GamePayload + games wrappers + winner-detect helpers + tests" +``` + +--- + +## Task 3: `useGame` hook + +**Files:** +- Create: `apps/desktop/src/hooks/useGame.ts` + +- [ ] **Step 1: Write the hook** + +```ts +import { useCallback, useEffect, useState } from 'react'; + +import { getGame, makeGameMove, type GameRecord } from '@chat-app/shared/chat'; + +import { supabase } from '../lib/supabase'; + +export function useGame(gameId: string | null): { + game: GameRecord | null; + loading: boolean; + error: string | null; + makeMove: (move: object) => Promise; +} { + const [game, setGame] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!gameId) { + setGame(null); + setLoading(false); + setError(null); + return; + } + let cancelled = false; + void (async () => { + try { + setLoading(true); + setError(null); + const fresh = await getGame(supabase, gameId); + if (!cancelled) { + setGame(fresh); + setLoading(false); + } + } catch (err) { + if (!cancelled) { + setLoading(false); + setError(err instanceof Error ? err.message : 'failed to load game'); + } + } + })(); + const channel = supabase + .channel('game:' + gameId) + .on( + 'postgres_changes', + { + event: 'UPDATE', + schema: 'public', + table: 'conversation_games', + filter: 'id=eq.' + gameId, + }, + () => { + void getGame(supabase, gameId) + .then((fresh) => { if (fresh) setGame(fresh); }) + .catch((err) => { console.warn('game realtime refetch failed', err); }); + }, + ) + .subscribe(); + return () => { + cancelled = true; + void supabase.removeChannel(channel); + }; + }, [gameId]); + + const makeMove = useCallback(async (move: object) => { + if (!gameId) return; + try { + await makeGameMove(supabase, { gameId, move }); + } catch (err) { + setError(err instanceof Error ? err.message : 'move failed'); + throw err; + } + }, [gameId]); + + return { game, loading, error, makeMove }; +} +``` + +- [ ] **Step 2: Typecheck** + +``` +pnpm --filter @chat-app/desktop typecheck +``` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add apps/desktop/src/hooks/useGame.ts +git commit -m "feat(P5B.T3): useGame hook with realtime + makeMove" +``` + +--- + +## Task 4: Board components โ€” `TicTacToeBoard` + `ConnectFourBoard` + +**Files:** +- Create: `apps/desktop/src/components/TicTacToeBoard.tsx` +- Create: `apps/desktop/src/components/ConnectFourBoard.tsx` + +- [ ] **Step 1: TicTacToeBoard** + +```tsx +import { type Cell, tttWinningLine } from '@chat-app/shared/chat'; + +interface Props { + board: Cell[]; + myPlayerIdx: 0 | 1 | null; + disabled: boolean; + onMove: (cell: number) => void; +} + +const MARK = ['ร—', 'โ—‹'] as const; + +export function TicTacToeBoard({ board, myPlayerIdx, disabled, onMove }: Props) { + const winLine = tttWinningLine(board); + const winSet = new Set(winLine ?? []); + + return ( +
+ {board.map((cell, i) => { + const filled = cell !== null; + const inWin = winSet.has(i); + const canClick = !disabled && !filled && myPlayerIdx !== null; + return ( + + ); + })} +
+ ); +} +``` + +- [ ] **Step 2: ConnectFourBoard** + +```tsx +import { + C4_COLS, + C4_ROWS, + c4DropRow, + c4WinningCells, + type Cell, +} from '@chat-app/shared/chat'; + +interface Props { + board: Cell[]; + myPlayerIdx: 0 | 1 | null; + disabled: boolean; + onMove: (column: number) => void; +} + +export function ConnectFourBoard({ board, myPlayerIdx, disabled, onMove }: Props) { + const winCells = c4WinningCells(board); + const winSet = new Set(winCells ?? []); + + return ( +
+
+ {Array.from({ length: C4_ROWS * C4_COLS }, (_, idx) => { + const cell = board[idx]; + const col = idx % C4_COLS; + const dropTo = c4DropRow(board, col); + const canClickColumn = !disabled && dropTo >= 0 && myPlayerIdx !== null; + const inWin = winSet.has(idx); + return ( +
+
+ ); +} +``` + +- [ ] **Step 3: Typecheck** + +``` +pnpm --filter @chat-app/desktop typecheck +``` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add apps/desktop/src/components/TicTacToeBoard.tsx apps/desktop/src/components/ConnectFourBoard.tsx +git commit -m "feat(P5B.T4): TicTacToeBoard + ConnectFourBoard render-only components" +``` + +--- + +## Task 5: `GameModal` โ€” fullscreen container + turn/winner status + +**Files:** +- Create: `apps/desktop/src/components/GameModal.tsx` + +- [ ] **Step 1: Write the modal** + +```tsx +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useAuth } from '../context/AuthContext'; +import { useGame } from '../hooks/useGame'; +import { ConnectFourBoard } from './ConnectFourBoard'; +import { TicTacToeBoard } from './TicTacToeBoard'; +import { XIcon } from './icons'; + +interface Props { + gameId: string; + onClose: () => void; +} + +export function GameModal({ gameId, onClose }: Props) { + const { t } = useTranslation(); + const { game, loading, error, makeMove } = useGame(gameId); + const { session: auth } = useAuth(); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + const myUserId = auth?.user.id ?? null; + const myPlayerIdx: 0 | 1 | null = + !game || !myUserId + ? null + : game.players[0] === myUserId + ? 0 + : game.players[1] === myUserId + ? 1 + : null; + const isMyTurn = !!game && game.currentTurnUserId === myUserId; + const finished = !!game?.finishedAt; + const winnerIdx: 0 | 1 | null = + !game?.winnerUserId + ? null + : game.players[0] === game.winnerUserId + ? 0 + : game.players[1] === game.winnerUserId + ? 1 + : null; + const title = + game?.gameType === 'c4' + ? t('app:game.c4', { defaultValue: 'Vier-Gewinnt' }) + : t('app:game.ttt', { defaultValue: 'Tic-Tac-Toe' }); + + const statusLine = (() => { + if (loading) return t('app:game.loading', { defaultValue: 'Lรคdtโ€ฆ' }); + if (error) return error; + if (!game) return t('app:game.missing', { defaultValue: 'Spiel nicht gefunden.' }); + if (finished) { + if (winnerIdx === null) return t('app:game.draw', { defaultValue: 'Unentschieden!' }); + if (winnerIdx === myPlayerIdx) return t('app:game.you_win', { defaultValue: 'Du hast gewonnen!' }); + return t('app:game.you_lose', { defaultValue: 'Du hast verloren.' }); + } + if (myPlayerIdx === null) return t('app:game.spectator', { defaultValue: 'Du schaust nur zu.' }); + if (isMyTurn) return t('app:game.your_turn', { defaultValue: 'Du bist dran' }); + return t('app:game.opponent_turn', { defaultValue: 'Gegner ist dranโ€ฆ' }); + })(); + + return ( +
+
+

{title}

+ +
+ +
+ {game?.gameType === 'ttt' ? ( + void makeMove({ cell }).catch(() => {})} + /> + ) : game?.gameType === 'c4' ? ( + void makeMove({ column }).catch(() => {})} + /> + ) : null} +
+ +
+ {statusLine} +
+
+ ); +} +``` + +- [ ] **Step 2: Typecheck** + +``` +pnpm --filter @chat-app/desktop typecheck +``` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add apps/desktop/src/components/GameModal.tsx +git commit -m "feat(P5B.T5): GameModal โ€” picks board + turn/winner status" +``` + +--- + +## Task 6: Composer button + game-type picker + MessageBubble + listener + +**Files:** +- Modify: `apps/desktop/src/lib/conversationFeatures.ts` +- Modify: `apps/desktop/src/pages/ConversationPage.tsx` +- Modify: `apps/desktop/src/components/MessageBubble.tsx` + +- [ ] **Step 1: Payload helper** + +In `conversationFeatures.ts`, after `createWatchTogetherPayload` (P5A.T5), append: + +```ts +export function createGamePayload(gameId: string, gameType: 'ttt' | 'c4'): string { + return serializeMessagePayload({ + v: 1, + type: 'game', + game_id: gameId, + game_type: gameType, + }); +} +``` + +Add `GamePayload` to the same `@chat-app/shared/chat` import that brings in `WatchTogetherPayload`. + +- [ ] **Step 2: Wire ConversationPage** + +**A. Imports:** +```ts +import { createGamePayload } from '../lib/conversationFeatures'; +import { GameModal } from '../components/GameModal'; +import { createGame, type GameType } from '@chat-app/shared/chat'; +``` + +**B. State** โ€” near `openWatchSessionId`: +```ts +const [openGameId, setOpenGameId] = useState(null); +const [gameDialogOpen, setGameDialogOpen] = useState(false); +const [gameError, setGameError] = useState(null); +const [gameCreating, setGameCreating] = useState(false); +``` + +**C. Handler** โ€” near `handleStartWatchTogether`: +```ts +const handleStartGame = useCallback(async (gameType: GameType) => { + if (!id) return; + if (!conversation || conversation.members.length !== 2) { + setGameError('Spiele aktuell nur in 1:1-Chats.'); + return; + } + const opponent = conversation.members.find((m) => m.userId !== myId); + if (!opponent) { + setGameError('Kein Gegner gefunden.'); + return; + } + setGameCreating(true); + setGameError(null); + try { + const game = await createGame(supabase, { + conversationId: id, + gameType, + opponentUserId: opponent.userId, + }); + const payload = createGamePayload(game.id, gameType); + await send(payload, [], replyTo?.id ?? null); + setReplyTo(null); + setStickToBottom(true); + setGameDialogOpen(false); + setOpenGameId(game.id); + } catch (err: unknown) { + setGameError(err instanceof Error ? err.message : 'Konnte Spiel nicht starten'); + } finally { + setGameCreating(false); + } +}, [id, conversation, myId, send, replyTo?.id]); +``` + +Verify `conversation.members[].userId` shape โ€” if the codebase uses `user_id`, switch. Grep `Grep -n "conversation.members" apps/desktop/src/pages/ConversationPage.tsx`. + +**D. Composer button** โ€” after the Watch-Together button (search `PlayBoxIcon`): +```tsx + +``` + +Inline icon near the other inline icons: +```tsx +function GameIcon(props: React.SVGProps) { + return ( + + + + + ); +} +``` + +**E. Game-picker dialog** at the page root: +```tsx +{gameDialogOpen && ( +
{ + if (e.target === e.currentTarget) setGameDialogOpen(false); + }} + > +
+

+ {t('app:game.pick_title', { defaultValue: 'Spiel auswรคhlen' })} +

+ {gameError && ( +

{gameError}

+ )} +
+ + +
+
+ +
+
+
+)} +``` + +**F. Modal mount** alongside WatchTogetherModal: +```tsx +{openGameId && ( + setOpenGameId(null)} + /> +)} +``` + +**G. Open-event listener** alongside the existing `chatapp:open-watch-together` listener: +```ts +useEffect(() => { + const onOpen = (e: Event) => { + const detail = (e as CustomEvent<{ id?: string }>).detail; + if (detail?.id) setOpenGameId(detail.id); + }; + window.addEventListener('chatapp:open-game', onOpen); + return () => window.removeEventListener('chatapp:open-game', onOpen); +}, []); +``` + +- [ ] **Step 3: Wire MessageBubble** + +In `apps/desktop/src/components/MessageBubble.tsx`, find the existing `parsed.kind === 'watch_together'` branch (P5A.T5). Add the game branch JUST BEFORE it: + +```tsx +if (parsed.kind === 'game') { + const id = parsed.gameId; + const gType = parsed.gameType; + const label = gType === 'c4' ? 'Vier-Gewinnt' : 'Tic-Tac-Toe'; + return ( +
+
+ + + + +
+
+
{label}
+
Gemeinsam spielen
+
+ +
+ ); +} +``` + +- [ ] **Step 4: Typecheck** + +``` +pnpm --filter @chat-app/desktop typecheck +``` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/desktop/src/lib/conversationFeatures.ts apps/desktop/src/pages/ConversationPage.tsx apps/desktop/src/components/MessageBubble.tsx +git commit -m "feat(P5B.T6): composer Spielen button + bubble dispatch + game-picker" +``` + +--- + +## Final gate + +- [ ] **Step 1: Typecheck both packages** + +Run: `pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck` +Expected: both PASS. + +- [ ] **Step 2: Run shared tests** + +Run: `pnpm --filter @chat-app/shared test -- --run` +Expected: PASS โ€” ~73 tests (60 baseline + ~13 new). + +- [ ] **Step 3: Verify no uncommitted changes** + +Run: `git status` +Expected: clean. + +- [ ] **Step 4: Report** + +Report: "Phase 5B (Mini-Games) code-complete on `main`; migration + RPC applied to prod. Restart dev โ†’ 1:1 chat โ†’ composer โ†’ Spielen icon โ†’ pick Tic-Tac-Toe or Vier-Gewinnt โ†’ bubble appears + modal opens. Other account in the same DM clicks Spielen โ†’ modal opens, makes a move, owner sees it within ~1s. All five phases of the fifteen-features initiative now complete on main." + +--- + +## Self-review (resolved inline) + +1. **Spec coverage** (lines 126-130): + - "New table `conversation_games`" โ†’ T1 + - "Move RPC `game_make_move(...)` server-authoritative" โ†’ T1 + - "Realtime subscription on the game row" โ†’ T3 useGame + - "Tic-Tac-Toe + Vier-Gewinnt" โ†’ T4 boards + - Disconnect-on-mid-game โ†’ realtime + initial fetch handle it + - 24h auto-draw โ†’ server cron, out of scope + +2. **Placeholders:** none. + +3. **Type consistency:** + - `Cell = 0 | 1 | null` everywhere downstream. + - `GameType = 'ttt' | 'c4'` โ€” T1 DB check, T2 helpers, T4 boards, T5 modal, T6 composer. + - `GamePayload.game_id`/`game_type` โ†” `ParsedMessagePayload.gameId`/`gameType` โ€” same camelCase pattern as `WhiteboardPayload`/`WatchTogetherPayload`. + +4. **Server-authoritative win check:** `ttt_check_winner`/`c4_check_winner` PL/pgSQL mirror `tttWinningLine`/`c4WinningCells` TypeScript. Server enforces; client renders. + +5. **Race-condition guard:** `select ... for update` in the RPC serializes concurrent moves. + +6. **MVP scope cut:** game creation is gated to 2-member conversations. Groups can spectate via the bubble; only DMs can start.