nerdware/apps/server/src/game/engine.ts
AyoubBenziza 03a0dfa998 feat: end-of-game round recap (all rounds with answers + scorers)
- shared: RoundRecap (+ scorers); game:end carries recap[]
- server: GameRound.recap(ctx) per mode (quiz: prompt + correct answer +
  image; blindtest: "title — artist" + addedBy + links); engine accumulates a
  history per round (with that round's scorers from the score deltas) and sends
  it at game:end
- client: GameEndView shows a collapsible "Récapitulatif des manches" — each
  round's label, answer, media thumbnail (image/youtube) and who scored
- engine test dummy round implements recap

Verified e2e: game:end recap lists every round with answer + scorers.

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

242 lines
7.9 KiB
TypeScript

// Moteur de jeu générique. Orchestre la séquence de manches d'une room :
// pour chaque manche start → (votes | timer) → reveal → score, puis game:end.
// Tout est tranché serveur ; les clients n'affichent que ce qui est broadcasté.
import type {
Answer,
MediaControlPayload,
PlayerScore,
RoundConfig,
RoundRecap,
} from "@nerdware/shared"
import type { IoServer } from "../socket"
import type { RoomManager, ServerRoom } from "../rooms"
import { createRound as defaultCreateRound, type RoundFactory } from "./registry"
import type { GameRound, RoomGameController, RoundContext } from "./round"
interface RoundRuntime {
round: GameRound
djId: string | null
/** Contributeur (blindtest) : reçoit round:start privé et ne vote pas. */
secretPlayerId: string | null
votes: Map<string, Answer>
startedAt: number
endsAt: number
data: unknown
}
export interface GameEngineOptions {
/** Injection de l'instanciation d'épreuve (tests). Défaut = registre global. */
createRound?: (type: RoundConfig["type"]) => GameRound
/** Pause d'affichage du reveal/scores entre deux manches (ms). */
revealPauseMs?: number
/** Délai de préparation avant le démarrage du chrono (transition WarioWare, ms). */
leadMs?: number
/** Permet de patcher l'horloge en test ; défaut = Date.now. */
now?: () => number
}
const DEFAULT_REVEAL_PAUSE_MS = 5000
// Le chrono ne démarre qu'après ce délai, le temps que la transition s'affiche.
const DEFAULT_LEAD_MS = 1600
export class GameEngine implements RoomGameController {
private runtime: RoundRuntime | null = null
private timer: ReturnType<typeof setTimeout> | null = null
private resolveRoundEnd: (() => void) | null = null
private readonly history: RoundRecap[] = []
private readonly createRound: (type: RoundConfig["type"]) => GameRound
private readonly revealPauseMs: number
private readonly leadMs: number
private readonly now: () => number
constructor(
private readonly io: IoServer,
private readonly rooms: RoomManager,
private readonly room: ServerRoom,
options: GameEngineOptions = {}
) {
this.createRound = options.createRound ?? defaultCreateRound
this.revealPauseMs = options.revealPauseMs ?? DEFAULT_REVEAL_PAUSE_MS
this.leadMs = options.leadMs ?? DEFAULT_LEAD_MS
this.now = options.now ?? Date.now
}
/** Joue toute la partie, manche par manche, puis émet game:end. */
async run(): Promise<void> {
const { rounds } = this.room.settings
for (let i = 0; i < rounds.length; i++) {
this.room.currentRound = i
await this.playRound(rounds[i])
if (i < rounds.length - 1) {
await this.sleep(this.revealPauseMs)
}
}
this.room.status = "ended"
this.broadcastState()
// Récap des titres blindtest joués (pour récupérer les liens YouTube).
const tracks = this.room.blindtestTracks.map((t) => ({
title: t.title,
artist: t.artist,
youtubeId: t.youtubeId,
url: t.url,
submittedByName: this.room.players.get(t.submittedBy)?.name ?? "?",
}))
this.io.to(this.room.code).emit("game:end", {
finalScores: this.scoreboard(),
tracks: tracks.length > 0 ? tracks : undefined,
recap: this.history.length > 0 ? this.history : undefined,
})
}
/** Vote d'un joueur pendant une manche. Délègue à l'épreuve, puis check fin anticipée. */
handleVote(playerId: string, answer: Answer): void {
if (!this.runtime || this.room.status !== "in_round") {
return
}
const ctx = this.context(this.runtime)
this.runtime.round.submitAnswer(ctx, playerId, answer)
const total = this.eligibleVoters().length
this.io.to(this.room.code).emit("round:voteAck", {
count: this.runtime.votes.size,
total,
voted: [...this.runtime.votes.keys()],
})
// Fin anticipée : tous les éligibles ont voté.
if (total > 0 && this.runtime.votes.size >= total) {
this.endRound()
}
}
/** Relais média : seul le DJ de la manche peut piloter la lecture. */
handleMediaControl(playerId: string, payload: MediaControlPayload): void {
if (
!this.runtime ||
this.room.status !== "in_round" ||
playerId !== this.runtime.djId
) {
return
}
this.io.to(this.room.code).emit("media:sync", {
action: payload.action,
positionSec: payload.positionSec,
atServerTs: this.now(),
})
}
private async playRound(config: RoundConfig): Promise<void> {
const round = this.createRound(config.type)
const start = round.start(this.room)
const durationSec = start.durationSec ?? this.room.settings.roundDuration
const now = this.now()
// Les réponses ne comptent qu'après le délai de préparation (transition).
const startedAt = now + this.leadMs
const endsAt = startedAt + durationSec * 1000
this.runtime = {
round,
djId: start.djId ?? null,
secretPlayerId: start.secretPlayerId ?? null,
votes: new Map(),
startedAt,
endsAt,
data: start.data,
}
this.room.status = "in_round"
this.broadcastState()
const base = {
type: round.type,
djId: this.runtime.djId ?? undefined,
startsAt: startedAt,
endsAt,
payload: start.payload,
}
// Le contributeur reçoit un round:start privé (mine: true) ; il reste secret
// pour les autres (qui ne reçoivent que `base`).
const secretSocketId = this.runtime.secretPlayerId
? this.room.players.get(this.runtime.secretPlayerId)?.socketId
: null
if (secretSocketId) {
this.io.to(secretSocketId).emit("round:start", { ...base, mine: true })
this.io.to(this.room.code).except(secretSocketId).emit("round:start", base)
} else {
this.io.to(this.room.code).emit("round:start", base)
}
// Attend la première condition de fin : timer écoulé OU tous votés.
await new Promise<void>((resolve) => {
this.resolveRoundEnd = resolve
this.timer = setTimeout(() => this.endRound(), endsAt - now)
})
const ctx = this.context(this.runtime)
this.room.status = "reveal"
this.broadcastState()
this.io.to(this.room.code).emit("round:reveal", round.reveal(ctx))
const deltas = round.score(ctx)
for (const { playerId, delta } of deltas) {
this.room.scores.set(playerId, (this.room.scores.get(playerId) ?? 0) + delta)
}
this.history.push({
index: this.room.currentRound,
type: round.type,
...round.recap(ctx),
scorers: deltas.map((d) => ({ playerId: d.playerId, points: d.delta })),
})
this.room.status = "scores"
this.io.to(this.room.code).emit("score:update", { scores: this.scoreboard() })
this.broadcastState()
this.runtime = null
}
/** Termine la manche en cours (idempotent : timer et "tous votés" peuvent se croiser). */
private endRound(): void {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
const resolve = this.resolveRoundEnd
this.resolveRoundEnd = null
resolve?.()
}
private context(runtime: RoundRuntime): RoundContext {
return {
room: this.room,
djId: runtime.djId,
votes: runtime.votes,
startedAt: runtime.startedAt,
endsAt: runtime.endsAt,
data: runtime.data,
}
}
/**
* Votants éligibles : joueurs connectés (DJ neutre inclus), hors contributeur
* (blindtest) qui n'a rien à saisir.
*/
private eligibleVoters(): string[] {
const secret = this.runtime?.secretPlayerId
return [...this.room.players.values()]
.filter((p) => p.connected && p.name !== "" && p.id !== secret)
.map((p) => p.id)
}
private scoreboard(): PlayerScore[] {
return [...this.room.scores.entries()].map(([playerId, score]) => ({
playerId,
score,
}))
}
private broadcastState(): void {
this.io.to(this.room.code).emit("room:state", this.rooms.toSnapshot(this.room))
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}
export type { RoundFactory }