Spaces:
Running
Running
| // 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<ReactionKind, number> = { | |
| 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<PeekEmotion, ReactionKind> = { | |
| 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<PeekEmotion, readonly string[]> = { | |
| 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<string, Reaction> = { | |
| 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 | |
| } | |