diff --git a/src/games/npc-chat/handler.ts b/src/games/npc-chat/handler.ts new file mode 100644 index 0000000..f56fee1 --- /dev/null +++ b/src/games/npc-chat/handler.ts @@ -0,0 +1,81 @@ +/** + * Authoritative room handler for "The Prancing Pony" — the AI NPC showcase. + * + * Players chat; Bram the tavern keeper answers via a server-side LLM. The whole + * AI flow lives on the server (`ctx.ai.npc().say()` → the room runs inference → + * `onAiResult`); the client just sends a `say` message and renders the reply. + * + * Cost shape: tickRate 0 — this is a chat room, not a simulation, so there is NO + * game loop. The room only wakes on a player message and hibernates between + * them (zero idle cost). Each reply is one billed "AI message"; per-player rate + * limits, the app's monthly cap, and moderation are enforced server-side. + */ +import { defineRoom } from '@plot/handler'; + +/** The single client→server message: a line the player says to the room. */ +type ChatMsg = { kind: 'say'; text: string }; + +/** Nothing is simulated, so the shared state is just the recent transcript + * (handy for a late joiner to see context; capped so it never grows). */ +interface TavernState { + log: { who: string; text: string }[]; +} + +const MAX_LOG = 40; +const NPC = 'bram'; + +/** In-character fallback lines per server error code, so a rate-limit or a + * blocked reply still reads as Bram rather than a raw error. */ +function fallback(error: string): string { + switch (error) { + case 'rate_limited': return '*Bram keeps wiping the same tankard* — one at a time, friend.'; + case 'quota_exceeded': return '*Bram yawns* — the Pony’s heard enough tales for today. Come back tomorrow.'; + case 'blocked': return '*Bram frowns* — I’ll not have that talk under my roof.'; + case 'disabled': return '*Bram is oddly silent tonight.*'; + default: return '*Bram stares into the fire, lost in thought.*'; + } +} + +export const handler = defineRoom({ + initialState: { log: [] }, + channels: { + // Chat is reliable + ordered; nothing is high-frequency here. + event: { reliable: true, ordered: true, rateLimit: { perPlayer: 4, perSeconds: 10 } }, + }, + tickRate: 0, + + onJoin(player, ctx) { + ctx.sendTo(player.id, 'event', { + kind: 'npc', + npc: 'Bram', + text: 'Welcome to the Prancing Pony, traveller. Pull up a stool — what brings you to Bree?', + }); + }, + + async onMessage(player, msg, ctx) { + if (msg?.kind !== 'say' || typeof msg.text !== 'string') return; + const text = msg.text.trim().slice(0, 400); + if (!text) return; + + // Echo the player's line to everyone in the room, then ask Bram. The reply + // streams back as `npcChunk` deltas and finishes in onAiResult. + ctx.state.log.push({ who: player.id, text }); + if (ctx.state.log.length > MAX_LOG) ctx.state.log.shift(); + ctx.broadcast('event', { kind: 'player', from: player.id, text }); + ctx.sendTo(player.id, 'event', { kind: 'thinking' }); + + // conversationKey = player id → each traveller has their own thread with Bram. + await ctx.ai.npc(NPC).say({ from: player.id, text, conversationKey: player.id, stream: true }); + }, + + onAiResult(result, ctx) { + const text = result.error ? fallback(result.error) : result.text; + ctx.state.log.push({ who: 'Bram', text }); + if (ctx.state.log.length > MAX_LOG) ctx.state.log.shift(); + // Final line to the traveller who spoke (streaming deltas already arrived + // via npcChunk; this is the authoritative complete line + end marker). + ctx.sendTo(result.from, 'event', { kind: 'npc', npc: 'Bram', text, done: true, requestId: result.requestId }); + }, +}); + +export default handler; diff --git a/src/games/npc-chat/index.ts b/src/games/npc-chat/index.ts new file mode 100644 index 0000000..486d25d --- /dev/null +++ b/src/games/npc-chat/index.ts @@ -0,0 +1,150 @@ +/** + * "The Prancing Pony" — the AI NPC showcase module. + * + * A chat room where players talk to Bram, an LLM-backed tavern keeper. The AI + * runs entirely server-side (the handler's `ctx.ai.npc().say()`); the client is + * pure chat UI. Bram's replies stream in token-by-token via the `npcChunk` + * event (from `say({ stream: true })`), with the final authoritative line on the + * `event` channel. + */ +import './npc-chat.css'; +import { Plot, type Room } from '@plot/client'; +import type { GameModule, PlotConfig } from '../../plot-config'; +import { nameFor } from '../../shell/names'; + +type NpcEvent = + | { kind: 'npc'; npc: string; text: string; done?: boolean; requestId?: string } + | { kind: 'player'; from: string; text: string } + | { kind: 'thinking' }; + +const game: GameModule = { + meta: { + id: 'npc-chat', + name: 'The Prancing Pony', + usecase: 'AI NPCs', + blurb: + 'Chat with Bram, an LLM tavern keeper. Server-side inference, per-conversation memory, world lore via RAG, and live token streaming — a few lines of handler code.', + defaultRoom: 'PONY1', + hue: '--pg-y', + artLabel: 'one keeper · many tales', + }, + mount(host, config, roomCode) { + return mountPony(host, config, roomCode); + }, +}; + +export default game; + +function mountPony(host: HTMLElement, config: PlotConfig, roomCode: string): () => void { + const me = config.playerId; + + const root = document.createElement('div'); + root.className = 'pony'; + root.innerHTML = ` +
+
The Prancing Pony
+
connecting…
+
+
+
+ + +
`; + host.appendChild(root); + + const sub = root.querySelector('.pony-sub') as HTMLElement; + const logEl = root.querySelector('.pony-log') as HTMLElement; + const form = root.querySelector('.pony-form') as HTMLFormElement; + const input = root.querySelector('.pony-input') as HTMLInputElement; + const sendBtn = root.querySelector('.pony-send') as HTMLButtonElement; + + // Track the in-flight streamed NPC bubble so npcChunk deltas append to it. + let streamingBubble: HTMLElement | null = null; + + const atBottom = (): boolean => logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 40; + const scroll = (was: boolean): void => { if (was) logEl.scrollTop = logEl.scrollHeight; }; + + function bubble(cls: string, who: string, text: string): HTMLElement { + const was = atBottom(); + const row = document.createElement('div'); + row.className = `pony-msg ${cls}`; + const nameEl = document.createElement('div'); + nameEl.className = 'pony-who'; + nameEl.textContent = who; + const body = document.createElement('div'); + body.className = 'pony-text'; + body.textContent = text; + row.append(nameEl, body); + logEl.appendChild(row); + scroll(was); + return body; + } + + let room: Room | null = null; + let disposed = false; + + void (async () => { + try { + room = await new Plot({ + appKey: config.appKey, + appId: config.appId, + playerId: me, + apiUrl: config.apiUrl, + }).join({ mode: 'code', roomCode }); + if (disposed) { room.leave(); return; } + + sub.textContent = `You are ${nameFor(me)} · room ${roomCode}`; + input.disabled = false; + sendBtn.disabled = false; + input.focus(); + + room.on('message', (e) => { + const ev = e.data as NpcEvent; + if (!ev || typeof ev !== 'object') return; + if (ev.kind === 'player') { + if (ev.from === me) return; // our own echo — already shown optimistically + bubble('them', nameFor(ev.from), ev.text); + } else if (ev.kind === 'thinking') { + if (!streamingBubble) streamingBubble = bubble('npc', 'Bram', '…'); + } else if (ev.kind === 'npc') { + const was = atBottom(); + if (streamingBubble) { streamingBubble.textContent = ev.text; streamingBubble = null; } + else bubble('npc', ev.npc, ev.text); + scroll(was); + } + }); + + // Streamed tokens: append each delta to the current Bram bubble. + room.on('npcChunk', ({ delta }) => { + const was = atBottom(); + if (!streamingBubble) streamingBubble = bubble('npc', 'Bram', ''); + if (streamingBubble.textContent === '…') streamingBubble.textContent = ''; + streamingBubble.textContent += delta; + scroll(was); + }); + + room.on('disconnect', ({ willRetry }) => { + sub.textContent = willRetry ? 'reconnecting…' : 'disconnected'; + }); + room.on('reconnect', () => { sub.textContent = `You are ${nameFor(me)} · room ${roomCode}`; }); + } catch { + sub.textContent = 'could not reach the tavern — try again'; + } + })(); + + form.addEventListener('submit', (e) => { + e.preventDefault(); + const text = input.value.trim(); + if (!text || !room) return; + bubble('me', nameFor(me), text); // optimistic + room.send({ kind: 'say', text }, { channel: 'event' }); + input.value = ''; + }); + + return () => { + disposed = true; + room?.leave(); + host.removeChild(root); + }; +} diff --git a/src/games/npc-chat/npc-chat.css b/src/games/npc-chat/npc-chat.css new file mode 100644 index 0000000..23bb5d4 --- /dev/null +++ b/src/games/npc-chat/npc-chat.css @@ -0,0 +1,91 @@ +.pony { + --pn-bg: #17130e; + --pn-panel: #241d14; + --pn-panel-2: #2f2619; + --pn-accent: #f5c451; + --pn-text: #f3ead6; + --pn-muted: #c2b393; + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + color: var(--pn-text); + background: radial-gradient(1200px 600px at 50% -10%, #322613, var(--pn-bg)); + min-height: 100%; + box-sizing: border-box; + padding: 24px; + display: flex; + flex-direction: column; + gap: 14px; +} +.pony * { box-sizing: border-box; } + +.pony-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} +.pony-title { font-size: 20px; font-weight: 800; letter-spacing: 0.3px; } +.pony-sub { font-size: 13px; color: var(--pn-muted); } + +.pony-log { + flex: 1; + min-height: 320px; + max-height: 62vh; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px; + background: var(--pn-panel); + border: 1px solid #3a2f1e; + border-radius: 12px; +} + +.pony-msg { + display: flex; + flex-direction: column; + gap: 2px; + max-width: 78%; +} +.pony-msg.me { align-self: flex-end; align-items: flex-end; } +.pony-msg.them, .pony-msg.npc { align-self: flex-start; } + +.pony-who { font-size: 11px; color: var(--pn-muted); padding: 0 4px; } +.pony-text { + padding: 9px 13px; + border-radius: 12px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} +.pony-msg.me .pony-text { background: var(--pn-accent); color: #241d14; border-bottom-right-radius: 4px; } +.pony-msg.them .pony-text { background: var(--pn-panel-2); border-bottom-left-radius: 4px; } +.pony-msg.npc .pony-text { + background: #1c2b1c; + border: 1px solid #2f4a2f; + color: #d8f0d8; + border-bottom-left-radius: 4px; +} + +.pony-form { display: flex; gap: 10px; } +.pony-input { + flex: 1; + padding: 12px 14px; + border-radius: 10px; + border: 1px solid #3a2f1e; + background: var(--pn-panel); + color: var(--pn-text); + font-size: 15px; +} +.pony-input:focus { outline: none; border-color: var(--pn-accent); } +.pony-input:disabled { opacity: 0.5; } +.pony-send { + padding: 12px 20px; + border-radius: 10px; + border: none; + background: var(--pn-accent); + color: #241d14; + font-weight: 700; + cursor: pointer; +} +.pony-send:disabled { opacity: 0.5; cursor: default; } diff --git a/src/main.ts b/src/main.ts index 373bf70..56a8997 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,8 +14,9 @@ import { openRoomJoin } from './shell/overlays'; import blobs from './games/blobs'; import party from './games/party'; import siege from './games/siege'; +import npcChat from './games/npc-chat'; -const GAMES: GameModule[] = [blobs, party, siege]; +const GAMES: GameModule[] = [blobs, party, siege, npcChat]; const app = document.getElementById('app') as HTMLElement; const config = getPlotConfig(); diff --git a/src/plot-config.ts b/src/plot-config.ts index 96272a2..b21370e 100644 --- a/src/plot-config.ts +++ b/src/plot-config.ts @@ -75,6 +75,7 @@ const GAME_APPS: Record = { blobs: { appKey: 'pl_pub_live_QAyEo-Vh0H3oe85uUiP_B-leg-BWB7Z2KQTlAYCPriE', appId: 'blobs' }, party: { appKey: 'pl_pub_live_6iyGpKoJSS3TYk-xziw3MPBEmnBiKcabqP2K-FNDdDA', appId: 'party' }, siege: { appKey: 'pl_pub_live_McaY6Kx-TtPe7zjWg-0qnbYYKGSUB8dsN_ClUSkes6A', appId: 'siege' }, + 'npc-chat': { appKey: 'pl_pub_live_N4u1yTmxmOfju6mArkywP3777WvOQLW-svrQ0KIv2-8', appId: 'npc-chat' }, }; /** diff --git a/vendor/plot-handler/index.d.ts b/vendor/plot-handler/index.d.ts index bea2c20..30c3df3 100644 --- a/vendor/plot-handler/index.d.ts +++ b/vendor/plot-handler/index.d.ts @@ -1,4 +1,4 @@ -import { Channel, Profile, PeriodOverride, LeaderboardEntry } from './protocol'; +import { Channel, Profile, PeriodOverride, LeaderboardEntry } from '@plot/protocol'; /** * Read-only view of the world at a past timestamp. @@ -40,6 +40,89 @@ interface ReplayApi { data: unknown; }): void; } +interface AssetMeta { + path: string; + hash: string; + size: number; + contentType: string; + visibility: 'public' | 'private'; +} +interface AssetsApi { + /** Public, immutable content-hash CDN URL. Synchronous, offline (built from + * the seeded manifest). Throws if the path is unknown or private (use + * `signedUrl`). */ + url(path: string): string; + /** Short-lived signed URL for a private asset (ttl seconds, clamped to an app + * ceiling). Resolves via local HMAC for managed/proxy apps. */ + signedUrl(path: string, opts?: { + ttl?: number; + }): Promise; + /** Manifest read from the seeded map — no I/O. */ + list(prefix?: string): Promise; +} +/** The completion of an NPC line requested via `ctx.ai.npc(id).say()`. Delivered + * later via the room's `onAiResult(result, ctx)` — never returned inline. */ +interface AiResult { + requestId: string; + npcId: string; + from: string; + /** The generated line, or '' when `error` is set. */ + text: string; + usage?: { + inputTokens: number; + outputTokens: number; + cached: boolean; + }; + /** `timeout` = the request was in flight when the room was evicted and could + * not complete; the reaper delivered this so your handler never hangs. + * `blocked` = the generated line failed the (opt-in) moderation filter. */ + error?: 'rate_limited' | 'quota_exceeded' | 'disabled' | 'model_error' | 'timeout' | 'blocked'; + /** Tool calls the NPC requested (when `say({ tools })` was used). Present + * instead of `text` when the model chose to call a tool. */ + toolCalls?: AiToolCall[]; +} +/** A function/tool the NPC decided to invoke. Your `onAiResult` executes it and + * may continue the exchange with another `say()`. */ +interface AiToolCall { + /** The tool name (matches one you passed in `say({ tools })`). */ + name: string; + /** Arguments the model produced, parsed from its JSON. */ + arguments: Record; +} +/** A tool the NPC may call, declared per `say()`. The model is told the name, + * description, and JSON-schema parameters; if it calls the tool, the call + * arrives on `onAiResult.toolCalls` for your handler to execute. */ +interface AiToolDef { + name: string; + description: string; + /** JSON Schema for the arguments object (as Workers AI expects). */ + parameters: Record; +} +interface NpcHandle { + /** Request one in-character NPC line. Records an intent and resolves with a + * requestId — it does NOT return the line; the completion arrives later via + * `onAiResult` (matching requestId). maxTokens is clamped server-side. + * + * - `stream: true` streams the line to the room's players as it generates + * (an `ai-chunk` message per delta); the final `onAiResult` still fires. + * - `tools` lets the NPC call your game functions — a chosen call arrives on + * `onAiResult.toolCalls` instead of `text`. */ + say(input: { + from: string; + text: string; + conversationKey?: string; + maxTokens?: number; + stream?: boolean; + tools?: AiToolDef[]; + }): Promise<{ + requestId: string; + }>; + /** Reset short-term memory for a conversation (default key = the player id). */ + forget(conversationKey?: string): Promise; +} +interface AiApi { + npc(id: string): NpcHandle; +} interface HandlerContext { state: S; roomCode: string; @@ -68,6 +151,8 @@ interface HandlerContext { leaderboard: (name: string) => LeaderboardHandle; save: SaveApi; replay: ReplayApi; + assets: AssetsApi; + ai: AiApi; /** * Run `cb` against the world state at `targetTs` (clamped to the * server's 500ms rewind horizon). Use this to validate hits or other @@ -116,7 +201,13 @@ interface RoomDefinition { * Workers-for-Platforms isolate boundary). */ onTimer?: (payload: unknown, ctx: HandlerContext) => void | Promise; + /** + * Fired when an NPC completion requested via `ctx.ai.npc(id).say()` is ready. + * Delivered as a fresh RPC (like onTimer) — the originating say() call returned + * a requestId long before the model finished. `result.requestId` matches. + */ + onAiResult?: (result: AiResult, ctx: HandlerContext) => void | Promise; } declare function defineRoom(def: RoomDefinition): RoomDefinition; -export { type ChannelConfig, type ChannelsConfig, type HandlerContext, HandlerReject, type LeaderboardHandle, PersistenceError, type Player, type ProfileApi, type ReplayApi, type RewoundContext, type RoomDefinition, type SaveApi, defineRoom }; +export { type AiApi, type AiResult, type AssetMeta, type AssetsApi, type ChannelConfig, type ChannelsConfig, type HandlerContext, HandlerReject, type LeaderboardHandle, type NpcHandle, PersistenceError, type Player, type ProfileApi, type ReplayApi, type RewoundContext, type RoomDefinition, type SaveApi, defineRoom }; diff --git a/vendor/plot-handler/protocol.d.ts b/vendor/plot-handler/protocol.d.ts index 4d998c0..b268ce8 100644 --- a/vendor/plot-handler/protocol.d.ts +++ b/vendor/plot-handler/protocol.d.ts @@ -12,6 +12,8 @@ type ConnectResponse = { token: string; expiresAt: number; wsUrl: string; + assetBaseUrl?: string; + assetSignMode?: 'local' | 'server'; }; type ServerEnvelope = { type: 'join'; @@ -43,6 +45,11 @@ type ServerEnvelope = { type: 'reconnect-token'; token: string; expiresAt: number; +} | { + type: 'ai-chunk'; + requestId: string; + npcId: string; + delta: string; } | { type: 'error'; code: string;