feat(backend): add private-room theme-voting flow
This commit is contained in:
parent
5190ec19a3
commit
74a6ab471f
2 changed files with 111 additions and 9 deletions
|
|
@ -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);
|
||||
|
|
|
|||
89
backend/src/rooms/themeVote.js
Normal file
89
backend/src/rooms/themeVote.js
Normal 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 });
|
||||
});
|
||||
Loading…
Reference in a new issue