Files
idle_clicker/public/js/services/storage.js
T

122 lines
3.6 KiB
JavaScript

// Persistence in browser cookies.
//
// A cookie holds ~4 KB, so a save is base64url-encoded and split in chunks:
// ic_game_n -> number of chunks
// ic_game_0.. -> the chunks
// There is ONE game, shared by every theme (a theme is only a skin).
// Preferences (theme, language) are stored in a single small cookie.
import { readCookie, removeCookie, writeCookie } from './cookies.js';
const CHUNK_SIZE = 3000;
const MAX_CHUNKS = 6;
const PREFS_KEY = 'ic_prefs';
const GAME_KEY = 'ic_game';
const legacyKey = (themeId) => `ic_save_${themeId}`; // v1: one save per theme
// ------------------------------------------------------------------ encoding
export function encode(text) {
const bytes = new TextEncoder().encode(text);
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
}
export function decode(encoded) {
const base64 = encoded.replaceAll('-', '+').replaceAll('_', '/');
const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4));
return new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
}
// ------------------------------------------------------------------- chunked
function writeChunked(key, text) {
const encoded = encode(text);
const chunks = [];
for (let i = 0; i < encoded.length; i += CHUNK_SIZE) chunks.push(encoded.slice(i, i + CHUNK_SIZE));
if (chunks.length > MAX_CHUNKS) throw new Error('save_too_large');
const previous = Number(readCookie(`${key}_n`)) || 0;
chunks.forEach((chunk, i) => writeCookie(`${key}_${i}`, chunk));
writeCookie(`${key}_n`, String(chunks.length));
for (let i = chunks.length; i < previous; i++) removeCookie(`${key}_${i}`);
}
function readChunked(key) {
const count = Number(readCookie(`${key}_n`));
if (!Number.isInteger(count) || count < 1 || count > MAX_CHUNKS) return null;
let encoded = '';
for (let i = 0; i < count; i++) {
const chunk = readCookie(`${key}_${i}`);
if (chunk == null) return null;
encoded += chunk;
}
try {
return decode(encoded);
} catch {
return null;
}
}
function removeChunked(key) {
const count = Number(readCookie(`${key}_n`)) || 0;
for (let i = 0; i < count; i++) removeCookie(`${key}_${i}`);
removeCookie(`${key}_n`);
}
// -------------------------------------------------------------------- public
/** False when the browser refuses cookies (private mode, blocked, ...). */
export function cookiesAvailable() {
try {
writeCookie('ic_probe', '1');
const ok = readCookie('ic_probe') === '1';
removeCookie('ic_probe');
return ok;
} catch {
return false;
}
}
/** Throws if the save does not fit in cookies. */
export function saveGameText(text) {
writeChunked(GAME_KEY, text);
}
export function loadGameText() {
return readChunked(GAME_KEY);
}
export function deleteGame() {
removeChunked(GAME_KEY);
}
/**
* Saves from the first version (one per theme). Returns their texts and
* removes those cookies, so the caller can merge them into the shared game.
*/
export function takeLegacySaves(themeIds) {
const texts = [];
for (const id of themeIds) {
const text = readChunked(legacyKey(id));
if (text) texts.push(text);
removeChunked(legacyKey(id));
}
return texts;
}
export function savePrefs(prefs) {
writeCookie(PREFS_KEY, encode(JSON.stringify(prefs)));
}
export function loadPrefs() {
try {
const raw = readCookie(PREFS_KEY);
const prefs = raw && JSON.parse(decode(raw));
return prefs && typeof prefs === 'object' ? prefs : {};
} catch {
return {};
}
}