Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
201 lines
7.7 KiB
TypeScript
201 lines
7.7 KiB
TypeScript
// 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.
|
|
|
|
import { game } from '$lib/game';
|
|
import { SPECTATOR, type SeatId, type TableOptions } 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". */
|
|
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);
|
|
others = $derived(this.view.seats.filter((s) => s.id !== this.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));
|
|
/** 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));
|
|
/** A chair at the table is empty, held for someone or not. */
|
|
seatEmpty = $derived(!this.started && this.view.seats.length < this.view.size);
|
|
/** An empty chair not held for anyone: a stranger may sit. */
|
|
seatUnheld = $derived(this.seatEmpty && this.view.seats.length + this.view.expected.length < this.view.size);
|
|
|
|
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.');
|
|
}
|
|
|
|
/** Send a bot away before the game begins; only the host may. */
|
|
unseat(seat: SeatId): Promise<boolean> {
|
|
return this.act(() => api.unseat(this.roomId, this.token, seat), 'The bot could not be sent away.');
|
|
}
|
|
|
|
/** Out of the game, with the bots playing on: play the rest at once. */
|
|
hurry(): Promise<boolean> {
|
|
return this.act(() => api.hurry(this.roomId, this.token), 'The table did not hear you.');
|
|
}
|
|
|
|
/** This seat's ranking of the table's choices, best first, before the game begins. */
|
|
rank(ranking: string[]): Promise<boolean> {
|
|
return this.act(() => api.rank(this.roomId, this.token, ranking), 'Your ranking was not kept.');
|
|
}
|
|
|
|
/** Talk to the whole table, or, naming seats, to them alone. */
|
|
say(text: string, to?: SeatId[]): Promise<boolean> {
|
|
if (this.spectating) return Promise.resolve(false);
|
|
return this.act(() => api.say(this.roomId, this.token, text, to), 'The table did not hear you.');
|
|
}
|
|
|
|
/** A word from the gallery, signed; only when the host has let the gallery talk. */
|
|
sayFromGallery(name: string, text: string): Promise<boolean> {
|
|
if (!this.spectating) return Promise.resolve(false);
|
|
return this.act(() => api.sayFromGallery(this.roomId, name, text), 'The table did not hear you.');
|
|
}
|
|
|
|
/** Host only: let the Peanut Gallery talk at this table, or hush it. */
|
|
setGalleryTalk(on: boolean): Promise<boolean> {
|
|
return this.act(() => api.setGalleryTalk(this.roomId, this.token, on), 'The gallery could not be told.');
|
|
}
|
|
|
|
/** Call the keeper of the site to a held seat; their phone rings. */
|
|
callKeeper(): Promise<boolean> {
|
|
return this.act(() => api.challenge(this.roomId, this.token), 'The keeper could not be called.');
|
|
}
|
|
|
|
/** A finished table opens another with the same company; the caller is seated as its host. */
|
|
async rematch(): Promise<string> {
|
|
const next = await api.rematch(this.roomId, this.token);
|
|
if (next.created && next.seat && next.token) rememberSeat(next.roomId, { seat: next.seat, token: next.token });
|
|
return next.roomId;
|
|
}
|
|
|
|
/** A phrase that brings this seat to another device, good for ten minutes. */
|
|
transfer(): Promise<{ phrase: string; expiresAt: number }> {
|
|
return api.transfer(this.roomId, this.token);
|
|
}
|
|
|
|
async submit(input: Input): Promise<boolean> {
|
|
this.sending = true;
|
|
try {
|
|
return await this.act(() => api.turn(this.roomId, this.token, input, this.view.resolved), '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, options?: TableOptions): Promise<Room> {
|
|
const seated = await api.create(name, size, options);
|
|
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
|
|
return new Room(seated.view, seated.token);
|
|
}
|
|
|
|
/** A room with a bot seated and the game begun, so a solo game is a ledger like any other. */
|
|
static async createWithBot(name: string, options?: TableOptions): Promise<Room> {
|
|
const seated = await api.create(name, 2, options);
|
|
rememberSeat(seated.view.roomId, { seat: seated.seat, token: seated.token });
|
|
const view = await api.addBot(seated.view.roomId, seated.token);
|
|
// A table that asks for rankings waits in the lobby for the player's; the rest begin at once.
|
|
if (game.rankable?.(view.options)) return new Room(view, seated.token);
|
|
return new Room(await api.begin(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), '');
|
|
}
|
|
}
|