← 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
M apps/mobile/package-lock.jsonM apps/mobile/package.jsonM apps/mobile/screens/GameScreen.tsx
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 →