diff --git a/backend/src/rooms/room.js b/backend/src/rooms/room.js index e9b3f9d..9776070 100644 --- a/backend/src/rooms/room.js +++ b/backend/src/rooms/room.js @@ -3,8 +3,10 @@ // the same round-session engine (game/session.js) up to 5 times in a // row, picking a random theme per match (excluding the previous // match's theme) unless the creator asked for `themeMode: 'chosen'`, -// in which case real theme voting is ticket #7 — this ticket only -// wires a narrow, synchronous stand-in (see resolveChosenTheme below). +// in which case a 10-second theme vote (rooms/themeVote.js, see +// PROTOCOL.md "Theme voting") settles match 1's theme once; every +// later match in the series reuses that same voted theme (see +// resolveChosenTheme below). // // Rooms never import or touch matchmaking's ban store: quitting a // private room carries no penalty, unlike quitting a matchmaking match. @@ -13,6 +15,7 @@ import { registerHandler, onDisconnect } from '../ws/connectionHandler.js'; import { getPlayerId, getPlayerName, sendTo } from '../ws/registry.js'; import { createSession } from '../game/session.js'; import { pickRandomThemeId } from '../themeIds.js'; +import { startVote } from './themeVote.js'; const MAX_MATCHES = 5; const CODE_MIN = 100000; @@ -77,15 +80,24 @@ function closeRoom(room, reason, quitterId) { } /** - * `themeMode: 'chosen'` theme resolution is not implemented by this - * ticket (#6) — real theme voting (`vote:start`/`vote:cast`/ - * `vote:settled`) is ticket #7. This is a narrow, synchronous stand-in - * so #7 has an obvious integration point without this ticket blocking - * on it: swap this function's body for the real voting flow. + * `themeMode: 'chosen'` theme resolution: runs the real theme vote + * (rooms/themeVote.js) for match 1 only. PROTOCOL.md "Theme voting" + * says the voted theme is reused for the whole 5-match series, so once + * `room.votedThemeId` is set every later startMatch() call short- + * circuits straight to it instead of voting again. */ function resolveChosenTheme(room, cb) { - // TODO(#7): replace with real theme-voting flow (startVote) - cb(pickRandomThemeId()); + if (room.votedThemeId !== null) { + cb(room.votedThemeId); + return; + } + startVote(room, (themeId) => { + // The room may have been torn down (e.g. a player disconnected) + // while the 10s vote was in flight; don't resurrect it. + if (!rooms.has(room.code)) return; + room.votedThemeId = themeId; + cb(themeId); + }); } function resolveTheme(room, cb) { @@ -160,6 +172,7 @@ export function registerRoomHandlers() { matchesPlayed: 0, previousThemeId: null, currentThemeId: null, + votedThemeId: null, session: null, }; rooms.set(code, room); diff --git a/backend/src/rooms/themeVote.js b/backend/src/rooms/themeVote.js new file mode 100644 index 0000000..ce15a41 --- /dev/null +++ b/backend/src/rooms/themeVote.js @@ -0,0 +1,89 @@ +// Theme voting for `themeMode: 'chosen'` private rooms (see +// PROTOCOL.md "Theme voting"): a single 10-second, server-timed vote +// between a room's two players, run once before match 1's +// `round:start` (room.js reuses the settled theme for matches 2-5). +// +// registerHandler() is global and type-keyed (one handler per message +// type for the whole process, not per-room/per-vote — see +// game/session.js's identical pattern for round:progress/round:submit), +// so `vote:cast` looks up which in-progress vote the sending playerId +// currently belongs to via this module's own playerId -> vote map, +// populated by startVote() and cleared once the vote settles. + +import { registerHandler } from '../ws/connectionHandler.js'; +import { getPlayerId, sendTo } from '../ws/registry.js'; +import { THEME_IDS, pickRandomThemeId } from '../themeIds.js'; + +const VOTE_DURATION_MS = 10_000; + +// playerId -> the in-progress vote both of a room's players belong to. +const voteByPlayerId = new Map(); + +function otherPlayer(vote, playerId) { + return vote.players[0] === playerId ? vote.players[1] : vote.players[0]; +} + +/** + * Resolves a settled vote's final theme per PROTOCOL.md "Theme voting" + * Resolution rule: both-agree wins outright; a differing pair splits + * uniformly at random; a lone cast vote wins by default; no casts at + * all falls back to a uniform random pick so voting can never stall. + */ +function resolveOutcome(pickA, pickB) { + if (pickA !== undefined && pickB !== undefined) { + if (pickA === pickB) return { themeId: pickA, agreed: true }; + return { themeId: Math.random() < 0.5 ? pickA : pickB, agreed: false }; + } + if (pickA !== undefined) return { themeId: pickA, agreed: false }; + if (pickB !== undefined) return { themeId: pickB, agreed: false }; + return { themeId: pickRandomThemeId(), agreed: false }; +} + +function settleVote(vote) { + if (vote.settled) return; + vote.settled = true; + for (const id of vote.players) voteByPlayerId.delete(id); + + const [playerA, playerB] = vote.players; + const { themeId, agreed } = resolveOutcome(vote.picks.get(playerA), vote.picks.get(playerB)); + + for (const id of vote.players) { + sendTo(id, { type: 'vote:settled', themeId, agreed }); + } + vote.onSettled(themeId); +} + +/** + * Starts a 10-second theme vote between `room.players` (exactly two + * playerIds). Calls `onSettled(themeId)` exactly once, at the 10s mark + * — never early, even if both players already agree (PROTOCOL.md + * "Theme voting": `vote:settled` only fires once `endsAt` elapses). + */ +export function startVote(room, onSettled) { + const endsAt = Date.now() + VOTE_DURATION_MS; + const vote = { + players: room.players, + picks: new Map(), + settled: false, + onSettled, + }; + for (const id of vote.players) voteByPlayerId.set(id, vote); + + for (const id of vote.players) { + sendTo(id, { type: 'vote:start', themeIds: THEME_IDS, endsAt }); + } + + setTimeout(() => settleVote(vote), Math.max(0, endsAt - Date.now())); +} + +registerHandler('vote:cast', (socket, message) => { + const playerId = getPlayerId(socket); + const vote = voteByPlayerId.get(playerId); + if (!vote || vote.settled) return; + + const { themeId } = message; + if (!THEME_IDS.includes(themeId)) return; + + vote.picks.set(playerId, themeId); + sendTo(otherPlayer(vote, playerId), { type: 'vote:opponentChoice', themeId }); +});