First commit, done on M6 from the PLAN.MD
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
// Save strings for export and import: "CG1:" + CRC32 of the JSON (8 hex digits) + ":" + base64(UTF-8 JSON).
|
||||
// The checksum only catches corrupted or truncated strings. It is not protection against editing:
|
||||
// anyone can decode, change and re-encode a save, and a single-player game does not need more.
|
||||
|
||||
import { STATE_VERSION } from "../engine/state.js";
|
||||
|
||||
const PREFIX = "CG1:";
|
||||
const FORMAT = /^CG1:([0-9a-f]{8}):([A-Za-z0-9+/]+={0,2})$/;
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
})();
|
||||
|
||||
export function crc32(bytes) {
|
||||
let c = 0xffffffff;
|
||||
for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function toBase64(bytes) {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function fromBase64(text) {
|
||||
const binary = atob(text);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function encodeSave(state) {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(state));
|
||||
return `${PREFIX}${crc32(bytes).toString(16).padStart(8, "0")}:${toBase64(bytes)}`;
|
||||
}
|
||||
|
||||
// Upgrades older saves one version at a time. Add an entry here whenever STATE_VERSION
|
||||
// changes: MIGRATIONS[n] turns a version n state into a version n+1 state.
|
||||
export const MIGRATIONS = {};
|
||||
|
||||
export function migrate(state) {
|
||||
if (!Number.isInteger(state?.version)) return { ok: false, error: "format" };
|
||||
if (state.version > STATE_VERSION) return { ok: false, error: "tooNew" };
|
||||
let s = state;
|
||||
while (s.version < STATE_VERSION) {
|
||||
const step = MIGRATIONS[s.version];
|
||||
if (!step) return { ok: false, error: "version" };
|
||||
s = step(s);
|
||||
}
|
||||
return { ok: true, state: s };
|
||||
}
|
||||
|
||||
// Returns { ok: true, state } or { ok: false, error: "format" | "checksum" | "tooNew" | "version" }.
|
||||
// Whitespace is ignored, so strings that were wrapped when pasted still work.
|
||||
export function decodeSave(text) {
|
||||
const match = FORMAT.exec(String(text).replace(/\s+/g, ""));
|
||||
if (!match) return { ok: false, error: "format" };
|
||||
|
||||
let bytes;
|
||||
try {
|
||||
bytes = fromBase64(match[2]);
|
||||
} catch {
|
||||
return { ok: false, error: "format" };
|
||||
}
|
||||
if (crc32(bytes).toString(16).padStart(8, "0") !== match[1]) return { ok: false, error: "checksum" };
|
||||
|
||||
let state;
|
||||
try {
|
||||
state = JSON.parse(new TextDecoder().decode(bytes));
|
||||
} catch {
|
||||
return { ok: false, error: "format" };
|
||||
}
|
||||
return migrate(state);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Browser storage for the autosave, the manual slots and the settings.
|
||||
// Every access is wrapped: if localStorage is missing or throws (private window, blocked
|
||||
// site data, full quota) the game keeps working and uses memory for the rest of the session.
|
||||
|
||||
export const SLOT_COUNT = 3;
|
||||
export const KEYS = {
|
||||
auto: "cg.autosave",
|
||||
slot: (n) => `cg.slot.${n}`,
|
||||
settings: "cg.settings",
|
||||
};
|
||||
|
||||
function browserStorage() {
|
||||
try {
|
||||
const probe = "cg.probe";
|
||||
globalThis.localStorage.setItem(probe, "1");
|
||||
globalThis.localStorage.removeItem(probe);
|
||||
return globalThis.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// backend: anything with getItem/setItem/removeItem, for tests. Default: localStorage.
|
||||
export function createStorage(backend = browserStorage()) {
|
||||
const memory = new Map();
|
||||
const store = { persistent: backend !== null };
|
||||
|
||||
store.get = (key) => {
|
||||
try {
|
||||
if (backend) return backend.getItem(key);
|
||||
} catch {
|
||||
store.persistent = false;
|
||||
}
|
||||
return memory.get(key) ?? null;
|
||||
};
|
||||
|
||||
// Returns false when the write could not be made durable.
|
||||
store.set = (key, value) => {
|
||||
try {
|
||||
if (backend) {
|
||||
backend.setItem(key, value);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
store.persistent = false;
|
||||
}
|
||||
memory.set(key, value);
|
||||
return false;
|
||||
};
|
||||
|
||||
store.remove = (key) => {
|
||||
memory.delete(key);
|
||||
try {
|
||||
backend?.removeItem(key);
|
||||
} catch {
|
||||
store.persistent = false;
|
||||
}
|
||||
};
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
// ---- Saved games: { meta, state } under one key ----
|
||||
|
||||
export function writeSave(store, key, state) {
|
||||
const meta = {
|
||||
savedAt: Date.now(),
|
||||
day: state.day,
|
||||
countryName: state.countryName,
|
||||
pronouns: state.pronouns,
|
||||
scenarioId: state.scenarioId,
|
||||
over: state.over?.type ?? null,
|
||||
};
|
||||
return store.set(key, JSON.stringify({ meta, state }));
|
||||
}
|
||||
|
||||
// Returns { meta, state } or null when the slot is empty or unreadable.
|
||||
export function readSave(store, key) {
|
||||
const raw = store.get(key);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed?.state && parsed?.meta ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Settings ----
|
||||
|
||||
const DEFAULT_SETTINGS = { lang: null, slow: false };
|
||||
|
||||
export function loadSettings(store) {
|
||||
try {
|
||||
const s = JSON.parse(store.get(KEYS.settings) ?? "{}");
|
||||
return {
|
||||
lang: s.lang === "en" || s.lang === "fr" ? s.lang : DEFAULT_SETTINGS.lang,
|
||||
slow: s.slow === true,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettings(store, settings) {
|
||||
return store.set(KEYS.settings, JSON.stringify(settings));
|
||||
}
|
||||
Reference in New Issue
Block a user