1374 lines
42 KiB
Markdown
1374 lines
42 KiB
Markdown
# 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:<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<number | null> };
|
||
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<number | null> };
|
||
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<number | null> };
|
||
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<number | null> };
|
||
};
|
||
```
|
||
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<GamePayload>;
|
||
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<readonly [number, number, number]> = [
|
||
[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<GameRecord> {
|
||
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<GameRecord | null> {
|
||
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<void> {
|
||
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<void>;
|
||
} {
|
||
const [game, setGame] = useState<GameRecord | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(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<number>(winLine ?? []);
|
||
|
||
return (
|
||
<div
|
||
className="mx-auto grid w-full max-w-md grid-cols-3 gap-2 p-4"
|
||
style={{ aspectRatio: '1 / 1' }}
|
||
role="grid"
|
||
aria-label="Tic-Tac-Toe"
|
||
>
|
||
{board.map((cell, i) => {
|
||
const filled = cell !== null;
|
||
const inWin = winSet.has(i);
|
||
const canClick = !disabled && !filled && myPlayerIdx !== null;
|
||
return (
|
||
<button
|
||
key={i}
|
||
type="button"
|
||
onClick={() => canClick && onMove(i)}
|
||
disabled={!canClick}
|
||
aria-label={'Feld ' + (i + 1) + (filled ? ' belegt' : ' frei')}
|
||
className={
|
||
'flex aspect-square items-center justify-center rounded-xl border-2 text-5xl font-bold transition ' +
|
||
(inWin
|
||
? 'border-emerald-400 bg-emerald-400/20 text-emerald-200'
|
||
: filled
|
||
? cell === 0
|
||
? 'border-rose-500/60 bg-rose-500/10 text-rose-300'
|
||
: 'border-sky-500/60 bg-sky-500/10 text-sky-300'
|
||
: canClick
|
||
? 'cursor-pointer border-line bg-surface-2 text-fg-muted hover:bg-surface-3 hover:text-fg'
|
||
: 'cursor-not-allowed border-line bg-surface-2 text-fg-muted opacity-60')
|
||
}
|
||
>
|
||
{filled ? MARK[cell as 0 | 1] : ''}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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<number>(winCells ?? []);
|
||
|
||
return (
|
||
<div
|
||
className="mx-auto w-full max-w-2xl rounded-2xl bg-sky-900/40 p-3"
|
||
role="grid"
|
||
aria-label="Vier-Gewinnt"
|
||
>
|
||
<div
|
||
className="grid gap-1.5"
|
||
style={{ gridTemplateColumns: 'repeat(' + C4_COLS + ', minmax(0, 1fr))' }}
|
||
>
|
||
{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 (
|
||
<button
|
||
key={idx}
|
||
type="button"
|
||
onClick={() => canClickColumn && onMove(col)}
|
||
disabled={!canClickColumn}
|
||
aria-label={'Spalte ' + (col + 1) + (cell !== null ? ' belegt' : '')}
|
||
className={
|
||
'flex aspect-square items-center justify-center rounded-full border-2 transition ' +
|
||
(inWin
|
||
? 'border-emerald-300 bg-emerald-400 shadow-[0_0_12px_rgba(110,231,183,0.7)]'
|
||
: cell === 0
|
||
? 'border-rose-300 bg-rose-500'
|
||
: cell === 1
|
||
? 'border-amber-300 bg-amber-400'
|
||
: canClickColumn
|
||
? 'cursor-pointer border-sky-700 bg-sky-950 hover:bg-sky-900'
|
||
: 'cursor-not-allowed border-sky-800 bg-sky-950 opacity-80')
|
||
}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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 (
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={title}
|
||
className="fixed inset-0 z-[80] flex flex-col bg-ink-950/95"
|
||
>
|
||
<header className="flex shrink-0 items-center justify-between border-b border-line/40 bg-surface-2 px-4 py-2">
|
||
<h2 className="font-display text-sm font-semibold text-fg">{title}</h2>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
aria-label={t('app:game.close', { defaultValue: 'Schließen' })}
|
||
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md text-fg-muted hover:bg-surface-3 hover:text-fg"
|
||
>
|
||
<XIcon className="h-3.5 w-3.5" />
|
||
</button>
|
||
</header>
|
||
|
||
<div className="flex min-h-0 flex-1 items-center justify-center overflow-auto p-6">
|
||
{game?.gameType === 'ttt' ? (
|
||
<TicTacToeBoard
|
||
board={game.state.board}
|
||
myPlayerIdx={myPlayerIdx}
|
||
disabled={!isMyTurn || finished}
|
||
onMove={(cell) => void makeMove({ cell }).catch(() => {})}
|
||
/>
|
||
) : game?.gameType === 'c4' ? (
|
||
<ConnectFourBoard
|
||
board={game.state.board}
|
||
myPlayerIdx={myPlayerIdx}
|
||
disabled={!isMyTurn || finished}
|
||
onMove={(column) => void makeMove({ column }).catch(() => {})}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
|
||
<footer className="flex shrink-0 items-center justify-center border-t border-line/40 bg-surface-2 px-4 py-3 text-sm font-medium text-fg">
|
||
{statusLine}
|
||
</footer>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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<string | null>(null);
|
||
const [gameDialogOpen, setGameDialogOpen] = useState(false);
|
||
const [gameError, setGameError] = useState<string | null>(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
|
||
<button
|
||
type="button"
|
||
onClick={() => setGameDialogOpen(true)}
|
||
title={t('app:composer.game', { defaultValue: 'Spielen' })}
|
||
aria-label={t('app:composer.game', { defaultValue: 'Spielen' })}
|
||
className="flex h-9 w-9 cursor-pointer items-center justify-center rounded-md text-fg-muted transition hover:bg-surface-3 hover:text-fg"
|
||
>
|
||
<GameIcon className="h-4 w-4" />
|
||
</button>
|
||
```
|
||
|
||
Inline icon near the other inline icons:
|
||
```tsx
|
||
function GameIcon(props: React.SVGProps<SVGSVGElement>) {
|
||
return (
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
||
strokeLinecap="round" strokeLinejoin="round" {...props}>
|
||
<rect x="3" y="6" width="18" height="12" rx="3" />
|
||
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
|
||
</svg>
|
||
);
|
||
}
|
||
```
|
||
|
||
**E. Game-picker dialog** at the page root:
|
||
```tsx
|
||
{gameDialogOpen && (
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
className="fixed inset-0 z-[70] flex items-center justify-center bg-ink-950/90 p-6"
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) setGameDialogOpen(false);
|
||
}}
|
||
>
|
||
<div className="w-full max-w-sm rounded-2xl border border-line bg-surface-2 p-5 shadow-xl">
|
||
<h2 className="mb-3 font-display text-lg font-semibold text-fg">
|
||
{t('app:game.pick_title', { defaultValue: 'Spiel auswählen' })}
|
||
</h2>
|
||
{gameError && (
|
||
<p className="mb-2 text-xs text-rose-400">{gameError}</p>
|
||
)}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleStartGame('ttt')}
|
||
disabled={gameCreating}
|
||
className="flex flex-col items-center gap-2 rounded-xl border border-line bg-surface-3 p-4 text-fg transition hover:border-accent/40 hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
<span className="text-3xl">×○</span>
|
||
<span className="text-xs font-semibold">Tic-Tac-Toe</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleStartGame('c4')}
|
||
disabled={gameCreating}
|
||
className="flex flex-col items-center gap-2 rounded-xl border border-line bg-surface-3 p-4 text-fg transition hover:border-accent/40 hover:bg-surface disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
<span className="text-3xl">🔴🟡</span>
|
||
<span className="text-xs font-semibold">Vier-Gewinnt</span>
|
||
</button>
|
||
</div>
|
||
<div className="mt-4 flex justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={() => { setGameDialogOpen(false); setGameError(null); }}
|
||
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-1.5 text-xs font-medium text-fg-muted transition hover:bg-surface"
|
||
>
|
||
{t('app:game.cancel', { defaultValue: 'Abbrechen' })}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
```
|
||
|
||
**F. Modal mount** alongside WatchTogetherModal:
|
||
```tsx
|
||
{openGameId && (
|
||
<GameModal
|
||
gameId={openGameId}
|
||
onClose={() => 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 (
|
||
<div className="flex items-center gap-3 rounded-2xl border border-accent/30 bg-accent/5 px-4 py-3">
|
||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-accent/20 text-accent">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
|
||
strokeLinecap="round" strokeLinejoin="round" className="h-5 w-5">
|
||
<rect x="3" y="6" width="18" height="12" rx="3" />
|
||
<path d="M8 12h4M10 10v4M16 11v.01M16 14v.01" />
|
||
</svg>
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-sm font-semibold text-fg">{label}</div>
|
||
<div className="text-xs text-fg-muted">Gemeinsam spielen</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
window.dispatchEvent(
|
||
new CustomEvent('chatapp:open-game', { detail: { id } }),
|
||
);
|
||
}}
|
||
disabled={!id}
|
||
className="cursor-pointer rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
Spielen
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **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.
|