feat(webchat): lobster pet charm bundle - names, celebrations, poke moods, night owl (#103149)

Every pet gets a seeded name shown via native hover tooltip (rare
palettes carry signature names: Goldie, Boo, Picasso, Patches, Lantern,
Blueberry, OG). A busy->idle mode flip now earns a cheer act (double hop
with a mid-air twirl, claws up) instead of a startle, so the lobster
celebrates finished runs. Three fast pokes make it grumpy for a minute
(angry brows + frown overlay); ten pokes send it off in a huff until a
later scheduled visit (offline pets are on duty and never huff).
Visits between 22:00 and 06:00 local always act sleepy regardless of
personality.
This commit is contained in:
Peter Steinberger
2026-07-10 01:15:20 +01:00
committed by GitHub
parent 8b300e57f0
commit e26a850fbd
3 changed files with 250 additions and 6 deletions

View File

@@ -5,6 +5,8 @@ import {
LOBSTER_PET_ACT_DURATION_MS,
LOBSTER_PET_MODE_ACTS,
createLobsterPetLook,
isLobsterNightTime,
lobsterPetName,
lobsterPetSeed,
resolveLobsterPetMode,
type LobsterPet,
@@ -150,6 +152,27 @@ describe("lobster pet look", () => {
});
});
describe("lobsterPetName", () => {
it("is deterministic and rare palettes carry signature names", () => {
for (let seed = 0; seed < 50; seed++) {
const look = createLobsterPetLook(seed);
const name = lobsterPetName(look, seed);
expect(name).toBe(lobsterPetName(look, seed));
expect(name.length).toBeGreaterThan(1);
}
const retroLook = {
...createLobsterPetLook(1),
palette: { id: "retro" as const, shell: "#e8262c", claw: "#f04a3e" },
};
expect(lobsterPetName(retroLook, 1)).toBe("OG");
const goldLook = {
...createLobsterPetLook(1),
palette: { id: "gold" as const, shell: "#f4b840", claw: "#f9d47a" },
};
expect(lobsterPetName(goldLook, 1)).toBe("Goldie");
});
});
describe("resolveLobsterPetMode", () => {
it("maps connection and run state to modes", () => {
expect(resolveLobsterPetMode(false, [{ hasActiveRun: true }])).toBe("offline");
@@ -197,6 +220,7 @@ describe("lobster pet element", () => {
it("schedules acts while perched", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
const element = createPet(42);
await arrive(element);
@@ -214,6 +238,7 @@ describe("lobster pet element", () => {
it("startles on mode changes and then draws from the new mode's pool", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
const element = createPet(42);
await arrive(element);
@@ -297,6 +322,78 @@ describe("lobster pet element", () => {
expect(vi.getTimerCount()).toBe(0);
});
it("celebrates when a run finishes and startles on other status flips", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
const element = createPet(42, "busy");
await arrive(element);
element.mode = "idle";
await element.updateComplete;
expect(spriteClasses(element)).toContain("lobster-pet--act-cheer");
await vi.advanceTimersByTimeAsync(LOBSTER_PET_ACT_DURATION_MS.cheer);
element.mode = "offline";
await element.updateComplete;
expect(spriteClasses(element)).toContain("lobster-pet--act-startle");
});
it("gets grumpy after three fast pokes and recovers after a minute", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
const element = createPet(42);
await arrive(element);
for (let i = 0; i < 3; i++) {
element.querySelector(".lobster-pet")?.dispatchEvent(new Event("pointerdown"));
await element.updateComplete;
}
expect(spriteClasses(element)).toContain("lobster-pet--grumpy");
await vi.advanceTimersByTimeAsync(61_000);
await element.updateComplete;
expect(spriteClasses(element)).not.toContain("lobster-pet--grumpy");
});
it("leaves in a huff after ten pokes but returns for a later visit", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
const element = createPet(42);
await arrive(element);
for (let i = 0; i < 10; i++) {
element.querySelector(".lobster-pet")?.dispatchEvent(new Event("pointerdown"));
await element.updateComplete;
}
const gone = await advanceUntil(element, () => !spritePresent(element), 5_000);
expect(gone).toBe(true);
const returned = await advanceUntil(element, () => spritePresent(element), 1_300_000);
expect(returned).toBe(true);
});
it("night visits act sleepy regardless of personality", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-09T23:30:00"));
expect(isLobsterNightTime()).toBe(true);
const element = createPet(42);
await arrive(element);
// Sleepy-pool exclusives (nap/bubble) never appear in zoomy/showoff pools;
// observing one proves the override. Seeded, so the sequence is stable.
const seen = new Set<string>();
for (let i = 0; i < 6 && !(seen.has("nap") || seen.has("bubble")); i++) {
const act = await advanceUntilAct(element, 30_000);
if (act) {
seen.add(act);
await vi.advanceTimersByTimeAsync(
LOBSTER_PET_ACT_DURATION_MS[act as keyof typeof LOBSTER_PET_ACT_DURATION_MS],
);
}
}
expect(seen.has("nap") || seen.has("bubble")).toBe(true);
});
it("stays static when reduced motion is preferred, including visibility resumes", async () => {
vi.useFakeTimers();
vi.stubGlobal(

View File

@@ -16,7 +16,8 @@ export type LobsterPetAct =
| "nap"
| "bubble"
| "scuttle"
| "startle";
| "startle"
| "cheer";
export type LobsterPetMode = "idle" | "busy" | "offline";
@@ -83,6 +84,7 @@ export const LOBSTER_PET_ACT_DURATION_MS: Record<LobsterPetAct, number> = {
bubble: 2600,
scuttle: 1250,
startle: 750,
cheer: 1300,
};
const PERSONALITIES: Record<LobsterPetPersonalityId, ActProfile> = {
@@ -244,6 +246,55 @@ const VISIT_FIRST_DELAY_MS = [15_000, 180_000] as const;
const VISIT_STAY_MS = [90_000, 300_000] as const;
const VISIT_GAP_MS = [360_000, 1_080_000] as const;
// Seeded pet names; rare palettes carry signature names. Shown via the
// sprite's native title tooltip, so no i18n surface.
const PET_NAMES = [
"Pinchy",
"Barnaby",
"Thermidor",
"Clawdette",
"Sheldon",
"Scuttles",
"Bisque",
"Crusty",
"Snips",
"Bubbles",
"Clawdia",
"Ferdinand",
"Maple",
"Pearl",
"Biscuit",
"Captain",
"Ziggy",
"Noodle",
"Waffles",
"Pippin",
"Squirt",
"Chip",
"Clementine",
"Moss",
] as const;
const RARE_NAMES: Partial<Record<LobsterPetPaletteId, string>> = {
blue: "Blueberry",
gold: "Goldie",
calico: "Patches",
abyss: "Lantern",
ghost: "Boo",
split: "Picasso",
retro: "OG",
};
export function lobsterPetName(look: LobsterPetLook, seed: number): string {
return RARE_NAMES[look.palette.id] ?? PET_NAMES[(seed >>> 3) % PET_NAMES.length];
}
// Late-night visitors are always sleepy, whatever their daytime personality.
export function isLobsterNightTime(now: Date = new Date()): boolean {
const hour = now.getHours();
return hour >= 22 || hour < 6;
}
function fnv1a(value: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < value.length; i++) {
@@ -423,6 +474,15 @@ const TAIL_FAN = svg`
</g>
`;
// Shown while grumpy (poked too much): angry brows and a frown.
const GRUMPY_FACE = svg`
<g stroke="#0a1014" stroke-linecap="round" fill="none">
<path d="M37 24 L51 28" stroke-width="3.5" />
<path d="M69 28 L83 24" stroke-width="3.5" />
<path d="M50 48 Q60 42 70 48" stroke-width="3" />
</g>
`;
const ANTENNAE_SPRITES: Record<LobsterPetAntennae, TemplateResult> = {
perky: svg`
<g class="lob-antennae" stroke="var(--lob-shell)" stroke-width="4" stroke-linecap="round" fill="none">
@@ -440,7 +500,7 @@ const ANTENNAE_SPRITES: Record<LobsterPetAntennae, TemplateResult> = {
// Same species as icons.lobster / the dreams-scene sleeper: smooth dome body
// with stubby legs, side claws, antennae, and teal-glint eyes.
function renderLobsterSvg(look: LobsterPetLook) {
function renderLobsterSvg(look: LobsterPetLook, options: { grumpy?: boolean } = {}) {
return svg`
<svg
class="lobster-pet__svg"
@@ -499,6 +559,7 @@ function renderLobsterSvg(look: LobsterPetLook) {
`
: nothing
}
${options.grumpy && look.palette.id !== "retro" ? GRUMPY_FACE : nothing}
${look.accessory === "none" ? nothing : ACCESSORY_SPRITES[look.accessory]}
</svg>
`;
@@ -522,6 +583,7 @@ export class LobsterPet extends LitElement {
@state() private anchor: LobsterPetAnchor = "ledge";
@state() private scheduledVisiting = false;
@state() private dismissed = false;
@state() private grumpy = false;
private look: LobsterPetLook | null = null;
private rng: () => number = mulberry32(0);
@@ -531,6 +593,8 @@ export class LobsterPet extends LitElement {
private enterTimer: number | null = null;
private visitTimer: number | null = null;
private leaveTimer: number | null = null;
private grumpyTimer: number | null = null;
private pokeTimes: number[] = [];
private restartPending = false;
override connectedCallback() {
@@ -542,6 +606,10 @@ export class LobsterPet extends LitElement {
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
this.clearActTimers();
this.clearVisitTimers();
if (this.grumpyTimer !== null) {
window.clearTimeout(this.grumpyTimer);
this.grumpyTimer = null;
}
super.disconnectedCallback();
}
@@ -568,9 +636,11 @@ export class LobsterPet extends LitElement {
this.presence = "out";
this.scheduleVisits();
} else if (changed.has("mode") && this.presence === "in" && !prefersReducedMotion()) {
// Status flips get an immediate reaction; the act-end timer then
// reschedules from the new mode's pool.
this.performAct("startle");
// Status flips get an immediate reaction; a finished run (busy -> idle)
// earns a celebration, everything else a startle. The act-end timer
// then reschedules from the new mode's pool.
const previousMode = changed.get("mode") as LobsterPetMode | undefined;
this.performAct(previousMode === "busy" && this.mode === "idle" ? "cheer" : "startle");
}
this.reconcilePresence();
}
@@ -626,13 +696,46 @@ export class LobsterPet extends LitElement {
}
};
// Pokes are fun until they are not: 3 fast pokes turn it grumpy for a
// minute, 10 send it off in a huff until a later visit. Offline pets are
// on duty and never huff.
private readonly handlePoke = () => {
if (prefersReducedMotion()) {
return;
}
const now = Date.now();
this.pokeTimes = [...this.pokeTimes.filter((at) => now - at < 6000), now];
if (this.pokeTimes.length >= 10 && this.mode !== "offline") {
this.huffOff();
return;
}
if (this.pokeTimes.length >= 3) {
this.enterGrumpy();
}
this.performAct("startle");
};
private enterGrumpy() {
this.grumpy = true;
if (this.grumpyTimer !== null) {
window.clearTimeout(this.grumpyTimer);
}
this.grumpyTimer = window.setTimeout(() => {
this.grumpyTimer = null;
this.grumpy = false;
}, 60_000);
}
private huffOff() {
this.pokeTimes = [];
this.grumpy = false;
// Ends the current visit only; unlike a right-click dismissal the pet
// still returns on a later scheduled visit.
this.clearVisitTimers();
this.scheduledVisiting = false;
this.armArrival(randomBetween(this.visitRng, VISIT_GAP_MS[0], VISIT_GAP_MS[1]));
}
// Right-click shoos the pet away for the rest of this page load.
private readonly handleShoo = (event: Event) => {
event.preventDefault();
@@ -711,6 +814,9 @@ export class LobsterPet extends LitElement {
if (this.mode === "busy" || this.mode === "offline") {
return LOBSTER_PET_MODE_ACTS[this.mode];
}
if (isLobsterNightTime()) {
return PERSONALITIES.sleepy;
}
return this.look ? PERSONALITIES[this.look.personality] : null;
}
@@ -781,6 +887,7 @@ export class LobsterPet extends LitElement {
`lobster-pet--palette-${look.palette.id}`,
this.presence === "leaving" ? "lobster-pet--away" : "",
this.entering ? "lobster-pet--entering" : "",
this.grumpy ? "lobster-pet--grumpy" : "",
this.act ? `lobster-pet--act-${this.act}` : "",
]
.filter(Boolean)
@@ -805,11 +912,12 @@ export class LobsterPet extends LitElement {
class=${classes}
style=${style}
aria-hidden="true"
title=${lobsterPetName(look, this.seed)}
@pointerdown=${this.handlePoke}
@contextmenu=${this.handleShoo}
>
<div class="lobster-pet__body">
${renderLobsterSvg(look)}
${renderLobsterSvg(look, { grumpy: this.grumpy })}
<span class="lobster-pet__z" style="--i:0">z</span>
<span class="lobster-pet__z" style="--i:1">z</span>
<span class="lobster-pet__z" style="--i:2">Z</span>

View File

@@ -229,6 +229,15 @@ openclaw-lobster-pet[data-spot="bar"] .lobster-pet {
animation: lobster-pet-snip 0.38s ease-in-out 2;
}
/* Cheer: a finished run earns a double hop with a mid-air twirl, claws up. */
.lobster-pet--act-cheer .lobster-pet__body {
animation: lobster-pet-cheer 1.3s ease-in-out;
}
.lobster-pet--act-cheer .lob-claw {
animation: lobster-pet-cheer-claws 1.3s ease-in-out;
}
/* ---- Nap: eyes shut, z's drift up ---- */
.lobster-pet--act-nap .lob-eye-open {
@@ -420,6 +429,36 @@ openclaw-lobster-pet[data-spot="bar"] .lobster-pet {
}
}
@keyframes lobster-pet-cheer {
0%,
100% {
transform: translateY(0) rotateY(0deg);
}
20% {
transform: translateY(-42%) rotateY(0deg);
}
40% {
transform: translateY(0) rotateY(0deg);
}
60% {
transform: translateY(-34%) rotateY(180deg);
}
80% {
transform: translateY(0) rotateY(360deg);
}
}
@keyframes lobster-pet-cheer-claws {
0%,
100% {
transform: translateY(0) rotate(0deg);
}
25%,
65% {
transform: translateY(-5px) rotate(-18deg);
}
}
@keyframes lobster-pet-z {
0% {
opacity: 0;