Skip to content

Commit a0eeee5

Browse files
committed
fix: skip non-best saves and harden local storage limits
1 parent 763402f commit a0eeee5

File tree

7 files changed

+188
-35
lines changed

7 files changed

+188
-35
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "torus-tauri",
33
"private": true,
4-
"version": "1.6.4",
4+
"version": "1.6.5",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "torus-app"
3-
version = "1.6.4"
3+
version = "1.6.5"
44
description = "Torus game desktop app"
55
authors = ["u-keunsong"]
66
edition = "2021"

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Torus",
4-
"version": "1.6.4",
4+
"version": "1.6.5",
55
"identifier": "com.u-keunsong.Torus",
66
"build": {
77
"beforeDevCommand": "npm run dev",

src/main.ts

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2019,6 +2019,20 @@ function closeGameOverModal(): void {
20192019
dom.gameOverModalEl.classList.add("hidden");
20202020
}
20212021

2022+
function finalizeGameOverFlow(): void {
2023+
closeGameOverModal();
2024+
game.reset();
2025+
canResume = false;
2026+
currentRunSkillUsage = [];
2027+
currentRunReplaySeed = null;
2028+
currentRunReplayInputs = [];
2029+
currentRunReplayDifficulty = null;
2030+
activeDailyChallengeKey = null;
2031+
activeDailyAttemptToken = null;
2032+
setStatus("Paused");
2033+
clearSessionSnapshot();
2034+
}
2035+
20222036
async function saveGameOverScore(): Promise<void> {
20232037
if (savingGameOver) {
20242038
return;
@@ -2064,12 +2078,24 @@ async function saveGameOverScore(): Promise<void> {
20642078
console.warn("Missing replay proof for this run. Global submission will be unavailable.", error);
20652079
}
20662080

2067-
const personalEntry = runReplayProof ? { ...entry, replayProof: runReplayProof } : entry;
2068-
await scoreboardStore.addPersonal(personalEntry);
20692081
const best = loadDeviceBestEntry();
2070-
const isDeviceBest = runReplayProof ? isBetterThanBest(personalEntry, best) : false;
2071-
if (isDeviceBest && personalEntry.replayProof) {
2072-
saveDeviceBestEntry(personalEntry);
2082+
const isDeviceBestByScore = isBetterThanBest(entry, best);
2083+
if (gameMode !== "daily" && !isDeviceBestByScore) {
2084+
// Non-best classic runs are treated as "skip": no persistence, just continue.
2085+
finalizeGameOverFlow();
2086+
return;
2087+
}
2088+
try {
2089+
await scoreboardStore.addPersonal(entry);
2090+
} catch (error) {
2091+
console.warn("Failed to save personal score locally.", error);
2092+
}
2093+
const isDeviceBest = runReplayProof ? isDeviceBestByScore : false;
2094+
if (isDeviceBest && runReplayProof) {
2095+
saveDeviceBestEntry({
2096+
...entry,
2097+
replayProof: runReplayProof,
2098+
});
20732099
}
20742100

20752101
try {
@@ -2120,17 +2146,7 @@ async function saveGameOverScore(): Promise<void> {
21202146
) {
21212147
await refreshScoreboard();
21222148
}
2123-
closeGameOverModal();
2124-
game.reset();
2125-
canResume = false;
2126-
currentRunSkillUsage = [];
2127-
currentRunReplaySeed = null;
2128-
currentRunReplayInputs = [];
2129-
currentRunReplayDifficulty = null;
2130-
activeDailyChallengeKey = null;
2131-
activeDailyAttemptToken = null;
2132-
setStatus("Paused");
2133-
clearSessionSnapshot();
2149+
finalizeGameOverFlow();
21342150
} catch (error) {
21352151
let message = error instanceof Error ? error.message : "Failed to submit score.";
21362152
if (gameMode === "daily") {
@@ -3118,7 +3134,7 @@ function updateGameOverSubmissionHint(payload: GameOverPayload): void {
31183134
dom.gameOverSubmitDbEl.disabled = true;
31193135
dom.gameOverBestHintEl.className = "gameover-hint warn";
31203136
dom.gameOverBestHintEl.textContent = best
3121-
? `Lower than this device best (${best.score} / Lv.${best.level}), so DB submission is disabled.`
3137+
? `Lower than this device best (${best.score} / Lv.${best.level}), so this run will be skipped.`
31223138
: "Current score is not your device best.";
31233139
}
31243140

src/scoreboard.ts

Lines changed: 149 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { invoke } from "@tauri-apps/api/core";
22
import {
33
type DailyReplayProof,
4-
normalizeReplayProof,
54
type ReplayMove,
65
} from "./replay-proof";
76
export type { DailyReplayProof, ReplayInputEvent, ReplayMove } from "./replay-proof";
@@ -115,10 +114,18 @@ class LocalEntryStore {
115114
return this.sort(this.load()).slice(0, limit);
116115
}
117116

117+
public compactToLimit(): void {
118+
this.save(this.sort(this.load()).slice(0, this.maxEntries));
119+
}
120+
118121
public async add(entry: ScoreEntry): Promise<void> {
119122
const scores = this.load();
120123
scores.push({
121-
...entry,
124+
user: entry.user,
125+
score: entry.score,
126+
level: entry.level,
127+
date: entry.date,
128+
skillUsage: this.normalizeSkillUsage(entry.skillUsage),
122129
isMe: entry.isMe === true,
123130
badgePower: normalizeOptionalBadgeMetric(entry.badgePower),
124131
badgeMaxStreak: normalizeOptionalBadgeMetric(entry.badgeMaxStreak),
@@ -135,18 +142,25 @@ class LocalEntryStore {
135142
const current = deduped.get(key);
136143
if (current) {
137144
deduped.set(key, {
138-
...row,
145+
user: row.user,
146+
score: row.score,
147+
level: row.level,
148+
date: row.date,
149+
skillUsage: this.normalizeSkillUsage(row.skillUsage),
139150
isMe: current.isMe === true || row.isMe === true,
140151
badgePower: normalizeOptionalBadgeMetric(row.badgePower)
141152
?? normalizeOptionalBadgeMetric(current.badgePower),
142153
badgeMaxStreak: normalizeOptionalBadgeMetric(row.badgeMaxStreak)
143154
?? normalizeOptionalBadgeMetric(current.badgeMaxStreak),
144-
replayProof: row.replayProof ?? current.replayProof,
145155
});
146156
continue;
147157
}
148158
deduped.set(key, {
149-
...row,
159+
user: row.user,
160+
score: row.score,
161+
level: row.level,
162+
date: row.date,
163+
skillUsage: this.normalizeSkillUsage(row.skillUsage),
150164
isMe: row.isMe === true,
151165
badgePower: normalizeOptionalBadgeMetric(row.badgePower),
152166
badgeMaxStreak: normalizeOptionalBadgeMetric(row.badgeMaxStreak),
@@ -178,15 +192,38 @@ class LocalEntryStore {
178192
badgeMaxStreak: normalizeOptionalBadgeMetric(entry.badgeMaxStreak),
179193
skillUsage: this.normalizeSkillUsage(entry.skillUsage),
180194
isMe: entry.isMe === true,
181-
replayProof: normalizeReplayProof(entry.replayProof),
182195
}));
183196
} catch {
184197
return [];
185198
}
186199
}
187200

188201
private save(scores: ScoreEntry[]): void {
189-
this.storage.setItem(this.storageKey, JSON.stringify(scores));
202+
let candidate = scores;
203+
for (let attempt = 0; attempt < 6; attempt += 1) {
204+
try {
205+
safeStorageSetItem(this.storage, this.storageKey, JSON.stringify(candidate));
206+
return;
207+
} catch (error) {
208+
if (!isStorageQuotaExceededError(error)) {
209+
console.warn(`Failed to persist ${this.storageKey}.`, error);
210+
return;
211+
}
212+
if (candidate.length <= 1) {
213+
break;
214+
}
215+
const shrinkTo = Math.floor(candidate.length * 0.6);
216+
const nextSize = Math.max(1, Math.min(candidate.length - 1, shrinkTo));
217+
candidate = candidate.slice(0, nextSize);
218+
}
219+
}
220+
221+
try {
222+
const fallback = candidate.length > 0 ? JSON.stringify([candidate[0]]) : "[]";
223+
safeStorageSetItem(this.storage, this.storageKey, fallback);
224+
} catch (error) {
225+
console.warn(`Failed to recover storage for ${this.storageKey}.`, error);
226+
}
190227
}
191228

192229
private sort(scores: ScoreEntry[]): ScoreEntry[] {
@@ -684,8 +721,11 @@ class TauriScoreboardStore implements ScoreboardStore {
684721
}
685722

686723
export function createScoreboardStore(): ScoreboardStore {
687-
const globalStore = new LocalEntryStore("torus-scores-v1", window.localStorage, 100);
688-
const personalStore = new LocalEntryStore("torus-personal-scores-v1", window.localStorage, 100);
724+
cleanupStaleDailyStorage(window.localStorage);
725+
const globalStore = new LocalEntryStore("torus-scores-v1", window.localStorage, 10);
726+
globalStore.compactToLimit();
727+
const personalStore = new LocalEntryStore("torus-personal-scores-v1", window.localStorage, 10);
728+
personalStore.compactToLimit();
689729
const resolveDailyStore = createDailyStoreResolver(window.localStorage, 100);
690730
const supabaseUrl = readEnv("VITE_SUPABASE_URL");
691731
const supabaseAnonKey = readEnv("VITE_SUPABASE_ANON_KEY");
@@ -748,9 +788,95 @@ function normalizeSkillCommand(raw: string | null | undefined): string | null {
748788

749789
type DailyStoreResolver = (challengeKey: string) => LocalEntryStore;
750790
const DAILY_CHALLENGE_MAX_ATTEMPTS = 3;
791+
const DAILY_SCORES_STORAGE_PREFIX = "torus-daily-scores-v1:";
751792
const DAILY_ATTEMPTS_STORAGE_PREFIX = "torus-daily-attempts-v1:";
752793
const DAILY_ACTIVE_ATTEMPT_TOKEN_STORAGE_PREFIX = "torus-daily-active-attempt-token-v1:";
753794
const DAILY_BADGE_MAX_POWER = 9;
795+
const DAILY_STORAGE_PREFIXES: ReadonlyArray<string> = [
796+
DAILY_SCORES_STORAGE_PREFIX,
797+
DAILY_ATTEMPTS_STORAGE_PREFIX,
798+
DAILY_ACTIVE_ATTEMPT_TOKEN_STORAGE_PREFIX,
799+
];
800+
801+
function safeStorageSetItem(storage: Storage, key: string, value: string): void {
802+
storage.setItem(key, value);
803+
}
804+
805+
function isStorageQuotaExceededError(error: unknown): boolean {
806+
if (typeof DOMException !== "undefined" && error instanceof DOMException) {
807+
if (error.name === "QuotaExceededError" || error.name === "NS_ERROR_DOM_QUOTA_REACHED") {
808+
return true;
809+
}
810+
if (error.code === 22 || error.code === 1014) {
811+
return true;
812+
}
813+
}
814+
if (!error || typeof error !== "object") {
815+
return false;
816+
}
817+
const record = error as Record<string, unknown>;
818+
const name = typeof record.name === "string" ? record.name : "";
819+
if (name === "QuotaExceededError" || name === "NS_ERROR_DOM_QUOTA_REACHED") {
820+
return true;
821+
}
822+
const code = typeof record.code === "number" ? record.code : -1;
823+
if (code === 22 || code === 1014) {
824+
return true;
825+
}
826+
const message = typeof record.message === "string" ? record.message.toLowerCase() : "";
827+
return message.includes("quota") && message.includes("exceed");
828+
}
829+
830+
function extractDailyChallengeKeyFromStorageKey(storageKey: string): string | null {
831+
for (const prefix of DAILY_STORAGE_PREFIXES) {
832+
if (!storageKey.startsWith(prefix)) {
833+
continue;
834+
}
835+
const challengeKey = normalizeChallengeKey(storageKey.slice(prefix.length));
836+
return isValidChallengeKey(challengeKey) ? challengeKey : null;
837+
}
838+
return null;
839+
}
840+
841+
function removeDailyStorageForChallenge(storage: Storage, challengeKey: string): void {
842+
for (const prefix of DAILY_STORAGE_PREFIXES) {
843+
try {
844+
storage.removeItem(`${prefix}${challengeKey}`);
845+
} catch {
846+
// Ignore local storage failures.
847+
}
848+
}
849+
}
850+
851+
function currentDailyChallengeKey(): string {
852+
return new Date().toISOString().slice(0, 10);
853+
}
854+
855+
function cleanupStaleDailyStorage(storage: Storage): void {
856+
try {
857+
const todayChallengeKey = currentDailyChallengeKey();
858+
const dailyChallengeKeys = new Set<string>();
859+
for (let index = 0; index < storage.length; index += 1) {
860+
const rawKey = storage.key(index);
861+
if (!rawKey) {
862+
continue;
863+
}
864+
const challengeKey = extractDailyChallengeKeyFromStorageKey(rawKey);
865+
if (challengeKey) {
866+
dailyChallengeKeys.add(challengeKey);
867+
}
868+
}
869+
870+
for (const challengeKey of dailyChallengeKeys) {
871+
if (challengeKey === todayChallengeKey) {
872+
continue;
873+
}
874+
removeDailyStorageForChallenge(storage, challengeKey);
875+
}
876+
} catch {
877+
// Ignore local storage failures.
878+
}
879+
}
754880

755881
function createDailyStoreResolver(
756882
storage: Storage,
@@ -759,12 +885,13 @@ function createDailyStoreResolver(
759885
const cache = new Map<string, LocalEntryStore>();
760886
return (challengeKey: string) => {
761887
const normalized = normalizeChallengeKey(challengeKey);
888+
cleanupStaleDailyStorage(storage);
762889
const existing = cache.get(normalized);
763890
if (existing) {
764891
return existing;
765892
}
766893
const created = new LocalEntryStore(
767-
`torus-daily-scores-v1:${normalized}`,
894+
`${DAILY_SCORES_STORAGE_PREFIX}${normalized}`,
768895
storage,
769896
maxEntries,
770897
);
@@ -807,7 +934,12 @@ function readDailyAttempts(storage: Storage, challengeKey: string): number {
807934

808935
function writeDailyAttempts(storage: Storage, challengeKey: string, attemptsUsed: number): void {
809936
try {
810-
storage.setItem(dailyAttemptsStorageKey(challengeKey), String(clampAttempts(attemptsUsed)));
937+
cleanupStaleDailyStorage(storage);
938+
safeStorageSetItem(
939+
storage,
940+
dailyAttemptsStorageKey(challengeKey),
941+
String(clampAttempts(attemptsUsed)),
942+
);
811943
} catch {
812944
// Ignore local storage failures.
813945
}
@@ -837,7 +969,12 @@ function writeDailyActiveAttemptToken(
837969
storage.removeItem(dailyActiveAttemptTokenStorageKey(challengeKey));
838970
return;
839971
}
840-
storage.setItem(dailyActiveAttemptTokenStorageKey(challengeKey), token);
972+
cleanupStaleDailyStorage(storage);
973+
safeStorageSetItem(
974+
storage,
975+
dailyActiveAttemptTokenStorageKey(challengeKey),
976+
token,
977+
);
841978
} catch {
842979
// Ignore local storage failures.
843980
}

0 commit comments

Comments
 (0)