Card wave 2: durations, doors, teleports, modifiers; harden room auth
Duration system: sustained effects expire at the start of the caster's turns; SLOW (movement 1, no number cards, attack every other turn), NO SPELL, MEDUSA (paralysis + damage immunity), INVISIBLE (1-in-4 hit roll), SHRINK (50% miss, movement 2). Doors: PICK LOCK and MASTER KEY (displayed, reusable) unlock adjacent doors until end of turn, REMOVE LOCK is permanent, JAM LOCK seals a door for everyone. Movement: TELEPORT (4 spaces through walls, ends movement), PASS THROUGH WALL charges, POWER RUN (life for spaces), SWAP (consumes movement), GO AWAY (knockback + lost turn), TELEPORT OPPONENT. Card warfare: CARD ERASURE (named), THOUGHT-STEAL (2 random via seeded RNG), TELEPATH (private hand reveal), POWER DRAIN (damage feeds the caster), SUDDEN DEATH, STONE DEAD, WIZARDBLADE (same-square, number-powered, stays displayed). Cast modifiers: AMPLIFY doubles power/duration (stackable x2), ADD permits two number cards, EXTEND doubles duration; REVERSE heals instead of harms but keeps secondary effects. Counters now also halve durations (BLUNT) and split them (REFLECTION). Security (from review findings): room codes and game seeds now come from node:crypto, and every seat gets a secret token — reclaiming a name in a room requires its token, closing the impersonation hole. 29 cards implemented; 51 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
36b3ffe9a6
commit
67743dd17e
@@ -2,6 +2,7 @@
|
||||
// the append-only command log (the seed + log IS the game — the basis for
|
||||
// replays and async play). Clients get per-player redacted views and events.
|
||||
|
||||
import { randomBytes, randomInt } from "node:crypto";
|
||||
import {
|
||||
applyCommand,
|
||||
createGame,
|
||||
@@ -25,6 +26,8 @@ export interface Room {
|
||||
id: string;
|
||||
hostId: PlayerId;
|
||||
players: PlayerId[]; // join order
|
||||
/** Per-player secrets: reclaiming a seat requires the matching token. */
|
||||
tokens: Map<PlayerId, string>;
|
||||
seed: number;
|
||||
state: GameState | null; // null until started
|
||||
log: LoggedCommand[];
|
||||
@@ -38,35 +41,47 @@ const ROOM_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
function makeRoomCode(): string {
|
||||
let code = "";
|
||||
for (let i = 0; i < 4; i++) {
|
||||
code += ROOM_CODE_ALPHABET[Math.floor(Math.random() * ROOM_CODE_ALPHABET.length)];
|
||||
code += ROOM_CODE_ALPHABET[randomInt(ROOM_CODE_ALPHABET.length)];
|
||||
}
|
||||
return rooms.has(code) ? makeRoomCode() : code;
|
||||
}
|
||||
|
||||
export function createRoom(hostId: PlayerId): Room {
|
||||
export function createRoom(hostId: PlayerId): { room: Room; token: string } {
|
||||
const token = randomBytes(16).toString("hex");
|
||||
const room: Room = {
|
||||
id: makeRoomCode(),
|
||||
hostId,
|
||||
players: [hostId],
|
||||
seed: Math.floor(Math.random() * 0xffffffff),
|
||||
tokens: new Map([[hostId, token]]),
|
||||
seed: randomInt(0, 0xffffffff),
|
||||
state: null,
|
||||
log: [],
|
||||
events: [],
|
||||
};
|
||||
rooms.set(room.id, room);
|
||||
return room;
|
||||
return { room, token };
|
||||
}
|
||||
|
||||
export function getRoom(id: string): Room | undefined {
|
||||
return rooms.get(id.toUpperCase());
|
||||
}
|
||||
|
||||
export function joinRoom(room: Room, playerId: PlayerId): string | null {
|
||||
if (room.state) return "game already started";
|
||||
if (room.players.includes(playerId)) return null; // rejoin is fine
|
||||
if (room.players.length >= 4) return "room is full";
|
||||
export function joinRoom(
|
||||
room: Room,
|
||||
playerId: PlayerId,
|
||||
token: string | null,
|
||||
): { token: string } | { error: string } {
|
||||
if (room.players.includes(playerId)) {
|
||||
// Reclaiming an existing seat requires that seat's secret.
|
||||
if (token && room.tokens.get(playerId) === token) return { token };
|
||||
return { error: "that wizard name is taken in this room" };
|
||||
}
|
||||
if (room.state) return { error: "game already started" };
|
||||
if (room.players.length >= 4) return { error: "room is full" };
|
||||
const fresh = randomBytes(16).toString("hex");
|
||||
room.players.push(playerId);
|
||||
return null;
|
||||
room.tokens.set(playerId, fresh);
|
||||
return { token: fresh };
|
||||
}
|
||||
|
||||
export function startGame(room: Room): { events: GameEvent[] } | { error: string } {
|
||||
|
||||
Reference in New Issue
Block a user