// 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(); 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>(null!); token: string; connected = $state(false); error = $state(''); /** A move is on its way to the server. */ sending = $state(false); constructor(view: RoomView, 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): 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>, failure: string): Promise { this.error = ''; try { this.apply(await fn()); return true; } catch (e) { this.error = e instanceof Error ? e.message : failure; return false; } } begin(): Promise { return this.act(() => api.begin(this.roomId, this.token), 'The game could not begin.'); } addBot(): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { return new Room(await api.watch(roomId), ''); } }