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 ('<name> 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.
This commit is contained in:
Peter Steinberger
2026-07-10 01:32:42 +01:00
committed by GitHub
parent d8624a4e1b
commit b0e77bbe72
3 changed files with 299 additions and 25 deletions

View File

@@ -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(

View File

@@ -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<LobsterPetAct, number> = {
scuttle: 1250,
startle: 750,
cheer: 1300,
molt: 2600,
};
const PERSONALITIES: Record<LobsterPetPersonalityId, ActProfile> = {
@@ -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<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, options: { grumpy?: boolean } = {}) {
function renderLobsterSvg(
look: LobsterPetLook,
options: { grumpy?: boolean; shell?: boolean } = {},
) {
return svg`
<svg
class="lobster-pet__svg"
@@ -535,7 +551,7 @@ function renderLobsterSvg(look: LobsterPetLook, options: { grumpy?: boolean } =
${look.palette.id === "split" ? SPLIT_HALF : nothing}
${look.palette.id === "calico" ? CALICO_SPOTS : nothing}
<ellipse cx="48" cy="28" rx="20" ry="11" fill="#ffffff" opacity="0.1" />
<g class="lob-eye-open">
<g class="lob-eye-open" style=${options.shell ? "display:none" : ""}>
<circle cx="45" cy="32" r="5.5" fill="#0a1014" />
<circle cx="75" cy="32" r="5.5" fill="#0a1014" />
<circle cx="46.5" cy="30.5" r="2.2" fill="var(--lob-glint, #00e5cc)" />
@@ -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]}
</svg>
`;
}
@@ -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`
<div
class=${classes}
style=${style}
aria-hidden="true"
title=${lobsterPetName(look, this.seed)}
title=${twin ? `${name} Jr.` : name}
@pointerdown=${this.handlePoke}
@contextmenu=${this.handleShoo}
>
@@ -928,6 +1012,40 @@ export class LobsterPet extends LitElement {
</div>
`;
}
// 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`
<div class="lobster-pet lobster-pet--shell" style=${style} aria-hidden="true">
<div class="lobster-pet__body">${renderLobsterSvg(look, { shell: true })}</div>
</div>
`;
}
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")) {

View File

@@ -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% {