mirror of
https://github.com/VickScarlet/lifeRestart.git
synced 2026-08-19 18:43:53 +08:00
578 lines
19 KiB
JavaScript
578 lines
19 KiB
JavaScript
(function attachToyCloudSaveCore(root, factory) {
|
|
if (typeof module === 'object' && module.exports) {
|
|
module.exports = factory();
|
|
} else {
|
|
debugger
|
|
root.ToyCloudSaveCore = factory();
|
|
}
|
|
}(typeof globalThis !== 'undefined' ? globalThis : this, function buildToyCloudSaveCore() {
|
|
'use strict';
|
|
|
|
const CHUNK_CHARS = 960;
|
|
const MAX_CHUNKS = 48;
|
|
const EVENT_LIMIT = 60;
|
|
const BANKS = ['a', 'b'];
|
|
const CRC32_TABLE = buildCrc32Table();
|
|
|
|
function buildCrc32Table() {
|
|
const table = new Uint32Array(256);
|
|
for (let index = 0; index < table.length; index += 1) {
|
|
let value = index;
|
|
for (let bit = 0; bit < 8; bit += 1) {
|
|
value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0);
|
|
}
|
|
table[index] = value >>> 0;
|
|
}
|
|
return table;
|
|
}
|
|
|
|
function utf8Encode(value) {
|
|
if (typeof TextEncoder !== 'undefined') {
|
|
return new TextEncoder().encode(value);
|
|
}
|
|
if (typeof Buffer !== 'undefined') {
|
|
return Uint8Array.from(Buffer.from(value, 'utf8'));
|
|
}
|
|
|
|
const binary = unescape(encodeURIComponent(value));
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let index = 0; index < binary.length; index += 1) {
|
|
bytes[index] = binary.charCodeAt(index);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function utf8Decode(bytes) {
|
|
if (typeof TextDecoder !== 'undefined') {
|
|
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
}
|
|
if (typeof Buffer !== 'undefined') {
|
|
const value = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('utf8');
|
|
if (!sameBytes(utf8Encode(value), bytes)) {
|
|
throw new Error('Invalid UTF-8');
|
|
}
|
|
return value;
|
|
}
|
|
|
|
let binary = '';
|
|
for (let index = 0; index < bytes.length; index += 1) {
|
|
binary += String.fromCharCode(bytes[index]);
|
|
}
|
|
return decodeURIComponent(escape(binary));
|
|
}
|
|
|
|
function sameBytes(left, right) {
|
|
if (left.length !== right.length) return false;
|
|
for (let index = 0; index < left.length; index += 1) {
|
|
if (left[index] !== right[index]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function toBytes(input) {
|
|
if (typeof input === 'string') return utf8Encode(input);
|
|
if (typeof ArrayBuffer !== 'undefined' && input instanceof ArrayBuffer) {
|
|
return new Uint8Array(input);
|
|
}
|
|
if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(input)) {
|
|
return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
}
|
|
throw new TypeError('crc32Hex expects a string, ArrayBuffer, or typed array');
|
|
}
|
|
|
|
function crc32Hex(input) {
|
|
const bytes = toBytes(input);
|
|
let crc = 0xffffffff;
|
|
for (let index = 0; index < bytes.length; index += 1) {
|
|
crc = CRC32_TABLE[(crc ^ bytes[index]) & 0xff] ^ (crc >>> 8);
|
|
}
|
|
return ((crc ^ 0xffffffff) >>> 0).toString(16).padStart(8, '0');
|
|
}
|
|
|
|
function bytesToBase64(bytes) {
|
|
if (typeof Buffer !== 'undefined') {
|
|
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64');
|
|
}
|
|
if (typeof btoa !== 'function') {
|
|
throw new Error('Base64 encoding is unavailable');
|
|
}
|
|
|
|
let binary = '';
|
|
const batch = 0x8000;
|
|
for (let offset = 0; offset < bytes.length; offset += batch) {
|
|
const slice = bytes.subarray(offset, offset + batch);
|
|
for (let index = 0; index < slice.length; index += 1) {
|
|
binary += String.fromCharCode(slice[index]);
|
|
}
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
function base64ToBytes(value) {
|
|
if (!isCanonicalBase64(value)) throw new Error('Invalid Base64');
|
|
if (typeof Buffer !== 'undefined') {
|
|
return Uint8Array.from(Buffer.from(value, 'base64'));
|
|
}
|
|
if (typeof atob !== 'function') {
|
|
throw new Error('Base64 decoding is unavailable');
|
|
}
|
|
|
|
const binary = atob(value);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let index = 0; index < binary.length; index += 1) {
|
|
bytes[index] = binary.charCodeAt(index);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function isCanonicalBase64(value) {
|
|
if (value === '') return true;
|
|
if (value.length % 4 !== 0) return false;
|
|
return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value);
|
|
}
|
|
|
|
function preparePayload(json) {
|
|
if (typeof json !== 'string') {
|
|
throw new TypeError('Cloud save payload must be a JSON string');
|
|
}
|
|
try {
|
|
JSON.parse(json);
|
|
} catch (parseError) {
|
|
const error = new SyntaxError('Cloud save payload must contain valid JSON');
|
|
error.code = 'INVALID_JSON';
|
|
throw error;
|
|
}
|
|
|
|
const bytes = utf8Encode(json);
|
|
const base64 = bytesToBase64(bytes);
|
|
const chunks = [];
|
|
if (base64.length === 0) {
|
|
chunks.push('');
|
|
} else {
|
|
for (let offset = 0; offset < base64.length; offset += CHUNK_CHARS) {
|
|
chunks.push(base64.slice(offset, offset + CHUNK_CHARS));
|
|
}
|
|
}
|
|
|
|
return {
|
|
json,
|
|
bytes: bytes.length,
|
|
base64Chars: base64.length,
|
|
chunks,
|
|
checksum: crc32Hex(bytes),
|
|
};
|
|
}
|
|
|
|
function inspectPayload(json) {
|
|
const payload = preparePayload(json);
|
|
return {
|
|
json: payload.json,
|
|
bytes: payload.bytes,
|
|
base64Chars: payload.base64Chars,
|
|
chunkCount: payload.chunks.length,
|
|
checksum: payload.checksum,
|
|
exceedsLimit: payload.chunks.length > MAX_CHUNKS,
|
|
chunks: payload.chunks.map((value, index) => ({
|
|
index,
|
|
chars: value.length,
|
|
preview: value.length > 24 ? `${value.slice(0, 24)}…` : value,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function createCloudSave(options) {
|
|
const config = options || {};
|
|
const storage = config.storage;
|
|
const prefix = config.prefix;
|
|
const onEvent = config.onEvent;
|
|
|
|
if (!storage || typeof storage.get !== 'function' || typeof storage.set !== 'function') {
|
|
throw new TypeError('storage must provide async get(keys) and set(record, context) methods');
|
|
}
|
|
if (typeof prefix !== 'string' || prefix.length === 0) {
|
|
throw new TypeError('prefix must be a non-empty string');
|
|
}
|
|
if (onEvent !== undefined && typeof onEvent !== 'function') {
|
|
throw new TypeError('onEvent must be a function');
|
|
}
|
|
|
|
const keys = {
|
|
head: `${prefix}_head`,
|
|
meta(bank) {
|
|
return `${prefix}_${bank}_meta`;
|
|
},
|
|
chunk(bank, index) {
|
|
return `${prefix}_${bank}_${String(index).padStart(3, '0')}`;
|
|
},
|
|
};
|
|
const state = {
|
|
pendingSaves: 0,
|
|
activeSave: false,
|
|
head: null,
|
|
banks: { a: null, b: null },
|
|
lastSave: null,
|
|
lastLoad: null,
|
|
events: [],
|
|
};
|
|
let saveTail = Promise.resolve();
|
|
|
|
function emit(type, details) {
|
|
const allowed = [
|
|
'bank', 'generation', 'bytes', 'chunks', 'checksum', 'phase',
|
|
'source', 'fallback', 'recovered', 'pending', 'code', 'found',
|
|
];
|
|
const event = { type, at: Date.now() };
|
|
const source = details || {};
|
|
for (const key of allowed) {
|
|
if (source[key] !== undefined) event[key] = source[key];
|
|
}
|
|
const frozen = Object.freeze(event);
|
|
state.events.push({ ...event });
|
|
if (state.events.length > EVENT_LIMIT) state.events.shift();
|
|
if (onEvent) {
|
|
try {
|
|
const result = onEvent(frozen);
|
|
if (result && typeof result.catch === 'function') result.catch(function ignoreEventError() {});
|
|
} catch (error) {
|
|
// Observers cannot affect persistence.
|
|
}
|
|
}
|
|
}
|
|
|
|
async function getRecord(requestedKeys) {
|
|
const record = await storage.get(requestedKeys);
|
|
return record && typeof record === 'object' ? record : {};
|
|
}
|
|
|
|
function own(record, key) {
|
|
return Object.prototype.hasOwnProperty.call(record, key);
|
|
}
|
|
|
|
function parseObject(value) {
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
|
|
if (typeof value !== 'string') return null;
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseHead(value) {
|
|
const head = parseObject(value);
|
|
if (!head || head.v !== 1 || !BANKS.includes(head.bank)) return null;
|
|
if (!Number.isSafeInteger(head.generation) || head.generation < 1) return null;
|
|
return { v: 1, bank: head.bank, generation: head.generation };
|
|
}
|
|
|
|
function parseMeta(value) {
|
|
const meta = parseObject(value);
|
|
if (!meta || meta.v !== 1) return null;
|
|
if (!Number.isSafeInteger(meta.generation) || meta.generation < 1) return null;
|
|
if (!Number.isSafeInteger(meta.chunks) || meta.chunks < 1 || meta.chunks > MAX_CHUNKS) return null;
|
|
if (!Number.isSafeInteger(meta.bytes) || meta.bytes < 0) return null;
|
|
if (typeof meta.checksum !== 'string' || !/^[0-9a-f]{8}$/.test(meta.checksum)) return null;
|
|
return {
|
|
v: 1,
|
|
generation: meta.generation,
|
|
chunks: meta.chunks,
|
|
bytes: meta.bytes,
|
|
checksum: meta.checksum,
|
|
};
|
|
}
|
|
|
|
function summary(bank, meta) {
|
|
return {
|
|
bank,
|
|
generation: meta.generation,
|
|
bytes: meta.bytes,
|
|
chunks: meta.chunks,
|
|
checksum: meta.checksum,
|
|
};
|
|
}
|
|
|
|
function invalidBank(bank, status) {
|
|
state.banks[bank] = { bank, valid: false, status };
|
|
return { valid: false, bank, status };
|
|
}
|
|
|
|
function validateBankRecord(bank, record, expectedGeneration) {
|
|
const metaKey = keys.meta(bank);
|
|
const meta = own(record, metaKey) ? parseMeta(record[metaKey]) : null;
|
|
if (!meta) return invalidBank(bank, own(record, metaKey) ? 'invalid' : 'missing');
|
|
if (expectedGeneration !== undefined && meta.generation !== expectedGeneration) {
|
|
return invalidBank(bank, 'generation-mismatch');
|
|
}
|
|
|
|
const chunks = [];
|
|
for (let index = 0; index < meta.chunks; index += 1) {
|
|
const key = keys.chunk(bank, index);
|
|
const value = record[key];
|
|
if (typeof value !== 'string' || value.length > CHUNK_CHARS) {
|
|
return invalidBank(bank, 'invalid-chunk');
|
|
}
|
|
if (index < meta.chunks - 1 && value.length !== CHUNK_CHARS) {
|
|
return invalidBank(bank, 'invalid-chunk');
|
|
}
|
|
chunks.push(value);
|
|
}
|
|
|
|
try {
|
|
const bytes = base64ToBytes(chunks.join(''));
|
|
if (bytes.length !== meta.bytes || crc32Hex(bytes) !== meta.checksum) {
|
|
return invalidBank(bank, 'checksum-mismatch');
|
|
}
|
|
const json = utf8Decode(bytes);
|
|
try {
|
|
JSON.parse(json);
|
|
} catch (parseError) {
|
|
return invalidBank(bank, 'invalid-json');
|
|
}
|
|
const result = { valid: true, bank, json, meta, summary: summary(bank, meta) };
|
|
state.banks[bank] = { ...result.summary, valid: true };
|
|
return result;
|
|
} catch (error) {
|
|
return invalidBank(bank, 'decode-failed');
|
|
}
|
|
}
|
|
|
|
async function readHead() {
|
|
const record = await getRecord([keys.head]);
|
|
const head = own(record, keys.head) ? parseHead(record[keys.head]) : null;
|
|
state.head = head
|
|
? { bank: head.bank, generation: head.generation, valid: true }
|
|
: { valid: false, status: own(record, keys.head) ? 'invalid' : 'missing' };
|
|
return head;
|
|
}
|
|
|
|
async function readBank(bank, expectedGeneration) {
|
|
const metaKey = keys.meta(bank);
|
|
const metaRecord = await getRecord([metaKey]);
|
|
const meta = own(metaRecord, metaKey) ? parseMeta(metaRecord[metaKey]) : null;
|
|
if (!meta) return invalidBank(bank, own(metaRecord, metaKey) ? 'invalid' : 'missing');
|
|
if (expectedGeneration !== undefined && meta.generation !== expectedGeneration) {
|
|
return invalidBank(bank, 'generation-mismatch');
|
|
}
|
|
|
|
const chunkKeys = [];
|
|
for (let index = 0; index < meta.chunks; index += 1) {
|
|
chunkKeys.push(keys.chunk(bank, index));
|
|
}
|
|
const chunkRecord = await getRecord(chunkKeys);
|
|
return validateBankRecord(bank, { ...metaRecord, ...chunkRecord }, expectedGeneration);
|
|
}
|
|
|
|
function otherBank(bank) {
|
|
return bank === 'a' ? 'b' : 'a';
|
|
}
|
|
|
|
function newestValid(results) {
|
|
return results
|
|
.filter((result) => result.valid)
|
|
.sort((left, right) => right.meta.generation - left.meta.generation)[0] || null;
|
|
}
|
|
|
|
async function chooseSaveTarget() {
|
|
const head = await readHead();
|
|
let first;
|
|
let second;
|
|
let base = null;
|
|
|
|
if (head) {
|
|
first = await readBank(head.bank, head.generation);
|
|
second = await readBank(otherBank(head.bank));
|
|
base = first.valid ? first : (second.valid ? second : null);
|
|
} else {
|
|
first = await readBank('a');
|
|
second = await readBank('b');
|
|
base = newestValid([first, second]);
|
|
}
|
|
|
|
const highestGeneration = Math.max(
|
|
head ? head.generation : 0,
|
|
first.valid ? first.meta.generation : 0,
|
|
second.valid ? second.meta.generation : 0,
|
|
);
|
|
return {
|
|
bank: base ? otherBank(base.bank) : (head ? otherBank(head.bank) : 'a'),
|
|
generation: highestGeneration + 1,
|
|
};
|
|
}
|
|
|
|
function makeError(message, code, ErrorType) {
|
|
const error = new (ErrorType || Error)(message);
|
|
error.code = code;
|
|
return error;
|
|
}
|
|
|
|
async function savePrepared(payload) {
|
|
state.activeSave = true;
|
|
let transaction;
|
|
try {
|
|
transaction = await chooseSaveTarget();
|
|
const bank = transaction.bank;
|
|
const generation = transaction.generation;
|
|
const meta = {
|
|
v: 1,
|
|
generation,
|
|
chunks: payload.chunks.length,
|
|
bytes: payload.bytes,
|
|
checksum: payload.checksum,
|
|
};
|
|
const savedSummary = summary(bank, meta);
|
|
const context = { bank, generation };
|
|
emit('save:start', savedSummary);
|
|
|
|
const chunkRecord = {};
|
|
for (let index = 0; index < payload.chunks.length; index += 1) {
|
|
chunkRecord[keys.chunk(bank, index)] = payload.chunks[index];
|
|
}
|
|
await storage.set(chunkRecord, { ...context, phase: 'chunks' });
|
|
emit('save:phase', { ...savedSummary, phase: 'chunks' });
|
|
|
|
await storage.set({ [keys.meta(bank)]: JSON.stringify(meta) }, { ...context, phase: 'meta' });
|
|
emit('save:phase', { ...savedSummary, phase: 'meta' });
|
|
|
|
const verifyKeys = [keys.meta(bank), ...Object.keys(chunkRecord)];
|
|
const readback = await getRecord(verifyKeys);
|
|
const verified = validateBankRecord(bank, readback, generation);
|
|
if (
|
|
!verified.valid
|
|
|| verified.json !== payload.json
|
|
|| verified.meta.bytes !== meta.bytes
|
|
|| verified.meta.chunks !== meta.chunks
|
|
|| verified.meta.checksum !== meta.checksum
|
|
) {
|
|
throw makeError('Cloud save readback verification failed', 'VERIFICATION_FAILED');
|
|
}
|
|
emit('save:verified', { ...savedSummary, phase: 'verify' });
|
|
|
|
const head = { v: 1, bank, generation };
|
|
await storage.set({ [keys.head]: JSON.stringify(head) }, { ...context, phase: 'head' });
|
|
state.head = { bank, generation, valid: true };
|
|
state.lastSave = { ...savedSummary };
|
|
emit('save:success', savedSummary);
|
|
return savedSummary;
|
|
} catch (error) {
|
|
emit('save:error', {
|
|
bank: transaction && transaction.bank,
|
|
generation: transaction && transaction.generation,
|
|
code: error && error.code ? error.code : 'STORAGE_ERROR',
|
|
});
|
|
throw error;
|
|
} finally {
|
|
state.activeSave = false;
|
|
}
|
|
}
|
|
|
|
function save(json) {
|
|
let payload;
|
|
try {
|
|
payload = preparePayload(json);
|
|
if (payload.chunks.length > MAX_CHUNKS) {
|
|
throw makeError(
|
|
`Cloud save payload needs ${payload.chunks.length} chunks; maximum is ${MAX_CHUNKS}`,
|
|
'PAYLOAD_TOO_LARGE',
|
|
RangeError,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
emit('save:rejected', {
|
|
bytes: payload && payload.bytes,
|
|
chunks: payload && payload.chunks.length,
|
|
code: error && error.code ? error.code : 'INVALID_PAYLOAD',
|
|
});
|
|
return Promise.reject(error);
|
|
}
|
|
|
|
state.pendingSaves += 1;
|
|
emit('save:queued', {
|
|
bytes: payload.bytes,
|
|
chunks: payload.chunks.length,
|
|
checksum: payload.checksum,
|
|
pending: state.pendingSaves,
|
|
});
|
|
const operation = saveTail.then(function runQueuedSave() {
|
|
return savePrepared(payload);
|
|
});
|
|
const completed = operation.finally(function finishQueuedSave() {
|
|
state.pendingSaves -= 1;
|
|
});
|
|
saveTail = completed.catch(function keepQueueAlive() {});
|
|
return completed;
|
|
}
|
|
|
|
function finishLoad(result, flags) {
|
|
const loadSummary = {
|
|
...result.summary,
|
|
fallback: Boolean(flags.fallback),
|
|
recovered: Boolean(flags.recovered),
|
|
source: flags.recovered ? 'scan' : (flags.fallback ? 'fallback' : 'head'),
|
|
};
|
|
state.lastLoad = loadSummary;
|
|
emit('load:success', loadSummary);
|
|
return result.json;
|
|
}
|
|
|
|
async function load() {
|
|
const savesQueuedBeforeLoad = saveTail;
|
|
emit('load:start');
|
|
await savesQueuedBeforeLoad;
|
|
const head = await readHead();
|
|
if (head) {
|
|
const active = await readBank(head.bank, head.generation);
|
|
if (active.valid) return finishLoad(active, { fallback: false, recovered: false });
|
|
|
|
const fallback = await readBank(otherBank(head.bank));
|
|
if (fallback.valid && fallback.meta.generation < head.generation) {
|
|
return finishLoad(fallback, { fallback: true, recovered: false });
|
|
}
|
|
if (fallback.valid) {
|
|
state.banks[fallback.bank] = {
|
|
...fallback.summary,
|
|
valid: false,
|
|
status: 'uncommitted',
|
|
};
|
|
}
|
|
|
|
state.lastLoad = { found: false, fallback: true, recovered: false };
|
|
emit('load:empty', state.lastLoad);
|
|
return null;
|
|
}
|
|
|
|
const bankA = await readBank('a');
|
|
const bankB = await readBank('b');
|
|
const recovered = newestValid([bankA, bankB]);
|
|
if (recovered) return finishLoad(recovered, { fallback: false, recovered: true });
|
|
|
|
state.lastLoad = { found: false, fallback: false, recovered: true };
|
|
emit('load:empty', state.lastLoad);
|
|
return null;
|
|
}
|
|
|
|
function getDebugState() {
|
|
return JSON.parse(JSON.stringify({
|
|
prefix,
|
|
pendingSaves: state.pendingSaves,
|
|
activeSave: state.activeSave,
|
|
head: state.head,
|
|
banks: state.banks,
|
|
lastSave: state.lastSave,
|
|
lastLoad: state.lastLoad,
|
|
events: state.events,
|
|
}));
|
|
}
|
|
|
|
return Object.freeze({ save, load, getDebugState });
|
|
}
|
|
|
|
return Object.freeze({
|
|
createCloudSave,
|
|
inspectPayload,
|
|
crc32Hex,
|
|
CHUNK_CHARS,
|
|
MAX_CHUNKS,
|
|
});
|
|
}));
|