|
1 | 1 | // 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). |
4 | 7 | import { getSafeLocalStorage } from "../local-storage.ts"; |
5 | 8 |
|
6 | 9 | const LOBSTERDEX_KEY = "openclaw.control.lobsterdex.v1"; |
| 10 | +const FAMILIARITY_KEY = "openclaw.control.lobsterpet.familiarity.v1"; |
7 | 11 |
|
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> { |
9 | 20 | try { |
10 | 21 | 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; |
17 | 45 | } catch { |
18 | | - return new Set(); |
| 46 | + return new Map(); |
19 | 47 | } |
20 | 48 | } |
21 | 49 |
|
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 { |
25 | 70 | 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 }); |
29 | 85 | } |
30 | | - seen.add(paletteId); |
31 | | - getSafeLocalStorage()?.setItem(LOBSTERDEX_KEY, JSON.stringify([...seen].toSorted())); |
| 86 | + writeDex(entries); |
32 | 87 | } catch { |
33 | 88 | // best-effort — a full or blocked storage must not break visits |
34 | 89 | } |
35 | 90 | } |
| 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 | +} |
0 commit comments