122 lines
3.6 KiB
JavaScript
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 {};
|
|
}
|
|
}
|