puck / frontend /src /engine /reactions.ts
vu1n's picture
Puck — desktop fairy familiar (HF Build Small)
3c124f3
Raw
History Blame Contribute Delete
6.22 kB
// 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
}