diff --git a/ui/src/components/lobster-dex.test.ts b/ui/src/components/lobster-dex.test.ts index b6ad45b31e7b..ffb10ba2930d 100644 --- a/ui/src/components/lobster-dex.test.ts +++ b/ui/src/components/lobster-dex.test.ts @@ -6,6 +6,8 @@ import { getLobsterFamiliarity, getLobsterdex, getLobsterdexEntries, + isLobsterFirstVisitAnniversary, + lobsterHonorific, recordLobsterArrivalStats, recordLobsterShoo, recordLobsterVisit, @@ -91,3 +93,40 @@ describe("lobster familiarity", () => { expect(LOBSTER_FAMILIARITY_TUNING.waryGapMul).toBeGreaterThan(1); }); }); + +describe("long memory", () => { + it("awards honorifics at visit milestones", () => { + expect(lobsterHonorific(0)).toBeNull(); + expect(lobsterHonorific(49)).toBeNull(); + expect(lobsterHonorific(50)).toBe("Sir"); + expect(lobsterHonorific(99)).toBe("Sir"); + expect(lobsterHonorific(100)).toBe("Captain"); + expect(lobsterHonorific(250)).toBe("Elder"); + expect(lobsterHonorific(9001)).toBe("Elder"); + }); + + it("recognizes first-visit anniversaries by month and day", () => { + const first = new Date("2025-07-09T15:30:00").getTime(); + expect(isLobsterFirstVisitAnniversary(first, new Date("2026-07-09T09:00:00"))).toBe(true); + expect(isLobsterFirstVisitAnniversary(first, new Date("2027-07-09T21:00:00"))).toBe(true); + expect(isLobsterFirstVisitAnniversary(first, new Date("2026-07-10T09:00:00"))).toBe(false); + expect(isLobsterFirstVisitAnniversary(first, new Date("2026-06-09T09:00:00"))).toBe(false); + expect(isLobsterFirstVisitAnniversary(null, new Date("2026-07-09T09:00:00"))).toBe(false); + }); + + it("does not celebrate fresh memories", () => { + // Same month/day but same moment (a first visit today) and short gaps + // stay quiet; the celebration needs a real year behind it. + const now = new Date("2026-07-09T12:00:00"); + expect(isLobsterFirstVisitAnniversary(now.getTime(), now)).toBe(false); + const lastMonth = new Date("2026-06-09T12:00:00").getTime(); + expect(isLobsterFirstVisitAnniversary(lastMonth, new Date("2026-07-09T12:00:00"))).toBe(false); + }); + + it("celebrates leap-day firsts only on leap years", () => { + const leapFirst = new Date("2024-02-29T12:00:00").getTime(); + expect(isLobsterFirstVisitAnniversary(leapFirst, new Date("2028-02-29T12:00:00"))).toBe(true); + expect(isLobsterFirstVisitAnniversary(leapFirst, new Date("2026-02-28T12:00:00"))).toBe(false); + expect(isLobsterFirstVisitAnniversary(leapFirst, new Date("2026-03-01T12:00:00"))).toBe(false); + }); +}); diff --git a/ui/src/components/lobster-dex.ts b/ui/src/components/lobster-dex.ts index d4ea0c8452a8..2422bb642d7b 100644 --- a/ui/src/components/lobster-dex.ts +++ b/ui/src/components/lobster-dex.ts @@ -147,3 +147,35 @@ export function getLobsterFamiliarity(): LobsterFamiliarity { const wary = shoos >= 3 && shoos > visits * 0.3; return { tier, wary, visits, shoos }; } + +// ---- Long memory ---- + +// Milestone honorifics for the hover title, earned by lifetime visits across +// all palettes. Highest earned title wins; below the first rung there is none. +const HONORIFICS: Array<[number, string]> = [ + [250, "Elder"], + [100, "Captain"], + [50, "Sir"], +]; + +export function lobsterHonorific(visits: number): string | null { + for (const [threshold, title] of HONORIFICS) { + if (visits >= threshold) { + return title; + } + } + return null; +} + +// True when `now` is the month/day anniversary of a palette's first recorded +// visit. The elapsed guard keeps the first weeks from counting; Feb 29 firsts +// celebrate on leap years only - rarity is the point. +const ANNIVERSARY_MIN_ELAPSED_MS = 300 * 24 * 60 * 60 * 1000; + +export function isLobsterFirstVisitAnniversary(firstSeenAt: number | null, now: Date): boolean { + if (firstSeenAt === null || now.getTime() - firstSeenAt < ANNIVERSARY_MIN_ELAPSED_MS) { + return false; + } + const first = new Date(firstSeenAt); + return first.getMonth() === now.getMonth() && first.getDate() === now.getDate(); +} diff --git a/ui/src/components/lobster-pet.test.ts b/ui/src/components/lobster-pet.test.ts index 6ba63cc7b38d..39f9cecbbdfd 100644 --- a/ui/src/components/lobster-pet.test.ts +++ b/ui/src/components/lobster-pet.test.ts @@ -809,6 +809,68 @@ describe("lobster pet element", () => { expect(audioContextCtor).toHaveBeenCalledTimes(1); }); + it("wears the party hat on its first-visit anniversary", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-09T12:00:00")); + vi.stubGlobal("localStorage", window.localStorage); + const look = createLobsterPetLook(42, new Date("2026-07-09T12:00:00")); + localStorage.setItem( + "openclaw.control.lobsterdex.v1", + JSON.stringify({ + [look.palette.id]: { + firstSeenAt: new Date("2025-07-09T12:00:00").getTime(), + name: "Original", + }, + }), + ); + const element = createPet(42); + await arrive(element); + + expect(spriteClasses(element)).toContain("lobster-pet--party"); + // The memory itself stays immutable through the celebratory visit. + expect(getLobsterdexEntries().get(look.palette.id)?.name).toBe("Original"); + }); + + it("keeps ordinary days ordinary - no hat, no title", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-09T12:00:00")); + vi.stubGlobal("localStorage", window.localStorage); + const look = createLobsterPetLook(42, new Date("2026-07-09T12:00:00")); + localStorage.setItem( + "openclaw.control.lobsterdex.v1", + JSON.stringify({ + [look.palette.id]: { + firstSeenAt: new Date("2025-11-03T12:00:00").getTime(), + name: "Original", + }, + }), + ); + const element = createPet(42); + await arrive(element); + + expect(spriteClasses(element)).not.toContain("lobster-pet--party"); + expect(element.querySelector(".lobster-pet")?.getAttribute("title")).toBe( + lobsterPetName(look, 42), + ); + }); + + it("earns honorifics from lifetime visit milestones", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-09T12:00:00")); + vi.stubGlobal("localStorage", window.localStorage); + localStorage.setItem( + "openclaw.control.lobsterpet.familiarity.v1", + JSON.stringify({ visits: 120, shoos: 0 }), + ); + const look = createLobsterPetLook(42, new Date("2026-07-09T12:00:00")); + const element = createPet(42); + await arrive(element); + + expect(element.querySelector(".lobster-pet")?.getAttribute("title")).toBe( + `Captain ${lobsterPetName(look, 42)}`, + ); + }); + 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 c75b6a43faa7..42ba0c33ff5e 100644 --- a/ui/src/components/lobster-pet.ts +++ b/ui/src/components/lobster-pet.ts @@ -10,6 +10,9 @@ import { getSafeLocalStorage } from "../local-storage.ts"; import { LOBSTER_FAMILIARITY_TUNING, getLobsterFamiliarity, + getLobsterdexEntries, + isLobsterFirstVisitAnniversary, + lobsterHonorific, recordLobsterArrivalStats, recordLobsterShoo, recordLobsterVisit, @@ -870,6 +873,7 @@ export class LobsterPet extends LitElement { @state() private passer: LobsterPasserPlan | null = null; @state() private movingDay = false; private movingDayChecked = false; + @state() private anniversary = false; @state() private shellVisible = false; private shellSpotPct = 50; private shellScale = 2; @@ -1015,6 +1019,12 @@ export class LobsterPet extends LitElement { if (this.presence === "out") { this.rollPerch(); if (this.look) { + // Anniversary check reads the dex before this arrival records into + // it: a first-ever visit today must not celebrate itself. + this.anniversary = isLobsterFirstVisitAnniversary( + getLobsterdexEntries().get(this.look.palette.id)?.firstSeenAt ?? null, + new Date(), + ); // Every genuine arrival (visit or offline summon) logs the palette // with the first visitor's name, and bumps the familiarity count. recordLobsterVisit(this.look.palette.id, { @@ -1466,12 +1476,18 @@ export class LobsterPet extends LitElement { } private renderSprite(look: LobsterPetLook, twin: boolean) { + // On the month/day anniversary of this palette's first Lobsterdex visit, + // the party hat overrides whatever accessory the seed rolled. + const dressed = + this.anniversary && look.accessory !== "party" + ? { ...look, accessory: "party" as const } + : look; const classes = [ "lobster-pet", `lobster-pet--${this.mode}`, `lobster-pet--palette-${look.palette.id}`, twin ? "lobster-pet--twin" : "", - look.accessory === "party" ? "lobster-pet--party" : "", + dressed.accessory === "party" ? "lobster-pet--party" : "", this.presence === "leaving" ? "lobster-pet--away" : "", this.entering ? "lobster-pet--entering" : "", this.grumpy ? "lobster-pet--grumpy" : "", @@ -1490,7 +1506,11 @@ export class LobsterPet extends LitElement { 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); + // Milestone honorifics come from the load-start familiarity snapshot, so + // a title never pops mid-visit; it is simply there next time. + const honorific = lobsterHonorific(this.familiarity.visits); + const baseName = lobsterPetName(look, this.seed); + const name = honorific ? `${honorific} ${baseName}` : baseName; // The twin travels light; only the resident pet hauls the moving bindle. const bindle = this.movingDay && !twin; const title = twin ? `${name} Jr.` : bindle ? `${name} ยท just moved in` : name; @@ -1506,7 +1526,7 @@ export class LobsterPet extends LitElement { @contextmenu=${this.handleShoo} >
- ${renderLobsterSvg(look, { grumpy: this.grumpy, bindle })} + ${renderLobsterSvg(dressed, { grumpy: this.grumpy, bindle })} z z Z