[object Object]

← back to CelebritySignatures

CelebritySignatures mobile: early-game respects Difficulty + persist personal best (TK-10398)

93f75788865080f2771c5760be95531c7a3c5bce · 2026-08-25 14:46:54 -0700 · Steve Abrams

Two next-build polish fixes from the TK-10262 Cody review, both in
apps/mobile/screens/GameScreen.tsx:

1. 'Early or Late?' now honors the Difficulty selector. It previously
   sampled EVO directly and ignored diff, so Icons/Scholar/Curator/Deep
   Cuts had no effect. Now the EVO people are filtered to the notability-
   ranked top-N tier (joined by qid), with a fallback to the full set if
   the tier is too thin to build a round (never regresses to unstartable).
   Category is left as-is: the Category selector is already hidden for
   'early' via catRelevant, so this stays catalog-wide by design.

2. Personal best now survives an app restart. Added
   @react-native-async-storage/async-storage (2.2.0, expo-installed for
   SDK 56); BEST hydrates from disk on mount and saves on finish, with a
   try/catch fallback to the existing in-memory cache so it never crashes
   even in a build where the native module isn't linked yet.

Neither was a crash — both are quality polish. tsc --noEmit passes clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 93f75788865080f2771c5760be95531c7a3c5bce
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Aug 25 14:46:54 2026 -0700

    CelebritySignatures mobile: early-game respects Difficulty + persist personal best (TK-10398)
    
    Two next-build polish fixes from the TK-10262 Cody review, both in
    apps/mobile/screens/GameScreen.tsx:
    
    1. 'Early or Late?' now honors the Difficulty selector. It previously
       sampled EVO directly and ignored diff, so Icons/Scholar/Curator/Deep
       Cuts had no effect. Now the EVO people are filtered to the notability-
       ranked top-N tier (joined by qid), with a fallback to the full set if
       the tier is too thin to build a round (never regresses to unstartable).
       Category is left as-is: the Category selector is already hidden for
       'early' via catRelevant, so this stays catalog-wide by design.
    
    2. Personal best now survives an app restart. Added
       @react-native-async-storage/async-storage (2.2.0, expo-installed for
       SDK 56); BEST hydrates from disk on mount and saves on finish, with a
       try/catch fallback to the existing in-memory cache so it never crashes
       even in a build where the native module isn't linked yet.
    
    Neither was a crash — both are quality polish. tsc --noEmit passes clean.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 apps/mobile/package-lock.json      | 34 ++++++++++++++++++++++++++
 apps/mobile/package.json           |  1 +
 apps/mobile/screens/GameScreen.tsx | 50 ++++++++++++++++++++++++++++++++++++--
 3 files changed, 83 insertions(+), 2 deletions(-)

diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json
index 615777a..6c71766 100644
--- a/apps/mobile/package-lock.json
+++ b/apps/mobile/package-lock.json
@@ -8,6 +8,7 @@
       "name": "celebrity-signatures-mobile",
       "version": "1.1.0",
       "dependencies": {
+        "@react-native-async-storage/async-storage": "2.2.0",
         "expo": "^56.0.18",
         "expo-asset": "~56.0.21",
         "expo-blur": "~56.0.4",
@@ -1508,6 +1509,18 @@
         "@jridgewell/sourcemap-codec": "^1.4.14"
       }
     },
+    "node_modules/@react-native-async-storage/async-storage": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
+      "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
+      "license": "MIT",
+      "dependencies": {
+        "merge-options": "^3.0.4"
+      },
+      "peerDependencies": {
+        "react-native": "^0.0.0-0 || >=0.65 <1.0"
+      }
+    },
     "node_modules/@react-native/assets-registry": {
       "version": "0.85.3",
       "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.85.3.tgz",
@@ -3581,6 +3594,15 @@
         "node": ">=0.12.0"
       }
     },
+    "node_modules/is-plain-obj": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
+      "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/is-wsl": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@@ -4201,6 +4223,18 @@
       "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
       "license": "MIT"
     },
+    "node_modules/merge-options": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
+      "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
+      "license": "MIT",
+      "dependencies": {
+        "is-plain-obj": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/merge-stream": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index eb71b6b..2bf27bb 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -9,6 +9,7 @@
     "android": "expo run:android"
   },
   "dependencies": {
+    "@react-native-async-storage/async-storage": "2.2.0",
     "expo": "^56.0.18",
     "expo-asset": "~56.0.21",
     "expo-blur": "~56.0.4",
diff --git a/apps/mobile/screens/GameScreen.tsx b/apps/mobile/screens/GameScreen.tsx
index 579289b..5ea6bed 100644
--- a/apps/mobile/screens/GameScreen.tsx
+++ b/apps/mobile/screens/GameScreen.tsx
@@ -35,6 +35,7 @@ import {
   View,
 } from 'react-native';
 import * as Haptics from 'expo-haptics';
+import AsyncStorage from '@react-native-async-storage/async-storage';
 
 import { api, Signature, LeaderRow, Evolution, qidOf, sigImg } from '../api';
 import { GLASS, GlassPanel, GlassButton, GlassPill } from '../ui/glass';
@@ -86,8 +87,33 @@ const PROMPTS: Record<GameKey, string> = {
 const TOTAL = 10;
 const LIGHTNING_MS = 5000;
 
-// module-level best-score cache (no AsyncStorage dep; resets on app restart)
+// Personal-best cache. Kept in a synchronous module-level map for instant reads
+// during render, and mirrored to AsyncStorage so bests survive an app restart.
 const BEST: Record<string, number> = {};
+const BEST_STORE_KEY = 'signatureGames.best.v1';
+
+/** Hydrate the in-memory BEST cache from disk (merging, never lowering a value). */
+async function loadBest(): Promise<void> {
+  try {
+    const raw = await AsyncStorage.getItem(BEST_STORE_KEY);
+    if (!raw) return;
+    const saved = JSON.parse(raw) as Record<string, number>;
+    for (const [k, v] of Object.entries(saved)) {
+      if (typeof v === 'number') BEST[k] = Math.max(v, BEST[k] || 0);
+    }
+  } catch {
+    /* storage unavailable (e.g. module not linked) — stay in-memory only */
+  }
+}
+
+/** Persist the current BEST map. Non-fatal on failure. */
+async function saveBest(): Promise<void> {
+  try {
+    await AsyncStorage.setItem(BEST_STORE_KEY, JSON.stringify(BEST));
+  } catch {
+    /* non-fatal */
+  }
+}
 
 // ---------------------------------------------------------------------------
 // Helpers (ported from game.html)
@@ -227,9 +253,25 @@ async function buildRounds(
   }
 
   if (gameKey === 'early') {
-    const people = Object.entries(EVO)
+    const allPeople = Object.entries(EVO)
       .map(([qid, e]) => ({ qid, e, dated: (e.sigs || []).filter((s) => s.year) }))
       .filter((x) => new Set(x.dated.map((s) => s.year)).size >= 2);
+    // Honor the Difficulty tier: keep only people inside the notability-ranked
+    // top-N slice of the catalog, so Icons/Scholar/Curator/Deep Cuts actually
+    // change the pool (previously 'early' sampled EVO directly and ignored diff).
+    // Category is intentionally NOT applied here — the Category selector is hidden
+    // for 'early' (catRelevant), so this stays catalog-wide. Fall back to the full
+    // set if the tier is too thin to build a round, so the game never regresses
+    // into an unstartable "still loading" state.
+    const tierQids = new Set(
+      [...ALL]
+        .sort((a, b) => notability(b) - notability(a))
+        .slice(0, DIFF_N[diff])
+        .map((r) => qidOf(r))
+        .filter((q): q is string => !!q),
+    );
+    const tiered = allPeople.filter((x) => tierQids.has(x.qid));
+    const people = tiered.length >= 5 ? tiered : allPeople;
     if (people.length < 5) throw new Error('Evolution data still loading — try again in a moment.');
     for (let i = 0; i < TOTAL; i++) {
       const P = people[Math.floor(Math.random() * people.length)];
@@ -422,6 +464,9 @@ export default function GameScreen() {
 
   useEffect(() => { loadCatalog(); }, [loadCatalog]);
 
+  // hydrate personal bests from disk once, so they survive an app restart
+  useEffect(() => { void loadBest(); }, []);
+
   const clearTimer = useCallback(() => {
     if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
     barW.stopAnimation();
@@ -528,6 +573,7 @@ export default function GameScreen() {
     }
     const key = `${gameKey}.${diff}.${cat}`;
     BEST[key] = Math.max(finalScore, BEST[key] || 0);
+    void saveBest();
     setPhase('over');
     loadLeaderboard();
   }

← 0770f00 chore: lint, refactor, session-close fixes, v1.0.2 → v1.0.3  ·  back to CelebritySignatures  ·  Add AI-readable site guide 19ad3e6 →