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: [];
|
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: {
|
Views: {
|
||||||
[_ in never]: never
|
[_ in never]: never
|
||||||
@@ -620,6 +655,10 @@ export type Database = {
|
|||||||
accept_dm: { Args: { conversation_id: string }; Returns: undefined }
|
accept_dm: { Args: { conversation_id: string }; Returns: undefined }
|
||||||
are_friends: { Args: { a: string; b: string }; Returns: boolean }
|
are_friends: { Args: { a: string; b: string }; Returns: boolean }
|
||||||
revoke_device: { Args: { p_device_id: string }; Returns: undefined }
|
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: {
|
attachment_object_conv_id: {
|
||||||
Args: { object_name: string }
|
Args: { object_name: string }
|
||||||
Returns: string
|
Returns: string
|
||||||
|
|||||||
@@ -88,12 +88,20 @@ export interface WatchTogetherPayload {
|
|||||||
session_id: string;
|
session_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GamePayload {
|
||||||
|
v: 1;
|
||||||
|
type: 'game';
|
||||||
|
game_id: string;
|
||||||
|
game_type: 'ttt' | 'c4';
|
||||||
|
}
|
||||||
|
|
||||||
export type MessagePayload =
|
export type MessagePayload =
|
||||||
| TextMessagePayload
|
| TextMessagePayload
|
||||||
| CallEventPayload
|
| CallEventPayload
|
||||||
| PollPayload
|
| PollPayload
|
||||||
| WhiteboardPayload
|
| WhiteboardPayload
|
||||||
| WatchTogetherPayload;
|
| WatchTogetherPayload
|
||||||
|
| GamePayload;
|
||||||
|
|
||||||
export type ParsedMessagePayload =
|
export type ParsedMessagePayload =
|
||||||
| {
|
| {
|
||||||
@@ -120,6 +128,11 @@ export type ParsedMessagePayload =
|
|||||||
| {
|
| {
|
||||||
kind: 'watch_together';
|
kind: 'watch_together';
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'game';
|
||||||
|
gameId: string;
|
||||||
|
gameType: 'ttt' | 'c4';
|
||||||
};
|
};
|
||||||
|
|
||||||
export function serializeMessagePayload(payload: MessagePayload): string {
|
export function serializeMessagePayload(payload: MessagePayload): string {
|
||||||
@@ -190,6 +203,12 @@ export function parseMessagePayload(raw: string | null): ParsedMessagePayload {
|
|||||||
: '';
|
: '';
|
||||||
return { kind: 'watch_together', sessionId: id };
|
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;
|
const t = obj as TextMessagePayload;
|
||||||
return {
|
return {
|
||||||
kind: 'text',
|
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 './whiteboards';
|
||||||
export * from './soundboards';
|
export * from './soundboards';
|
||||||
export * from './watchTogether';
|
export * from './watchTogether';
|
||||||
|
export * from './games';
|
||||||
|
|
||||||
// ----- RPC wrappers ---------------------------------------------------------
|
// ----- RPC wrappers ---------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user