// Puck's visible reactions — the personality layer that sits on top of judgment. // The engine already decides *whether* to surface an event (the Decision ladder) // and *what words* to use (speech tiers); this decides *how he physically reacts*: // a one-shot body gesture plus an optional shout floating over the sprite. // Pure data — SimApp plays the gesture; nothing here touches React or the DOM. import type { EventDef, Rating, Rng } from "./types"; import { pick } from "./util"; export type ReactionKind = | "celebrate" // joyful bounce + spin — a win landed | "nani" // anime freeze-frame snap-zoom — alarm, "wait, WHAT?" | "perk" // lean-in scale-up — curiosity, something worth a look | "shrug" // deflated tilt — nonchalance, "not this again" | "sulk" // droop + shrink — chastened, an annoyed swat landed | "dance" // wiggle spin — delight / high mischief | "summon" // big insistent size-up + bounce — "hey, look at this!" (replaces the // screen-takeover interrupt modal: demands attention without blocking) | "laugh" // giggle bounce — found something funny (a meme, a joke) | "sad" // droop + dim — something poignant, lonely, or failing | "fret" // recoil + tremble — the human seems angry or frustrated | "pop"; // tiny default acknowledgement export interface Reaction { kind: ReactionKind; /** Floating exclamation over the sprite; omit for a wordless gesture. */ shout?: string; /** One-shot duration (ms). Kept in lockstep with the CSS animation lengths in * ui.css (.puck.react-*) so SimApp clears the reaction exactly as it ends. */ ms: number; } const MS: Record = { celebrate: 1100, nani: 900, perk: 750, shrug: 1000, sulk: 1050, dance: 1500, summon: 1600, laugh: 1200, sad: 1300, fret: 1100, pop: 480, }; const make = (kind: ReactionKind, shout?: string): Reaction => ({ kind, shout, ms: MS[kind] }); // ---- peek emotions: how Puck FEELS about a patch he peeked at ---------------- // The vision brain classifies each peek into one of these (it's a learning, not-too- // clever sprite — confusion is common and endearing). Each maps to a body gesture + // a pool of shouts. This is the companion loop's personality layer (the event map // below is the older, dormant notification path). export type PeekEmotion = "curious" | "confused" | "amused" | "delighted" | "worried" | "wistful"; const EMOTION_GESTURE: Record = { curious: "perk", confused: "nani", // it doesn't understand — the freeze-frame "wait, what IS that?" amused: "laugh", delighted: "celebrate", worried: "fret", // the human seems angry/frustrated, or errors are piling up wistful: "sad", }; const EMOTION_SHOUTS: Record = { curious: ["ooh?", "hm?", "?"], confused: ["NANI?!", "eh?!", "wha—", "huh?!"], amused: ["pfft", "hehe", "haha!", "heh"], delighted: ["ooh!", "yes!", "nice!", "✨"], worried: ["eep", "easy…", "uh oh", "yikes"], wistful: ["…oh", "hm.", "…"], }; /** Canonical emotion list (mirrors PEEK_EMOTIONS in brain.py). */ export const PEEK_EMOTIONS = Object.keys(EMOTION_GESTURE) as PeekEmotion[]; /** Coerce an arbitrary brain string to a known emotion (defaults to curious). */ export function asPeekEmotion(s: string | undefined | null): PeekEmotion { return s != null && Object.hasOwn(EMOTION_GESTURE, s) ? (s as PeekEmotion) : "curious"; } /** Map a peeked-at emotion to the gesture Puck plays, with a fitting shout. * A giddy (high-mischief) Puck escalates plain delight into a full dance. */ export function reactionForEmotion( emotion: PeekEmotion, opts: { mischief?: number; rng?: Rng } = {}, ): Reaction { const { mischief = 45, rng = Math.random } = opts; const shout = pick(EMOTION_SHOUTS[emotion], rng); if (emotion === "delighted" && mischief > 70 && rng() < 0.5) return make("dance", shout); return make(EMOTION_GESTURE[emotion], shout); } /** The attention-grab for important events — Puck's non-blocking answer to the * old interrupt modal. Triggered by the orchestrator, not the event→reaction map. */ export function summonReaction(): Reaction { return make("summon", "!"); } // Base reaction per event id. Anything not listed falls back to a small pop. const BY_ID: Record = { claude_done: make("celebrate", "done!"), claude_permission: make("perk", "?"), build_done: make("celebrate", "green!"), build_fail: make("nani", "NANI?!"), mail_important: make("perk"), mail_finance: make("shrug"), discord_mention: make("perk", "!"), discord_noise: make("shrug"), calendar_soon: make("nani", "!"), stale_tab: make("shrug"), browser_newpage: make("perk", "ooh"), browser_rabbithole: make("perk", "deeper…"), git_push: make("celebrate", "shipped!"), }; // Decay toward nonchalance when the same thing keeps happening — the first push // is "shipped!", the second a quiet pop, the third a tired shrug. const REPEAT_SHRUGS = ["again?", "…another one", "not again", "sure, fine"] as const; /** How Puck physically reacts to an event surfacing. * Repetition is the personality beat (celebrate → pop → shrug), but *alarms* * keep their bite no matter how often they recur — a fire is always a fire. */ export function reactionForEvent( ev: EventDef, opts: { repeats?: number; mischief?: number; rng?: Rng } = {}, ): Reaction { const { repeats = 0, mischief = 45, rng = Math.random } = opts; const base = BY_ID[ev.id] ?? make("pop"); if (base.kind !== "nani") { if (repeats >= 2) return make("shrug", pick(REPEAT_SHRUGS, rng)); if (repeats === 1) return make("pop"); // muted second take } // a high-mischief Puck sometimes turns a celebration into a little dance if (base.kind === "celebrate" && mischief > 70 && rng() < 0.4) return make("dance", base.shout); return base; } /** Reaction to the user rating one of Puck's comments. */ export function reactionForRating(rating: Rating): Reaction { if (rating === "cute") return make("dance", "♪"); if (rating === "annoying") return make("sulk", "…sorry"); return make("pop"); // helpful — a small satisfied pop }