mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 22:17:00 +00:00
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:
committed by
GitHub
parent
8b49bc61a8
commit
be151c6f14
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user