mirror of
https://github.com/VickScarlet/lifeRestart.git
synced 2026-08-29 18:14:41 +08:00
update: bili-toy
This commit is contained in:
+577
@@ -0,0 +1,577 @@
|
||||
(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,
|
||||
});
|
||||
}));
|
||||
Reference in New Issue
Block a user