A duel server: rooms, seat tokens, ledgers, and resolution when all have moved
Plain HTTP for actions and a websocket that only says "fetch again". A room is its append-only ledger replayed from the top; the engine is deterministic given the seed and the recorded inputs. Each seat sees a view filtered to what the rules let it know. Bot seats are filled at resolution time. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
95a1915cae
commit
8366c9d7ff
@@ -0,0 +1,246 @@
|
||||
// Rooms: who sits where, whose move it is, and the duel itself. Every change
|
||||
// goes to the ledger first and is then applied, so a restart replays to the
|
||||
// same place. Inputs wait in memory until every seat that must move has moved;
|
||||
// then the turn resolves at once, bots included.
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { chooseBotTurn } from '../../src/lib/game/bot';
|
||||
import { resolveTurn } from '../../src/lib/game/resolve';
|
||||
import { GESTURES, type Gesture } from '../../src/lib/game/spells';
|
||||
import { createGame, inDuel, type GameState, type TurnInput, type WizardId } from '../../src/lib/game/state';
|
||||
import { viewFor } from '../../src/lib/game/view';
|
||||
import type { LedgerLine, Store } from './store';
|
||||
|
||||
const SEAT_IDS: WizardId[] = ['A', 'B', 'C', 'D'];
|
||||
const BOT_NAMES = ['Aldric', 'Morwenna', 'Thessaly', 'Gandric', 'Ysolde', 'Ormund', 'Corwin', 'Isaura'];
|
||||
export const MAX_SEATS = SEAT_IDS.length;
|
||||
|
||||
export interface Seat {
|
||||
id: WizardId;
|
||||
name: string;
|
||||
token: string;
|
||||
bot: boolean;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
size: number;
|
||||
createdAt: number;
|
||||
seats: Seat[];
|
||||
state: GameState | null;
|
||||
/** Inputs received for the turn in progress, by seat. */
|
||||
pending: Record<WizardId, TurnInput>;
|
||||
/** Bumped on every change a client might want to see. */
|
||||
seq: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** What a seated player is told. Other seats' pending inputs and tokens never leave the server. */
|
||||
export interface View {
|
||||
roomId: string;
|
||||
seq: number;
|
||||
size: number;
|
||||
me: WizardId;
|
||||
seats: { id: WizardId; name: string; bot: boolean }[];
|
||||
/** Seats that still have to move before the turn resolves. */
|
||||
waitingOn: WizardId[];
|
||||
state: GameState | null;
|
||||
}
|
||||
|
||||
export class RoomError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status = 400
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function newId(bytes: number): string {
|
||||
return randomBytes(bytes).toString('base64url');
|
||||
}
|
||||
|
||||
export class Rooms {
|
||||
private rooms = new Map<string, Room>();
|
||||
private listeners = new Map<string, Set<(room: Room) => void>>();
|
||||
|
||||
constructor(private store: Store) {
|
||||
for (const id of store.roomIds()) {
|
||||
const room = replay(id, store.read(id));
|
||||
if (room) this.rooms.set(id, room);
|
||||
}
|
||||
}
|
||||
|
||||
get(id: string): Room {
|
||||
const room = this.rooms.get(id);
|
||||
if (!room) throw new RoomError('No such duel.', 404);
|
||||
return room;
|
||||
}
|
||||
|
||||
/** The seat a token unlocks. */
|
||||
seatOf(room: Room, token: string | undefined): Seat {
|
||||
const seat = room.seats.find((s) => !s.bot && s.token === token);
|
||||
if (!seat) throw new RoomError('That token opens no seat at this duel.', 403);
|
||||
return seat;
|
||||
}
|
||||
|
||||
create(name: string, size: number): { room: Room; seat: Seat } {
|
||||
if (!Number.isInteger(size) || size < 2 || size > MAX_SEATS) throw new RoomError(`A duel seats 2 to ${MAX_SEATS} wizards.`);
|
||||
let id = newId(6);
|
||||
while (this.rooms.has(id)) id = newId(6);
|
||||
const room: Room = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, seq: 0, updatedAt: Date.now() };
|
||||
this.rooms.set(id, room);
|
||||
this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt });
|
||||
const seat = this.sit(room, name, false);
|
||||
return { room, seat };
|
||||
}
|
||||
|
||||
join(room: Room, name: string): Seat {
|
||||
return this.sit(room, name, false);
|
||||
}
|
||||
|
||||
addBot(room: Room, token: string | undefined): Seat {
|
||||
this.seatOf(room, token);
|
||||
const taken = new Set(room.seats.map((s) => s.name.toLowerCase()));
|
||||
const pool = BOT_NAMES.filter((n) => !taken.has(n.toLowerCase()));
|
||||
const name = pool[Math.floor(Math.random() * pool.length)] ?? 'The Construct';
|
||||
return this.sit(room, name, true);
|
||||
}
|
||||
|
||||
private sit(room: Room, rawName: string, bot: boolean): Seat {
|
||||
if (room.seats.length >= room.size) throw new RoomError('Every seat at this duel is taken.', 409);
|
||||
const name = cleanName(rawName);
|
||||
if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another wizard here already has that name.', 409);
|
||||
const seat: Seat = { id: SEAT_IDS[room.seats.length], name, token: bot ? '' : newId(18), bot };
|
||||
this.commit(room, { t: 'seat', id: seat.id, name, token: seat.token, bot });
|
||||
if (room.seats.length === room.size) this.commit(room, { t: 'start', seed: (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0 });
|
||||
return seat;
|
||||
}
|
||||
|
||||
/** Record a seat's move; resolve the turn once nobody else is awaited. */
|
||||
submit(room: Room, token: string | undefined, raw: unknown): void {
|
||||
const seat = this.seatOf(room, token);
|
||||
if (!room.state) throw new RoomError('The duel has not begun: seats are still empty.', 409);
|
||||
if (room.state.over) throw new RoomError('The duel is over.', 409);
|
||||
if (!waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409);
|
||||
room.pending[seat.id] = cleanInput(raw);
|
||||
room.updatedAt = Date.now();
|
||||
room.seq += 1;
|
||||
if (waitingOn(room).length === 0) this.resolve(room);
|
||||
this.notify(room);
|
||||
}
|
||||
|
||||
private resolve(room: Room): void {
|
||||
const state = room.state!;
|
||||
const inputs: Record<WizardId, TurnInput> = { ...room.pending };
|
||||
for (const seat of room.seats) {
|
||||
if (seat.bot && needsInput(state, seat.id)) inputs[seat.id] = chooseBotTurn(state, seat.id);
|
||||
}
|
||||
this.commit(room, { t: 'turn', at: Date.now(), inputs });
|
||||
// A time stop owed to a bot resolves at once; a human's waits for them.
|
||||
while (room.state && !room.state.over && room.state.timeStops[0] && room.seats.find((s) => s.id === room.state!.timeStops[0])?.bot) {
|
||||
const actor = room.state.timeStops[0];
|
||||
this.commit(room, { t: 'turn', at: Date.now(), inputs: { [actor]: chooseBotTurn(room.state, actor) } });
|
||||
}
|
||||
}
|
||||
|
||||
view(room: Room, seatId: WizardId): View {
|
||||
return {
|
||||
roomId: room.id,
|
||||
seq: room.seq,
|
||||
size: room.size,
|
||||
me: seatId,
|
||||
seats: room.seats.map((s) => ({ id: s.id, name: s.name, bot: s.bot })),
|
||||
waitingOn: waitingOn(room),
|
||||
state: room.state ? viewFor(room.state, seatId) : null
|
||||
};
|
||||
}
|
||||
|
||||
subscribe(roomId: string, fn: (room: Room) => void): () => void {
|
||||
if (!this.listeners.has(roomId)) this.listeners.set(roomId, new Set());
|
||||
this.listeners.get(roomId)!.add(fn);
|
||||
return () => this.listeners.get(roomId)?.delete(fn);
|
||||
}
|
||||
|
||||
/** Write to the ledger, then apply; the file is the truth a restart returns to. */
|
||||
private commit(room: Room, line: LedgerLine): void {
|
||||
this.store.append(room.id, line);
|
||||
apply(room, line);
|
||||
room.seq += 1;
|
||||
room.updatedAt = Date.now();
|
||||
this.notify(room);
|
||||
}
|
||||
|
||||
private notify(room: Room): void {
|
||||
for (const fn of this.listeners.get(room.id) ?? []) fn(room);
|
||||
}
|
||||
}
|
||||
|
||||
/** Seats whose input the next resolution needs and has not yet received. */
|
||||
export function waitingOn(room: Room): WizardId[] {
|
||||
if (!room.state || room.state.over) return [];
|
||||
return room.seats
|
||||
.filter((s) => !s.bot && needsInput(room.state!, s.id) && !(s.id in room.pending))
|
||||
.map((s) => s.id);
|
||||
}
|
||||
|
||||
function needsInput(state: GameState, id: WizardId): boolean {
|
||||
if (state.timeStops.length > 0) return state.timeStops[0] === id;
|
||||
return inDuel(state.wizards[id]);
|
||||
}
|
||||
|
||||
function apply(room: Room, line: LedgerLine): void {
|
||||
switch (line.t) {
|
||||
case 'room':
|
||||
break;
|
||||
case 'seat':
|
||||
room.seats.push({ id: line.id, name: line.name, token: line.token, bot: line.bot });
|
||||
break;
|
||||
case 'start':
|
||||
room.state = createGame(Object.fromEntries(room.seats.map((s) => [s.id, s.name])), line.seed);
|
||||
break;
|
||||
case 'turn':
|
||||
if (!room.state) return;
|
||||
room.state = resolveTurn(room.state, line.inputs);
|
||||
room.pending = {};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function replay(id: string, lines: LedgerLine[]): Room | null {
|
||||
const first = lines[0];
|
||||
if (!first || first.t !== 'room') return null;
|
||||
const room: Room = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, seq: lines.length, updatedAt: first.createdAt };
|
||||
for (const line of lines) {
|
||||
apply(room, line);
|
||||
if (line.t === 'turn') room.updatedAt = line.at;
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
export function cleanName(raw: unknown): string {
|
||||
const name = String(raw ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 24);
|
||||
if (!name) throw new RoomError('A wizard needs a name.');
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Only the shapes the engine understands get through; the engine validates the rest. */
|
||||
function cleanInput(raw: unknown): TurnInput {
|
||||
const r = (raw ?? {}) as Record<string, unknown>;
|
||||
const gesture = (g: unknown): Gesture => (GESTURES.includes(g as Gesture) ? (g as Gesture) : '-');
|
||||
const casts = Array.isArray(r.casts) ? (r.casts as TurnInput['casts']).slice(0, 4) : [];
|
||||
const input: TurnInput = { left: gesture(r.left), right: gesture(r.right), casts };
|
||||
if (r.second && typeof r.second === 'object') {
|
||||
const s = r.second as Record<string, unknown>;
|
||||
input.second = { left: gesture(s.left), right: gesture(s.right), casts: Array.isArray(s.casts) ? (s.casts as TurnInput['casts']).slice(0, 4) : [] };
|
||||
if (typeof s.stabTarget === 'string') input.second.stabTarget = s.stabTarget;
|
||||
}
|
||||
if (r.release && typeof r.release === 'object') input.release = r.release as TurnInput['release'];
|
||||
if (typeof r.stabTarget === 'string') input.stabTarget = r.stabTarget;
|
||||
if (r.monsterOrders && typeof r.monsterOrders === 'object') input.monsterOrders = r.monsterOrders as TurnInput['monsterOrders'];
|
||||
if (typeof r.charmGesture === 'string') input.charmGesture = gesture(r.charmGesture);
|
||||
return input;
|
||||
}
|
||||
Reference in New Issue
Block a user