feat(server): generic game engine — GameRound interface + orchestration loop
- game/round.ts: GameRound contract (start/submitAnswer/reveal/score) + RoundContext + RoomGameController (decouples room from engine) - game/registry.ts: register/createRound — adding a mode = one registerRound - game/engine.ts: server-authoritative orchestration. Per round: start → broadcast round:start → wait (timer OR all eligible voted) → reveal → apply score deltas → score:update; then game:end. DJ counts as eligible voter; round ends on first condition met. - handlers: game:start (host-only, validates rounds/modes) + round:vote wired to the engine - shared: add game:start client event - engine.test.ts: covers both end conditions + voteAck (bun test) - turbo/root: add test task Roadmap V1 step 3. No UI yet — concrete quiz mode + client come in step 4. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7dca1a5da7
commit
9ed3b8a4cb
10 changed files with 437 additions and 1 deletions
|
|
@ -6,6 +6,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun --watch src/index.ts",
|
"dev": "bun --watch src/index.ts",
|
||||||
"start": "bun src/index.ts",
|
"start": "bun src/index.ts",
|
||||||
|
"test": "bun test",
|
||||||
"build": "bun build ./src/index.ts --target bun --outdir dist",
|
"build": "bun build ./src/index.ts --target bun --outdir dist",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"format": "prettier --write \"**/*.ts\"",
|
"format": "prettier --write \"**/*.ts\"",
|
||||||
|
|
|
||||||
133
apps/server/src/game/engine.test.ts
Normal file
133
apps/server/src/game/engine.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
expect(ack).toEqual({ count: 1, total: 2 })
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
172
apps/server/src/game/engine.ts
Normal file
172
apps/server/src/game/engine.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
// 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, PlayerScore, RoundConfig } 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
|
||||||
|
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
|
||||||
|
/** Permet de patcher l'horloge en test ; défaut = Date.now. */
|
||||||
|
now?: () => number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_REVEAL_PAUSE_MS = 5000
|
||||||
|
|
||||||
|
export class GameEngine implements RoomGameController {
|
||||||
|
private runtime: RoundRuntime | null = null
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
private resolveRoundEnd: (() => void) | null = null
|
||||||
|
private readonly createRound: (type: RoundConfig["type"]) => GameRound
|
||||||
|
private readonly revealPauseMs: 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.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()
|
||||||
|
this.io.to(this.room.code).emit("game:end", { finalScores: this.scoreboard() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
})
|
||||||
|
// Fin anticipée : tous les éligibles ont voté.
|
||||||
|
if (total > 0 && this.runtime.votes.size >= total) {
|
||||||
|
this.endRound()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 startedAt = this.now()
|
||||||
|
const endsAt = startedAt + durationSec * 1000
|
||||||
|
|
||||||
|
this.runtime = {
|
||||||
|
round,
|
||||||
|
djId: start.djId ?? null,
|
||||||
|
votes: new Map(),
|
||||||
|
startedAt,
|
||||||
|
endsAt,
|
||||||
|
data: start.data,
|
||||||
|
}
|
||||||
|
this.room.status = "in_round"
|
||||||
|
this.broadcastState()
|
||||||
|
this.io.to(this.room.code).emit("round:start", {
|
||||||
|
type: round.type,
|
||||||
|
djId: this.runtime.djId ?? undefined,
|
||||||
|
endsAt,
|
||||||
|
payload: start.payload,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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(), durationSec * 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
const ctx = this.context(this.runtime)
|
||||||
|
this.room.status = "reveal"
|
||||||
|
this.broadcastState()
|
||||||
|
this.io.to(this.room.code).emit("round:reveal", round.reveal(ctx))
|
||||||
|
|
||||||
|
for (const { playerId, delta } of round.score(ctx)) {
|
||||||
|
this.room.scores.set(playerId, (this.room.scores.get(playerId) ?? 0) + 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 : tous les joueurs connectés (le DJ inclus). */
|
||||||
|
private eligibleVoters(): string[] {
|
||||||
|
return [...this.room.players.values()]
|
||||||
|
.filter((p) => p.connected)
|
||||||
|
.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 }
|
||||||
25
apps/server/src/game/registry.ts
Normal file
25
apps/server/src/game/registry.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
// Registre des épreuves. Chaque mode s'enregistre via registerRound() ;
|
||||||
|
// le moteur instancie une manche fraîche par round via createRound().
|
||||||
|
|
||||||
|
import type { RoundType } from "@nerdware/shared"
|
||||||
|
import type { GameRound } from "./round"
|
||||||
|
|
||||||
|
export type RoundFactory = () => GameRound
|
||||||
|
|
||||||
|
const factories = new Map<RoundType, RoundFactory>()
|
||||||
|
|
||||||
|
export function registerRound(type: RoundType, factory: RoundFactory): void {
|
||||||
|
factories.set(type, factory)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRound(type: RoundType): GameRound {
|
||||||
|
const factory = factories.get(type)
|
||||||
|
if (!factory) {
|
||||||
|
throw new Error(`Aucune épreuve enregistrée pour le type "${type}"`)
|
||||||
|
}
|
||||||
|
return factory()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasRound(type: RoundType): boolean {
|
||||||
|
return factories.has(type)
|
||||||
|
}
|
||||||
55
apps/server/src/game/round.ts
Normal file
55
apps/server/src/game/round.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
// Contrat générique d'une épreuve. Le moteur ne connaît QUE cette interface :
|
||||||
|
// brancher une nouvelle épreuve = écrire un module, pas retoucher la plomberie.
|
||||||
|
|
||||||
|
import type { Answer, RevealPayload, RoundType, ScoreDelta } from "@nerdware/shared"
|
||||||
|
import type { ServerRoom } from "../rooms"
|
||||||
|
|
||||||
|
/** Ce que `start()` renvoie : la partie publique + les données privées de la manche. */
|
||||||
|
export interface RoundStart {
|
||||||
|
/** Joueur DJ (blindtest) ; null pour les épreuves sans DJ (quiz). */
|
||||||
|
djId?: string | null
|
||||||
|
/** Durée de la manche en secondes ; défaut = room.settings.roundDuration. */
|
||||||
|
durationSec?: number
|
||||||
|
/** Payload broadcasté aux clients, SANS la réponse. */
|
||||||
|
payload: unknown
|
||||||
|
/** Données privées (ex: la bonne réponse), conservées jusqu'au reveal. Jamais diffusées. */
|
||||||
|
data?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Contexte runtime d'une manche, fourni aux callbacks de l'épreuve. */
|
||||||
|
export interface RoundContext {
|
||||||
|
room: ServerRoom
|
||||||
|
djId: string | null
|
||||||
|
/** Votes reçus : playerId → réponse. Muté par submitAnswer. */
|
||||||
|
votes: Map<string, Answer>
|
||||||
|
startedAt: number
|
||||||
|
endsAt: number
|
||||||
|
/** Données privées renvoyées par start(). */
|
||||||
|
data: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une épreuve. Implémente le cycle start → submitAnswer → reveal → score.
|
||||||
|
* Le serveur est autoritaire : start/reveal/score ne s'exécutent que côté serveur.
|
||||||
|
*/
|
||||||
|
export interface GameRound {
|
||||||
|
type: RoundType
|
||||||
|
|
||||||
|
/** Prépare la manche (tire le DJ, choisit le contenu) et renvoie le payload public. */
|
||||||
|
start(room: ServerRoom): RoundStart
|
||||||
|
|
||||||
|
/** Enregistre/valide le vote d'un joueur dans ctx.votes. Doit être idempotent. */
|
||||||
|
submitAnswer(ctx: RoundContext, playerId: string, answer: Answer): void
|
||||||
|
|
||||||
|
/** Calcule la vérité + le détail révélé à tous. */
|
||||||
|
reveal(ctx: RoundContext): RevealPayload
|
||||||
|
|
||||||
|
/** Renvoie les variations de score à appliquer (le moteur les applique). */
|
||||||
|
score(ctx: RoundContext): ScoreDelta[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vue minimale du moteur exposée à la room (évite un cycle d'import). */
|
||||||
|
export interface RoomGameController {
|
||||||
|
run(): Promise<void>
|
||||||
|
handleVote(playerId: string, answer: Answer): void
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,8 @@ import type {
|
||||||
} from "@nerdware/shared"
|
} from "@nerdware/shared"
|
||||||
import type { IoServer } from "./socket"
|
import type { IoServer } from "./socket"
|
||||||
import { RoomError, RoomManager, type ServerRoom } from "./rooms"
|
import { RoomError, RoomManager, type ServerRoom } from "./rooms"
|
||||||
|
import { GameEngine } from "./game/engine"
|
||||||
|
import { hasRound } from "./game/registry"
|
||||||
|
|
||||||
type AppSocket = Socket<
|
type AppSocket = Socket<
|
||||||
ClientToServerEvents,
|
ClientToServerEvents,
|
||||||
|
|
@ -95,6 +97,45 @@ export function registerRoomHandlers(
|
||||||
broadcastState(room)
|
broadcastState(room)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
socket.on("game:start", (ack) => {
|
||||||
|
const code = socket.data.roomCode
|
||||||
|
const room = code ? rooms.get(code) : undefined
|
||||||
|
if (!room || room.hostId !== socket.data.playerId) {
|
||||||
|
ack({ ok: false, error: { code: "NOT_HOST", message: "Seul l'hôte peut lancer." } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (room.status !== "lobby") {
|
||||||
|
ack({ ok: false, error: { code: "ALREADY_STARTED", message: "Partie déjà lancée." } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (room.settings.rounds.length === 0) {
|
||||||
|
ack({ ok: false, error: { code: "NO_ROUNDS", message: "Aucune épreuve configurée." } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const unknown = room.settings.rounds.find((r) => !hasRound(r.type))
|
||||||
|
if (unknown) {
|
||||||
|
ack({
|
||||||
|
ok: false,
|
||||||
|
error: { code: "UNKNOWN_MODE", message: `Épreuve indisponible : ${unknown.type}.` },
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ack({ ok: true, data: null })
|
||||||
|
const engine = new GameEngine(io, rooms, room)
|
||||||
|
room.game = engine
|
||||||
|
// Fire-and-forget : la boucle de jeu broadcast son propre état.
|
||||||
|
void engine.run().catch((err) => console.error("[game] run failed", err))
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on("round:vote", (payload) => {
|
||||||
|
const code = socket.data.roomCode
|
||||||
|
const room = code ? rooms.get(code) : undefined
|
||||||
|
if (!room || !room.game || !socket.data.playerId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
room.game.handleVote(socket.data.playerId, payload.answer)
|
||||||
|
})
|
||||||
|
|
||||||
socket.on("disconnect", () => {
|
socket.on("disconnect", () => {
|
||||||
const affected = rooms.handleDisconnect(socket.id)
|
const affected = rooms.handleDisconnect(socket.id)
|
||||||
if (!affected) {
|
if (!affected) {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
type RoomSnapshot,
|
type RoomSnapshot,
|
||||||
type RoomStatus,
|
type RoomStatus,
|
||||||
} from "@nerdware/shared"
|
} from "@nerdware/shared"
|
||||||
|
import type { RoomGameController } from "./game/round"
|
||||||
|
|
||||||
/** Joueur côté serveur : porte le socketId, jamais exposé au client. */
|
/** Joueur côté serveur : porte le socketId, jamais exposé au client. */
|
||||||
export interface ServerPlayer {
|
export interface ServerPlayer {
|
||||||
|
|
@ -26,6 +27,8 @@ export interface ServerRoom {
|
||||||
settings: RoomSettings
|
settings: RoomSettings
|
||||||
scores: Map<string, number>
|
scores: Map<string, number>
|
||||||
currentRound: number
|
currentRound: number
|
||||||
|
/** Moteur de jeu actif (null tant qu'on est dans le lobby). */
|
||||||
|
game: RoomGameController | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Codes lisibles, sans caractères ambigus (0/O, 1/I/L). */
|
/** Codes lisibles, sans caractères ambigus (0/O, 1/I/L). */
|
||||||
|
|
@ -47,6 +50,7 @@ export class RoomManager {
|
||||||
settings: { ...DEFAULT_ROOM_SETTINGS },
|
settings: { ...DEFAULT_ROOM_SETTINGS },
|
||||||
scores: new Map([[player.id, 0]]),
|
scores: new Map([[player.id, 0]]),
|
||||||
currentRound: -1,
|
currentRound: -1,
|
||||||
|
game: null,
|
||||||
}
|
}
|
||||||
this.rooms.set(code, room)
|
this.rooms.set(code, room)
|
||||||
return { room, player }
|
return { room, player }
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
"dev": "turbo dev",
|
"dev": "turbo dev",
|
||||||
"lint": "turbo lint",
|
"lint": "turbo lint",
|
||||||
"format": "turbo format",
|
"format": "turbo format",
|
||||||
"typecheck": "turbo typecheck"
|
"typecheck": "turbo typecheck",
|
||||||
|
"test": "turbo test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ export interface ClientToServerEvents {
|
||||||
"room:create": (payload: RoomCreatePayload, ack: Ack<RoomCreatedPayload>) => void
|
"room:create": (payload: RoomCreatePayload, ack: Ack<RoomCreatedPayload>) => void
|
||||||
"room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
"room:join": (payload: RoomJoinPayload, ack: Ack<RoomCreatedPayload>) => void
|
||||||
"lobby:updateSettings": (payload: UpdateSettingsPayload) => void
|
"lobby:updateSettings": (payload: UpdateSettingsPayload) => void
|
||||||
|
"game:start": (ack: Ack<null>) => void
|
||||||
"blindtest:submitTrack": (payload: SubmitTrackPayload, ack: Ack<SubmitOkPayload>) => void
|
"blindtest:submitTrack": (payload: SubmitTrackPayload, ack: Ack<SubmitOkPayload>) => void
|
||||||
"media:control": (payload: MediaControlPayload) => void
|
"media:control": (payload: MediaControlPayload) => void
|
||||||
"round:vote": (payload: VotePayload) => void
|
"round:vote": (payload: VotePayload) => void
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@
|
||||||
"typecheck": {
|
"typecheck": {
|
||||||
"dependsOn": ["^typecheck"]
|
"dependsOn": ["^typecheck"]
|
||||||
},
|
},
|
||||||
|
"test": {
|
||||||
|
"dependsOn": ["^build"]
|
||||||
|
},
|
||||||
"dev": {
|
"dev": {
|
||||||
"cache": false,
|
"cache": false,
|
||||||
"persistent": true
|
"persistent": true
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue