96 lines
3.2 KiB
JavaScript
96 lines
3.2 KiB
JavaScript
// 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 = {
|
|
// v2 added the world cycle: older saves start it at neutral.
|
|
1: (s) => ({ ...s, version: 2, world: { cycle: 0 } }),
|
|
// v3 replaced the single pending card by the in-tray queue.
|
|
2: ({ pendingCard, ...s }) => ({
|
|
...s,
|
|
version: 3,
|
|
inbox: pendingCard ? [{ id: pendingCard.id, day: pendingCard.day }] : [],
|
|
pending: { ...s.pending, weeklyCard: Boolean(s.pending?.weeklyCard) && !pendingCard, inboxFull: false },
|
|
}),
|
|
};
|
|
|
|
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" };
|
|
try {
|
|
s = step(s);
|
|
} catch {
|
|
return { ok: false, error: "format" }; // a damaged or hostile save must never throw
|
|
}
|
|
}
|
|
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);
|
|
}
|