nerdware/apps/server/src/game/engine.ts
AyoubBenziza 8eac8d0123 feat: bots — virtual players to fill a game
- 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>
2026-06-11 17:48:33 +02:00

314 lines
10 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 { saveGameHistory } from "../db/history-repo"
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
/** Payload public de la manche (pour la reconnexion). */
payload: 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,
})
// Historique (fire-and-forget, DB optionnelle).
const modes = [...new Set(this.room.settings.rounds.map((r) => r.type))]
const results = this.scoreboard()
.map((s) => ({
name: this.room.players.get(s.playerId)?.name ?? "?",
score: s.score,
}))
.sort((a, b) => b.score - a.score)
void saveGameHistory(this.room.code, modes, results).catch((err) =>
console.error("[history] save failed", err)
)
}
/** 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(),
})
}
/** Reconnexion : re-pousse la manche en cours au socket d'un joueur. */
resync(socketId: string, playerId: string): void {
if (!this.runtime || this.room.status !== "in_round") {
return
}
const r = this.runtime
this.io.to(socketId).emit("round:start", {
type: r.round.type,
djId: r.djId === playerId ? r.djId : undefined,
mine: r.secretPlayerId === playerId ? true : undefined,
startsAt: r.startedAt,
endsAt: r.endsAt,
payload: r.payload,
})
}
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,
payload: start.payload,
}
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)
}
this.scheduleBotVotes()
// 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
}
/** Programme le vote automatique des bots (jamais DJ ni contributeur). */
private scheduleBotVotes(): void {
if (!this.runtime) {
return
}
const { djId, secretPlayerId, endsAt, startedAt } = this.runtime
const window = Math.max(0, endsAt - startedAt)
const bots = [...this.room.players.values()].filter(
(p) => p.isBot && p.id !== djId && p.id !== secretPlayerId
)
for (const bot of bots) {
// Vote après l'ouverture des réponses, réparti dans la première moitié.
const delay = this.leadMs + 700 + Math.random() * Math.min(window * 0.5, 3500)
setTimeout(() => this.botVote(bot.id), delay)
}
}
/** Exécute le vote d'un bot (no-op si la manche est finie ou s'il a déjà voté). */
private botVote(botId: string): void {
if (!this.runtime || this.room.status !== "in_round") {
return
}
if (this.runtime.votes.has(botId)) {
return
}
const ctx = this.context(this.runtime)
this.runtime.round.submitAnswer(ctx, botId, this.runtime.round.botAnswer(ctx, botId))
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()],
})
if (total > 0 && this.runtime.votes.size >= total) {
this.endRound()
}
}
/** 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 }