import type { Kysely } from 'kysely'; import { fetchGameMoves, fetchGamePasses } from '$lib/atproto-client'; import type { Database, GameRecord } from './db'; export interface Turn { /** Actions played so far (moves and passes), as the records have it. */ actionCount: number; /** Colour of the action about to be played. */ color: 'black' | 'white'; /** Who is expected to play it, or null while the game has no opponent yet. */ expectedPlayer: string | null; /** Type of the last recorded action, for consecutive-pass detection. */ lastActionType: string | null; } /** Black plays the first action, unless handicap stones reverse the order. */ function nextColor(actionCount: number, handicap: number): 'black' | 'white' { const black = handicap > 0 ? actionCount % 2 === 1 : actionCount % 2 === 0; return black ? 'black' : 'white'; } function turnFrom(actionCount: number, game: GameRecord, lastActionType: string | null): Turn { const color = nextColor(actionCount, game.handicap || 0); return { actionCount, color, expectedPlayer: color === 'black' ? game.player_one : game.player_two, lastActionType, }; } /** * Count the actions a game really has, straight from its records. * * Returns null when the records cannot be read, so callers can fall back to * the index instead of failing the request. */ async function countRecordedActions( gameAtUri: string ): Promise<{ actionCount: number; lastActionType: string | null; updatedAt: string } | null> { try { const [moves, passes] = await Promise.all([ fetchGameMoves(gameAtUri), fetchGamePasses(gameAtUri), ]); const actions = [ ...moves.map((move) => ({ type: 'move', createdAt: move.createdAt })), ...passes.map((pass) => ({ type: 'pass', createdAt: pass.createdAt })), ].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); const latest = actions[0]; return { actionCount: actions.length, lastActionType: latest ? latest.type : null, updatedAt: latest ? latest.createdAt : new Date().toISOString(), }; } catch (err) { console.error('[turn] could not read the game records:', err); return null; } } /** * Whose turn is it, and how many actions have been played? * * The discovery index answers this cheaply, but it is only as fresh as its * writers: this app updates it on its own writes, and the firehose consumer * applies records written through other clients whenever it gets to them. A * count that is one move behind therefore says the turn belongs to the player * who just moved, and the player who is genuinely next is refused — which * looks exactly like the app failing to record moves. * * So when the caller does not match what the index expects, the count is * re-derived from the records themselves and the index is repaired, rather * than trusting a number that has been shown to disagree with the game. */ export async function resolveTurn( game: GameRecord, db: Kysely, sessionDid: string ): Promise { const indexed = turnFrom(game.action_count, game, game.last_action_type); if (sessionDid === indexed.expectedPlayer) { return indexed; } const recorded = await countRecordedActions(game.id); if (!recorded || recorded.actionCount === game.action_count) { return indexed; } // The index is behind the game: bring it forward so later requests, and the // games list, see the position the records describe. await db .updateTable('games') .set({ action_count: recorded.actionCount, last_action_type: recorded.lastActionType, updated_at: recorded.updatedAt, }) .where('rkey', '=', game.rkey) .execute(); console.log( `[turn] index was ${game.action_count}, records say ${recorded.actionCount}; ` + `repaired game ${game.rkey}` ); return turnFrom(recorded.actionCount, game, recorded.lastActionType); }