← back to CelebritySignatures
mobile: harden games port per adversarial review
701c0095c7da9641779258d67359761ae67e8510 · 2026-08-05 17:12:55 -0700 · steve
- lightning timer: drop 'answered' from effect deps (avoids redundant re-run)
- next(): defensive clearTimer() so a timeout can't bleed into the next round
- century options: iteration guard + deterministic boundary fill (no rare soft-lock)
- api: LeaderRow.id optional (endpoint returns name/score/at, no id)
Files touched
M apps/mobile/api.tsM apps/mobile/screens/GameScreen.tsx
Diff
commit 701c0095c7da9641779258d67359761ae67e8510
Author: steve <steve@designerwallcoverings.com>
Date: Wed Aug 5 17:12:55 2026 -0700
mobile: harden games port per adversarial review
- lightning timer: drop 'answered' from effect deps (avoids redundant re-run)
- next(): defensive clearTimer() so a timeout can't bleed into the next round
- century options: iteration guard + deterministic boundary fill (no rare soft-lock)
- api: LeaderRow.id optional (endpoint returns name/score/at, no id)
---
apps/mobile/api.ts | 2 +-
apps/mobile/screens/GameScreen.tsx | 13 +++++++++++--
2 files changed, 12 insertions(+), 3 deletions(-)
diff --git a/apps/mobile/api.ts b/apps/mobile/api.ts
index fe5ca2a..f367e42 100644
--- a/apps/mobile/api.ts
+++ b/apps/mobile/api.ts
@@ -36,7 +36,7 @@ export type MuralsCatalog = {
};
export type User = { email: string; name?: string } | null;
-export type LeaderRow = { id: string; name: string; score: number; at: string };
+export type LeaderRow = { id?: string; name: string; score: number; at: string };
/** Wikidata QID from a `.../Q12345` url — the key portraits + evolution use. */
export function qidOf(sig: Signature): string | null {
diff --git a/apps/mobile/screens/GameScreen.tsx b/apps/mobile/screens/GameScreen.tsx
index cc74310..fe74c0b 100644
--- a/apps/mobile/screens/GameScreen.tsx
+++ b/apps/mobile/screens/GameScreen.tsx
@@ -210,11 +210,17 @@ async function buildRounds(
const c = centuryOf(answer)!;
const opts = new Set<number>([c]);
let step = 1;
- while (opts.size < 4) {
+ let guard = 0;
+ while (opts.size < 4 && guard++ < 200) {
const cand = c + (Math.random() < 0.5 ? -step : step);
if (cand >= 6 && cand <= 21) opts.add(cand);
step = Math.min(step + 1, 6);
}
+ // boundary fallback: fill any shortfall deterministically from the valid range
+ for (let d = 1; opts.size < 4 && d <= 15; d++) {
+ if (c - d >= 6) opts.add(c - d);
+ if (opts.size < 4 && c + d <= 21) opts.add(c + d);
+ }
rounds.push({ kind: 'century', answer, century: c, options: shuffle([...opts]) });
}
return rounds;
@@ -454,8 +460,10 @@ export default function GameScreen() {
timerRef.current = setTimeout(() => answer('timeout'), LIGHTNING_MS);
}
return clearTimer;
+ // answered is intentionally NOT a dep: answering calls clearTimer() directly,
+ // so re-running this effect on answer would only redundantly cancel the timer.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [phase, i, answered, round?.kind]);
+ }, [phase, i, round?.kind]);
// --- answer a round ---
function answer(choice: number | 'left' | 'right' | 'timeout') {
@@ -503,6 +511,7 @@ export default function GameScreen() {
}
function next() {
+ clearTimer(); // defensive: a lightning timeout can't fire into the next round
const last = i >= rounds.length - 1;
if (last) { finish(); return; }
setI((v) => v + 1);
← 3d57856 mobile: port all 6 desktop games into GameScreen (was whose-
·
back to CelebritySignatures
·
iOS app: white gallery theme + all images render + tap-signa 91f1e57 →