// --------------------------------------------------------------------------- // NPC accounts — the client-side presentation layer for decorative NPCs (PLAN §9). // // The engine/wire/server never know NPCs exist; the map only authors, on a marker // or area node, one-or-more `town.deception.npc` properties whose value is an NPC // account DID (+ optional weight — see `npcEntriesOf` in render/properties.ts). // Each such account self-describes: `actor.rpg.sprite/self` (the walk sheet) + // `town.deception.npc/self` (behaviour). There is no central catalog — we resolve // each DID directly and cache the result, so a herd of the same DID shares one // loaded sheet. // // `buildNpc()` turns a loaded account + a placement (position, stable seed, on-map // cell size, leash bounds) into a ready `NpcDef` + seeded `rng` for render/npc.ts's // `spawnNpc`/`NpcGroup`. Presentation only — none of this crosses the WebSocket. // --------------------------------------------------------------------------- import { mulberry32, type NpcDef, type NpcWeights, type Rect, type NpcEntry } from '../../render/index' import { loadSpriteSheet, selfSpriteUri, type LoadedSprite } from './sprite-import' import { parseAtUri, resolvePds } from './map-import' /** Behaviour read from a `town.deception.npc/self` record (all fields optional; * absent ones fall back to the render engine's defaults). */ interface NpcBehaviour { wanderRadius: number speed: number yieldRadius: number weights: NpcWeights idleMs: [number, number] spinMs: [number, number] /** on-map height in eighths-of-a-cell, overriding the sprite's own value. */ sizeEighths?: number /** an emoji the NPC occasionally emotes above its head. */ emoji?: string } /** A fully-loaded NPC account: its walk-sheet clips + parsed behaviour. */ interface LoadedNpc { sheet: LoadedSprite behaviour: NpcBehaviour } /** What a built NPC needs to enter the world: its def plus a seeded motion rng. */ export interface BuiltNpc { def: NpcDef /** seeded so every viewer animates this placement identically (and stably). */ rng: () => number } interface NpcRecordValue { wanderRadius?: number speed?: number yieldRadius?: number idleWeight?: number spinWeight?: number wanderWeight?: number idleMinMs?: number idleMaxMs?: number spinMinMs?: number spinMaxMs?: number sizeEighths?: number emoji?: string [k: string]: unknown } /** A non-negative int field, or `fallback` when absent/unparseable. */ function intOr(v: number | undefined, fallback: number): number { return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback } /** Order a [min,max] pair so randRange never gets an inverted range. */ function range(min: number, max: number): [number, number] { return min <= max ? [min, max] : [max, min] } function toBehaviour(rec: NpcRecordValue): NpcBehaviour { return { wanderRadius: intOr(rec.wanderRadius, 64), speed: intOr(rec.speed, 45), yieldRadius: intOr(rec.yieldRadius, 56), weights: { idle: intOr(rec.idleWeight, 3), spin: intOr(rec.spinWeight, 1), wander: intOr(rec.wanderWeight, 2), }, idleMs: range(intOr(rec.idleMinMs, 800), intOr(rec.idleMaxMs, 2600)), spinMs: range(intOr(rec.spinMinMs, 700), intOr(rec.spinMaxMs, 1800)), ...(typeof rec.sizeEighths === 'number' && rec.sizeEighths > 0 ? { sizeEighths: rec.sizeEighths } : {}), ...(typeof rec.emoji === 'string' && rec.emoji ? { emoji: rec.emoji } : {}), } } async function fetchNpcRecord(did: string): Promise { const pds = await resolvePds(did) const url = `${pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}` + `&collection=town.deception.npc&rkey=self` const res = await fetch(url) if (!res.ok) throw new Error(`town.deception.npc/self getRecord failed (${res.status}) for ${did}`) return ((await res.json()) as { value: NpcRecordValue }).value } // One load per DID, shared across every placement drawn from it (clip textures are // immutable views, safe to reuse). A failed load is cached as a rejection so we // don't re-hammer a dead account on each placement. const accountCache = new Map>() /** Load (and cache) an NPC account by DID: its walk sheet + behaviour. Rejects if * the sprite or npc record can't be fetched (the caller skips that placement). */ export function loadNpcAccount(did: string): Promise { let p = accountCache.get(did) if (!p) { p = (async () => { const [sheet, rec] = await Promise.all([loadSpriteSheet(selfSpriteUri(did)), fetchNpcRecord(did)]) return { sheet, behaviour: toBehaviour(rec) } })() p.catch(() => accountCache.delete(did)) accountCache.set(did, p) } return p } /** Build a ready NPC for a placement. `area` (an area crowd's leash bounds) wins * when given; otherwise the NPC leashes to a `wanderOverride`- or behaviour-radius * box around (`x`,`y`) — the point-marker case. */ export function buildNpc( loaded: LoadedNpc, opts: { x: number; y: number; seed: number; cellH: number; area?: Rect; wanderOverride?: number }, ): BuiltNpc { const { sheet, behaviour } = loaded const eighths = behaviour.sizeEighths ?? sheet.cellHeightEighths const scale = eighths && sheet.frameHeight > 0 ? ((eighths / 8) * opts.cellH) / sheet.frameHeight : 1 const radius = opts.wanderOverride ?? behaviour.wanderRadius const area: Rect = opts.area ?? { x: opts.x - radius, y: opts.y - radius, w: radius * 2, h: radius * 2, } const def: NpcDef = { clips: sheet.clips, weights: behaviour.weights, area, speed: behaviour.speed, yieldRadius: behaviour.yieldRadius, idleMs: behaviour.idleMs, spinMs: behaviour.spinMs, scale, ...(behaviour.emoji ? { emoji: behaviour.emoji } : {}), } return { def, rng: mulberry32(opts.seed ^ 0x9e3779b9) } } /** Pick one entry's DID by weight, deterministically from `seed`. For a marker that * lists several critters: a stable per-placement choice so a row of markers varies. */ export function weightedPick(entries: NpcEntry[], seed: number): string | null { const total = entries.reduce((s, e) => s + e.weight, 0) if (total <= 0) return null let roll = mulberry32(seed)() * total for (const e of entries) { roll -= e.weight if (roll < 0) return e.did } return entries[entries.length - 1]!.did } /** Union bounding box of an area's rects — the leash a scattered crowd roams. */ export function bboxOf(rects: Rect[]): Rect | null { if (rects.length === 0) return null let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity for (const r of rects) { minX = Math.min(minX, r.x) minY = Math.min(minY, r.y) maxX = Math.max(maxX, r.x + r.w) maxY = Math.max(maxY, r.y + r.h) } return { x: minX, y: minY, w: maxX - minX, h: maxY - minY } } /** A point inside an area's rects, drawn from `rng` (area-weighted rect choice then * a uniform point within it), so a crowd scatters proportionally across the boxes. */ export function scatterPoint(rects: Rect[], rng: () => number): { x: number; y: number } | null { if (rects.length === 0) return null const total = rects.reduce((s, r) => s + r.w * r.h, 0) let roll = rng() * total let pick = rects[0]! for (const r of rects) { roll -= r.w * r.h pick = r if (roll < 0) break } return { x: pick.x + rng() * pick.w, y: pick.y + rng() * pick.h } } /** Stable 32-bit seed from a node id (+ optional index), so every viewer derives the * same DID choice + scatter + motion for a placement (and it's stable across reloads). */ export function seedFromId(id: string): number { let h = 0 for (let i = 0; i < id.length; i++) h = (Math.imul(h, 31) + id.charCodeAt(i)) >>> 0 return h }