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
|
// the same round-session engine (game/session.js) up to 5 times in a
|
||||||
// row, picking a random theme per match (excluding the previous
|
// row, picking a random theme per match (excluding the previous
|
||||||
// match's theme) unless the creator asked for `themeMode: 'chosen'`,
|
// match's theme) unless the creator asked for `themeMode: 'chosen'`,
|
||||||
// in which case real theme voting is ticket #7 — this ticket only
|
// in which case a 10-second theme vote (rooms/themeVote.js, see
|
||||||
// wires a narrow, synchronous stand-in (see resolveChosenTheme below).
|
// 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
|
// Rooms never import or touch matchmaking's ban store: quitting a
|
||||||
// private room carries no penalty, unlike quitting a matchmaking match.
|
// 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 { getPlayerId, getPlayerName, sendTo } from '../ws/registry.js';
|
||||||
import { createSession } from '../game/session.js';
|
import { createSession } from '../game/session.js';
|
||||||
import { pickRandomThemeId } from '../themeIds.js';
|
import { pickRandomThemeId } from '../themeIds.js';
|
||||||
|
import { startVote } from './themeVote.js';
|
||||||
|
|
||||||
const MAX_MATCHES = 5;
|
const MAX_MATCHES = 5;
|
||||||
const CODE_MIN = 100000;
|
const CODE_MIN = 100000;
|
||||||
|
|
@ -77,15 +80,24 @@ function closeRoom(room, reason, quitterId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `themeMode: 'chosen'` theme resolution is not implemented by this
|
* `themeMode: 'chosen'` theme resolution: runs the real theme vote
|
||||||
* ticket (#6) — real theme voting (`vote:start`/`vote:cast`/
|
* (rooms/themeVote.js) for match 1 only. PROTOCOL.md "Theme voting"
|
||||||
* `vote:settled`) is ticket #7. This is a narrow, synchronous stand-in
|
* says the voted theme is reused for the whole 5-match series, so once
|
||||||
* so #7 has an obvious integration point without this ticket blocking
|
* `room.votedThemeId` is set every later startMatch() call short-
|
||||||
* on it: swap this function's body for the real voting flow.
|
* circuits straight to it instead of voting again.
|
||||||
*/
|
*/
|
||||||
function resolveChosenTheme(room, cb) {
|
function resolveChosenTheme(room, cb) {
|
||||||
// TODO(#7): replace with real theme-voting flow (startVote)
|
if (room.votedThemeId !== null) {
|
||||||
cb(pickRandomThemeId());
|
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) {
|
function resolveTheme(room, cb) {
|
||||||
|
|
@ -160,6 +172,7 @@ export function registerRoomHandlers() {
|
||||||
matchesPlayed: 0,
|
matchesPlayed: 0,
|
||||||
previousThemeId: null,
|
previousThemeId: null,
|
||||||
currentThemeId: null,
|
currentThemeId: null,
|
||||||
|
votedThemeId: null,
|
||||||
session: null,
|
session: null,
|
||||||
};
|
};
|
||||||
rooms.set(code, room);
|
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