import { describe, expect, test } from "bun:test" import type { IoServer } from "../socket" import { RoomManager, type ServerRoom } from "../rooms" import { GameEngine, type GameEngineOptions } from "./engine" import type { GameRound } from "./round" interface Emit { event: string payload: unknown } /** io stub : capture tous les emits, ignore le ciblage de room. */ function fakeIo(): { io: IoServer; emits: Emit[] } { const emits: Emit[] = [] const io = { to: () => ({ emit: (event: string, payload: unknown) => { emits.push({ event, payload }) }, }), } return { io: io as unknown as IoServer, emits } } /** Épreuve factice : +10 à qui choisit l'index correct (0). */ const dummyRound: GameRound = { type: "quiz", start: () => ({ payload: { prompt: "2+2 ?", choices: ["4", "5"] }, data: { correct: 0 }, }), submitAnswer: (ctx, playerId, answer) => { ctx.votes.set(playerId, answer) }, reveal: (ctx) => ({ truth: { correctIndex: (ctx.data as { correct: number }).correct }, perPlayerResult: {}, }), score: (ctx) => { const correct = (ctx.data as { correct: number }).correct const deltas: { playerId: string; delta: number }[] = [] for (const [playerId, answer] of ctx.votes) { if ((answer as { choiceIndex: number }).choiceIndex === correct) { deltas.push({ playerId, delta: 10 }) } } return deltas }, recap: () => ({ label: "2+2 ?", answer: "4", answers: [] }), botAnswer: () => ({ choiceIndex: 0 }), } function setup(roundDurationSec: number): { io: IoServer emits: Emit[] rooms: RoomManager room: ServerRoom p1: string p2: string options: GameEngineOptions } { const { io, emits } = fakeIo() const rooms = new RoomManager() const { room, player: a } = rooms.create("Alice", "sock-a") const { player: b } = rooms.join(room.code, "Bob", "sock-b") room.settings.roundDuration = roundDurationSec room.settings.rounds = [{ type: "quiz" }] const options: GameEngineOptions = { createRound: () => dummyRound, revealPauseMs: 0, leadMs: 0, } return { io, emits, rooms, room, p1: a.id, p2: b.id, options } } const eventsOf = (emits: Emit[]) => emits.map((e) => e.event) describe("GameEngine", () => { test("finit la manche dès que tous les éligibles ont voté (avant le timer)", async () => { const { io, emits, rooms, room, p1, p2, options } = setup(100) const engine = new GameEngine(io, rooms, room, options) const run = engine.run() // run() s'exécute jusqu'à round:start de façon synchrone, le runtime est prêt. expect(eventsOf(emits)).toContain("round:start") engine.handleVote(p1, { choiceIndex: 0 }) // juste engine.handleVote(p2, { choiceIndex: 1 }) // faux → 2/2 votes → fin anticipée await run const events = eventsOf(emits) expect(events).toContain("round:reveal") expect(events).toContain("score:update") expect(events).toContain("game:end") expect(room.status).toBe("ended") expect(room.scores.get(p1)).toBe(10) expect(room.scores.get(p2)).toBe(0) const end = emits.find((e) => e.event === "game:end")!.payload as { finalScores: { playerId: string; score: number }[] } expect(end.finalScores).toHaveLength(2) }) test("émet round:voteAck avec count/total à chaque vote", async () => { const { io, emits, rooms, room, p1, p2, options } = setup(100) const engine = new GameEngine(io, rooms, room, options) const run = engine.run() engine.handleVote(p1, { choiceIndex: 0 }) const ack = emits.find((e) => e.event === "round:voteAck")!.payload as { count: number total: number voted: string[] } expect(ack).toEqual({ count: 1, total: 2, voted: [p1] }) engine.handleVote(p2, { choiceIndex: 0 }) await run expect(room.scores.get(p1)).toBe(10) expect(room.scores.get(p2)).toBe(10) }) test("finit la manche au timer si tout le monde n'a pas voté", async () => { const { io, emits, rooms, room, p1, options } = setup(0.05) // 50 ms const engine = new GameEngine(io, rooms, room, options) const run = engine.run() engine.handleVote(p1, { choiceIndex: 0 }) // un seul vote → on attend le timer await run expect(eventsOf(emits)).toContain("game:end") expect(room.status).toBe("ended") expect(room.scores.get(p1)).toBe(10) }) })