diff --git a/packages/db-types/src/index.ts b/packages/db-types/src/index.ts index 7c9e6c1..5684173 100644 --- a/packages/db-types/src/index.ts +++ b/packages/db-types/src/index.ts @@ -612,6 +612,41 @@ export type Database = { }; Relationships: []; }; + 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: []; + }; } Views: { [_ in never]: never @@ -620,6 +655,10 @@ export type Database = { accept_dm: { Args: { conversation_id: string }; Returns: undefined } are_friends: { Args: { a: string; b: string }; Returns: boolean } revoke_device: { Args: { p_device_id: string }; Returns: undefined } + game_make_move: { + Args: { p_game_id: string; p_move: object }; + Returns: { kind: 'ttt' | 'c4'; board: Array }; + } attachment_object_conv_id: { Args: { object_name: string } Returns: string diff --git a/packages/shared/src/chat/attachments.ts b/packages/shared/src/chat/attachments.ts index 6aa6f64..e8e45ca 100644 --- a/packages/shared/src/chat/attachments.ts +++ b/packages/shared/src/chat/attachments.ts @@ -88,12 +88,20 @@ export interface WatchTogetherPayload { session_id: string; } +export interface GamePayload { + v: 1; + type: 'game'; + game_id: string; + game_type: 'ttt' | 'c4'; +} + export type MessagePayload = | TextMessagePayload | CallEventPayload | PollPayload | WhiteboardPayload - | WatchTogetherPayload; + | WatchTogetherPayload + | GamePayload; export type ParsedMessagePayload = | { @@ -120,6 +128,11 @@ export type ParsedMessagePayload = | { kind: 'watch_together'; sessionId: string; + } + | { + kind: 'game'; + gameId: string; + gameType: 'ttt' | 'c4'; }; export function serializeMessagePayload(payload: MessagePayload): string { @@ -190,6 +203,12 @@ export function parseMessagePayload(raw: string | null): ParsedMessagePayload { : ''; return { kind: 'watch_together', sessionId: id }; } + 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 }; + } const t = obj as TextMessagePayload; return { kind: 'text', diff --git a/packages/shared/src/chat/games.test.ts b/packages/shared/src/chat/games.test.ts new file mode 100644 index 0000000..25212f0 --- /dev/null +++ b/packages/shared/src/chat/games.test.ts @@ -0,0 +1,79 @@ +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); + }); +}); diff --git a/packages/shared/src/chat/games.ts b/packages/shared/src/chat/games.ts new file mode 100644 index 0000000..ec240af --- /dev/null +++ b/packages/shared/src/chat/games.ts @@ -0,0 +1,165 @@ +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, + }; +} diff --git a/packages/shared/src/chat/index.ts b/packages/shared/src/chat/index.ts index b58ae92..3f84a4b 100644 --- a/packages/shared/src/chat/index.ts +++ b/packages/shared/src/chat/index.ts @@ -13,6 +13,7 @@ export * from './viewOnceAttachments'; export * from './whiteboards'; export * from './soundboards'; export * from './watchTogether'; +export * from './games'; // ----- RPC wrappers ---------------------------------------------------------