feat(webchat): the lobster celebrates anniversaries and earns titles (#103563)

On the month/day anniversary of a palette's first Lobsterdex visit the
arriving lobster wears the party hat. Lifetime visit milestones add
honorifics to the hover name: Sir at 50, Captain at 100, Elder at 250.
This commit is contained in:
Peter Steinberger
2026-07-10 09:56:15 +01:00
committed by GitHub
parent 8b49bc61a8
commit be151c6f14
4 changed files with 156 additions and 3 deletions

View File

@@ -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);
});
});

View File

@@ -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();
}

View File

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

View File

@@ -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}
>
<div class="lobster-pet__body">
${renderLobsterSvg(look, { grumpy: this.grumpy, bindle })}
${renderLobsterSvg(dressed, { grumpy: this.grumpy, bindle })}
<span class="lobster-pet__z" style="--i:0">z</span>
<span class="lobster-pet__z" style="--i:1">z</span>
<span class="lobster-pet__z" style="--i:2">Z</span>