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
20 changes: 15 additions & 5 deletions src/games/npc-chat/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@
* 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.
* Cost shape: a low tickRate (5 Hz) drives the platform's rules-based Filler AI
* so a few "regulars" drift in and chatter ambiently alongside Bram, making the
* tavern feel lived-in even with one traveller present. Filler is ZERO-token
* (rules-based presence bots run on the room's in-memory tick — no LLM, no
* ai_usage, no durable alarm/storage writes; see packages/server/src/ai/filler.ts).
* The only billed cost is still Bram's LLM replies (one "AI message" each);
* per-player rate limits, the monthly cap, and moderation are enforced
* server-side. The idle-input watchdog stops the tick (and clears the bots) a
* few minutes after the last real message, so an empty tavern costs nothing.
*/
import { defineRoom } from '@plot/handler';

Expand Down Expand Up @@ -42,7 +47,12 @@ export const handler = defineRoom<TavernState, ChatMsg>({
// Chat is reliable + ordered; nothing is high-frequency here.
event: { reliable: true, ordered: true, rateLimit: { perPlayer: 4, perSeconds: 10 } },
},
tickRate: 0,
// A gentle tick so the platform's Filler AI "regulars" can drift in and chat
// ambiently. There is no onTick handler — the room simulates nothing; the tick
// only exists to let the server reconcile filler presence + ambient chatter.
// 5 Hz is well under the starter tier's 20 Hz cap and keeps idle cost minimal
// (the watchdog parks the tick after the last real message).
tickRate: 5,

onJoin(player, ctx) {
ctx.sendTo(player.id, 'event', {
Expand Down
41 changes: 39 additions & 2 deletions src/games/npc-chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
* 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.
*
* A few "regulars" also drift through — these are the platform's rules-based
* Filler AI presence bots (`bot_`-prefixed players). They cost ZERO LLM tokens:
* their ambient chatter arrives as plain `chat`-channel messages ({ text }) and
* their comings/goings as `join`/`leave` presence, all driven server-side. We
* render them as muted patron bubbles so the tavern feels lived-in without
* spending anything beyond Bram's replies.
*/
import './npc-chat.css';
import { Plot, type Room } from '@plot/client';
Expand Down Expand Up @@ -62,6 +69,15 @@ function mountPony(host: HTMLElement, config: PlotConfig, roomCode: string): ()
// Track the in-flight streamed NPC bubble so npcChunk deltas append to it.
let streamingBubble: HTMLElement | null = null;

// "Regulars" = the platform's Filler AI presence bots (bot_-prefixed ids).
const isRegular = (id: string): boolean => id.startsWith('bot_');
const regulars = new Set<string>();
const baseSub = (): string => `You are ${nameFor(me)} · room ${roomCode}`;
function refreshSub(): void {
const n = regulars.size;
sub.textContent = n > 0 ? `${baseSub()} · ${n} regular${n === 1 ? '' : 's'} by the fire` : baseSub();
}

const atBottom = (): boolean => logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 40;
const scroll = (was: boolean): void => { if (was) logEl.scrollTop = logEl.scrollHeight; };

Expand Down Expand Up @@ -94,12 +110,19 @@ function mountPony(host: HTMLElement, config: PlotConfig, roomCode: string): ()
}).join({ mode: 'code', roomCode });
if (disposed) { room.leave(); return; }

sub.textContent = `You are ${nameFor(me)} · room ${roomCode}`;
refreshSub();
input.disabled = false;
sendBtn.disabled = false;
input.focus();

room.on('message', (e) => {
// Filler "regulars" chatter arrives as a plain chat-channel line from a
// bot_ id (no NpcEvent `kind`). Render it as a muted patron bubble.
if (isRegular(e.from) && e.channel === 'chat') {
const text = (e.data as { text?: unknown } | null)?.text;
if (typeof text === 'string' && text) bubble('regular', nameFor(e.from), text);
return;
}
const ev = e.data as NpcEvent;
if (!ev || typeof ev !== 'object') return;
if (ev.kind === 'player') {
Expand All @@ -115,6 +138,18 @@ function mountPony(host: HTMLElement, config: PlotConfig, roomCode: string): ()
}
});

// Regulars drifting in/out — update the "N regulars by the fire" subline.
room.on('join', ({ playerId }) => {
if (!isRegular(playerId)) return;
regulars.add(playerId);
refreshSub();
});
room.on('leave', ({ playerId }) => {
if (!isRegular(playerId)) return;
regulars.delete(playerId);
refreshSub();
});

// Streamed tokens: append each delta to the current Bram bubble.
room.on('npcChunk', ({ delta }) => {
const was = atBottom();
Expand All @@ -125,9 +160,11 @@ function mountPony(host: HTMLElement, config: PlotConfig, roomCode: string): ()
});

room.on('disconnect', ({ willRetry }) => {
// A fresh room has no bots yet; clear so the subline is accurate on retry.
regulars.clear();
sub.textContent = willRetry ? 'reconnecting…' : 'disconnected';
});
room.on('reconnect', () => { sub.textContent = `You are ${nameFor(me)} · room ${roomCode}`; });
room.on('reconnect', () => { refreshSub(); });
} catch {
sub.textContent = 'could not reach the tavern — try again';
}
Expand Down
12 changes: 11 additions & 1 deletion src/games/npc-chat/npc-chat.css
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
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-msg.them, .pony-msg.npc, .pony-msg.regular { align-self: flex-start; }

.pony-who { font-size: 11px; color: var(--pn-muted); padding: 0 4px; }
.pony-text {
Expand All @@ -66,6 +66,16 @@
color: #d8f0d8;
border-bottom-left-radius: 4px;
}
/* "Regulars" — Filler AI presence bots. Dim, italic background chatter so they
read as ambient patrons and never compete with Bram or real travellers. */
.pony-msg.regular { max-width: 68%; opacity: 0.72; }
.pony-msg.regular .pony-text {
background: transparent;
border: 1px dashed #4a3c26;
color: var(--pn-muted);
font-style: italic;
border-bottom-left-radius: 4px;
}

.pony-form { display: flex; gap: 10px; }
.pony-input {
Expand Down
60 changes: 60 additions & 0 deletions src/games/siege/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,66 @@ describe('Siege — self-healing persistent room', () => {
expect(s.players['p1']!.hp).toBe(100);
});

it('a player joining a room parked on DEFEAT is not left on the game-over screen (regression: instantly-over on join)', () => {
// A persistent room whose tick-loop halted while parked on DEFEAT (a
// zero-socket room does not tick, so the 4s auto-restart never fired) still
// has a lingering player record from an abnormal drop / phantom socket — so
// the newcomer is NOT index 0. Before the fix, resetWorld only ran on
// index === 0, leaving this joiner staring at "DEFEAT — base destroyed".
const s = baseState({
phase: 'lost',
baseHp: 0,
wave: 7,
score: 640,
players: { phantom: { pos: { x: 10, y: 10 }, hp: 0 } } as never,
});
handler.onJoin!({ id: 'newcomer', joinedAt: 0 }, ctx(s));
expect(s.phase).toBe('playing'); // fresh siege, NOT stuck on DEFEAT
expect(s.baseHp).toBe(100);
expect(s.wave).toBe(1);
expect(s.score).toBe(0);
expect(s.players['newcomer']).toBeDefined();
expect(s.players['newcomer']!.hp).toBe(100);
});

it('a second player joining during the WIN/DEFEAT hold window gets a fresh siege', () => {
// p1 present + room on the DEFEAT hold (pre auto-restart). p2 joins (index
// !== 0). The newcomer must land in a playing game, not the results screen.
const s = baseState({
phase: 'lost',
baseHp: 0,
wave: 4,
score: 200,
players: { p1: { pos: { x: 0, y: 0 }, hp: 0 } } as never,
});
handler.onJoin!({ id: 'p2', joinedAt: 0 }, ctx(s));
expect(s.phase).toBe('playing');
expect(s.baseHp).toBe(100);
expect(Object.keys(s.players).sort()).toEqual(['p1', 'p2']); // both kept, respawned
expect(s.players['p2']!.hp).toBe(100);
});

it('does NOT reset a room that is actively playing when a newcomer joins', () => {
// Guard: the broadened reset must only fire on a non-playing phase. A live
// mid-wave game must be untouched by a second player joining.
const s = baseState({
phase: 'playing',
baseHp: 55,
wave: 3,
score: 120,
players: { p1: { pos: { x: 0, y: 0 }, hp: 80 } } as never,
enemies: { 'w3-0': { pos: { x: 100, y: 0 }, hp: 20 } } as never,
});
handler.onJoin!({ id: 'p2', joinedAt: 0 }, ctx(s));
expect(s.phase).toBe('playing');
expect(s.baseHp).toBe(55); // unchanged
expect(s.wave).toBe(3); // unchanged
expect(s.score).toBe(120); // unchanged
expect(Object.keys(s.enemies)).toHaveLength(1); // wave preserved
expect(s.players['p1']!.hp).toBe(80); // existing player NOT respawned
expect(s.players['p2']).toBeDefined();
});

it('auto-restarts a finished game after the hold delay', () => {
const s = baseState({ phase: 'lost', baseHp: 0, wave: 6, score: 500, players: { p1: { pos: { x: 10, y: 10 }, hp: 0 } } as never });
// First terminal tick arms the restart timer; state stays 'lost'.
Expand Down
18 changes: 14 additions & 4 deletions src/games/siege/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,20 @@ export default defineRoom<State, Msg>({

onJoin(player, ctx: HandlerContext<State>) {
const index = Object.keys(ctx.state.players).length;
// First player into a room starts a fresh siege — otherwise they'd inherit
// a persistent room's stale/finished state (e.g. a base left destroyed).
if (index === 0) resetWorld(ctx.state);
ctx.state.players[player.id] = { pos: spawnPlayerPos(index), hp: 100 };
// Start a fresh siege when a player joins into a room that isn't actively
// playing: either the first player into a room (index 0), OR anyone joining
// a room parked on a WIN/DEFEAT result. A persistent room can freeze on a
// terminal phase — the auto-restart only runs while the tick loop is alive,
// and the loop stops once the room empties (a zero-socket room does not
// tick), so a `won`/`lost` snapshot can be durable across an empty period.
// Gating the reset on `index === 0` alone left the next joiner staring at
// "DEFEAT — base destroyed" whenever a stale player record lingered
// (abnormal drop still in its grace window, or a phantom socket), making the
// demo look "instantly over" on join. Resetting on any non-playing phase is
// safe: an in-progress (`playing`) room is never reset by a newcomer.
if (index === 0 || ctx.state.phase !== 'playing') resetWorld(ctx.state);
const spawnIndex = Object.keys(ctx.state.players).length;
ctx.state.players[player.id] = { pos: spawnPlayerPos(spawnIndex), hp: 100 };
},

onMessage(player, msg, ctx: HandlerContext<State>) {
Expand Down
21 changes: 21 additions & 0 deletions vendor/plot-client/handler.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,24 @@ interface NpcHandle {
interface AiApi {
npc(id: string): NpcHandle;
}
interface AccountsApi {
/** The player's managed account, or null when identity is off / unknown. */
get(playerId: string): Promise<{
playerId: string;
kind: 'anonymous' | 'registered';
status: 'active' | 'banned';
methods: string[];
} | null>;
/** Server-authoritative per-player key/value store (durable, anti-cheat). */
data(playerId: string): {
get<T = unknown>(key: string): Promise<T | null>;
getAll(): Promise<Record<string, unknown>>;
set(key: string, value: unknown): Promise<void>;
delete(key: string): Promise<void>;
};
/** Ban a player: flips account status and revokes their sessions. */
ban(playerId: string, reason?: string): Promise<void>;
}
interface HandlerContext<S> {
state: S;
roomCode: string;
Expand Down Expand Up @@ -153,6 +171,9 @@ interface HandlerContext<S> {
replay: ReplayApi;
assets: AssetsApi;
ai: AiApi;
/** Managed player accounts: identity, server-authoritative per-player data, and ban.
* (Distinct from `players`, which is the live in-room roster.) */
accounts: AccountsApi;
/**
* Run `cb` against the world state at `targetTs` (clamped to the
* server's 500ms rewind horizon). Use this to validate hits or other
Expand Down
78 changes: 76 additions & 2 deletions vendor/plot-client/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { QueueEntry, CorrectionType } from '@plot/handler-client';
export { CorrectionType } from '@plot/handler-client';
import { RoomDefinition } from '@plot/handler';
import { ClientEnvelope, ServerEnvelope, Channel, Profile, LeaderboardEntry, MatchMode } from '@plot/protocol';
import { ClientEnvelope, ServerEnvelope, Channel, Profile, LeaderboardEntry, PlayerIdentity, PlayerSessionResponse, MatchMode } from '@plot/protocol';
export * from '@plot/protocol';
export { CHANNELS, DEFAULT_CHANNEL, isChannel } from '@plot/protocol';

Expand Down Expand Up @@ -334,6 +334,74 @@ declare class AssetClient {
}): Promise<string>;
}

type Stored = {
sessionToken: string;
playerId: string;
kind: 'anonymous' | 'registered';
methods: string[];
};
declare class PlayerSessionStore {
private store;
private appKey;
constructor(store: LocalStore, appKey: string);
private key;
load(): Stored | null;
save(s: Stored): void;
clear(): void;
}
declare class AuthClient {
private apiUrl;
private appKey;
private appId;
private store;
private onChange?;
constructor(apiUrl: string, appKey: string, appId: string, store: PlayerSessionStore, onChange?: ((id: PlayerIdentity | null) => void) | undefined);
current(): PlayerIdentity | null;
isAuthenticated(): boolean;
getSessionToken(): string | null;
anonymous(): Promise<PlayerIdentity>;
logout(): Promise<void>;
/** Exchange the durable session for a short-lived connect assertion. Returns null when not signed in. */
mintIdentityToken(): Promise<string | null>;
register(opts: {
email: string;
password: string;
turnstileToken?: string;
}): Promise<PlayerIdentity>;
login(opts: {
email: string;
password: string;
}): Promise<PlayerIdentity>;
/** Upgrade the current (anonymous) session by attaching a password. The durable
* session token is preserved; the account becomes 'registered'. */
link(opts: {
email: string;
password: string;
}): Promise<PlayerIdentity>;
/** URL to send the browser to for Discord OAuth. On return, the callback appends
* `#session=...&playerId=...` (or `#error=...`) — pass that to completeFromRedirect. */
discordAuthUrl(redirect: string): string;
/** Parse the `#session=...&playerId=...` fragment the OAuth callback appended and
* store it. Returns null (no throw) when the fragment carries an error instead. */
completeFromRedirect(hash: string): PlayerIdentity | null;
/** Internal: persist a session response and notify. (register/login/link in Phase 3 reuse this.) */
persist(r: PlayerSessionResponse, methods: string[]): PlayerIdentity;
}

/** Client access to the managed per-player key/value store. Reads work for the
* authenticated player; writes only succeed on keys the app has marked
* client-writable (otherwise the server returns 403). */
declare class PlayerDataClient {
private apiUrl;
private tokenGetter;
constructor(apiUrl: string, tokenGetter: () => string);
private h;
get<T = unknown>(key: string): Promise<T | null>;
getAll(): Promise<Record<string, unknown>>;
set(key: string, value: unknown): Promise<void>;
delete(key: string): Promise<void>;
}

/**
* Managed netcode layer.
*
Expand Down Expand Up @@ -458,10 +526,16 @@ declare class Plot {
save: SaveClient;
leaderboard: (name: string) => LeaderboardClient;
assets: AssetClient;
auth: AuthClient;
playerData: PlayerDataClient;
constructor(options: PlotOptions);
/** The /v1/connect request body. Includes the developer JWT as `token` when a
* `playerToken` is configured (the server derives the player id from it). */
private connectBody;
/** Resolve the managed identity for a /v1/connect: mint a short-lived assertion
* from the durable auth session when signed in. Empty when there is no session
* (the legacy playerId/playerToken path then applies). */
private resolveIdentity;
/** Player id for keying local session storage. Falls back to the token when
* no explicit playerId is set (keeps a stable-per-token key). */
private get playerKey();
Expand Down Expand Up @@ -500,4 +574,4 @@ type Quat = {
w: number;
};

export { AssetClient, type AssetMeta, type FrameEvent, type FrameView, type Game, type InterpType, type InterpolateOpts, type LocalStore, MemoryStore, type PathDecl, type PlayOptions, Plot, type PredictOpts, type PredictedEvent, type Quat, Room, type SendOptions, type SessionState, SessionStore, type Vec2, type Vec3, WebStorageStore, createLocalStore, sessionKey };
export { AssetClient, type AssetMeta, AuthClient, type FrameEvent, type FrameView, type Game, type InterpType, type InterpolateOpts, type LocalStore, MemoryStore, type PathDecl, type PlayOptions, PlayerDataClient, PlayerSessionStore, Plot, type PredictOpts, type PredictedEvent, type Quat, Room, type SendOptions, type SessionState, SessionStore, type Vec2, type Vec3, WebStorageStore, createLocalStore, sessionKey };
Loading
Loading