import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { GameEngine } from '../GameEngine.ts' import { BoardStore } from '../BoardStore.ts' import { GameManager } from 'shared/GameManager.ts' vi.mock('pixi.js', () => ({ Application: vi.fn(() => ({ init: vi.fn(), renderer: { background: { color: 0 }, resize: vi.fn() }, stage: { addChild: vi.fn() }, canvas: {} as HTMLCanvasElement, })), Container: vi.fn(() => ({ eventMode: 'none' })), Graphics: vi.fn(() => ({ rect: vi.fn(), fill: vi.fn(), stroke: vi.fn(), clear: vi.fn(), })), Ticker: { shared: { add: vi.fn(), remove: vi.fn(), deltaMS: 16 } }, })) vi.mock('../CameraController.ts', () => ({ CameraController: vi.fn(() => ({ setInitialZoom: vi.fn(), enable: vi.fn(), onChange: null, onCellClick: null, onCellHover: null, ensureCellsInView: vi.fn(), resize: vi.fn(), })), })) vi.mock('../GridRenderer.ts', () => ({ GridRenderer: vi.fn(() => ({ container: { addChild: vi.fn() }, draw: vi.fn(), })), })) vi.mock('../SymbolRenderer.ts', () => ({ SymbolRenderer: vi.fn(() => ({ container: { addChild: vi.fn() }, update: vi.fn(), clear: vi.fn(), getSymbol: vi.fn(), })), })) vi.mock('../effects/AudioManager.ts', () => ({ AudioManager: vi.fn(() => ({ play: vi.fn(), playHover: vi.fn(), startMusic: vi.fn(), stopMusic: vi.fn(), destroy: vi.fn(), toggleMute: vi.fn(() => false), playVictoryMusic: vi.fn(), playDefeatMusic: vi.fn(), muted: false, })), audioManager: { play: vi.fn(), playHover: vi.fn(), startMusic: vi.fn(), stopMusic: vi.fn(), destroy: vi.fn(), toggleMute: vi.fn(() => false), playVictoryMusic: vi.fn(), playDefeatMusic: vi.fn(), muted: false, }, })) vi.mock('../effects/ParticleSystem.ts', () => ({ ParticleSystem: vi.fn(() => ({ update: vi.fn(), clear: vi.fn(), })), })) vi.mock('../effects/PlacementEffect.ts', () => ({ PlacementEffect: vi.fn(() => ({ play: vi.fn() })), })) vi.mock('../effects/WinEffect.ts', () => ({ WinEffect: vi.fn(() => ({ play: vi.fn() })), })) beforeEach(() => { vi.useFakeTimers() }) afterEach(() => { vi.useRealTimers() }) function createEngine(): GameEngine { const engine = new GameEngine() engine.board = new BoardStore() engine.status = 'active' engine.localMode = true engine.currentPlayer = 0 engine.playerIndex = 0 engine.playerSymbols = ['X', 'O'] engine.players = ['P1', 'P2'] engine.manager = new GameManager('P1', 'P2', ['X', 'O']) engine.onStateChanged = vi.fn() engine.onError = vi.fn() engine['redraw'] = vi.fn() engine['playPlacementEffect'] = vi.fn() engine['winEffect'] = { play: vi.fn() } as any engine.audio = { play: vi.fn(), stopMusic: vi.fn(), startMusic: vi.fn(), playVictoryMusic: vi.fn(), playDefeatMusic: vi.fn(), } as any return engine } describe('GameEngine local mode', () => { describe('turn switching', () => { it('starts with player 0', () => { const engine = createEngine() expect(engine.currentPlayer).toBe(0) }) it('switches to player 1 after a non-winning move', () => { const engine = createEngine() engine['handleCellClick'](0, 0) expect(engine.currentPlayer).toBe(1) expect(engine.onStateChanged).toHaveBeenCalled() }) it('switches back to player 0 after second move', () => { const engine = createEngine() engine['handleCellClick'](0, 0) engine['handleCellClick'](1, 0) expect(engine.currentPlayer).toBe(0) }) it('sets currentPlayer to -1 during win check', () => { const engine = createEngine() engine.manager.addMove(0, 0, 0, 'X') engine.manager.addMove(1, 0, 0, 'X') engine.manager.addMove(2, 0, 0, 'X') engine.manager.addMove(3, 0, 0, 'X') engine['handleCellClick'](4, 0) vi.advanceTimersByTime(100) expect(engine.winnerPlayerId).toBe('0') }) it('does not switch turn when game is not active', () => { const engine = createEngine() engine.status = 'waiting' engine['handleCellClick'](0, 0) expect(engine.currentPlayer).toBe(0) }) }) describe('win detection', () => { it('detects a horizontal win and calls applyWin', () => { const engine = createEngine() engine.manager.addMove(0, 0, 0, 'X') engine.manager.addMove(1, 0, 0, 'X') engine.manager.addMove(2, 0, 0, 'X') engine.manager.addMove(3, 0, 0, 'X') engine['handleCellClick'](4, 0) vi.advanceTimersByTime(100) expect(engine.status).toBe('win') expect(engine.winnerPlayerId).toBe('0') }) it('detects a vertical win', () => { const engine = createEngine() engine.manager.addMove(0, 0, 1, 'O') engine.manager.addMove(0, 1, 1, 'O') engine.manager.addMove(0, 2, 1, 'O') engine.manager.addMove(0, 3, 1, 'O') engine.manager.setCurrentPlayer(1) engine['handleCellClick'](0, 4) vi.advanceTimersByTime(100) expect(engine.status).toBe('win') expect(engine.winnerPlayerId).toBe('1') }) it('detects a diagonal win (top-left to bottom-right)', () => { const engine = createEngine() engine.manager.addMove(0, 0, 0, 'X') engine.manager.addMove(1, 1, 0, 'X') engine.manager.addMove(2, 2, 0, 'X') engine.manager.addMove(3, 3, 0, 'X') engine['handleCellClick'](4, 4) vi.advanceTimersByTime(100) expect(engine.status).toBe('win') expect(engine.winnerPlayerId).toBe('0') }) it('detects a diagonal win (top-right to bottom-left)', () => { const engine = createEngine() engine.manager.addMove(4, 0, 0, 'X') engine.manager.addMove(3, 1, 0, 'X') engine.manager.addMove(2, 2, 0, 'X') engine.manager.addMove(1, 3, 0, 'X') engine['handleCellClick'](0, 4) vi.advanceTimersByTime(100) expect(engine.status).toBe('win') expect(engine.winnerPlayerId).toBe('0') }) it('does not trigger win for 4 in a row', () => { const engine = createEngine() engine.manager.addMove(0, 0, 0, 'X') engine.manager.addMove(1, 0, 0, 'X') engine.manager.addMove(2, 0, 0, 'X') engine['handleCellClick'](3, 0) expect(engine.status).toBe('active') expect(engine.currentPlayer).toBe(1) }) it('does not trigger win when blocked on both ends', () => { const engine = createEngine() engine.manager.addMove(0, 0, 1, 'O') engine.manager.addMove(1, 0, 0, 'X') engine.manager.addMove(2, 0, 0, 'X') engine.manager.addMove(3, 0, 0, 'X') engine.manager.addMove(4, 0, 0, 'X') engine.manager.addMove(5, 0, 0, 'X') engine.manager.setCurrentPlayer(1) engine['handleCellClick'](6, 0) vi.advanceTimersByTime(100) expect(engine.status).not.toBe('win') }) }) describe('draw detection', () => { it('detects a draw when the board is full', () => { const engine = createEngine() for (let x = 0; x < 15; x++) { for (let y = 0; y < 15; y++) { if (x === 14 && y === 14) break engine.manager.addMove(x, y, (x + y) % 2, (x + y) % 2 === 0 ? 'X' : 'O') } } engine.manager.setCurrentPlayer(1) engine['handleCellClick'](14, 14) vi.advanceTimersByTime(100) expect(engine.status).toBe('draw') }) it('does not trigger draw when board is not full', () => { const engine = createEngine() engine['handleCellClick'](0, 0) expect(engine.status).toBe('active') }) }) describe('cell placement', () => { it('places a cell on the board', () => { const engine = createEngine() engine['handleCellClick'](5, 5) const cell = engine.board.getCell(5, 5) expect(cell).toBeDefined() expect(cell!.playerId).toBe('0') }) it('does not place a cell on an occupied cell', () => { const engine = createEngine() engine.manager.addMove(5, 5, 0, 'X') engine['handleCellClick'](5, 5) expect(engine.board.getCell(5, 5)).toBeUndefined() expect(engine.currentPlayer).toBe(0) }) it('does not place a cell outside the board', () => { const engine = createEngine() engine['handleCellClick'](-1, 0) engine['handleCellClick'](0, -1) engine['handleCellClick'](15, 0) engine['handleCellClick'](0, 15) expect(engine.board.cells.size).toBe(0) expect(engine.manager?.getBoard().size).toBe(0) expect(engine.currentPlayer).toBe(0) }) it('does not place a cell on malformed coordinates', () => { const engine = createEngine() engine['handleCellClick'](1.5, 1) engine['handleCellClick'](Number.NaN, 1) expect(engine.board.cells.size).toBe(0) expect(engine.manager?.getBoard().size).toBe(0) expect(engine.currentPlayer).toBe(0) }) it('ignores out-of-bounds cells from remote updates', () => { const engine = createEngine() const cell = { playerId: '0', symbol: 'X', timestamp: 0 } engine.symbols = { clear: vi.fn() } as any engine.camera = { ensureCellsInView: vi.fn() } as any engine.addCell(15, 0, cell) engine.setCells({ '14:14': cell, '15:0': cell, '0:15': cell, '-1:0': cell, }) expect(engine.board.getCell(14, 14)).toEqual(cell) expect(engine.board.getCell(15, 0)).toBeUndefined() expect(engine.board.getCell(0, 15)).toBeUndefined() expect(engine.board.getCell(-1, 0)).toBeUndefined() }) it('uses correct symbol for each player', () => { const engine = createEngine() engine['handleCellClick'](0, 0) const cell0 = engine.board.getCell(0, 0) expect(cell0!.symbol).toBe('X') engine['handleCellClick'](1, 0) const cell1 = engine.board.getCell(1, 0) expect(cell1!.symbol).toBe('O') }) }) describe('swap2 rendering', () => { it('clears existing symbols before redrawing swapped owner icons', () => { const engine = createEngine() engine.manager = new GameManager('P1', 'P2', ['X', 'O'], { swap2: true, noOverlines: true, boardSize: 15 }) engine.symbols = { clear: vi.fn() } as any engine.manager.placeMove(7, 7) engine.manager.placeMove(8, 7) engine.manager.placeMove(9, 7) engine.manager.chooseSwap(true) engine.applySwapFromManager() expect(engine.symbols.clear).toHaveBeenCalled() expect(engine.board.getCell(7, 7)).toMatchObject({ playerId: '1', symbol: 'X' }) expect(engine.board.getCell(9, 7)).toMatchObject({ playerId: '0', symbol: 'O' }) }) }) })