Files
two-keys/server/src/rooms/state.ts
T
joshuaandClaude Opus 5.5 6669be11e5 V1.2.0
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>
2026-10-14 08:44:28 +02:00

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),
};
}