- 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>
242 lines
7.9 KiB
TypeScript
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 }
|