From b0e77bbe72cc58115ebd5983a73c6b3c8a67b9e8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 10 Jul 2026 01:32:42 +0100 Subject: [PATCH] feat(webchat): rare lobster pet events - molting and twin visits (#103154) ~12% of loads plan a molt: during the first idle act the pet shivers, squashes, and pops one size tier bigger, leaving its old shell behind as a frozen, washed-out silhouette that fades over a minute. The shell keeps the true pre-molt size, respects dismissal and the visits setting, and its timer resets with the seed. ~4% of loads are twin days: a mini copycat (' Jr.') tags along on the parent's trailing side and mirrors every act a beat later via an act-delay variable threaded through the act animations. Rare-event plans are pure per-seed functions so tests probe them directly. --- ui/src/components/lobster-pet.test.ts | 90 ++++++++++++++ ui/src/components/lobster-pet.ts | 168 ++++++++++++++++++++++---- ui/src/styles/lobster-pet.css | 66 ++++++++++ 3 files changed, 299 insertions(+), 25 deletions(-) diff --git a/ui/src/components/lobster-pet.test.ts b/ui/src/components/lobster-pet.test.ts index ef639ade2d90..6f21d20ef9c8 100644 --- a/ui/src/components/lobster-pet.test.ts +++ b/ui/src/components/lobster-pet.test.ts @@ -5,7 +5,9 @@ import { LOBSTER_PET_ACT_DURATION_MS, LOBSTER_PET_MODE_ACTS, createLobsterPetLook, + isLobsterMoltLoad, isLobsterNightTime, + isLobsterTwinLoad, lobsterPetName, lobsterPetSeed, resolveLobsterPetMode, @@ -394,6 +396,94 @@ describe("lobster pet element", () => { expect(seen.has("nap") || seen.has("bubble")).toBe(true); }); + it("molt loads shed a fading shell and size up one tier", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-09T12:00:00")); + // Seed 2 plans a molt (and no twin); probed via the pure planners. + expect(isLobsterMoltLoad(2)).toBe(true); + expect(isLobsterTwinLoad(2)).toBe(false); + const preScale = createLobsterPetLook(2).scale; + const element = createPet(2); + await arrive(element); + + const act = await advanceUntilAct(element, 30_000); + expect(act).toBe("molt"); + await vi.advanceTimersByTimeAsync(LOBSTER_PET_ACT_DURATION_MS.molt + 100); + await element.updateComplete; + + expect(element.querySelector(".lobster-pet--shell")).not.toBeNull(); + const mainStyle = + element.querySelector(".lobster-pet:not(.lobster-pet--shell)")?.getAttribute("style") ?? ""; + const tiers = [1.7, 2, 2.5]; + const expected = tiers[Math.min(tiers.indexOf(preScale) + 1, tiers.length - 1)]; + expect(mainStyle).toContain(`--lob-scale:${expected}`); + + // The shell fades out after a minute; only one molt per load. + await vi.advanceTimersByTimeAsync(61_000); + await element.updateComplete; + expect(element.querySelector(".lobster-pet--shell")).toBeNull(); + const nextAct = await advanceUntilAct(element, 30_000); + expect(nextAct).not.toBe("molt"); + }); + + it("shooing the pet also clears a fading molt shell", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-09T12:00:00")); + const element = createPet(2); + await arrive(element); + await advanceUntilAct(element, 30_000); + await vi.advanceTimersByTimeAsync(LOBSTER_PET_ACT_DURATION_MS.molt + 100); + await element.updateComplete; + expect(element.querySelector(".lobster-pet--shell")).not.toBeNull(); + + element + .querySelector(".lobster-pet:not(.lobster-pet--shell)") + ?.dispatchEvent(new Event("contextmenu", { cancelable: true })); + await element.updateComplete; + expect(element.querySelector(".lobster-pet--shell")).toBeNull(); + }); + + it("twin loads bring a mini copycat that leaves with the visit", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-09T12:00:00")); + expect(isLobsterTwinLoad(21)).toBe(true); + const element = createPet(21); + await arrive(element); + + const sprites = element.querySelectorAll(".lobster-pet:not(.lobster-pet--shell)"); + expect(sprites.length).toBe(2); + const twin = element.querySelector(".lobster-pet--twin"); + expect(twin).not.toBeNull(); + expect(twin?.getAttribute("title")).toMatch(/ Jr\.$/); + + const departed = await advanceUntil( + element, + () => element.querySelectorAll(".lobster-pet").length === 0, + 400_000, + ); + expect(departed).toBe(true); + }); + + it("plain loads stay solo and never molt", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-09T12:00:00")); + expect(isLobsterMoltLoad(4)).toBe(false); + expect(isLobsterTwinLoad(4)).toBe(false); + const element = createPet(4); + await arrive(element); + + expect(element.querySelectorAll(".lobster-pet").length).toBe(1); + for (let i = 0; i < 3; i++) { + const act = await advanceUntilAct(element, 30_000); + expect(act).not.toBe("molt"); + if (act) { + await vi.advanceTimersByTimeAsync( + LOBSTER_PET_ACT_DURATION_MS[act as keyof typeof LOBSTER_PET_ACT_DURATION_MS], + ); + } + } + }); + 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 63793794ceeb..d09f83e79ad3 100644 --- a/ui/src/components/lobster-pet.ts +++ b/ui/src/components/lobster-pet.ts @@ -17,7 +17,8 @@ export type LobsterPetAct = | "bubble" | "scuttle" | "startle" - | "cheer"; + | "cheer" + | "molt"; export type LobsterPetMode = "idle" | "busy" | "offline"; @@ -85,6 +86,7 @@ export const LOBSTER_PET_ACT_DURATION_MS: Record = { scuttle: 1250, startle: 750, cheer: 1300, + molt: 2600, }; const PERSONALITIES: Record = { @@ -289,6 +291,17 @@ export function lobsterPetName(look: LobsterPetLook, seed: number): string { return RARE_NAMES[look.palette.id] ?? PET_NAMES[(seed >>> 3) % PET_NAMES.length]; } +// Rare-event loads, planned per seed so tests can probe them purely: a molt +// load sheds its shell during the first idle act and sizes up one tier; a +// twin load brings a mini copycat along on every visit. +export function isLobsterMoltLoad(seed: number): boolean { + return mulberry32((seed ^ 0x301d) >>> 0)() < 0.12; +} + +export function isLobsterTwinLoad(seed: number): boolean { + return mulberry32((seed ^ 0x7715) >>> 0)() < 0.04; +} + // Late-night visitors are always sleepy, whatever their daytime personality. export function isLobsterNightTime(now: Date = new Date()): boolean { const hour = now.getHours(); @@ -500,7 +513,10 @@ 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, options: { grumpy?: boolean } = {}) { +function renderLobsterSvg( + look: LobsterPetLook, + options: { grumpy?: boolean; shell?: boolean } = {}, +) { return svg` - + @@ -560,7 +576,7 @@ function renderLobsterSvg(look: LobsterPetLook, options: { grumpy?: boolean } = : nothing } ${options.grumpy && look.palette.id !== "retro" ? GRUMPY_FACE : nothing} - ${look.accessory === "none" ? nothing : ACCESSORY_SPRITES[look.accessory]} + ${look.accessory === "none" || options.shell ? nothing : ACCESSORY_SPRITES[look.accessory]} `; } @@ -584,6 +600,13 @@ export class LobsterPet extends LitElement { @state() private scheduledVisiting = false; @state() private dismissed = false; @state() private grumpy = false; + @state() private shellVisible = false; + private shellSpotPct = 50; + private shellScale = 2; + private molted = false; + private moltPlanned = false; + private twinPlanned = false; + private shellTimer: number | null = null; private look: LobsterPetLook | null = null; private rng: () => number = mulberry32(0); @@ -610,6 +633,10 @@ export class LobsterPet extends LitElement { window.clearTimeout(this.grumpyTimer); this.grumpyTimer = null; } + if (this.shellTimer !== null) { + window.clearTimeout(this.shellTimer); + this.shellTimer = null; + } super.disconnectedCallback(); } @@ -634,6 +661,14 @@ export class LobsterPet extends LitElement { this.act = null; this.dismissed = false; this.presence = "out"; + this.molted = false; + this.shellVisible = false; + if (this.shellTimer !== null) { + window.clearTimeout(this.shellTimer); + this.shellTimer = null; + } + this.moltPlanned = isLobsterMoltLoad(this.seed); + this.twinPlanned = isLobsterTwinLoad(this.seed); this.scheduleVisits(); } else if (changed.has("mode") && this.presence === "in" && !prefersReducedMotion()) { // Status flips get an immediate reaction; a finished run (busy -> idle) @@ -843,6 +878,10 @@ export class LobsterPet extends LitElement { if (!nextProfile || document.hidden || this.presence !== "in") { return; } + if (this.moltPlanned && !this.molted && this.mode === "idle") { + this.performAct("molt"); + return; + } this.performAct(pickWeighted(this.rng, nextProfile.acts)); }, delay); } @@ -857,10 +896,41 @@ export class LobsterPet extends LitElement { this.actEndTimer = window.setTimeout(() => { this.actEndTimer = null; this.act = null; + if (act === "molt") { + this.completeMolt(); + } this.scheduleNextAct(); }, LOBSTER_PET_ACT_DURATION_MS[act]); } + // Shedding: the old shell stays behind and slowly fades while the pet + // steps aside one size bigger. Once per load. + private completeMolt() { + this.molted = true; + if (this.look) { + const tiers = [1.7, 2, 2.5]; + const index = tiers.indexOf(this.look.scale); + // The shed shell keeps the true pre-molt size; a max-tier pet sheds a + // max-tier shell. + this.shellScale = this.look.scale; + this.look = { ...this.look, scale: tiers[Math.min(index + 1, tiers.length - 1)] }; + } + this.shellSpotPct = this.spotPct; + this.shellVisible = true; + const zone = this.currentZone(); + this.spotPct = Math.min( + zone[1], + Math.max(zone[0], this.spotPct + (this.facing === 1 ? 9 : -9)), + ); + if (this.shellTimer !== null) { + window.clearTimeout(this.shellTimer); + } + this.shellTimer = window.setTimeout(() => { + this.shellTimer = null; + this.shellVisible = false; + }, 60_000); + } + private startScuttle() { if (!this.look) { return; @@ -876,15 +946,33 @@ export class LobsterPet extends LitElement { this.spotPct = target; } - override render() { - const look = this.look; - if (!look || this.presence === "out") { - return nothing; - } + private spriteStyle(look: LobsterPetLook, scale: number, spotPct: number, facing: 1 | -1) { + // Glint color stays class-driven (see lobster-pet.css): an inline + // --lob-glint would out-cascade the offline grey override. + return [ + `--lob-shell:${look.palette.shell}`, + `--lob-claw:${look.palette.claw}`, + `--lob-scale:${scale}`, + `--lob-x:${spotPct}%`, + `--lob-face:${facing}`, + `--lob-blink-delay:${look.blinkDelayS}s`, + `--lob-w:${LOBSTER_PET_BUILD_MULS[look.build].w}`, + `--lob-h:${LOBSTER_PET_BUILD_MULS[look.build].h}`, + `--lob-claw-scale:${LOBSTER_PET_CLAW_MULS[look.clawSize]}`, + ].join(";"); + } + + // The bar anchor stands inside the ~30px footer bar, so cap the sprite. + private anchoredScale(scale: number): number { + return this.anchor === "bar" ? Math.min(scale, BAR_MAX_SCALE) : scale; + } + + private renderSprite(look: LobsterPetLook, twin: boolean) { const classes = [ "lobster-pet", `lobster-pet--${this.mode}`, `lobster-pet--palette-${look.palette.id}`, + twin ? "lobster-pet--twin" : "", this.presence === "leaving" ? "lobster-pet--away" : "", this.entering ? "lobster-pet--entering" : "", this.grumpy ? "lobster-pet--grumpy" : "", @@ -892,27 +980,23 @@ export class LobsterPet extends LitElement { ] .filter(Boolean) .join(" "); - // The bar anchor stands inside the ~30px footer bar, so cap the sprite. - const scale = this.anchor === "bar" ? Math.min(look.scale, BAR_MAX_SCALE) : look.scale; - // Glint color stays class-driven (see lobster-pet.css): an inline - // --lob-glint would out-cascade the offline grey override. - const style = [ - `--lob-shell:${look.palette.shell}`, - `--lob-claw:${look.palette.claw}`, - `--lob-scale:${scale}`, - `--lob-x:${this.spotPct}%`, - `--lob-face:${this.facing}`, - `--lob-blink-delay:${look.blinkDelayS}s`, - `--lob-w:${LOBSTER_PET_BUILD_MULS[look.build].w}`, - `--lob-h:${LOBSTER_PET_BUILD_MULS[look.build].h}`, - `--lob-claw-scale:${LOBSTER_PET_CLAW_MULS[look.clawSize]}`, - ].join(";"); + const zone = this.currentZone(); + // The twin tags along on the parent's trailing side and copies every act + // a beat later (--lob-act-delay feeds each act's animation-delay). + const spotPct = twin + ? Math.min(zone[1], Math.max(zone[0], this.spotPct + (this.facing === 1 ? -12 : 12))) + : this.spotPct; + const scale = this.anchoredScale(twin ? look.scale * 0.55 : look.scale); + const style = twin + ? `${this.spriteStyle(look, scale, spotPct, this.facing === 1 ? -1 : 1)};--lob-act-delay:0.18s` + : this.spriteStyle(look, scale, spotPct, this.facing); + const name = lobsterPetName(look, this.seed); return html` `; } + + // The abandoned shell: the pre-molt silhouette, frozen and slowly fading. + private renderShell(look: LobsterPetLook) { + const style = this.spriteStyle( + look, + this.anchoredScale(this.shellScale), + this.shellSpotPct, + this.facing, + ); + return html` + + `; + } + + override render() { + const look = this.look; + if (!look) { + return nothing; + } + const showSprites = this.presence !== "out"; + // The shell may outlive the visit while it fades, but dismissal and the + // visits setting silence it like everything else. + const showShell = this.shellVisible && this.visitsEnabled && !this.dismissed; + if (!showSprites && !showShell) { + return nothing; + } + return html` + ${showShell ? this.renderShell(look) : nothing} + ${showSprites ? this.renderSprite(look, false) : nothing} + ${showSprites && this.twinPlanned ? this.renderSprite(look, true) : nothing} + `; + } } if (!customElements.get("openclaw-lobster-pet")) { diff --git a/ui/src/styles/lobster-pet.css b/ui/src/styles/lobster-pet.css index cc618d11eacd..3226d5ad8718 100644 --- a/ui/src/styles/lobster-pet.css +++ b/ui/src/styles/lobster-pet.css @@ -199,43 +199,76 @@ openclaw-lobster-pet[data-spot="bar"] .lobster-pet { .lobster-pet--act-wave .lob-claw--r { animation: lobster-pet-wave 1.4s ease-in-out; + animation-delay: var(--lob-act-delay, 0s); } .lobster-pet--act-snip .lob-claw { animation: lobster-pet-snip 0.5s ease-in-out 2; + animation-delay: var(--lob-act-delay, 0s); } .lobster-pet--act-hop .lobster-pet__body { animation: lobster-pet-hop 0.75s ease-in-out; + animation-delay: var(--lob-act-delay, 0s); } .lobster-pet--act-spin .lobster-pet__body { animation: lobster-pet-spin 0.95s ease-in-out; + animation-delay: var(--lob-act-delay, 0s); } .lobster-pet--act-peek .lobster-pet__body { animation: lobster-pet-peek 1.7s ease-in-out; + animation-delay: var(--lob-act-delay, 0s); } .lobster-pet--act-scuttle .lobster-pet__body { animation: lobster-pet-waddle 0.3s ease-in-out infinite; + animation-delay: var(--lob-act-delay, 0s); } .lobster-pet--act-startle .lobster-pet__body { animation: lobster-pet-startle 0.75s ease-out; + animation-delay: var(--lob-act-delay, 0s); } .lobster-pet--act-startle .lob-claw { animation: lobster-pet-snip 0.38s ease-in-out 2; + animation-delay: var(--lob-act-delay, 0s); } /* 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; + animation-delay: var(--lob-act-delay, 0s); } .lobster-pet--act-cheer .lob-claw { animation: lobster-pet-cheer-claws 1.3s ease-in-out; + animation-delay: var(--lob-act-delay, 0s); +} + +/* Molt: shiver, squash, pop. */ +.lobster-pet--act-molt .lobster-pet__body { + animation: lobster-pet-molt 2.6s ease-in-out; + animation-delay: var(--lob-act-delay, 0s); +} + +/* The abandoned shell: frozen, washed out, fading over a minute. */ +.lobster-pet--shell { + pointer-events: none; + cursor: default; + filter: saturate(0.45) brightness(0.75); + animation: lobster-pet-shell-fade 60s linear forwards; +} + +.lobster-pet--shell .lobster-pet__svg, +.lobster-pet--shell .lob-eye-closed { + animation: none; +} + +.lobster-pet--shell .lob-eye-closed { + opacity: 1; } /* ---- Nap: eyes shut, z's drift up ---- */ @@ -429,6 +462,39 @@ openclaw-lobster-pet[data-spot="bar"] .lobster-pet { } } +@keyframes lobster-pet-molt { + 0%, + 100% { + transform: rotate(0deg) scaleY(1); + } + 10%, + 30% { + transform: rotate(-2.5deg) scaleY(1); + } + 20%, + 40% { + transform: rotate(2.5deg) scaleY(1); + } + 55% { + transform: rotate(0deg) scaleY(0.8); + } + 70% { + transform: rotate(0deg) scaleY(1.12); + } + 85% { + transform: rotate(0deg) scaleY(0.96); + } +} + +@keyframes lobster-pet-shell-fade { + 0% { + opacity: 0.4; + } + 100% { + opacity: 0; + } +} + @keyframes lobster-pet-cheer { 0%, 100% {