import type { Cell, GameRules, SerializedBitBoard } from './types.ts' import { checkWin } from './checkWin.ts' import { BOARD_SIZE, isBoardCoordInBounds } from './board.ts' import { BitBoard } from './bot/BitBoard.ts' function generateId(): string { return Math.random().toString(36).substring(2, 8).toUpperCase() } export interface MoveRecord { x: number y: number player: number symbol: string } export interface Score { wins: number losses: number draws: number } export interface GameResult { winner: number | null winCells?: { x: number; y: number }[] } export type PlaceMoveResult = | { success: false; error: string } | { success: true; kind: 'continue' } | { success: true; kind: 'win'; winner: number; winCells: { x: number; y: number }[] } | { success: true; kind: 'draw' } | { success: true; kind: 'swap2_choice' } export interface GameExportData { id: string player1: string player2: string winner: number | null moves: MoveRecord[] createdAt: string } const DEFAULT_RULES: GameRules = { swap2: false, noOverlines: false } export class GameManager { private _board: BitBoard private _currentPlayer = 0 private _status: 'active' | 'win' | 'draw' = 'active' private _moveHistory: MoveRecord[] = [] private _playerNames: [string, string] private _playerSymbols: [string, string] private _scores: [Score, Score] private _gameId: string private _createdAt: string private _rules: GameRules private _boardSize: number private _phase: 'normal' | 'swap2_opening' | 'swap2_choice' | 'swap2_placement' | 'swap2_choice2' = 'normal' private _openingCount = 0 private _additionalCount = 0 private swapCellOwners(): void { this._board.swapPlayers() } constructor( player1Name?: string, player2Name?: string, symbols?: [string, string], rules?: GameRules, ) { this._rules = rules ?? { ...DEFAULT_RULES } this._boardSize = this._rules.boardSize ?? BOARD_SIZE this._board = BitBoard.empty(this._boardSize) this._playerNames = [player1Name ?? 'Player 1', player2Name ?? 'Player 2'] this._playerSymbols = symbols ?? ['X', 'O'] this._scores = [ { wins: 0, losses: 0, draws: 0 }, { wins: 0, losses: 0, draws: 0 }, ] this._gameId = generateId() this._createdAt = new Date().toISOString() if (this._rules.swap2) { this._phase = 'swap2_opening' } } placeMove(x: number, y: number): PlaceMoveResult { if (this._status !== 'active') { return { success: false, error: 'Game is not active' } } if (this._phase === 'swap2_choice' || this._phase === 'swap2_choice2') { return { success: false, error: 'Waiting for swap decision' } } if (!isBoardCoordInBounds(x, y, this._boardSize)) { return { success: false, error: 'Move out of bounds' } } if (this._board.has(x, y)) { return { success: false, error: 'Cell already occupied' } } let playerId: string let symbol: string if (this._phase === 'swap2_opening') { this._openingCount++ const isOpponentStone = this._openingCount === 3 playerId = isOpponentStone ? '1' : '0' symbol = isOpponentStone ? this._playerSymbols[1] : this._playerSymbols[0] } else if (this._phase === 'swap2_placement') { this._additionalCount++ // 1st additional stone is Player 2 ('1'), 2nd is Player 1 ('0') const isPlayer1Stone = this._additionalCount === 2 playerId = isPlayer1Stone ? '0' : '1' symbol = isPlayer1Stone ? this._playerSymbols[0] : this._playerSymbols[1] } else { playerId = String(this._currentPlayer) symbol = this._playerSymbols[this._currentPlayer] } this._board.place(x, y, Number(playerId) as 0 | 1) this._moveHistory.push({ x, y, player: Number(playerId), symbol, }) const { won, cells: winCells } = checkWin(this._board, x, y, playerId, this._rules.noOverlines) if (won) { this._status = 'win' const winner = this._phase === 'swap2_opening' ? 0 : this._currentPlayer this._scores[winner].wins++ this._scores[winner === 0 ? 1 : 0].losses++ this._phase = 'normal' return { success: true, kind: 'win', winner, winCells } } if (this._board.occupiedCount >= this._boardSize * this._boardSize) { this._status = 'draw' this._scores[0].draws++ this._scores[1].draws++ return { success: true, kind: 'draw' } } if (this._phase === 'swap2_opening') { if (this._openingCount >= 3) { this._phase = 'swap2_choice' return { success: true, kind: 'swap2_choice' } } return { success: true, kind: 'continue' } } if (this._phase === 'swap2_placement') { if (this._additionalCount >= 2) { this._phase = 'swap2_choice2' return { success: true, kind: 'swap2_choice' } } return { success: true, kind: 'continue' } } this._currentPlayer = this._currentPlayer === 0 ? 1 : 0 return { success: true, kind: 'continue' } } swapSides(): void { if (this._status !== 'active') return if (this._phase === 'swap2_choice' || this._phase === 'swap2_choice2') return ;[this._playerSymbols[0], this._playerSymbols[1]] = [this._playerSymbols[1], this._playerSymbols[0]] this.swapCellOwners() this._currentPlayer = this._currentPlayer === 0 ? 1 : 0 } chooseSwap(accept: boolean): void { if (this._phase !== 'swap2_choice') return this.chooseSwap2Decision(accept ? 'player1' : 'player2') } chooseSwap2Decision(decision: 'player1' | 'player2' | 'offer'): void { if (this._phase === 'swap2_choice') { if (decision === 'player1') { this._phase = 'normal' ;[this._playerSymbols[0], this._playerSymbols[1]] = [this._playerSymbols[1], this._playerSymbols[0]] this.swapCellOwners() this._currentPlayer = 0 } else if (decision === 'player2') { this._phase = 'normal' this._currentPlayer = 1 } else if (decision === 'offer') { this._phase = 'swap2_placement' this._additionalCount = 0 this._currentPlayer = 1 } } else if (this._phase === 'swap2_choice2') { this._phase = 'normal' if (decision === 'player1') { this._currentPlayer = 1 } else if (decision === 'player2') { ;[this._playerSymbols[0], this._playerSymbols[1]] = [this._playerSymbols[1], this._playerSymbols[0]] this.swapCellOwners() this._currentPlayer = 0 } } } getBoard(): ReadonlyMap { return this._board.toMap(this._playerSymbols) } getBitBoard(): BitBoard { return this._board } getBoardSnapshot(): SerializedBitBoard { return this._board.serialize() } getCurrentPlayer(): number { return this._currentPlayer } getStatus(): 'active' | 'win' | 'draw' { return this._status } isGameOver(): boolean { return this._status === 'win' || this._status === 'draw' } getMoveHistory(): readonly MoveRecord[] { return this._moveHistory } getPlayerNames(): readonly [string, string] { return this._playerNames } getPlayerSymbols(): readonly [string, string] { return this._playerSymbols } setPlayerSymbol(playerIndex: number, symbol: string): void { if (playerIndex === 0 || playerIndex === 1) { this._playerSymbols[playerIndex] = symbol } } setPlayerName(playerIndex: number, name: string): void { if (playerIndex === 0 || playerIndex === 1) { this._playerNames[playerIndex] = name } } getScores(): [Score, Score] { return [ { ...this._scores[0] }, { ...this._scores[1] }, ] } getWinner(): number | null { if (this._status === 'win') { return this._currentPlayer } return null } getBoardSize(): number { return this._boardSize } getRules(): GameRules { return { ...this._rules } } getPhase(): 'normal' | 'swap2_opening' | 'swap2_choice' | 'swap2_placement' | 'swap2_choice2' { return this._phase } reset(): void { this._board = BitBoard.empty(this._boardSize) this._currentPlayer = 0 this._status = 'active' this._moveHistory = [] this._gameId = generateId() this._createdAt = new Date().toISOString() this._phase = this._rules.swap2 ? 'swap2_opening' : 'normal' this._openingCount = 0 this._additionalCount = 0 } resetWithNewPlayers( player1Name?: string, player2Name?: string, symbols?: [string, string], rules?: GameRules, ): void { if (rules) { this._rules = { ...rules } this._boardSize = rules.boardSize ?? BOARD_SIZE } this.reset() this._playerNames = [player1Name ?? 'Player 1', player2Name ?? 'Player 2'] this._playerSymbols = symbols ?? ['X', 'O'] } exportGame(): GameExportData { return { id: this._gameId, player1: this._playerNames[0], player2: this._playerNames[1], winner: this.getWinner(), moves: [...this._moveHistory], createdAt: this._createdAt, } } replaceBoard(board: Record | SerializedBitBoard, currentPlayer: number, status: 'active' | 'win' | 'draw', moveHistory: MoveRecord[]): void { this._board = isSerializedBitBoard(board) ? BitBoard.deserialize(board) : BitBoard.fromBoard(board, this._boardSize) this._currentPlayer = currentPlayer this._status = status this._moveHistory = [...moveHistory] } addMove(x: number, y: number, player: number, symbol: string): void { this._board.place(x, y, player as 0 | 1) this._moveHistory.push({ x, y, player, symbol }) } setStatus(status: 'active' | 'win' | 'draw'): void { this._status = status } setCurrentPlayer(player: number): void { this._currentPlayer = player } get gameId(): string { return this._gameId } } function isSerializedBitBoard(board: Record | SerializedBitBoard): board is SerializedBitBoard { return Array.isArray((board as SerializedBitBoard).words) }