- 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>
314 lines
10 KiB
TypeScript
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 }
|