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:
commit
1cd24e3ddd
59 files changed
+7420
No files matched your search
@@ -0,0 +1,140 @@
|
||||
// The browser's side of the game server: a few JSON calls, and the seats this
|
||||
// browser holds, remembered so a shared link never has to carry a token.
|
||||
|
||||
import type { SeatId } from '$lib/game/spec';
|
||||
import type { RoomView } from './view';
|
||||
|
||||
export interface HeldSeat {
|
||||
seat: SeatId;
|
||||
token: string;
|
||||
}
|
||||
|
||||
const SEATS_KEY = '__SLUG__:seats';
|
||||
|
||||
function heldSeats(): Record<string, HeldSeat> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SEATS_KEY) ?? '{}') as Record<string, HeldSeat>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function seatFor(roomId: string): HeldSeat | null {
|
||||
return heldSeats()[roomId] ?? null;
|
||||
}
|
||||
|
||||
/** Every seat this browser holds, newest last. */
|
||||
export function allSeats(): { roomId: string; seat: SeatId; token: string }[] {
|
||||
return Object.entries(heldSeats()).map(([roomId, h]) => ({ roomId, ...h }));
|
||||
}
|
||||
|
||||
export function forgetSeat(roomId: string): void {
|
||||
try {
|
||||
const seats = heldSeats();
|
||||
delete seats[roomId];
|
||||
localStorage.setItem(SEATS_KEY, JSON.stringify(seats));
|
||||
} catch {
|
||||
// Nothing to forget without storage.
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberSeat(roomId: string, held: HeldSeat): void {
|
||||
try {
|
||||
localStorage.setItem(SEATS_KEY, JSON.stringify({ ...heldSeats(), [roomId]: held }));
|
||||
} catch {
|
||||
// Without storage the seat lasts for this page only.
|
||||
}
|
||||
}
|
||||
|
||||
export class ServerError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: body ? { 'content-type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
if (!res.ok) throw new ServerError(data.error ?? `The server answered ${res.status}.`, res.status);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
/** A report to the keeper, with the exchange under it. */
|
||||
export interface Report {
|
||||
id: string;
|
||||
at: string;
|
||||
roomId: string;
|
||||
player: string;
|
||||
seat: SeatId | null;
|
||||
turn: number | null;
|
||||
happened: string;
|
||||
expected: string;
|
||||
thread: { at: string; text: string; from: 'desk' | 'player'; status?: string }[];
|
||||
image?: string;
|
||||
}
|
||||
|
||||
export function makeApi<State, Input>() {
|
||||
type Seated = { seat: SeatId; token: string; view: RoomView<State> };
|
||||
type View = RoomView<State>;
|
||||
return {
|
||||
create: (name: string, size: number) => call<Seated>('POST', '/api/rooms', { name, size }),
|
||||
join: (roomId: string, name: string) => call<Seated>('POST', `/api/rooms/${roomId}/join`, { name }),
|
||||
addBot: (roomId: string, token: string) => call<View>('POST', `/api/rooms/${roomId}/bot`, { token }),
|
||||
begin: (roomId: string, token: string) => call<View>('POST', `/api/rooms/${roomId}/begin`, { token }),
|
||||
view: (roomId: string, token: string) => call<View>('GET', `/api/rooms/${roomId}?token=${encodeURIComponent(token)}`),
|
||||
/** The gallery's view: no token, no seat, nothing any player could not see. */
|
||||
watch: (roomId: string) => call<View>('GET', `/api/rooms/${roomId}`),
|
||||
turn: (roomId: string, token: string, input: Input) => call<View>('POST', `/api/rooms/${roomId}/turn`, { token, input }),
|
||||
say: (roomId: string, token: string, text: string) => call<View>('POST', `/api/rooms/${roomId}/say`, { token, text }),
|
||||
report: (roomId: string, token: string, happened: string, expected: string) =>
|
||||
call<{ id: string }>('POST', `/api/rooms/${roomId}/report`, { token, happened, expected }),
|
||||
reportImage: async (id: string, file: File) => {
|
||||
const res = await fetch(`/api/reports/${id}/image`, { method: 'POST', headers: { 'content-type': file.type || 'application/octet-stream' }, body: file });
|
||||
if (!res.ok) throw new ServerError(((await res.json().catch(() => ({}))) as { error?: string }).error ?? `The server answered ${res.status}.`, res.status);
|
||||
},
|
||||
myReports: (seats: { roomId: string; token: string }[]) => call<{ reports: Report[] }>('POST', '/api/reports/mine', { seats }),
|
||||
answerReport: (id: string, roomId: string, token: string, text: string) => call<{ ok: true }>('POST', `/api/reports/${id}/answer`, { roomId, token, text })
|
||||
};
|
||||
}
|
||||
|
||||
/** A socket that only ever says "fetch the view again"; reconnects on its own. An empty token sits in the gallery. */
|
||||
export function watchRoom(roomId: string, token: string, onUpdate: () => void, onStatus: (open: boolean) => void): () => void {
|
||||
let socket: WebSocket | null = null;
|
||||
let closed = false;
|
||||
let delay = 1000;
|
||||
const open = () => {
|
||||
if (closed) return;
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const seat = token ? `&token=${encodeURIComponent(token)}` : '';
|
||||
socket = new WebSocket(`${protocol}://${location.host}/ws?room=${encodeURIComponent(roomId)}${seat}`);
|
||||
socket.onopen = () => {
|
||||
delay = 1000;
|
||||
onStatus(true);
|
||||
onUpdate();
|
||||
};
|
||||
socket.onmessage = (e) => {
|
||||
try {
|
||||
if (JSON.parse(String(e.data)).type === 'update') onUpdate();
|
||||
} catch {
|
||||
// Not a message this client understands.
|
||||
}
|
||||
};
|
||||
socket.onclose = () => {
|
||||
onStatus(false);
|
||||
if (!closed) setTimeout(open, delay);
|
||||
delay = Math.min(delay * 2, 30000);
|
||||
};
|
||||
};
|
||||
open();
|
||||
return () => {
|
||||
closed = true;
|
||||
socket?.close();
|
||||
};
|
||||
}
|
||||
@@ -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), '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// How much of each table's talk this browser has read, so the hall can say what is new.
|
||||
|
||||
const SEEN_KEY = '__SLUG__:talk-seen';
|
||||
|
||||
function seen(): Record<string, number> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SEEN_KEY) ?? '{}') as Record<string, number>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function markTalkSeen(roomId: string, count: number): void {
|
||||
try {
|
||||
const all = seen();
|
||||
if ((all[roomId] ?? 0) >= count) return;
|
||||
localStorage.setItem(SEEN_KEY, JSON.stringify({ ...all, [roomId]: count }));
|
||||
} catch {
|
||||
// Without storage every line reads as new, which is the safe side.
|
||||
}
|
||||
}
|
||||
|
||||
export function unreadTalk(roomId: string, count: number): number {
|
||||
return Math.max(0, count - (seen()[roomId] ?? 0));
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// What the server tells a viewer about a room. The game's own state is
|
||||
// whatever GameSpec.view returned for that viewer; nothing else leaves.
|
||||
|
||||
import type { Outcome, SeatId } from '../game/spec';
|
||||
|
||||
/** One line of table talk. Only seated players speak; the gallery listens. */
|
||||
export interface ChatLine {
|
||||
id: SeatId;
|
||||
text: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
export interface RoomView<State> {
|
||||
roomId: string;
|
||||
seq: number;
|
||||
/** The most seats the table will take. */
|
||||
size: number;
|
||||
/** The viewer's seat, or SPECTATOR for the gallery. */
|
||||
me: SeatId;
|
||||
/** The player who opened the table and may begin the game. */
|
||||
host: SeatId;
|
||||
seats: { id: SeatId; name: string; bot: boolean }[];
|
||||
/** Seats that still have to move before the round resolves. */
|
||||
waitingOn: SeatId[];
|
||||
/** The round being written, from one; null before the game begins. */
|
||||
turn: number | null;
|
||||
over: Outcome | null;
|
||||
state: State | null;
|
||||
chat: ChatLine[];
|
||||
/** How many watch from the Peanut Gallery. */
|
||||
audience: number;
|
||||
}
|
||||
Reference in new issue
Block a user