'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 };