Skip to content

Commit 0d63f46

Browse files
authored
feat(webchat): the lobster remembers - dex memories and a familiarity arc (#103359)
The Lobsterdex upgrades to a v2 object schema (v1 arrays migrate in place, memories backfill on the next visit): each palette remembers its first visitor's name and date, shown in the gallery tooltips ('Goldie - first visited 7/5/2026'). Familiarity counters (visits, shoos) shape behavior with no UI: fewer than three lifetime visits is shy (shorter stays, later arrivals), fifteen or more is a friend (longer stays, sooner returns, and a hello wave on the first arrival of each load), and shooing it too often makes it wary, stretching the gaps between visits. All best-effort localStorage; tuning lives in one exported table.
1 parent ce47e6a commit 0d63f46

66 files changed

Lines changed: 411 additions & 111 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ui/src/components/lobster-dex.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
/* @vitest-environment jsdom */
22

33
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4-
import { getLobsterdex, recordLobsterVisit } from "./lobster-dex.ts";
4+
import {
5+
LOBSTER_FAMILIARITY_TUNING,
6+
getLobsterFamiliarity,
7+
getLobsterdex,
8+
getLobsterdexEntries,
9+
recordLobsterArrivalStats,
10+
recordLobsterShoo,
11+
recordLobsterVisit,
12+
} from "./lobster-dex.ts";
513

614
beforeEach(() => {
715
// getSafeLocalStorage only accepts an own value property under Vitest, so
@@ -23,10 +31,63 @@ describe("lobsterdex", () => {
2331
expect([...getLobsterdex()].toSorted()).toEqual(["crimson", "gold"]);
2432
});
2533

34+
it("remembers the first visitor's name and date, immutably", () => {
35+
const before = Date.now();
36+
recordLobsterVisit("gold", { name: "Goldie" });
37+
const entry = getLobsterdexEntries().get("gold");
38+
expect(entry?.name).toBe("Goldie");
39+
expect(entry?.firstSeenAt).toBeGreaterThanOrEqual(before);
40+
41+
recordLobsterVisit("gold", { name: "Impostor" });
42+
expect(getLobsterdexEntries().get("gold")?.name).toBe("Goldie");
43+
});
44+
45+
it("migrates v1 array entries and backfills memories on the next visit", () => {
46+
localStorage.setItem("openclaw.control.lobsterdex.v1", JSON.stringify(["crimson"]));
47+
const migrated = getLobsterdexEntries().get("crimson");
48+
expect(migrated).toEqual({ firstSeenAt: null, name: null });
49+
expect(getLobsterdex().has("crimson")).toBe(true);
50+
51+
recordLobsterVisit("crimson", { name: "Pinchy" });
52+
const backfilled = getLobsterdexEntries().get("crimson");
53+
expect(backfilled?.name).toBe("Pinchy");
54+
expect(backfilled?.firstSeenAt).not.toBeNull();
55+
});
56+
2657
it("tolerates corrupt storage", () => {
2758
localStorage.setItem("openclaw.control.lobsterdex.v1", "{not json");
2859
expect(getLobsterdex().size).toBe(0);
2960
recordLobsterVisit("teal");
3061
expect(getLobsterdex().has("teal")).toBe(true);
3162
});
3263
});
64+
65+
describe("lobster familiarity", () => {
66+
it("tiers by visit count and grows wary of frequent shooing", () => {
67+
expect(getLobsterFamiliarity()).toMatchObject({ tier: "shy", wary: false });
68+
for (let i = 0; i < 3; i++) {
69+
recordLobsterArrivalStats();
70+
}
71+
expect(getLobsterFamiliarity().tier).toBe("regular");
72+
for (let i = 0; i < 12; i++) {
73+
recordLobsterArrivalStats();
74+
}
75+
expect(getLobsterFamiliarity().tier).toBe("friend");
76+
77+
for (let i = 0; i < 3; i++) {
78+
recordLobsterShoo();
79+
}
80+
// 3 shoos over 15 visits is not wary yet (<= 30%); a few more are.
81+
expect(getLobsterFamiliarity().wary).toBe(false);
82+
for (let i = 0; i < 3; i++) {
83+
recordLobsterShoo();
84+
}
85+
expect(getLobsterFamiliarity().wary).toBe(true);
86+
});
87+
88+
it("keeps the tuning table sane", () => {
89+
expect(LOBSTER_FAMILIARITY_TUNING.shy.stayMul).toBeLessThan(1);
90+
expect(LOBSTER_FAMILIARITY_TUNING.friend.stayMul).toBeGreaterThan(1);
91+
expect(LOBSTER_FAMILIARITY_TUNING.waryGapMul).toBeGreaterThan(1);
92+
});
93+
});

ui/src/components/lobster-dex.ts

Lines changed: 132 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,149 @@
11
// The Lobsterdex: a quiet localStorage log of every lobster palette that has
2-
// ever visited this browser. Purely client-side collection flavor; the pet
3-
// records arrivals and the appearance settings card renders the gallery.
2+
// ever visited this browser, remembering who came first and when. Also home
3+
// to the familiarity counters the pet uses to warm up to (or grow wary of)
4+
// its human. Purely client-side; string-keyed so this stays a leaf module
5+
// (the pet imports us; importing pet types back would create an import
6+
// cycle).
47
import { getSafeLocalStorage } from "../local-storage.ts";
58

69
const LOBSTERDEX_KEY = "openclaw.control.lobsterdex.v1";
10+
const FAMILIARITY_KEY = "openclaw.control.lobsterpet.familiarity.v1";
711

8-
export function getLobsterdex(): ReadonlySet<string> {
12+
export type LobsterdexEntry = {
13+
firstSeenAt: number | null;
14+
name: string | null;
15+
};
16+
17+
type PersistedDex = Record<string, { firstSeenAt?: number; name?: string }>;
18+
19+
function readDex(): Map<string, LobsterdexEntry> {
920
try {
1021
const raw = getSafeLocalStorage()?.getItem(LOBSTERDEX_KEY);
11-
const parsed: unknown = raw ? JSON.parse(raw) : [];
12-
return new Set(
13-
Array.isArray(parsed)
14-
? parsed.flatMap((value) => (typeof value === "string" && value ? [value] : []))
15-
: [],
16-
);
22+
const parsed: unknown = raw ? JSON.parse(raw) : {};
23+
const entries = new Map<string, LobsterdexEntry>();
24+
if (Array.isArray(parsed)) {
25+
// v1 stored a bare palette-id array; carry ids over without memories.
26+
for (const value of parsed) {
27+
if (typeof value === "string" && value) {
28+
entries.set(value, { firstSeenAt: null, name: null });
29+
}
30+
}
31+
return entries;
32+
}
33+
if (parsed && typeof parsed === "object") {
34+
for (const [id, value] of Object.entries(parsed as PersistedDex)) {
35+
if (!id) {
36+
continue;
37+
}
38+
entries.set(id, {
39+
firstSeenAt: typeof value?.firstSeenAt === "number" ? value.firstSeenAt : null,
40+
name: typeof value?.name === "string" && value.name ? value.name : null,
41+
});
42+
}
43+
}
44+
return entries;
1745
} catch {
18-
return new Set();
46+
return new Map();
1947
}
2048
}
2149

22-
// Keyed by palette id strings so this stays a leaf module (the pet imports
23-
// us; importing pet types back would create an import cycle).
24-
export function recordLobsterVisit(paletteId: string): void {
50+
function writeDex(entries: Map<string, LobsterdexEntry>): void {
51+
const persisted: PersistedDex = {};
52+
for (const [id, entry] of [...entries.entries()].toSorted(([a], [b]) => a.localeCompare(b))) {
53+
persisted[id] = {
54+
...(entry.firstSeenAt !== null ? { firstSeenAt: entry.firstSeenAt } : {}),
55+
...(entry.name !== null ? { name: entry.name } : {}),
56+
};
57+
}
58+
getSafeLocalStorage()?.setItem(LOBSTERDEX_KEY, JSON.stringify(persisted));
59+
}
60+
61+
export function getLobsterdex(): ReadonlySet<string> {
62+
return new Set(readDex().keys());
63+
}
64+
65+
export function getLobsterdexEntries(): ReadonlyMap<string, LobsterdexEntry> {
66+
return readDex();
67+
}
68+
69+
export function recordLobsterVisit(paletteId: string, details: { name?: string } = {}): void {
2570
try {
26-
const seen = new Set(getLobsterdex());
27-
if (seen.has(paletteId)) {
28-
return;
71+
const entries = readDex();
72+
const existing = entries.get(paletteId);
73+
if (existing) {
74+
// First-visitor memories are immutable; later visits only backfill
75+
// fields the v1 schema never had.
76+
if (existing.firstSeenAt !== null && existing.name !== null) {
77+
return;
78+
}
79+
entries.set(paletteId, {
80+
firstSeenAt: existing.firstSeenAt ?? Date.now(),
81+
name: existing.name ?? details.name ?? null,
82+
});
83+
} else {
84+
entries.set(paletteId, { firstSeenAt: Date.now(), name: details.name ?? null });
2985
}
30-
seen.add(paletteId);
31-
getSafeLocalStorage()?.setItem(LOBSTERDEX_KEY, JSON.stringify([...seen].toSorted()));
86+
writeDex(entries);
3287
} catch {
3388
// best-effort — a full or blocked storage must not break visits
3489
}
3590
}
91+
92+
// ---- Familiarity ----
93+
94+
export type LobsterFamiliarityTier = "shy" | "regular" | "friend";
95+
96+
export type LobsterFamiliarity = {
97+
tier: LobsterFamiliarityTier;
98+
wary: boolean;
99+
visits: number;
100+
shoos: number;
101+
};
102+
103+
// Behavior multipliers per tier; the pet reads these once per load. Shy pets
104+
// keep visits short and arrive late; friends linger, return sooner, and
105+
// greet. Wary pets (shooed too often) leave longer gaps between visits.
106+
export const LOBSTER_FAMILIARITY_TUNING = {
107+
shy: { stayMul: 0.6, firstDelayMul: 1.3, gapMul: 1 },
108+
regular: { stayMul: 1, firstDelayMul: 1, gapMul: 1 },
109+
friend: { stayMul: 1.6, firstDelayMul: 0.7, gapMul: 0.8 },
110+
waryGapMul: 1.7,
111+
} as const;
112+
113+
function readFamiliarityCounters(): { visits: number; shoos: number } {
114+
try {
115+
const raw = getSafeLocalStorage()?.getItem(FAMILIARITY_KEY);
116+
const parsed: unknown = raw ? JSON.parse(raw) : {};
117+
const record = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
118+
const visits = typeof record.visits === "number" && record.visits >= 0 ? record.visits : 0;
119+
const shoos = typeof record.shoos === "number" && record.shoos >= 0 ? record.shoos : 0;
120+
return { visits, shoos };
121+
} catch {
122+
return { visits: 0, shoos: 0 };
123+
}
124+
}
125+
126+
function writeFamiliarityCounters(counters: { visits: number; shoos: number }): void {
127+
try {
128+
getSafeLocalStorage()?.setItem(FAMILIARITY_KEY, JSON.stringify(counters));
129+
} catch {
130+
// best-effort
131+
}
132+
}
133+
134+
export function recordLobsterArrivalStats(): void {
135+
const counters = readFamiliarityCounters();
136+
writeFamiliarityCounters({ ...counters, visits: counters.visits + 1 });
137+
}
138+
139+
export function recordLobsterShoo(): void {
140+
const counters = readFamiliarityCounters();
141+
writeFamiliarityCounters({ ...counters, shoos: counters.shoos + 1 });
142+
}
143+
144+
export function getLobsterFamiliarity(): LobsterFamiliarity {
145+
const { visits, shoos } = readFamiliarityCounters();
146+
const tier: LobsterFamiliarityTier = visits < 3 ? "shy" : visits < 15 ? "regular" : "friend";
147+
const wary = shoos >= 3 && shoos > visits * 0.3;
148+
return { tier, wary, visits, shoos };
149+
}

ui/src/components/lobster-pet.test.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* @vitest-environment jsdom */
22

33
import { afterEach, describe, expect, it, vi } from "vitest";
4-
import { getLobsterdex } from "./lobster-dex.ts";
4+
import { getLobsterdex, getLobsterdexEntries } from "./lobster-dex.ts";
55
import {
66
LOBSTER_PET_ACT_DURATION_MS,
77
LOBSTER_PET_MODE_ACTS,
@@ -567,8 +567,46 @@ describe("lobster pet element", () => {
567567
expect(getLobsterdex().size).toBe(0);
568568

569569
await arrive(element);
570-
const paletteId = createLobsterPetLook(42, new Date("2026-07-09T12:00:00")).palette.id;
571-
expect(getLobsterdex().has(paletteId)).toBe(true);
570+
const look = createLobsterPetLook(42, new Date("2026-07-09T12:00:00"));
571+
expect(getLobsterdex().has(look.palette.id)).toBe(true);
572+
// Memories: the first visitor's name and date stick to the entry.
573+
const entry = getLobsterdexEntries().get(look.palette.id);
574+
expect(entry?.name).toBe(lobsterPetName(look, 42));
575+
expect(entry?.firstSeenAt).not.toBeNull();
576+
});
577+
578+
it("old friends wave hello on their first arrival of the load", async () => {
579+
vi.useFakeTimers();
580+
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
581+
vi.stubGlobal("localStorage", window.localStorage);
582+
localStorage.setItem(
583+
"openclaw.control.lobsterpet.familiarity.v1",
584+
JSON.stringify({ visits: 30, shoos: 0 }),
585+
);
586+
const element = createPet(42);
587+
await arrive(element);
588+
589+
// The greeting fires right after the entrance settles.
590+
await vi.advanceTimersByTimeAsync(600);
591+
await element.updateComplete;
592+
expect(spriteClasses(element)).toContain("lobster-pet--act-wave");
593+
});
594+
595+
it("shooing is remembered in the familiarity counters", async () => {
596+
vi.useFakeTimers();
597+
vi.setSystemTime(new Date("2026-07-09T12:00:00"));
598+
vi.stubGlobal("localStorage", window.localStorage);
599+
const element = createPet(42);
600+
await arrive(element);
601+
602+
element
603+
.querySelector(".lobster-pet:not(.lobster-pet--shell)")
604+
?.dispatchEvent(new Event("contextmenu", { cancelable: true }));
605+
await element.updateComplete;
606+
const raw = JSON.parse(
607+
localStorage.getItem("openclaw.control.lobsterpet.familiarity.v1") ?? "{}",
608+
);
609+
expect(raw.shoos).toBe(1);
572610
});
573611

574612
it("pets on press-and-hold instead of poking", async () => {

0 commit comments

Comments
 (0)