Function bodies 456 total
getEventsForPhase function · javascript · L1117-L1121 (5 LOC)server/definitions/events.js
export function getEventsForPhase(phase) {
return Object.values(events)
.filter((e) => e.phase.includes(phase) && !e.isInterrupt)
.sort((a, b) => a.priority - b.priority);
}getAllEvents function · javascript · L1123-L1125 (3 LOC)server/definitions/events.js
export function getAllEvents() {
return { ...events };
}getItem function · javascript · L116-L118 (3 LOC)server/definitions/items.js
export function getItem(itemId) {
return items[itemId] || null;
}getAllItems function · javascript · L123-L125 (3 LOC)server/definitions/items.js
export function getAllItems() {
return Object.values(items);
}buildRolePool function · javascript · L446-L453 (8 LOC)server/definitions/roles.js
export function buildRolePool(playerCount) {
const composition = GAME_COMPOSITION[playerCount];
if (!composition)
throw new Error(`No composition for ${playerCount} players`);
const pool = [...composition];
while (pool.length < playerCount) pool.push(RoleId.NOBODY);
return pool;
}getRole function · javascript · L455-L457 (3 LOC)server/definitions/roles.js
export function getRole(roleId) {
return roles[roleId] || null;
}getAllRoles function · javascript · L459-L461 (3 LOC)server/definitions/roles.js
export function getAllRoles() {
return { ...roles };
}Repobility (the analyzer behind this table) · https://repobility.com
GovernorPardonFlow class · javascript · L41-L324 (284 LOC)server/flows/GovernorPardonFlow.js
export class GovernorPardonFlow extends InterruptFlow {
static get id() {
return 'pardon';
}
static get hooks() {
return ['onVoteResolution'];
}
/**
* Check if this flow should trigger
* @param {Object} context - { voteEventId, resolution, instance }
* @returns {boolean}
*/
canTrigger(context) {
const { resolution } = context;
// Only trigger on elimination
if (resolution?.outcome !== 'eliminated' || !resolution.victim) {
return false;
}
// Check for judges or gavel holders (excluding cowards — they lose all actions)
const judges = this.game.getAlivePlayers().filter((p) => {
if (p.hasItem(ItemId.COWARD)) return false;
return (
p.role.id === RoleId.JUDGE ||
(p.hasItem(ItemId.GAVEL) && p.canUseItem(ItemId.GAVEL))
);
});
return judges.length > 0;
}
/**
* Initialize the Judge pardon flow
* @param {Object} context - { voteEventId, resolution, instance }
* @returns {ObjecanTrigger method · javascript · L55-L73 (19 LOC)server/flows/GovernorPardonFlow.js
canTrigger(context) {
const { resolution } = context;
// Only trigger on elimination
if (resolution?.outcome !== 'eliminated' || !resolution.victim) {
return false;
}
// Check for judges or gavel holders (excluding cowards — they lose all actions)
const judges = this.game.getAlivePlayers().filter((p) => {
if (p.hasItem(ItemId.COWARD)) return false;
return (
p.role.id === RoleId.JUDGE ||
(p.hasItem(ItemId.GAVEL) && p.canUseItem(ItemId.GAVEL))
);
});
return judges.length > 0;
}trigger method · javascript · L80-L134 (55 LOC)server/flows/GovernorPardonFlow.js
trigger(context) {
const { voteEventId, resolution, instance } = context;
const condemned = resolution.victim;
const judges = this.game.getAlivePlayers().filter((p) => {
if (p.hasItem(ItemId.COWARD)) return false;
return (
p.role.id === RoleId.JUDGE ||
(p.hasItem(ItemId.GAVEL) && p.canUseItem(ItemId.GAVEL))
);
});
this.phase = 'active';
this.state = {
condemnedId: condemned.id,
condemnedName: condemned.name,
voteEventId,
voteResolution: resolution,
voteInstance: instance,
judgeIds: judges.map((g) => g.id),
};
// Remove vote from judges' pending events
for (const judge of judges) {
judge.pendingEvents.delete(voteEventId);
}
// Create the pardon event
this.game._startFlowEvent(this.id, {
name: str('events', 'judgePardon.name'),
description: str('events', 'judgePardon.description'),
verb: 'pardon',
participants: this.state.judgeIds,
getParticipants method · javascript · L140-L142 (3 LOC)server/flows/GovernorPardonFlow.js
getParticipants() {
return this.state?.judgeIds || [];
}getValidTargets method · javascript · L149-L158 (10 LOC)server/flows/GovernorPardonFlow.js
getValidTargets(playerId) {
if (!this.state) return [];
if (!this.state.judgeIds.includes(playerId)) return [];
// Cannot pardon yourself
if (this.state.condemnedId === playerId) return [];
const condemned = this.game.getPlayer(this.state.condemnedId);
return condemned ? [condemned] : [];
}onSelection method · javascript · L167-L194 (28 LOC)server/flows/GovernorPardonFlow.js
onSelection(judgeId, targetId) {
if (!this.state || !this.state.judgeIds.includes(judgeId)) {
return null;
}
const judge = this.game.getPlayer(judgeId);
const condemned = this.game.getPlayer(this.state.condemnedId);
if (!judge || !condemned) {
return { error: 'Invalid state' };
}
// Check gavel usage before resolve (canUseItem may change after state changes)
const usesGavel = judge.hasItem(ItemId.GAVEL) && judge.canUseItem(ItemId.GAVEL);
// Pardon = selected the condemned player
const result = targetId === this.state.condemnedId
? this.resolvePardon(judge, condemned)
: this.resolveExecution(judge, condemned);
// Add gavel consumption to the result
if (usesGavel) {
if (!result.consumeItems) result.consumeItems = [];
result.consumeItems.push({ playerId: judgeId, itemId: ItemId.GAVEL });
}
return result;
}resolvePardon method · javascript · L203-L228 (26 LOC)server/flows/GovernorPardonFlow.js
resolvePardon(judge, condemned) {
this.phase = 'resolving';
const message = str('log', 'pardoned', { judge: judge.getNameWithEmoji(), victim: condemned.getNameWithEmoji() });
this.cleanup();
return {
success: true,
pardoned: true,
message,
slides: [{
slide: {
type: 'death',
playerId: condemned.id,
title: str('slides', 'flow.pardonedTitle'),
subtitle: str('slides', 'flow.pardonedSubtitle', { name: condemned.name }),
revealRole: false,
style: SlideStyle.POSITIVE,
},
jumpTo: true,
isDeath: false,
}],
log: message,
};
}resolveExecution method · javascript · L237-L281 (45 LOC)server/flows/GovernorPardonFlow.js
resolveExecution(judge, condemned) {
this.phase = 'resolving';
const condemnedId = this.state.condemnedId;
const voteResolution = this.state.voteResolution;
const voteInstance = this.state.voteInstance;
const message = str('log', 'notPardoned', { judge: judge.getNameWithEmoji(), victim: condemned.getNameWithEmoji() });
// Build slides array: "NO PARDON" title, then execution death slide
const slides = [
{
slide: {
type: 'title',
title: str('slides', 'flow.noPardonTitle'),
subtitle: str('slides', 'flow.noPardonSubtitle', { name: condemned.name }),
style: SlideStyle.HOSTILE,
},
jumpTo: false,
isDeath: false,
},
];
if (voteResolution?.slide) {
const voterIds = Object.entries(voteInstance?.results || {})
.filter(([, targetId]) => targetId === condemnedId)
.map(([voterId]) => voterId);
slides.push({
slide: { ...voteResolution.slide,Source: Repobility analyzer · https://repobility.com
onPlayerDisconnect method · javascript · L289-L303 (15 LOC)server/flows/GovernorPardonFlow.js
onPlayerDisconnect(player) {
if (!this.state || !this.state.judgeIds.includes(player.id)) return null;
// Only auto-execute if every judge has fully disconnected
const anyStillConnected = this.state.judgeIds.some(gid => {
if (gid === player.id) return false; // this one just disconnected
const g = this.game.getPlayer(gid);
return g && g.connections.length > 0;
});
if (anyStillConnected) return null;
const condemned = this.game.getPlayer(this.state.condemnedId);
const judge = player; // use disconnecting player as the "decider" for logging
if (!condemned) { this.cleanup(); return null; }
this.game.addLog(str('log', 'judgeDisconnected', { name: player.getNameWithEmoji() }));
return this.resolveExecution(judge, condemned);
}cleanup method · javascript · L308-L323 (16 LOC)server/flows/GovernorPardonFlow.js
cleanup() {
// Clear pending events for judges
if (this.state?.judgeIds) {
for (const gid of this.state.judgeIds) {
const judge = this.game.getPlayer(gid);
if (judge) {
judge.clearFromEvent(this.id);
}
}
}
// Clean up legacy interruptData (for backwards compatibility)
this.game.interruptData = null;
super.cleanup();
}HunterRevengeFlow class · javascript · L38-L239 (202 LOC)server/flows/HunterRevengeFlow.js
export class HunterRevengeFlow extends InterruptFlow {
static get id() {
return 'hunterRevenge';
}
static get hooks() {
return ['onDeath'];
}
/**
* Check if this flow should trigger
* @param {Object} context - { player, cause, deathResult }
* @returns {boolean}
*/
canTrigger(context) {
const { player, deathResult } = context;
return (
deathResult?.interrupt === true && player.role?.id === RoleId.HUNTER
);
}
/**
* Initialize the Hunter revenge flow
* @param {Object} context - { player, cause, deathResult }
* @returns {Object} - { interrupt: true, flowId: 'hunterRevenge' }
*/
trigger(context) {
const { player } = context;
this.phase = 'active';
this.state = {
hunterId: player.id,
hunterName: player.name,
triggeredInPhase: this.game.phase,
// Store pending slide to be pushed AFTER the death slide
// (killPlayer triggers this flow before the death slide is pushed)
pendingcanTrigger method · javascript · L52-L57 (6 LOC)server/flows/HunterRevengeFlow.js
canTrigger(context) {
const { player, deathResult } = context;
return (
deathResult?.interrupt === true && player.role?.id === RoleId.HUNTER
);
}trigger method · javascript · L64-L97 (34 LOC)server/flows/HunterRevengeFlow.js
trigger(context) {
const { player } = context;
this.phase = 'active';
this.state = {
hunterId: player.id,
hunterName: player.name,
triggeredInPhase: this.game.phase,
// Store pending slide to be pushed AFTER the death slide
// (killPlayer triggers this flow before the death slide is pushed)
pendingSlide: {
type: 'title',
title: str('slides', 'flow.hunterTitle'),
subtitle: str('slides', 'flow.hunterSubtitle', { name: player.name }),
playerId: player.id,
style: SlideStyle.HOSTILE,
},
};
// Create the event using Game's flow event helper
this.game._startFlowEvent(this.id, {
name: str('events', 'hunterRevenge.name'),
description: str('events', 'hunterRevenge.description'),
verb: 'shoot',
participants: [player.id],
getValidTargets: (actorId) => this.getValidTargets(actorId),
allowAbstain: false,
playerResolved: false, // Host resolves, or augetPendingSlide method · javascript · L104-L111 (8 LOC)server/flows/HunterRevengeFlow.js
getPendingSlide() {
if (this.state?.pendingSlide) {
const slide = this.state.pendingSlide;
this.state.pendingSlide = null;
return slide;
}
return null;
}getParticipants method · javascript · L117-L119 (3 LOC)server/flows/HunterRevengeFlow.js
getParticipants() {
return this.state ? [this.state.hunterId] : [];
}getValidTargets method · javascript · L126-L131 (6 LOC)server/flows/HunterRevengeFlow.js
getValidTargets(playerId) {
if (!this.state || playerId !== this.state.hunterId) return [];
return this.game
.getAlivePlayers()
.filter((p) => p.id !== this.state.hunterId && !p.hasItem('coward'));
}Repobility · code-quality intelligence · https://repobility.com
onSelection method · javascript · L139-L155 (17 LOC)server/flows/HunterRevengeFlow.js
onSelection(playerId, targetId) {
if (!this.state || playerId !== this.state.hunterId) return null;
// Hunter cannot abstain
if (targetId === null) {
return { error: 'Hunter must choose a target' };
}
// Validate target
const validTargets = this.getValidTargets(playerId);
if (!validTargets.find((t) => t.id === targetId)) {
return { error: 'Invalid target' };
}
// Auto-resolve when hunter makes selection
return this.resolve(targetId);
}resolve method · javascript · L163-L205 (43 LOC)server/flows/HunterRevengeFlow.js
resolve(targetId) {
const hunter = this.game.getPlayer(this.state.hunterId);
const victim = this.game.getPlayer(targetId);
if (!victim) {
return { success: false, error: 'Invalid target' };
}
this.phase = 'resolving';
const teamNames = {
circle: str('slides', 'death.teamCircle'),
cell: str('slides', 'death.teamCell'),
neutral: str('slides', 'death.teamNeutral'),
};
const teamName = victim.role?.id === 'jester'
? str('slides', 'death.teamJester')
: (teamNames[victim.role?.team] || str('slides', 'death.teamUnknown'));
const message = str('log', 'hunterRevengeKill', { hunter: hunter.getNameWithEmoji(), victim: victim.getNameWithEmoji() });
// Cleanup before returning (frees flow for potential nested hunter revenge)
this.cleanup();
return {
success: true,
victim,
message,
kills: [{ playerId: victim.id, cause: 'hunter' }],
slides: [{
slide: {
type: 'deatonPlayerDisconnect method · javascript · L213-L224 (12 LOC)server/flows/HunterRevengeFlow.js
onPlayerDisconnect(player) {
if (!this.state || player.id !== this.state.hunterId) return null;
const targets = this.getValidTargets(player.id);
if (targets.length === 0) {
// No valid targets — skip the revenge quietly.
this.cleanup();
return { success: true, kills: [], slides: [], log: str('log', 'hunterDisconnected', { name: player.getNameWithEmoji() }) };
}
const target = targets[Math.floor(Math.random() * targets.length)];
this.game.addLog(str('log', 'hunterAutoResolved', { name: player.getNameWithEmoji() }));
return this.resolve(target.id);
}cleanup method · javascript · L229-L238 (10 LOC)server/flows/HunterRevengeFlow.js
cleanup() {
// Clear pending events BEFORE super.cleanup() which nulls state
if (this.state?.hunterId) {
const hunter = this.game.getPlayer(this.state.hunterId);
if (hunter) {
hunter.clearFromEvent(this.id);
}
}
super.cleanup();
}InterruptFlow class · javascript · L51-L174 (124 LOC)server/flows/InterruptFlow.js
export class InterruptFlow {
constructor(game) {
this.game = game;
this.state = null;
this.phase = 'idle';
}
/**
* Unique identifier for this flow (used as event ID)
* @returns {string}
*/
static get id() {
throw new Error('Subclass must implement static id getter');
}
/**
* Game hooks this flow responds to (e.g., 'onDeath', 'onVoteResolution')
* @returns {string[]}
*/
static get hooks() {
return [];
}
/**
* Instance accessor for the flow ID
* @returns {string}
*/
get id() {
return this.constructor.id;
}
/**
* Check if this flow should trigger given the context
* @param {Object} context - Trigger context (varies by flow type)
* @returns {boolean}
*/
canTrigger(context) {
return false;
}
/**
* Initialize and start the flow
* @param {Object} context - Trigger context
* @returns {Object} Result with { interrupt: boolean, flowId: string }
*/
trigger(context) {
throw new Econstructor method · javascript · L52-L56 (5 LOC)server/flows/InterruptFlow.js
constructor(game) {
this.game = game;
this.state = null;
this.phase = 'idle';
}canTrigger method · javascript · L87-L89 (3 LOC)server/flows/InterruptFlow.js
canTrigger(context) {
return false;
}trigger method · javascript · L96-L98 (3 LOC)server/flows/InterruptFlow.js
trigger(context) {
throw new Error('Subclass must implement trigger()');
}Repobility · open methodology · https://repobility.com/research/
onSelection method · javascript · L106-L108 (3 LOC)server/flows/InterruptFlow.js
onSelection(playerId, targetId) {
return null;
}resolve method · javascript · L115-L117 (3 LOC)server/flows/InterruptFlow.js
resolve(...args) {
throw new Error('Subclass must implement resolve()');
}cleanup method · javascript · L122-L129 (8 LOC)server/flows/InterruptFlow.js
cleanup() {
this.state = null;
this.phase = 'idle';
// Remove from activeEvents if still present
if (this.game.activeEvents.has(this.id)) {
this.game.activeEvents.delete(this.id);
}
}getParticipants method · javascript · L135-L137 (3 LOC)server/flows/InterruptFlow.js
getParticipants() {
return [];
}getValidTargets method · javascript · L144-L146 (3 LOC)server/flows/InterruptFlow.js
getValidTargets(playerId) {
return [];
}getPendingSlide method · javascript · L153-L155 (3 LOC)server/flows/InterruptFlow.js
getPendingSlide() {
return null;
}isActive method · javascript · L161-L163 (3 LOC)server/flows/InterruptFlow.js
isActive() {
return this.phase === 'active' || this.phase === 'resolving';
}onPlayerDisconnect method · javascript · L171-L173 (3 LOC)server/flows/InterruptFlow.js
onPlayerDisconnect(player) {
return null;
}Repobility (the analyzer behind this table) · https://repobility.com
constructor method · javascript · L41-L79 (39 LOC)server/Game.js
constructor(broadcast, sendToHostFn, sendToScreenFn) {
this.broadcast = broadcast; // Function to send to all clients
this._sendToHost = sendToHostFn; // Function to find & send to host from clients set
this._sendToScreen = sendToScreenFn; // Function to find & send to screen from clients set
this.host = null; // Legacy reference (kept for handler compat)
this.slideIdCounter = 0; // Unique ID counter for slides
this.screen = null; // Legacy reference (kept for handler compat)
this.playerCustomizations = new Map(); // Persist player names/portraits across resets
// Initialize interrupt flows (these persist across resets)
this.flows = new Map([
[HunterRevengeFlow.id, new HunterRevengeFlow(this)],
[GovernorPardonFlow.id, new GovernorPardonFlow(this)],
]);
// Death processing queue (prevents recursive killPlayer cascades)
this._deathQueue = [];
this._processingDeaths = false;
// Debounced broadcast for rapid dial inpureset method · javascript · L81-L177 (97 LOC)server/Game.js
reset() {
// Save player customizations before clearing (if players exist)
if (this.players) {
for (const [playerId, player] of this.players) {
this.playerCustomizations.set(playerId, {
name: player.name,
portrait: player.portrait,
preAssignedRole: player.preAssignedRole || null,
});
}
}
// Clear any running event timers
if (this.eventTimers) {
for (const { timeout } of this.eventTimers.values()) {
clearTimeout(timeout);
}
}
this.eventTimers = new Map();
resetSeatCounter();
this.players = new Map(); // id -> Player
// NOTE: We keep host and screen connections alive during reset
// Only clear them if explicitly needed (not during game reset)
this.phase = GamePhase.LOBBY;
this.dayCount = 0;
// Event management
this.pendingEvents = []; // Events that can be started this phase
this.activeEvents = new Map(); // eventId -> { event, results, partiaddPlayer method · javascript · L181-L216 (36 LOC)server/Game.js
addPlayer(id, ws) {
if (this.players.size >= MAX_PLAYERS) {
return { success: false, error: 'Game is full' };
}
if (this.phase !== GamePhase.LOBBY) {
return { success: false, error: 'Game already in progress' };
}
const player = new Player(id, ws);
// Restore customizations if they exist from previous games
const customization = this.playerCustomizations.get(id);
if (customization) {
player.name = customization.name;
player.portrait = customization.portrait;
if (this.phase === GamePhase.LOBBY && customization.preAssignedRole) {
player.preAssignedRole = customization.preAssignedRole;
}
}
this.players.set(id, player);
// Ensure player has a default calibration entry
const calConfig = this._hostSettings.heartbeatCalibration || {};
if (!calConfig[id]) {
calConfig[id] = { restingBpm: 60, elevatedBpm: 100, enabled: false };
this._hostSettings.heartbeatCalibration = calConfig;
persistPlayerCustomization method · javascript · L218-L226 (9 LOC)server/Game.js
persistPlayerCustomization(player) {
const customization = this.playerCustomizations.get(player.id) || {};
this.playerCustomizations.set(player.id, {
...customization,
name: player.name,
portrait: player.portrait,
preAssignedRole: player.preAssignedRole || customization.preAssignedRole || null,
});
}_loadHostSettingsFromDisk method · javascript · L230-L252 (23 LOC)server/Game.js
_loadHostSettingsFromDisk() {
const defaults = {
timerDuration: 30,
autoAdvanceEnabled: false,
heartbeatThreshold: 110,
scoringConfig: { survived: 1, winningTeam: 1, bestInvestigator: 2 },
heartbeatCalibration: {},
heartbeatDisplayResting: 65,
heartbeatDisplayElevated: 110,
simsCanLose: false,
heartbeatAddNoise: false,
};
if (!fs.existsSync(HOST_SETTINGS_PATH)) {
this._hostSettings = defaults;
return;
}
try {
this._hostSettings = { ...defaults, ...JSON.parse(fs.readFileSync(HOST_SETTINGS_PATH, 'utf-8')) };
} catch (e) {
console.error('[Server] Failed to load host settings:', e.message);
this._hostSettings = defaults;
}
}getHostSettings method · javascript · L254-L256 (3 LOC)server/Game.js
getHostSettings() {
return { ...this._hostSettings };
}saveHostSettings method · javascript · L258-L262 (5 LOC)server/Game.js
saveHostSettings(settings) {
this._hostSettings = { ...this._hostSettings, ...settings };
fs.writeFileSync(HOST_SETTINGS_PATH, JSON.stringify(this._hostSettings, null, 2));
this.sendToHost(ServerMsg.HOST_SETTINGS, this.getHostSettings());
}setDefaultPreset method · javascript · L264-L268 (5 LOC)server/Game.js
setDefaultPreset(id) {
// Toggle off if already the default
const defaultPresetId = this._hostSettings.defaultPresetId === id ? null : id;
this.saveHostSettings({ defaultPresetId });
}Source: Repobility analyzer · https://repobility.com
_loadScoresFromDisk method · javascript · L272-L284 (13 LOC)server/Game.js
_loadScoresFromDisk() {
if (!fs.existsSync(SCORES_PATH)) {
this._scores = new Map();
return;
}
try {
const data = JSON.parse(fs.readFileSync(SCORES_PATH, 'utf-8'));
this._scores = new Map(Object.entries(data).map(([k, v]) => [k, Number(v) || 0]));
} catch (e) {
console.error('[Server] Failed to load scores:', e.message);
this._scores = new Map();
}
}_saveScoresToDisk method · javascript · L286-L289 (4 LOC)server/Game.js
_saveScoresToDisk() {
const obj = Object.fromEntries(this._scores);
fs.writeFileSync(SCORES_PATH, JSON.stringify(obj, null, 2));
}setScore method · javascript · L291-L295 (5 LOC)server/Game.js
setScore(name, score) {
this._scores.set(name, score);
this._saveScoresToDisk();
this.sendScoresToHost();
}