256a613134
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>
166 lines
4.9 KiB
TypeScript
166 lines
4.9 KiB
TypeScript
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,
|
|
};
|
|
}
|