The game kit: the shared infrastructure of Wiz-War and Waving Hands as a template

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric Wagoner
2026-09-23 11:00:05 -04:00
co-authored by Claude Fable 5.1
commit 1cd24e3ddd
59 changed files with 7420 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
// The reactive room a page shows: the seat this browser holds (or the
// gallery), the table, the talk, and the game's view. It knows the game only
// through GameSpec; a game's own store wraps or extends this one.
import { game } from '$lib/game';
import { SPECTATOR, type SeatId } from '$lib/game/spec';
import type { Input, State } from '$lib/game';
import { makeApi, rememberSeat, watchRoom, type HeldSeat } from './client';
import { markTalkSeen } from './talk';
import type { RoomView } from './view';
export const api = makeApi<State, Input>();
export const NAME_MAX = 24;
const NAME_KEY = '__SLUG__:name';
export function playerName(): string {
try {
return localStorage.getItem(NAME_KEY) ?? '';
} catch {
return '';
}
}
export function rememberName(raw: string): void {
const name = raw.trim().slice(0, NAME_MAX);
try {
if (name) localStorage.setItem(NAME_KEY, name);
else localStorage.removeItem(NAME_KEY);
} catch {
// The name still applies to this visit.
}
}
/** "Ysolde", "Ysolde and Grey", "Ysolde, Grey and Ormund". */
export function listText(names: string[]): string {
if (names.length <= 1) return names[0] ?? '';
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
}
export class Room {
view = $state<RoomView<State>>(null!);
token: string;
connected = $state(false);
error = $state('');
/** A move is on its way to the server. */
sending = $state(false);
constructor(view: RoomView<State>, token: string) {
this.view = view;
this.token = token;
}
me = $derived(this.view.me);
roomId = $derived(this.view.roomId);
/** Watching from the gallery, with no seat at the table. */
spectating = $derived(this.view.me === SPECTATOR);
started = $derived(this.view.state !== null);
state = $derived(this.view.state);
isHost = $derived(!this.spectating && this.view.host === this.view.me);
/** Your move is in and the round waits on someone else. */
moved = $derived(this.started && !this.view.over && !this.spectating && !this.view.waitingOn.includes(this.me));
yourMove = $derived(this.started && !this.view.over && this.view.waitingOn.includes(this.me));
/** Names of the seats the round is waiting on, yours aside. */
awaiting = $derived(this.view.waitingOn.filter((id) => id !== this.me).map((id) => this.nameOf(id)));
awaitingText = $derived(listText(this.awaiting));
others = $derived(this.view.seats.filter((s) => s.id !== this.me));
nameOf(id: SeatId): string {
return this.view.seats.find((s) => s.id === id)?.name ?? (this.view.state ? game.nameOf(this.view.state, id) : id);
}
apply(view: RoomView<State>): void {
this.view = view;
markTalkSeen(view.roomId, view.chat.length);
}
/** Fetch the view now, and keep fetching whenever the server says something changed. */
connect(): () => void {
const fetchView = () => (this.spectating ? api.watch(this.roomId) : api.view(this.roomId, this.token));
const refresh = () => {
fetchView()
.then((v) => this.apply(v))
.catch((e: Error) => (this.error = e.message));
};
return watchRoom(this.roomId, this.token, refresh, (open) => (this.connected = open));
}
private async act(fn: () => Promise<RoomView<State>>, failure: string): Promise<boolean> {
this.error = '';
try {
this.apply(await fn());
return true;
} catch (e) {
this.error = e instanceof Error ? e.message : failure;
return false;
}
}
begin(): Promise<boolean> {
return this.act(() => api.begin(this.roomId, this.token), 'The game could not begin.');
}
addBot(): Promise<boolean> {
return this.act(() => api.addBot(this.roomId, this.token), 'The bot could not be seated.');
}
say(text: string): Promise<boolean> {
if (this.spectating) return Promise.resolve(false);
return this.act(() => api.say(this.roomId, this.token, text), 'The table did not hear you.');
}
async submit(input: Input): Promise<boolean> {
this.sending = true;
try {
return await this.act(() => api.turn(this.roomId, this.token, input), 'The move did not reach the server.');
} finally {
this.sending = false;
}
}
/** Open a table on the server, seated as its host; it begins when the host says. */
static async create(name: string, size: number): Promise<Room> {
const seated = await api.create(name, size);
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
return new Room(seated.view, seated.token);
}
/** A room with a bot already seated, so a solo game is a ledger like any other. */
static async createWithBot(name: string): Promise<Room> {
const seated = await api.create(name, 2);
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
return new Room(await api.addBot(seated.view.roomId, seated.token), seated.token);
}
static async join(roomId: string, name: string): Promise<Room> {
const seated = await api.join(roomId, name);
rememberSeat(roomId, { seat: seated.seat, token: seated.token });
return new Room(seated.view, seated.token);
}
/** Return to a seat this browser already holds. */
static async resume(roomId: string, held: HeldSeat): Promise<Room> {
return new Room(await api.view(roomId, held.token), held.token);
}
/** Take a place in the Peanut Gallery: watch a table with no seat and no voice. */
static async watch(roomId: string): Promise<Room> {
return new Room(await api.watch(roomId), '');
}
}