Bring games 9-18 in line with GAMES-NEW.md: - Library sorts easiest first, filters by category and difficulty; difficulty 5 shows a Nightmare chip, and a no-hint finish earns Nightmare Survivors. - Time limits end on "Out of time" (no rank); Deep Lab Nereid's countdown is a rising water gauge behind the header. - Evidence Locker search, chapter filter and starred clues (partner sees a count). - One-shot accusations ask "One shot. Are you both sure?" first. - Symbol font fallback, tight monospace blocks for grids/keypads/SQL. - Rank rounds reorder by drag or up/down buttons; reveal highlights #1 and #4. - Telepathy capped at 30 characters; maze:move accepts forward/left/right/back; gate buttons read "Gate (won't open from this side)". - Overtime solver tables checked against Appendix A; keypad generator re-rolls. - More content checks (rank items, password fixtures, glyphs/gates on traps) and sanitize tests for all ten new games. - GAMES.md carries the full spoiler guide for 9-18. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
86 lines
2.9 KiB
TypeScript
86 lines
2.9 KiB
TypeScript
import { rankFor, type GameResult, type Role, type RoomState } from '@two-keys/shared';
|
|
import type { Game } from '../content/schemas';
|
|
import { engineView, gameMeta } from '../content/sanitize';
|
|
import { totalKeys } from '../engines/index';
|
|
import { timeLimitSeconds } from '../engines/story';
|
|
import type { PlayerRec, Room } from './store';
|
|
|
|
export function computeResult(room: Room, game: Game): GameResult | null {
|
|
if (room.phase !== 'finished' || !room.result || room.startedAt === null || room.finishedAt === null) {
|
|
return null;
|
|
}
|
|
const elapsedSeconds = Math.round((room.finishedAt - room.startedAt) / 1000);
|
|
const finalSeconds = elapsedSeconds + room.penaltySeconds;
|
|
const keys = totalKeys(game);
|
|
const outOfTime = room.result.outOfTime ?? false;
|
|
return {
|
|
success: room.result.success,
|
|
elapsedSeconds,
|
|
penaltySeconds: room.penaltySeconds,
|
|
finalSeconds,
|
|
hintsUsed: room.hintsUsed,
|
|
wrongGuesses: room.wrongGuesses,
|
|
keysEarned: room.keys.length,
|
|
totalKeys: keys,
|
|
rank: rankFor({
|
|
success: room.result.success,
|
|
hintsUsed: room.hintsUsed,
|
|
finalSeconds,
|
|
estimatedMinutes: game.estimatedMinutes,
|
|
keysEarned: room.keys.length,
|
|
totalKeys: keys,
|
|
outOfTime,
|
|
}),
|
|
outOfTime,
|
|
// Difficulty 5, finished without a single hint.
|
|
nightmareSurvivors: room.result.success && game.difficulty >= 5 && room.hintsUsed === 0,
|
|
ending: room.result.ending,
|
|
reveal: room.result.reveal,
|
|
};
|
|
}
|
|
|
|
/** The full, sanitized state for one player. This is the only thing sockets ever receive. */
|
|
export function buildState(
|
|
room: Room,
|
|
player: PlayerRec,
|
|
game: Game,
|
|
isOnline: (token: string) => boolean,
|
|
now = Date.now(),
|
|
): RoomState {
|
|
const roles: Role[] = player.seat === 'both' ? ['A', 'B'] : player.seat ? [player.seat] : [];
|
|
const views: RoomState['views'] = {};
|
|
if (room.phase !== 'lobby' && room.engine) {
|
|
for (const role of roles) views[role] = engineView(game, room, role, now);
|
|
}
|
|
|
|
const partner = room.players.find((p) => p !== player);
|
|
return {
|
|
code: room.code,
|
|
mode: room.mode,
|
|
phase: room.phase,
|
|
game: gameMeta(game),
|
|
you: { seat: player.seat, ready: player.ready },
|
|
partner: partner
|
|
? {
|
|
online: isOnline(partner.token),
|
|
seat: partner.seat === 'both' ? null : partner.seat,
|
|
ready: partner.ready,
|
|
viewing: partner.viewing,
|
|
}
|
|
: null,
|
|
notepad: room.notepad,
|
|
keys: [...room.keys],
|
|
timer: {
|
|
startedAt: room.startedAt,
|
|
finishedAt: room.finishedAt,
|
|
penaltySeconds: room.penaltySeconds,
|
|
serverNow: now,
|
|
limitSeconds: game.engine === 'story' ? timeLimitSeconds(game) : null,
|
|
limitLabel: game.engine === 'story' ? (game.rules.timeLimitLabel ?? null) : null,
|
|
},
|
|
stats: { hintsUsed: room.hintsUsed, wrongGuesses: room.wrongGuesses },
|
|
views,
|
|
result: computeResult(room, game),
|
|
};
|
|
}
|