feat(backend): add match/round session engine
This commit is contained in:
parent
5162f090cb
commit
fea76e94cc
2 changed files with 204 additions and 0 deletions
|
|
@ -1 +1,2 @@
|
||||||
PORT=8080
|
PORT=8080
|
||||||
|
SNIPPET_CATALOG_LENGTH=6
|
||||||
|
|
|
||||||
203
backend/src/game/session.js
Normal file
203
backend/src/game/session.js
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
// Server-authoritative round session engine, shared by matchmaking and
|
||||||
|
// private-room matches (see PROTOCOL.md "Round lifecycle (shared by
|
||||||
|
// matchmaking and rooms)"). A session owns exactly one round: picking a
|
||||||
|
// snippet, running the countdown, relaying opponent progress, firing the
|
||||||
|
// one-time 20s warning, and delivering a synchronized reveal. It knows
|
||||||
|
// nothing about matchmaking bans, ban tables, or private-room match-count
|
||||||
|
// limits — that all lives upstream, in the (not-yet-written)
|
||||||
|
// backend/src/matchmaking/ and backend/src/rooms/ modules, which call
|
||||||
|
// createSession() and react to onEnd().
|
||||||
|
|
||||||
|
import { getPlayerId, sendTo } from '../ws/registry.js';
|
||||||
|
import { registerHandler, onDisconnect } from '../ws/connectionHandler.js';
|
||||||
|
|
||||||
|
// PROTOCOL.md "Snippet identity contract": the server never hardcodes
|
||||||
|
// snippet content or count, only the frontend catalog's length, so it can
|
||||||
|
// pick a valid random snippetIndex. Env-configurable, default 6.
|
||||||
|
export const SNIPPET_CATALOG_LENGTH = Number(process.env.SNIPPET_CATALOG_LENGTH) || 6;
|
||||||
|
|
||||||
|
// PROTOCOL.md "round:timeWarning": fired exactly once, 20s before endsAt.
|
||||||
|
const WARNING_OFFSET_MS = 20_000;
|
||||||
|
// Short grace window after endsAt for a straggling round:submit before the
|
||||||
|
// server reveals with whatever it has (empty color map for a client that
|
||||||
|
// never submitted).
|
||||||
|
const SUBMIT_GRACE_MS = 3_000;
|
||||||
|
|
||||||
|
const HEX_COLOR_PATTERN = /^#?[0-9a-fA-F]{3,8}$/;
|
||||||
|
|
||||||
|
// registerHandler() is global and type-keyed (one handler per message
|
||||||
|
// type for the whole process), not per-session, so round:progress,
|
||||||
|
// round:submit, and player:quit all look up which active session the
|
||||||
|
// sending playerId currently belongs to via this map, updated on
|
||||||
|
// createSession() and cleared when a session ends.
|
||||||
|
const sessionsByPlayerId = new Map();
|
||||||
|
|
||||||
|
function isPlainCategoryId(value) {
|
||||||
|
// A CategoryId is a short identifier string (e.g. "keyword",
|
||||||
|
// "background"); reject anything empty, non-string, or shaped like a
|
||||||
|
// hex color so a misbehaving/malicious client can't smuggle a color
|
||||||
|
// value through round:progress.
|
||||||
|
return typeof value === 'string' && value.length > 0 && !HEX_COLOR_PATTERN.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function otherPlayer(session, playerId) {
|
||||||
|
return session.playerA === playerId ? session.playerB : session.playerA;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionTimers(session) {
|
||||||
|
clearTimeout(session.warningTimer);
|
||||||
|
clearTimeout(session.endTimer);
|
||||||
|
clearTimeout(session.graceTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function endSession(session) {
|
||||||
|
if (session.ended) return;
|
||||||
|
session.ended = true;
|
||||||
|
clearSessionTimers(session);
|
||||||
|
sessionsByPlayerId.delete(session.playerA);
|
||||||
|
sessionsByPlayerId.delete(session.playerB);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealSession(session) {
|
||||||
|
if (session.ended) return;
|
||||||
|
clearTimeout(session.graceTimer);
|
||||||
|
session.graceTimer = null;
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
[session.playerA]: session.submissions.get(session.playerA) ?? {},
|
||||||
|
[session.playerB]: session.submissions.get(session.playerB) ?? {},
|
||||||
|
};
|
||||||
|
const payload = { type: 'round:reveal', colors };
|
||||||
|
sendTo(session.playerA, payload);
|
||||||
|
sendTo(session.playerB, payload);
|
||||||
|
|
||||||
|
endSession(session);
|
||||||
|
session.onEnd?.('reveal', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryReveal(session) {
|
||||||
|
if (!session.roundEnded || session.ended) return;
|
||||||
|
const bothSubmitted = session.submissions.has(session.playerA) && session.submissions.has(session.playerB);
|
||||||
|
if (bothSubmitted) {
|
||||||
|
revealSession(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!session.graceTimer) {
|
||||||
|
session.graceTimer = setTimeout(() => revealSession(session), SUBMIT_GRACE_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function quitSession(session, quitterId) {
|
||||||
|
if (session.ended) return;
|
||||||
|
const remainingId = otherPlayer(session, quitterId);
|
||||||
|
// PROTOCOL.md "Quit": opponent gets room:playerLeft then room:closed
|
||||||
|
// (reason: "quit").
|
||||||
|
sendTo(remainingId, { type: 'room:playerLeft' });
|
||||||
|
sendTo(remainingId, { type: 'room:closed', reason: 'quit' });
|
||||||
|
endSession(session);
|
||||||
|
session.onEnd?.('quit', quitterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts one round session between two already-identified players.
|
||||||
|
* `timeMode` is 1 or 2 (minutes, per PROTOCOL.md); `themeId` and
|
||||||
|
* `snippetIndex` are decided upstream (or `snippetIndex` is picked
|
||||||
|
* randomly here if omitted). Sends round:start to both sockets
|
||||||
|
* immediately and returns the session object.
|
||||||
|
*/
|
||||||
|
export function createSession({ playerA, playerB, timeMode, themeId, snippetIndex, onEnd }) {
|
||||||
|
if (typeof playerA !== 'string' || playerA.length === 0) {
|
||||||
|
throw new Error('createSession requires a string playerA');
|
||||||
|
}
|
||||||
|
if (typeof playerB !== 'string' || playerB.length === 0) {
|
||||||
|
throw new Error('createSession requires a string playerB');
|
||||||
|
}
|
||||||
|
if (playerA === playerB) {
|
||||||
|
throw new Error('createSession requires two distinct players');
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedSnippetIndex = Number.isInteger(snippetIndex)
|
||||||
|
? snippetIndex
|
||||||
|
: Math.floor(Math.random() * SNIPPET_CATALOG_LENGTH);
|
||||||
|
|
||||||
|
const endsAt = Date.now() + timeMode * 60_000;
|
||||||
|
|
||||||
|
const session = {
|
||||||
|
playerA,
|
||||||
|
playerB,
|
||||||
|
timeMode,
|
||||||
|
themeId,
|
||||||
|
snippetIndex: resolvedSnippetIndex,
|
||||||
|
endsAt,
|
||||||
|
onEnd,
|
||||||
|
submissions: new Map(),
|
||||||
|
ended: false,
|
||||||
|
roundEnded: false,
|
||||||
|
warningTimer: null,
|
||||||
|
endTimer: null,
|
||||||
|
graceTimer: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
sessionsByPlayerId.set(playerA, session);
|
||||||
|
sessionsByPlayerId.set(playerB, session);
|
||||||
|
|
||||||
|
const startPayload = {
|
||||||
|
type: 'round:start',
|
||||||
|
snippetIndex: resolvedSnippetIndex,
|
||||||
|
themeId,
|
||||||
|
timeMode,
|
||||||
|
endsAt,
|
||||||
|
};
|
||||||
|
sendTo(playerA, startPayload);
|
||||||
|
sendTo(playerB, startPayload);
|
||||||
|
|
||||||
|
session.warningTimer = setTimeout(() => {
|
||||||
|
sendTo(playerA, { type: 'round:timeWarning' });
|
||||||
|
sendTo(playerB, { type: 'round:timeWarning' });
|
||||||
|
}, Math.max(0, endsAt - WARNING_OFFSET_MS - Date.now()));
|
||||||
|
|
||||||
|
session.endTimer = setTimeout(() => {
|
||||||
|
session.roundEnded = true;
|
||||||
|
tryReveal(session);
|
||||||
|
}, Math.max(0, endsAt - Date.now()));
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerHandler('round:progress', (socket, message) => {
|
||||||
|
const playerId = getPlayerId(socket);
|
||||||
|
const session = sessionsByPlayerId.get(playerId);
|
||||||
|
if (!session || session.ended) return;
|
||||||
|
|
||||||
|
const { categoryId } = message;
|
||||||
|
// Payload must carry only a category id — never a color value.
|
||||||
|
if (!isPlainCategoryId(categoryId) || 'color' in message || 'colors' in message) return;
|
||||||
|
|
||||||
|
sendTo(otherPlayer(session, playerId), { type: 'round:progress', categoryId });
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHandler('round:submit', (socket, message) => {
|
||||||
|
const playerId = getPlayerId(socket);
|
||||||
|
const session = sessionsByPlayerId.get(playerId);
|
||||||
|
if (!session || session.ended) return;
|
||||||
|
|
||||||
|
const colors =
|
||||||
|
message.colors && typeof message.colors === 'object' && !Array.isArray(message.colors) ? message.colors : {};
|
||||||
|
session.submissions.set(playerId, colors);
|
||||||
|
tryReveal(session);
|
||||||
|
});
|
||||||
|
|
||||||
|
registerHandler('player:quit', (socket) => {
|
||||||
|
const playerId = getPlayerId(socket);
|
||||||
|
const session = sessionsByPlayerId.get(playerId);
|
||||||
|
if (!session) return;
|
||||||
|
quitSession(session, playerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A dropped socket mid-round is handled identically to an explicit
|
||||||
|
// player:quit.
|
||||||
|
onDisconnect((playerId) => {
|
||||||
|
const session = sessionsByPlayerId.get(playerId);
|
||||||
|
if (!session) return;
|
||||||
|
quitSession(session, playerId);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue