- server-side bot players (isBot): host adds/removes them in the lobby
(lobby:addBot / lobby:removeBot), capped at 8 players
- bots auto-vote each round: GameRound.botAnswer generates a plausible answer
(quiz ~50% right, blindtest guesses a random player / title); engine schedules
the votes within the answer window and triggers early end when all have voted
- bots are never DJ (pickDj excludes them) and are excluded from the blindtest
track-submission gate (they don't submit)
- snapshot players carry `bot`; lobby shows a 🤖 marker + add/remove controls
Verified e2e: 2 bots added, they vote, game ends with all 3 players scored
and rounds end early.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
137 lines
4.3 KiB
TypeScript
137 lines
4.3 KiB
TypeScript
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)
|
|
})
|
|
})
|