diff --git a/src/__tests__/GameStateManager.test.ts b/src/__tests__/GameStateManager.test.ts index 402ca69..4817019 100644 --- a/src/__tests__/GameStateManager.test.ts +++ b/src/__tests__/GameStateManager.test.ts @@ -1,7 +1,8 @@ // src/__tests__/GameStateManager.test.ts - Unit tests for GameStateManager class import { describe, test, expect, beforeEach, vi } from 'vitest'; -import { GameStateManager } from '../state/GameStateManager'; +import { GameStateManager, GameState } from '../state/GameStateManager'; import { TypedEventEmitter } from '../game/EventEmitter'; +import type { GameEvents } from '../types'; // Mock TypedEventEmitter for testing vi.mock('../game/EventEmitter', () => ({ @@ -9,64 +10,95 @@ vi.mock('../game/EventEmitter', () => ({ on = vi.fn(); emit = vi.fn(); off = vi.fn(); + removeAllListeners = vi.fn(); }, })); describe('GameStateManager', () => { - let mockEventEmitter: TypedEventEmitter; + let mockEventEmitter: TypedEventEmitter; let stateManager: GameStateManager; beforeEach(() => { // Create fresh mock event emitter for each test mockEventEmitter = new TypedEventEmitter() as any; stateManager = new GameStateManager(mockEventEmitter); + vi.clearAllMocks(); }); describe('initialization', () => { test('should initialize in IDLE state', () => { - // TODO: Implement test - expect(true).toBe(true); + expect(stateManager.getState()).toBe(GameState.IDLE); }); }); describe('state transitions', () => { test('should validate state transitions correctly', () => { - // TODO: Implement test - expect(true).toBe(true); + // Valid transition: IDLE → SELECTING + expect(stateManager.transitionTo(GameState.SELECTING)).toBe(true); + expect(stateManager.getState()).toBe(GameState.SELECTING); + + // Valid transition: SELECTING → MATCHING + expect(stateManager.transitionTo(GameState.MATCHING)).toBe(true); + expect(stateManager.getState()).toBe(GameState.MATCHING); }); test('should emit state change events on valid transition', () => { - // TODO: Implement test - expect(true).toBe(true); + stateManager.transitionTo(GameState.SELECTING); + + expect(mockEventEmitter.emit).toHaveBeenCalledWith('game:stateChange', { + from: GameState.IDLE, + to: GameState.SELECTING + }); }); test('should return false for invalid transitions', () => { - // TODO: Implement test - expect(true).toBe(true); + // Invalid: IDLE → GAME_OVER (must go through MATCHING first) + expect(stateManager.transitionTo(GameState.GAME_OVER)).toBe(false); + expect(stateManager.getState()).toBe(GameState.IDLE); + + // Invalid: SELECTING → IDLE is valid + expect(stateManager.transitionTo(GameState.SELECTING)).toBe(true); + expect(stateManager.transitionTo(GameState.IDLE)).toBe(true); }); }); describe('tile selection by state', () => { test('should allow tile selection in IDLE state', () => { - // TODO: Implement test - expect(true).toBe(true); + expect(stateManager.canSelectTile()).toBe(true); + }); + + test('should allow tile selection in SELECTING state', () => { + stateManager.transitionTo(GameState.SELECTING); + expect(stateManager.canSelectTile()).toBe(true); }); test('should block tile selection in MATCHING state', () => { - // TODO: Implement test - expect(true).toBe(true); + stateManager.transitionTo(GameState.SELECTING); + stateManager.transitionTo(GameState.MATCHING); + expect(stateManager.canSelectTile()).toBe(false); }); test('should block tile selection in GAME_OVER state', () => { - // TODO: Implement test - expect(true).toBe(true); + stateManager.transitionTo(GameState.SELECTING); + stateManager.transitionTo(GameState.MATCHING); + stateManager.transitionTo(GameState.GAME_OVER); + expect(stateManager.canSelectTile()).toBe(false); }); }); describe('game reset', () => { test('should reset from GAME_OVER to IDLE', () => { - // TODO: Implement test - expect(true).toBe(true); + stateManager.transitionTo(GameState.SELECTING); + stateManager.transitionTo(GameState.MATCHING); + stateManager.transitionTo(GameState.GAME_OVER); + + stateManager.reset(); + + expect(stateManager.getState()).toBe(GameState.IDLE); + expect(mockEventEmitter.emit).toHaveBeenCalledWith('game:stateChange', { + from: GameState.GAME_OVER, + to: GameState.IDLE + }); }); }); }); diff --git a/src/state/GameStateManager.ts b/src/state/GameStateManager.ts index 714e5eb..90ccb78 100644 --- a/src/state/GameStateManager.ts +++ b/src/state/GameStateManager.ts @@ -1,35 +1,98 @@ -// src/state/GameStateManager.ts - Stub implementation for TDD -// This will be implemented in Plan 04-01 - +// src/state/GameStateManager.ts - Game state machine with transition validation import { TypedEventEmitter } from '../game/EventEmitter'; +import type { GameEvents } from '../types'; +/** + * Represents a game state in the state machine + * String enum for better debugging and logging + */ export enum GameState { + /** Waiting for player input */ IDLE = 'IDLE', + /** One tile selected, waiting for second tile */ SELECTING = 'SELECTING', + /** Processing match, input blocked */ MATCHING = 'MATCHING', + /** Game ended (win or no moves) */ GAME_OVER = 'GAME_OVER', } +/** + * Manages game state transitions with validation and event emission + * Enforces valid state transitions and provides helper methods for state-based logic + */ export class GameStateManager { - private currentState: GameState = GameState.IDLE; + private currentState: GameState; + private readonly events: TypedEventEmitter; - constructor(private events: TypedEventEmitter) {} + /** + * Valid state transitions + * Maps each state to the list of states it can transition to + */ + private readonly validTransitions: Record = { + [GameState.IDLE]: [GameState.SELECTING], + [GameState.SELECTING]: [GameState.IDLE, GameState.MATCHING], + [GameState.MATCHING]: [GameState.IDLE, GameState.GAME_OVER], + [GameState.GAME_OVER]: [GameState.IDLE], // restart only + }; - getCurrentState(): GameState { + constructor(events: TypedEventEmitter) { + this.events = events; + this.currentState = GameState.IDLE; + } + + /** + * Transition to a new state if valid + * @param newState - The state to transition to + * @returns true if transition succeeded, false if invalid + */ + transitionTo(newState: GameState): boolean { + // Validate transition + const allowedStates = this.validTransitions[this.currentState]; + if (!allowedStates.includes(newState)) { + return false; + } + + // Perform transition + const previousState = this.currentState; + this.currentState = newState; + + // Emit event for other components to react + this.events.emit('game:stateChange', { + from: previousState, + to: newState + }); + + return true; + } + + /** + * Get the current game state + * @returns The current GameState + */ + getState(): GameState { return this.currentState; } + /** + * Check if tiles can be selected in the current state + * @returns true if selection is allowed, false otherwise + */ canSelectTile(): boolean { return this.currentState === GameState.IDLE || this.currentState === GameState.SELECTING; } - transitionTo(newState: GameState): boolean { - // TODO: Implement state transition validation - this.currentState = newState; - return true; - } - + /** + * Reset the state machine to IDLE (for restart functionality) + * Emits a state change event from current state to IDLE + */ reset(): void { + const previousState = this.currentState; this.currentState = GameState.IDLE; + + this.events.emit('game:stateChange', { + from: previousState, + to: GameState.IDLE + }); } }