nerdware/apps/server/src/game/engine.test.ts
AyoubBenziza 7dda00c65c feat: lead-in timer compensation + GIF support for round transitions
Server (option 2 — don't eat answer time):
- engine: leadMs prep delay (default 1600ms) before the clock starts; the
  round timer now fires at lead + duration, and scoring uses the post-lead
  start so the speed bonus spans the full answer window
- shared: round:start carries startsAt (answers begin) alongside endsAt
- engine test passes leadMs:0

Client:
- Countdown shows the full duration (no ticking) until startsAt, then counts
  down — so the WarioWare transition plays in full without stealing time
- store/ActiveRound carry startsAt

Custom transition media:
- RoundTransition auto-loads any gif/webp/apng/png/avif dropped in
  src/assets/transitions/ (import.meta.glob), plays one at random per round,
  falls back to the Framer animation when none are present
- README documenting how to add animations

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:25:17 +02:00

135 lines
4.2 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
},
}
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)
})
})