puck / frontend /src /engine /nightBloom.ts
vu1n's picture
Puck β€” desktop fairy familiar (HF Build Small)
3c124f3
Raw
History Blame Contribute Delete
3.8 kB
import { EVENTS } from "./events";
import type { FairyStat, FeedItem, MemorySeed, NightBloomResult, Rating, Rng } from "./types";
import { pick } from "./util";
const DREAMS = [
"I walked across three desktops. One smelled of code smoke, one of unopened letters, one of abandoned tabs.",
"I dreamed Desktop 2 was a forest of half-built things, all of them humming to be finished.",
"In the dream the inbox was a swamp and I was a small loyal frog, choosing which croaks to carry to you.",
"I dreamed I grew a second pair of wings, just for patrolling the corners you forget.",
] as const;
const MORNINGS = [
"I dreamed of Desktop 2. It is where unfinished things go to molt.",
"Morning. I sorted the moss from the noise. I am a slightly better familiar than yesterday.",
"I woke a little less chatty and a little more loyal to the things you leave running.",
"The bog is quieter in my head now. I kept what mattered, composted the rest.",
] as const;
/** A surfaced comment's learning signal, derived from BEHAVIOR (not buttons):
* tapping through = wanted it, swatting away = too noisy. Explicit ratings (the
* sim's bubble/modal) still win when present. This is the heart of "learn while
* you sleep" β€” the day's actions become tomorrow's taste. */
export function outcomeRating(item: FeedItem): Rating | null {
if (item.rating) return item.rating;
const o = item.outcome;
if (!o) return null;
if (o.type === "clicked_through") return "helpful"; // opened it β†’ worth surfacing
if (o.type === "swatted" || o.type === "dismissed") return "annoying"; // flung away β†’ too much
return null; // expired / unresolved β€” no clear signal, don't punish silence
}
/** Consolidate the day's feed into personality drift, memories, and a dream. */
export function nightBloom(log: FeedItem[], rng: Rng = Math.random): NightBloomResult {
const signals = log
.map((e) => ({ e, r: outcomeRating(e) }))
.filter((s): s is { e: FeedItem; r: Rating } => s.r != null);
const counts: Record<Rating, number> = { helpful: 0, annoying: 0, cute: 0 };
for (const s of signals) counts[s.r] += 1;
// personality drift from the day's reactions
const deltas: NightBloomResult["deltas"] = {};
const add = (k: FairyStat | "age", v: number) => {
deltas[k] = (deltas[k] || 0) + v;
};
if (counts.helpful) {
add("protectiveness", counts.helpful * 2);
add("attachment", counts.helpful);
}
if (counts.annoying) {
add("chattiness", -counts.annoying * 3);
add("mischief", -counts.annoying * 2);
add("protectiveness", counts.annoying);
}
if (counts.cute) {
add("mischief", counts.cute * 2);
add("chattiness", -counts.cute);
}
if (!signals.length) add("curiosity", 2);
add("age", 1); // a small drift each night regardless
// memories: promote the strongest signals (a thing tapped-through or swatted)
const memories: MemorySeed[] = [];
const seen = new Set<string>();
for (const { e, r } of signals) {
const def = EVENTS.find((x) => x.id === e.id);
if (!def || seen.has(def.id)) continue;
if (r === "helpful" || r === "annoying") {
memories.push({ ...def.memory });
seen.add(def.id);
}
}
if (!memories.length && log.length) {
memories.push({
text: "Vu mostly let me patrol in peace today. I'll trust my own taste more.",
type: "tone",
icon: "🌿",
});
}
const rules: string[] = [];
if (counts.helpful) rules.push("Keep surfacing Claude and build completions promptly.");
if (counts.annoying) rules.push("Fewer interruptions for low-stakes noise.");
if (counts.cute) rules.push("A little more theatre is allowed.");
return {
deltas,
memories,
rules,
dream: pick(DREAMS, rng),
morning: pick(MORNINGS, rng),
counts,
};
}