feat(P5B.T2): GamePayload + games wrappers + winner-detect helpers + tests
Adds conversation_games table type + game_make_move RPC to db-types, GamePayload variant and parseMessagePayload branch to shared/attachments, games.ts with createGame/getGame/makeGameMove wrappers plus pure tttWinningLine/c4WinningCells/c4DropRow/isBoardFull helpers, 11 tests covering all winner helpers, and re-export from chat/index.ts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<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: [];
|
||||
};
|
||||
}
|
||||
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<number | null> };
|
||||
}
|
||||
attachment_object_conv_id: {
|
||||
Args: { object_name: string }
|
||||
Returns: string
|
||||
|
||||
@@ -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<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 };
|
||||
}
|
||||
const t = obj as TextMessagePayload;
|
||||
return {
|
||||
kind: 'text',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<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,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export * from './viewOnceAttachments';
|
||||
export * from './whiteboards';
|
||||
export * from './soundboards';
|
||||
export * from './watchTogether';
|
||||
export * from './games';
|
||||
|
||||
// ----- RPC wrappers ---------------------------------------------------------
|
||||
|
||||
|
||||
Reference in New Issue
Block a user