ログインなしのブラウザゲーム記録を壊さず保存する

localStorage schema・migration・corruption recovery

なぜバックエンドを使わないか

『60秒後の彼女』の記録(streak・日別ベスト・履歴)はすべてブラウザの localStorage のみに保存する。サーバー側のアカウントも、分析も、外部送信もない。理由はプライバシー配備の単純さ。記録は端末に留まり、ユーザーが消せば完全に消える。

スキーマバージョンを分ける

「チャレンジプロトコルのバージョン」と「保存データのスキーマバージョン」は別物だ。本作は STORAGE_SCHEMA_VERSION = 2 を持ち、保存Blobに schemaVersion を埋め込む。将来フォーマットを変えるとき、古いデータを新形式へ変換(migration)する根拠になる。

壊れたデータからの復旧

localStorage は信頼できない入力源だ。JSONが壊れている、未知のフィールドがある、quota超過、プライベートブラウズで書けない——こうした状況でもクラッシュしてはならない。本作の parseStore は:

  • 空文字/null → デフォルトストア
  • JSONパース失敗 → デフォルトストア(例外を外に出さない)
  • スキーマ不一致 → migrateStore で既知フィールドだけ持ち上げ、未知フィールドは無視
  • 履歴の不正な要素(nullや形違い)はフィルタで落とす
  • 履歴は直近50件にcap

単体テストで「不正なJSON」「数値でないstreak」「壊れた履歴要素」を投入し、クラッシュせずデフォルトに近い状態になることを確認済み。

streak の意味(失敗→成功も含む)

「その日に1回以上クリアしたか」でstreakを判定する。重要な点:同じ日の失敗だけで、それまでのstreakを壊さない。例えば前日クリア済みで、今日まず失敗し、その後クリアした場合、streakは前日から繋がる。同日の複数クリアで加算しない。

export function computeStreak(prev, todayKey, success) {
  const current = Number(prev?.current) || 0;
  const best = Number(prev?.best) || 0;
  const last = prev?.lastSuccessDate || null;
  if (!success) return { current, best, lastSuccessDate: last }; // 失敗はstreakを壊さない
  let next;
  if (last === todayKey) next = current;            // 同日2回目以降は加算しない
  else if (last === yesterday(todayKey)) next = current + 1; // 連日
  else next = 1;                                     // ギャップ後または初回
  return { current: next, best: Math.max(best, next), lastSuccessDate: todayKey };
}

失敗時の挙動は単体テストで確認済み(fail then success same day keeps connecting to previous success)。

daily best の比較関数

同じ日の記録は「最良1件」だけ残す。比較順位は明確にする:

  1. 成功 > 失敗
  2. 完了フェーズ数が多い
  3. 救助率(rescue gauge)が高い
  4. 残り時間が長い
  5. ミスが少ない
export function compareResults(a, b) {
  const sa = a.success ? 1 : 0, sb = b.success ? 1 : 0;
  if (sa !== sb) return sa - sb;
  if ((a.phaseCount||0) !== (b.phaseCount||0)) return (a.phaseCount||0) - (b.phaseCount||0);
  if ((a.rescueGauge||0) !== (b.rescueGauge||0)) return (a.rescueGauge||0) - (b.rescueGauge||0);
  if ((a.timeLeft||0) !== (b.timeLeft||0)) return (a.timeLeft||0) - (b.timeLeft||0);
  return (b.misses||0) - (a.misses||0);
}

単体テストで「成功は救助率が低くても失敗より上」「各優先度が正しく効く」を確認済み。

clear data と quota

「データを消す」ボタンは localStorage.removeItem で該当キーを消し、ストアをデフォルトに戻す(dismiss設定もリセットされる)。quota超過やプライベートブラウズで書き込みが失敗しても、ゲーム自体は続行できる(保存失敗は致命にしない try/catch)。

制限(limitations)

  • 記録は端末間で同期しない。別のPCやブラウザでは初期状態から。
  • localStorage は「安全なバックアップ」ではない。OSのクリアやブラウザ削除で失われる。
  • スキーマ変更時、未知フィールドは意図的に破棄される。