Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions src/games/npc-chat/handler.ts
Original file line number Diff line number Diff line change
@@ -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<TavernState, ChatMsg>({
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;
150 changes: 150 additions & 0 deletions src/games/npc-chat/index.ts
Original file line number Diff line number Diff line change
@@ -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 = `
<div class="pony-header">
<div class="pony-title">The Prancing Pony</div>
<div class="pony-sub">connecting…</div>
</div>
<div class="pony-log" role="log" aria-live="polite"></div>
<form class="pony-form">
<input class="pony-input" type="text" maxlength="400" autocomplete="off"
placeholder="Say something to Bram…" disabled />
<button class="pony-send" type="submit" disabled>Send</button>
</form>`;
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);
};
}
91 changes: 91 additions & 0 deletions src/games/npc-chat/npc-chat.css
Original file line number Diff line number Diff line change
@@ -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; }
3 changes: 2 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/plot-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const GAME_APPS: Record<string, { appKey: string; appId: string }> = {
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' },
};

/**
Expand Down
Loading
Loading