Files

404 lines
24 KiB
JavaScript

'use strict';
const v = require('./validation');
// Network/filesystem are injected so publication, retries and login deadlines can be tested offline.
function create(builtin, io, sha256, options = {}) {
const perf = io.perf || require('./perf').create();
v.builtin(builtin);
const environment = options.environment || 'production';
const warn = error => io.warn && io.warn(error);
const now = () => io.now();
const sleep = ms => io.sleep ? io.sleep(ms) : new Promise(resolve => setTimeout(resolve, ms));
const readState = key => { try { return perf.measure('state.read.' + key.split(':')[0], () => io.readState(key)); } catch (_) { return null; } };
const save = (key, value) => { try { perf.measure('state.write.' + key.split(':')[0], () => io.writeState(key, value)); } catch (e) { warn(e); } };
let current = null;
try { current = v.manifest(readState('manifest'), builtin.contentVersion, environment); } catch (_) {}
let index = readState('index') || {};
if (!index || typeof index !== 'object' || Array.isArray(index)) index = {};
let epoch = 0, started = 0, refreshed = false, refreshing = null, limits = null;
let active = true, online = true, nextId = 1, mainGroup = 'A', protectAll = false;
let allReadyRelease = null, playing = null;
let cacheRevision = 0, maintainedRevision = -1, maintainedManifest = null, maintainedAt = -Infinity, collecting = null, allDemand = false;
const holds = new Set(), waiters = new Set();
const entryWaiters = new Set();
const entryError = code => Object.assign(Error('关卡读取失败,请连接网络!'), { code });
// Only the caller's foreground wait is cancelled. Shared downloads may still populate cache.
function waitForEntry(task) {
if (!online) return Promise.reject(entryError('LEVEL_OFFLINE'));
let timer, fail;
return new Promise((resolve, reject) => {
fail = error => reject(error);
entryWaiters.add(fail);
timer = setTimeout(() => fail(entryError('LEVEL_TIMEOUT')), options.entryTimeoutMs || 60000);
Promise.resolve().then(task).then(resolve, reject);
}).finally(() => { clearTimeout(timer); entryWaiters.delete(fail); });
}
const work = new Map(), downloads = new Map(), packs = new Map(), pins = new Map();
const prefetching = new Map();
let lastPrefetch = null;
const cancelled = () => Error('关卡准备已取消');
function guard(token) { if (token !== epoch) throw cancelled(); }
function wake() { for (const resolve of waiters) resolve(); waiters.clear(); }
function holdBackground() {
const hold = {}; holds.add(hold); perf.emit('background.hold', { count: holds.size });
return () => { if (holds.delete(hold)) { perf.emit('background.release', { count: holds.size }); wake(); } };
}
async function checkpoint(token, background = () => false) {
guard(token);
while (!active || !online || (holds.size && background())) { await new Promise(resolve => waiters.add(resolve)); guard(token); }
}
function retry(key, task) {
const token = epoch, fullKey = token + ':' + key;
if (work.has(fullKey)) return work.get(fullKey);
const job = (async () => {
let attempt = 0;
for (;;) {
await checkpoint(token);
try { const result = await task(); guard(token); return result; }
catch (error) {
guard(token); warn(error);
perf.emit('retry', { task: key, attempt: attempt + 1, delayMs: [2000, 5000, 10000, 30000, 60000][Math.min(attempt, 4)] });
await sleep([2000, 5000, 10000, 30000, 60000][Math.min(attempt++, 4)]);
}
}
})().finally(() => work.delete(fullKey));
work.set(fullKey, job); job.catch(() => {}); return job;
}
function background(key, task) { retry(key, task).catch(warn); }
function refresh() {
if (refreshed) return Promise.resolve(current);
if (refreshing) return refreshing;
const token = epoch;
const finish = perf.begin('manifest.refresh', { epoch: token });
const job = (async () => {
await checkpoint(token);
const response = await io.fetchManifest();
const m = perf.measure('manifest.validate', () => v.manifest(response, builtin.contentVersion, environment));
guard(token); current = m; refreshed = true; allReadyRelease = null; save('manifest', m);
perf.emit('manifest.ready', { release: m.release, A: m.groups.A.resourceCount, B: m.groups.B.resourceCount });
return m;
})().then(value => { finish({ status: 'ok' }); return value; }, error => { finish({ status: 'error' }); throw error; })
.finally(() => { if (refreshing === job) refreshing = null; });
refreshing = job; return job;
}
function beginLogin(data = {}) {
for (const fail of entryWaiters) fail(cancelled());
perf.session({ version: data.version, environment, cachedRelease: current && current.release });
epoch++; holds.clear(); wake(); prefetching.clear(); lastPrefetch = null; allDemand = false; collecting = null; maintainedManifest = null; refreshing = null; refreshed = false; started = now(); allReadyRelease = null; protectAll = false; playing = null;
const key = 'policy:' + String(data.version || 'unknown');
const old = readState(key) || {};
const validMax = n => v.integer(n, 100);
const maxLevel = validMax(data.maxLevel) ? data.maxLevel : validMax(old.maxLevel) ? old.maxLevel : options.defaultMaxLevel || 2040;
const validB = n => v.integer(n) && n <= maxLevel;
const bLevelCount = validB(data.bLevelCount) ? data.bLevelCount : validB(old.bLevelCount) ? old.bLevelCount : 0;
// Group assignment is never inferred from the retired new1_50 experiment or from N alone.
mainGroup = data.levelGroup === 'B' && bLevelCount > 0 ? 'B' : 'A';
limits = { maxLevel, bLevelCount, levelGroup: mainGroup };
perf.emit('policy', limits);
save(key, { maxLevel, bLevelCount });
background('manifest', refresh);
return Object.assign({}, limits);
}
function groupFor(id, group = mainGroup) { return group === 'B' && limits && id <= limits.bLevelCount ? 'B' : 'A'; }
function parse(bytes, entry) {
v.entry(entry);
v.check(bytes && bytes.byteLength === entry.size && perf.measure('json.sha256', () => sha256(new Uint8Array(bytes)), bytes.byteLength) === entry.hash, '关卡文件校验失败');
const text = perf.measure('json.decode', () => io.decode(bytes), bytes.byteLength);
return perf.measure('json.parseValidate', () => v.level(JSON.parse(text)), bytes.byteLength);
}
async function read(g, id, e) {
if (!e) return null;
try {
const local = builtin.groups[g][id];
if (local && local.hash === e.hash) {
try {
const bytes = await perf.measureAsync('file.readBuiltin', () => io.readLocal(local.file), e.size);
const data = parse(bytes, e); perf.metric('source.builtin', 0);
return { data, group: g, id, hash: e.hash };
} catch (_) { perf.metric('source.builtinInvalid', 0); }
}
if (io.hasCache && io.hasCache(g, id, e.hash) === false) { perf.metric('source.absentSkipped', 0); return null; }
const bytes = await perf.measureAsync('file.readCache', () => io.readCache(g, id, e.hash), e.size);
const data = parse(bytes, e); perf.metric('source.cache', 0);
return { data, group: g, id, hash: e.hash };
} catch (_) { perf.metric('source.missingOrInvalid', 0); return null; }
}
async function available(g, id, m = current, skipHash = null) {
const target = m && m.groups[g].levels[id];
const old = index[g + '/' + id];
const seen = new Set(skipHash ? [skipHash] : []);
for (const e of [target, old && old.entry, builtin.groups[g][id]]) {
if (!e || seen.has(e.hash)) continue;
seen.add(e.hash);
const data = await read(g, id, e);
if (data) {
if (old && e === old.entry) old.used = now();
if (target && data.hash !== target.hash) perf.metric('source.oldFallback', 0);
return data;
}
}
return null;
}
function remember(g, id, e) {
const key = g + '/' + id, old = index[key];
index[key] = { entry: e, used: now() };
const changed = !old || !old.entry || old.entry.hash !== e.hash;
if (changed) cacheRevision++;
return changed;
}
function single(m, g, id, foreground = true, demanded = () => false) {
const e = m.groups[g].levels[id];
if (!e) return Promise.reject(Error('当前资源清单缺少关卡 ' + g + '/' + id));
const token = epoch, key = token + ':' + g + '/' + id + '/' + e.hash;
if (downloads.has(key)) {
const existing = downloads.get(key);
const previousDemand = existing.demanded;
existing.demanded = () => previousDemand() || demanded();
if (foreground) { existing.foreground = true; wake(); }
else if (demanded()) wake();
return existing.promise;
}
const job = { foreground, demanded, promise: null }, isBackground = () => !job.foreground && !job.demanded();
const task = (async () => {
await checkpoint(token, isBackground);
if (await read(g, id, e)) { guard(token); if (remember(g, id, e)) save('index', index); return; }
await checkpoint(token, isBackground);
const bytes = await io.fetchFile(Object.assign({ file: v.levelFile(g, id, e) }, e));
parse(bytes, e); guard(token);
await checkpoint(token, isBackground);
await io.writeCache(g, id, e.hash, bytes); guard(token);
remember(g, id, e); save('index', index);
})().finally(() => downloads.delete(key));
job.promise = task; downloads.set(key, job); return task;
}
function repair(m, g, id) { if (m && m.groups[g].levels[id]) background('level:' + g + '/' + id + '/' + m.groups[g].levels[id].hash, () => single(m, g, id, false)); }
async function load(id, group = mainGroup) {
v.check(limits && v.integer(id, 1) && id <= limits.maxLevel, '关卡超出登录开放范围');
const token = epoch, g = groupFor(id, group), pin = g + '/' + id; pins.set(pin, (pins.get(pin) || 0) + 1);
const finish = perf.begin('level.load', { id, group: g, epoch: token });
let status = 'error', source = 'download';
try {
let data = await available(g, id);
guard(token);
if (data) {
playing = pin;
const target = current && current.groups[g].levels[id];
if (target && data.hash !== target.hash) repair(current, g, id);
status = 'ok'; source = 'local'; return data;
}
data = await waitForEntry(async () => {
const m = current || await refresh();
await single(m, g, id); guard(token);
return read(g, id, m.groups[g].levels[id]);
});
guard(token); v.check(data, '关卡缓存不可用'); playing = pin; status = 'ok'; return data;
} finally { finish({ status, source }); const count = pins.get(pin) - 1; if (count) pins.set(pin, count); else pins.delete(pin); }
}
function indexed(g, id, entry) {
if (!entry) return false;
const local = builtin.groups[g][id];
// The runtime inventory comes from committed package catalogs / standalone filenames.
// Never treat the logical usage index alone as proof that a file is installed.
return !!((local && local.hash === entry.hash) || (io.hasCache && io.hasCache(g, id, entry.hash)));
}
async function ensurePack(m, g, name, allowOld = true) {
const p = m.groups[g].packs[name];
if (!p) throw Error('关卡包尚未发布:' + g + '/' + name);
const token = epoch, key = token + ':' + g + '/' + name + '/' + p.hash + '/' + allowOld;
if (packs.has(key)) return packs.get(key);
const finish = perf.begin('pack.ensure', { group: g, pack: name, count: p.end - p.start + 1 });
const isBackground = () => !(name === 'all' && allDemand);
const task = (async () => {
const scanStarted = now();
const missing = [];
for (let id = p.start; id <= p.end; id++) {
await checkpoint(token, isBackground);
const e = m.groups[g].levels[id];
if (!indexed(g, id, e)) {
const cached = index[g + '/' + id];
const old = allowOld && (indexed(g, id, cached && cached.entry) || indexed(g, id, builtin.groups[g][id]));
if (old) repair(m, g, id); else missing.push(id);
}
if (id % 16 === 0) await sleep(0);
}
guard(token);
perf.emit('pack.scan', { group: g, pack: name, mode: 'metadata', ms: now() - scanStarted, scanned: p.end - p.start + 1, missing: missing.length });
if (!missing.length) return;
const bytesMissing = missing.reduce((sum, id) => sum + m.groups[g].levels[id].size, 0);
perf.emit('pack.strategy', { group: g, pack: name, strategy: missing.length > 4 && bytesMissing >= p.size ? 'zip' : 'single', bytesMissing, zipBytes: p.size });
if (missing.length > 4 && bytesMissing >= p.size) {
await checkpoint(token, isBackground);
const archive = await io.fetchPack(m, g, Object.assign({ file: v.packFile(g, name, p) }, p), () => checkpoint(token, isBackground));
const installed = perf.begin('zip.install', { group: g, pack: name, missing: missing.length });
let count = 0, status = 'error';
try {
await checkpoint(token, isBackground);
await archive.install(); guard(token);
for (const id of missing) {
// Per-level hashes come from the manifest belonging to the verified ZIP.
remember(g, id, m.groups[g].levels[id]); count++;
}
status = 'ok';
} finally {
// The directory and its lookup metadata commit together before the logical index.
try { if (token === epoch && count) save('index', index); }
finally { try { await archive.dispose(); } finally { installed({ status, installed: count }); } }
}
} else {
for (let i = 0; i < missing.length; i += 3) {
await checkpoint(token, isBackground);
await Promise.all(missing.slice(i, i + 3).map(id => single(m, g, id, false, () => !isBackground())));
}
}
})().then(value => { finish({ status: 'ok' }); return value; }, error => { finish({ status: 'error' }); throw error; }).finally(() => packs.delete(key));
packs.set(key, task); return task;
}
function ensureAll(foreground = false) {
protectAll = true;
if (allReady()) return Promise.resolve(true);
if (foreground) { allDemand = true; wake(); }
return retry('all', async () => {
const token = epoch;
const m = refreshed ? current : await refresh().catch(error => { if (current) return current; throw error; });
await maintain(); guard(token);
await ensurePack(m, 'A', 'all'); guard(token);
// A later successful login manifest supersedes an offline snapshot while this task was running.
if (m !== current) throw Error('资源清单已更新,继续准备完整关卡池');
allReadyRelease = m.release;
await maintain(true); allDemand = false; return true;
});
}
function allReady() { return !!(current && allReadyRelease === current.release); }
function cacheIdentity(file) {
const match = /^([AB])\/([1-9][0-9]*)\.([a-f0-9]{64})\.json$/.exec(file);
return match && { group: match[1], id: Number(match[2]), hash: match[3], key: match[1] + '/' + match[2] };
}
function keepFile(file) {
const f = cacheIdentity(file); if (!f) return false;
const target = current && current.groups[f.group].levels[f.id], old = index[f.key];
return !!((target && target.hash === f.hash) || (old && old.entry && old.entry.hash === f.hash));
}
function protectFile(file) {
const f = cacheIdentity(file); if (!f) return false;
return (protectAll && f.group === 'A' && keepFile(file)) || playing === f.key || pins.has(f.key)
|| (f.id >= nextId && f.id <= nextId + 12 && f.group === groupFor(f.id));
}
function maintain(force = false) {
if (!io.collect || !current) return Promise.resolve();
if (collecting) return collecting;
if (!force && maintainedManifest === current && maintainedRevision === cacheRevision && now() - maintainedAt < 10000) {
perf.metric('cache.maintenanceSkipped', 0); return Promise.resolve();
}
const token = epoch;
const task = (async () => {
await checkpoint(token, () => !allDemand);
const finish = perf.begin('cache.maintain', { protectAll }), m = current, revision = cacheRevision;
try {
const buildStarted = now();
const files = io.listCache ? io.listCache() : Object.keys(index).filter(key => index[key] && index[key].entry)
.map(key => key + '.' + index[key].entry.hash + '.json');
const keep = new Set(), protect = new Set();
for (const file of files) { if (keepFile(file)) keep.add(file); if (protectFile(file)) protect.add(file); }
perf.emit('cache.plan', { files: files.length, ms: now() - buildStarted });
const collected = await io.collect(keep, protect, options.cacheBudgetBytes || 32 * 1024 * 1024,
{ checkpoint: () => checkpoint(token, () => !allDemand), keep: keepFile, protect: protectFile,
size: file => {
const f = cacheIdentity(file); if (!f) return null;
const entries = [current.groups[f.group].levels[f.id], index[f.key] && index[f.key].entry, builtin.groups[f.group][f.id]];
const entry = entries.find(e => e && e.hash === f.hash);
return entry ? entry.size : null;
} });
if (collected === false) return;
guard(token); maintainedManifest = m; maintainedRevision = revision; maintainedAt = now();
} finally { finish(); }
})().finally(() => { if (collecting === task) collecting = null; });
collecting = task; return task;
}
function prefetch(id, group = mainGroup) {
const key = epoch + ':' + group + '/' + id;
if (prefetching.has(key)) { perf.metric('prefetch.joined', 0); return prefetching.get(key); }
if (refreshed && lastPrefetch && lastPrefetch.key === key && lastPrefetch.manifest === current && lastPrefetch.revision === cacheRevision) {
perf.metric('prefetch.unchangedSkipped', 0); return Promise.resolve();
}
const token = epoch;
const task = prefetchOnce(id, group).then(snapshot => {
guard(token); lastPrefetch = Object.assign({ key }, snapshot);
}).finally(() => { if (prefetching.get(key) === task) prefetching.delete(key); });
prefetching.set(key, task); return task;
}
async function prefetchOnce(id, group) {
const finish = perf.begin('mainline.prefetch', { id, group });
try {
nextId = id;
const token = epoch, m = refreshed ? current : await refresh(); guard(token);
await checkpoint(token, () => true);
const snapshot = { manifest: m, revision: cacheRevision };
if (id > limits.maxLevel) { ensureAll().catch(warn); return snapshot; }
// Repair only locally owned unpassed mainline resources. Past levels are checked on real use.
const owned = new Set(Object.keys(index));
const scanStarted = now(); let checked = 0, repairs = 0;
for (const g of ['A', 'B']) for (const localId of Object.keys(builtin.groups[g])) owned.add(g + '/' + localId);
for (const key of owned) {
await checkpoint(token, () => true);
const [g, text] = key.split('/'), n = Number(text);
if (n >= id && n <= limits.maxLevel && g === groupFor(n, group) && m.groups[g].levels[n]) {
checked++;
// Repair discovery uses metadata, not a read/hash/parse of every owned JSON.
// Actual use (load/single) still verifies bytes and recovers missing/corrupt files.
const target = m.groups[g].levels[n], cached = index[key] && index[key].entry, local = builtin.groups[g][n];
if ((!cached || cached.hash !== target.hash) && (!local || local.hash !== target.hash)) {
repairs++; repair(m, g, n);
}
}
}
guard(token);
perf.emit('prefetch.scan', { id, owned: owned.size, checked, repairs, mode: 'metadata', ms: now() - scanStarted });
const g = groupFor(id, group), localCount = Object.keys(builtin.groups[g]).length;
if (id > localCount || id >= localCount - 10) {
const key = String(Math.floor((id - 1) / 100) * 100 + 1);
if (m.groups[g].packs[key]) background('pack:' + g + '/' + key, () => ensurePack(m, g, key));
}
// Resolve every look-ahead ID through N, including an A/B boundary or a partial built-in range.
for (let n = id; n <= Math.min(id + 11, limits.maxLevel); n++) {
const targetGroup = groupFor(n, group), start = Math.floor((n - 1) / 100) * 100 + 1;
if (m.groups[targetGroup].packs[start] && !builtin.groups[targetGroup][n])
background('pack:' + targetGroup + '/' + start, () => ensurePack(m, targetGroup, String(start)));
}
if (id >= limits.maxLevel - 10) ensureAll().catch(warn);
await maintain();
return snapshot;
} finally { finish(); }
}
async function prepareLogin(id, group = mainGroup) {
const finish = perf.begin('login.levelGate', { id, group, elapsedSinceLoginMs: now() - started });
let status = 'error', urgentReady = false;
try {
v.check(limits, '尚未收到登录关卡配置'); nextId = id;
const token = epoch, endless = id > limits.maxLevel;
const urgent = (async () => {
const m = await refresh(); guard(token);
if (endless) return ensureAll();
await Promise.all([id, id + 1, id + 2].filter(n => n <= limits.maxLevel).map(n => single(m, groupFor(n, group), n)));
})();
urgent.then(() => { urgentReady = true; }, warn);
background('mainline-prefetch', () => prefetch(id, group));
if (endless) ensureAll().catch(warn);
const remaining = Math.max(0, (options.loginBudgetMs === undefined ? 8000 : options.loginBudgetMs) - (now() - started));
await Promise.race([urgent.catch(() => sleep(remaining)), sleep(remaining)]); guard(token);
if (endless) { status = allReady() ? 'allReady' : 'homeReleasedPreparingAll'; return; }
const ready = await available(groupFor(id, group), id);
guard(token); v.check(ready, '当前关卡尚未准备好,请重试');
status = urgentReady ? 'urgentReady' : 'localFallback';
} finally { finish({ status, urgentReady }); perf.flush(); }
}
return { beginLogin, prepareLogin, load, prefetch, ensureAll, allReady, refresh, maintain, holdBackground,
limits: () => limits, groupFor,
setActive(value) { active = !!value; wake(); }, setOnline(value) {
online = !!value;
if (!online) for (const fail of entryWaiters) fail(entryError('LEVEL_OFFLINE'));
wake();
},
dispose() { epoch++; holds.clear(); for (const fail of entryWaiters) fail(cancelled()); wake(); active = true; online = true; }
};
}
module.exports = { create };