feat(backend): add private-room theme-voting flow

This commit is contained in:
Gabriel Franco 2026-09-10 12:40:44 -03:00
parent 5190ec19a3
commit 74a6ab471f
2 changed files with 111 additions and 9 deletions

View file

@ -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);

View file

@ -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 });
});