diff --git a/ui/src/components/lobster-pet.test.ts b/ui/src/components/lobster-pet.test.ts index fe3957dde599..ef639ade2d90 100644 --- a/ui/src/components/lobster-pet.test.ts +++ b/ui/src/components/lobster-pet.test.ts @@ -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(); + 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( diff --git a/ui/src/components/lobster-pet.ts b/ui/src/components/lobster-pet.ts index fcb567a89ae2..63793794ceeb 100644 --- a/ui/src/components/lobster-pet.ts +++ b/ui/src/components/lobster-pet.ts @@ -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 = { bubble: 2600, scuttle: 1250, startle: 750, + cheer: 1300, }; const PERSONALITIES: Record = { @@ -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> = { + 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` `; +// Shown while grumpy (poked too much): angry brows and a frown. +const GRUMPY_FACE = svg` + + + + + +`; + const ANTENNAE_SPRITES: Record = { perky: svg` @@ -440,7 +500,7 @@ const ANTENNAE_SPRITES: Record = { // 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` `; @@ -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} >
- ${renderLobsterSvg(look)} + ${renderLobsterSvg(look, { grumpy: this.grumpy })} z z Z diff --git a/ui/src/styles/lobster-pet.css b/ui/src/styles/lobster-pet.css index aadb6ae73552..cc618d11eacd 100644 --- a/ui/src/styles/lobster-pet.css +++ b/ui/src/styles/lobster-pet.css @@ -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;