From b557942124c5a48b0a932c2abc7385c0ea421d9b Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 7 Apr 2026 21:01:22 +0700 Subject: [PATCH] feat: add shared game state manager with EventEmitter --- superpowers/src/game/state.ts | 122 +++++++++++++++++++++++++++ superpowers/tests/game/state.test.ts | 108 ++++++++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 superpowers/src/game/state.ts create mode 100644 superpowers/tests/game/state.test.ts diff --git a/superpowers/src/game/state.ts b/superpowers/src/game/state.ts new file mode 100644 index 0000000..86b6742 --- /dev/null +++ b/superpowers/src/game/state.ts @@ -0,0 +1,122 @@ +import { Difficulty, GameStatus } from "../types"; +import { DIFFICULTY_CONFIGS } from "./constants"; + +type Listener = () => void; + +interface GameState { + status: GameStatus; + difficulty: Difficulty | null; + score: number; + timerSeconds: number; + hintsRemaining: number; + shufflesRemaining: number; + combo: number; + lastMatchTime: number; +} + +export class GameStateManager { + private state: GameState; + private listeners: Map = new Map(); + + constructor() { + this.state = { + status: "menu", + difficulty: null, + score: 0, + timerSeconds: 0, + hintsRemaining: 0, + shufflesRemaining: 0, + combo: 1, + lastMatchTime: 0, + }; + } + + getState(): Readonly { + return { ...this.state }; + } + + on(event: string, listener: Listener): void { + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event)!.push(listener); + } + + off(event: string, listener: Listener): void { + const listeners = this.listeners.get(event); + if (listeners) { + const idx = listeners.indexOf(listener); + if (idx !== -1) listeners.splice(idx, 1); + } + } + + emit(event: string): void { + const listeners = this.listeners.get(event); + if (listeners) { + for (const l of listeners) l(); + } + } + + startGame(difficulty: Difficulty): void { + const config = DIFFICULTY_CONFIGS[difficulty]; + this.state = { + status: "playing", + difficulty, + score: 0, + timerSeconds: config.timerSeconds, + hintsRemaining: config.hints, + shufflesRemaining: config.shuffles, + combo: 1, + lastMatchTime: Date.now(), + }; + this.emit("stateChange"); + } + + addScore(points: number): void { + this.state.score += points; + this.emit("stateChange"); + } + + useHint(): boolean { + if (this.state.hintsRemaining <= 0) return false; + this.state.hintsRemaining--; + this.emit("stateChange"); + return true; + } + + useShuffle(): boolean { + if (this.state.shufflesRemaining <= 0) return false; + this.state.shufflesRemaining--; + this.emit("stateChange"); + return true; + } + + tick(): void { + if (this.state.status !== "playing") return; + this.state.timerSeconds--; + if (this.state.timerSeconds <= 0) { + this.state.timerSeconds = 0; + this.state.status = "lost"; + } + this.emit("stateChange"); + } + + incrementCombo(): void { + this.state.combo++; + this.emit("stateChange"); + } + + resetCombo(): void { + this.state.combo = 1; + this.emit("stateChange"); + } + + setStatus(status: GameStatus): void { + this.state.status = status; + this.emit("stateChange"); + } + + setLastMatchTime(time: number): void { + this.state.lastMatchTime = time; + } +} diff --git a/superpowers/tests/game/state.test.ts b/superpowers/tests/game/state.test.ts new file mode 100644 index 0000000..a5796f9 --- /dev/null +++ b/superpowers/tests/game/state.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { GameStateManager } from "../../src/game/state"; + +describe("GameStateManager", () => { + let state: GameStateManager; + + beforeEach(() => { + state = new GameStateManager(); + }); + + it("initializes with menu status", () => { + expect(state.getState().status).toBe("menu"); + }); + + it("startGame sets state for given difficulty", () => { + state.startGame("easy"); + const s = state.getState(); + expect(s.status).toBe("playing"); + expect(s.score).toBe(0); + expect(s.hintsRemaining).toBe(5); + expect(s.shufflesRemaining).toBe(3); + expect(s.timerSeconds).toBe(300); + expect(s.difficulty).toBe("easy"); + expect(s.combo).toBe(1); + }); + + it("addScore increases score", () => { + state.startGame("easy"); + state.addScore(150); + expect(state.getState().score).toBe(150); + state.addScore(100); + expect(state.getState().score).toBe(250); + }); + + it("useHint decrements hints", () => { + state.startGame("easy"); + expect(state.useHint()).toBe(true); + expect(state.getState().hintsRemaining).toBe(4); + }); + + it("useHint returns false when no hints left", () => { + state.startGame("hard"); + expect(state.useHint()).toBe(true); + expect(state.useHint()).toBe(false); + expect(state.getState().hintsRemaining).toBe(0); + }); + + it("useShuffle decrements shuffles", () => { + state.startGame("easy"); + expect(state.useShuffle()).toBe(true); + expect(state.getState().shufflesRemaining).toBe(2); + }); + + it("useShuffle returns false when no shuffles left", () => { + state.startGame("hard"); + expect(state.useShuffle()).toBe(true); + expect(state.useShuffle()).toBe(false); + }); + + it("tick decrements timer", () => { + state.startGame("easy"); + state.tick(); + expect(state.getState().timerSeconds).toBe(299); + }); + + it("tick sets status to lost when timer reaches 0", () => { + state.startGame("easy"); + for (let i = 0; i < 300; i++) { + state.tick(); + } + expect(state.getState().status).toBe("lost"); + expect(state.getState().timerSeconds).toBe(0); + }); + + it("emits events on state change", () => { + const listener = vi.fn(); + state.on("stateChange", listener); + state.startGame("easy"); + expect(listener).toHaveBeenCalled(); + }); + + it("incrementCombo and resetCombo work", () => { + state.startGame("easy"); + state.incrementCombo(); + expect(state.getState().combo).toBe(2); + state.incrementCombo(); + expect(state.getState().combo).toBe(3); + state.resetCombo(); + expect(state.getState().combo).toBe(1); + }); + + it("setStatus changes status and emits", () => { + state.startGame("easy"); + const listener = vi.fn(); + state.on("stateChange", listener); + state.setStatus("won"); + expect(state.getState().status).toBe("won"); + expect(listener).toHaveBeenCalled(); + }); + + it("pause and resume toggle status", () => { + state.startGame("easy"); + state.setStatus("paused"); + expect(state.getState().status).toBe("paused"); + state.setStatus("playing"); + expect(state.getState().status).toBe("playing"); + }); +});