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:
byGalax
2026-05-16 21:47:36 +02:00
parent e1423dba32
commit 256a613134
5 changed files with 304 additions and 1 deletions
+165
View File
@@ -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,
};
}