From 70bb770e6b3acde4f54cf0eeb8298e383cef2a6c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 9 Jul 2026 15:14:11 +0100 Subject: [PATCH] feat(webchat): rare lobster pet variants incl. retro homage to the classic logo (#102829) * feat(webchat): rare lobster pet variants modeled on real lobster genetics Extends the sidebar pet's palette ladder with a rare tier: blue (7%), calico with mottled spots (3%), deep-sea abyss with glowing eye glints (2%), translucent ghost/albino (1%), and a bicolor split two-tone with mismatched claws and antennae (1%). Weights loosely mirror real-world odds (blue ~1 in 2M, calico ~1 in 30M, split ~1 in 50M, ghost ~1 in 100M). Offline mode still greys out every variant's glints. Drops the unused --lob-dim inline variable. * feat(webchat): retro grail variant honoring the classic OpenClaw logo Adds a ~0.5% super-rare 'retro' lobster pet: classic logo red with a white sticker-outline halo, one oversized raised claw with a pincer notch (small left pincher stays), determined angled brows, a smirk, and tall V antennae. The mega claw rides the .lob-claw--r group so wave and snip acts swing it. --- ui/src/components/lobster-pet.test.ts | 36 +++++++- ui/src/components/lobster-pet.ts | 128 +++++++++++++++++++++++--- ui/src/styles/lobster-pet.css | 50 +++++++++- 3 files changed, 198 insertions(+), 16 deletions(-) diff --git a/ui/src/components/lobster-pet.test.ts b/ui/src/components/lobster-pet.test.ts index 59357b9d329..7b46d34d454 100644 --- a/ui/src/components/lobster-pet.test.ts +++ b/ui/src/components/lobster-pet.test.ts @@ -9,8 +9,24 @@ import { resolveLobsterPetMode, type LobsterPet, type LobsterPetMode, + type LobsterPetPaletteId, } from "./lobster-pet.ts"; +const LOBSTER_PET_PALETTE_IDS: LobsterPetPaletteId[] = [ + "crimson", + "coral", + "teal", + "violet", + "ink", + "blue", + "gold", + "calico", + "abyss", + "ghost", + "split", + "retro", +]; + const SPOT_ZONES = { left: [12, 38], right: [60, 84] } as const; type LobsterPetElement = LobsterPet & HTMLElement; @@ -59,7 +75,7 @@ describe("lobster pet look", () => { const look = createLobsterPetLook(seed); palettes.add(look.palette.id); personalities.add(look.personality); - expect(["crimson", "coral", "teal", "violet", "ink", "gold"]).toContain(look.palette.id); + expect(LOBSTER_PET_PALETTE_IDS).toContain(look.palette.id); expect([1.7, 2, 2.5]).toContain(look.scale); expect(["none", "crown", "sprout", "patch"]).toContain(look.accessory); expect(["perky", "droopy"]).toContain(look.antennae); @@ -72,6 +88,24 @@ describe("lobster pet look", () => { expect(personalities.size).toBeGreaterThan(2); }); + it("hatches every rarity tier, with rares staying rare", () => { + const counts = new Map(); + const total = 20_000; + for (let seed = 0; seed < total; seed++) { + const id = createLobsterPetLook(seed).palette.id; + counts.set(id, (counts.get(id) ?? 0) + 1); + } + // Every palette, including the 1% grails, must be reachable. + for (const id of LOBSTER_PET_PALETTE_IDS) { + expect(counts.get(id) ?? 0).toBeGreaterThan(0); + } + // Grails stay grails: ghost/split roll ~1%, retro ~0.5%; commons dominate. + for (const grail of ["ghost", "split", "retro"]) { + expect(counts.get(grail) ?? 0).toBeLessThan(total * 0.03); + } + expect((counts.get("crimson") ?? 0) + (counts.get("coral") ?? 0)).toBeGreaterThan(total * 0.4); + }); + it("derives distinct salted seeds per session key, stable within a load", () => { expect(lobsterPetSeed("agent:a:main")).toBe(lobsterPetSeed("agent:a:main")); expect(lobsterPetSeed("agent:a:main")).not.toBe(lobsterPetSeed("agent:b:other")); diff --git a/ui/src/components/lobster-pet.ts b/ui/src/components/lobster-pet.ts index 659ba2c4255..e9ffc7b2ae1 100644 --- a/ui/src/components/lobster-pet.ts +++ b/ui/src/components/lobster-pet.ts @@ -22,8 +22,22 @@ export type LobsterPetMode = "idle" | "busy" | "offline"; export type LobsterPetPersonalityId = "sleepy" | "zoomy" | "friendly" | "showoff"; +export type LobsterPetPaletteId = + | "crimson" + | "coral" + | "teal" + | "violet" + | "ink" + | "blue" + | "gold" + | "calico" + | "abyss" + | "ghost" + | "split" + | "retro"; + export type LobsterPetPalette = { - id: "crimson" | "coral" | "teal" | "violet" | "ink" | "gold"; + id: LobsterPetPaletteId; shell: string; claw: string; }; @@ -131,13 +145,26 @@ export const LOBSTER_PET_MODE_ACTS: Record, ActP }, }; +// Rarity ladder loosely mirrors real lobster genetics: blue ~1 in 2 million, +// yellow ~1 in 30 million, calico ~1 in 30 million, split two-tone ~1 in +// 50 million, albino/ghost ~1 in 100 million. Abyss is our deep-sea fantasy. +// Split/calico extra geometry and ghost/abyss styling key off the palette id +// (see lobster-pet.css and renderLobsterSvg). const PALETTES: Array<[LobsterPetPalette, number]> = [ - [{ id: "crimson", shell: "#ff4f40", claw: "#ff775f" }, 30], - [{ id: "coral", shell: "#d0836a", claw: "#de9b80" }, 30], - [{ id: "teal", shell: "#2fbfa7", claw: "#5cd9c4" }, 12], - [{ id: "violet", shell: "#9f7dfa", claw: "#bba4fd" }, 12], - [{ id: "ink", shell: "#5e6b7a", claw: "#7b8996" }, 11], + [{ id: "crimson", shell: "#ff4f40", claw: "#ff775f" }, 26], + [{ id: "coral", shell: "#d0836a", claw: "#de9b80" }, 26], + [{ id: "teal", shell: "#2fbfa7", claw: "#5cd9c4" }, 10], + [{ id: "violet", shell: "#9f7dfa", claw: "#bba4fd" }, 10], + [{ id: "ink", shell: "#5e6b7a", claw: "#7b8996" }, 9], + [{ id: "blue", shell: "#4a7dfc", claw: "#7fa4ff" }, 7], [{ id: "gold", shell: "#f4b840", claw: "#f9d47a" }, 5], + [{ id: "calico", shell: "#d97a3d", claw: "#e89a63" }, 3], + [{ id: "abyss", shell: "#2c3b68", claw: "#465b96" }, 2], + [{ id: "ghost", shell: "#dce8f2", claw: "#ecf3fa" }, 1], + [{ id: "split", shell: "#ff4f40", claw: "#ff775f" }, 1], + // The grail: homage to the classic OpenClaw logo (big raised claw, smirk, + // angry brows, white sticker outline). ~0.5% of sessions. + [{ id: "retro", shell: "#e8262c", claw: "#f04a3e" }, 0.5], ]; const ACCESSORIES: Array<[LobsterPetAccessory, number]> = [ @@ -262,6 +289,61 @@ const ACCESSORY_SPRITES: Record, TemplateRe `, }; +// Calico mottling: dark blotches scattered clear of the eye line. +const CALICO_SPOTS = svg` + + + + + + + + +`; + +// Split two-tone: the right half of the body (down to the belly midline) +// repainted in the second shell color; the right claw and antenna follow via +// CSS. Mirrors the famous bilateral half-and-half lobsters. +const SPLIT_HALF = svg` + +`; + +// Retro homage parts (classic OpenClaw logo): one oversized raised claw with +// a pincer notch, tall V antennae, angry brows, and a smirk. The mega claw +// lives inside the .lob-claw--r group so wave/snip acts swing it. +const RETRO_MEGA_CLAW = svg` + + +`; + +const RETRO_ANTENNAE = svg` + + + + +`; + +const RETRO_FACE = svg` + + + + + +`; + const ANTENNAE_SPRITES: Record = { perky: svg` @@ -287,23 +369,31 @@ function renderLobsterSvg(look: LobsterPetLook) { preserveAspectRatio="xMidYMax meet" aria-hidden="true" > - ${ANTENNAE_SPRITES[look.antennae]} + ${look.palette.id === "retro" ? RETRO_ANTENNAE : ANTENNAE_SPRITES[look.antennae]} - - - + ${ + look.palette.id === "retro" + ? nothing + : svg` + + + + ` + } + ${look.palette.id === "split" ? SPLIT_HALF : nothing} + ${look.palette.id === "calico" ? CALICO_SPOTS : nothing} @@ -321,6 +411,14 @@ function renderLobsterSvg(look: LobsterPetLook) { + ${ + look.palette.id === "retro" + ? svg` + ${RETRO_FACE} + ${RETRO_MEGA_CLAW} + ` + : nothing + } ${look.accessory === "none" ? nothing : ACCESSORY_SPRITES[look.accessory]} `; @@ -487,15 +585,17 @@ export class LobsterPet extends LitElement { const classes = [ "lobster-pet", `lobster-pet--${this.mode}`, + `lobster-pet--palette-${look.palette.id}`, this.entering ? "lobster-pet--entering" : "", this.act ? `lobster-pet--act-${this.act}` : "", ] .filter(Boolean) .join(" "); + // 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-dim:color-mix(in srgb, ${look.palette.shell} 72%, #10181f)`, `--lob-scale:${look.scale}`, `--lob-x:${this.spotPct}%`, `--lob-face:${this.facing}`, diff --git a/ui/src/styles/lobster-pet.css b/ui/src/styles/lobster-pet.css index 2fdb74fcc96..58021fc8394 100644 --- a/ui/src/styles/lobster-pet.css +++ b/ui/src/styles/lobster-pet.css @@ -42,11 +42,59 @@ openclaw-lobster-pet { transition: left 1.15s ease-in-out; } -/* Disconnected pets lose their teal eye glow. */ +/* ---- Rare palette variants (weights + lore in lobster-pet.ts) ---- */ + +/* Ghost/albino: pale translucent shell, icy glints. */ +.lobster-pet--palette-ghost { + --lob-glint: #a8dcff; +} + +.lobster-pet--palette-ghost .lobster-pet__svg { + opacity: 0.62; +} + +/* Abyss: deep-sea navy with bioluminescent eye glow. */ +.lobster-pet--palette-abyss { + --lob-glint: #52f5e3; +} + +.lobster-pet--palette-abyss .lob-eye-open circle:nth-of-type(3), +.lobster-pet--palette-abyss .lob-eye-open circle:nth-of-type(4) { + filter: drop-shadow(0 0 2.5px rgba(82, 245, 227, 0.9)); +} + +/* Retro: homage to the classic OpenClaw logo — white sticker outline around + the whole sprite (the halo is two stacked 1px drop-shadows). */ +.lobster-pet--palette-retro .lobster-pet__svg { + filter: drop-shadow(0 0 1.2px #f5f7fa) drop-shadow(0 0 1.2px #f5f7fa) + drop-shadow(0 0 1.2px #f5f7fa); +} + +/* Split two-tone: the right body half (drawn in lobster-pet.ts) plus the + right claw and antenna wear the second shell color. */ +.lobster-pet--palette-split { + --lob-shell2: #46536b; +} + +.lobster-pet--palette-split .lob-claw--r path { + fill: var(--lob-shell2); +} + +.lobster-pet--palette-split .lob-antennae path:last-of-type { + stroke: var(--lob-shell2); +} + +/* Disconnected pets lose their eye glow; declared after the palette glints so + offline wins the cascade at equal specificity. */ .lobster-pet--offline { --lob-glint: #8a949d; } +.lobster-pet--offline .lob-eye-open circle:nth-of-type(3), +.lobster-pet--offline .lob-eye-open circle:nth-of-type(4) { + filter: none; +} + .lobster-pet__body { position: relative; width: 100%;