gengxin
This commit is contained in:
@@ -0,0 +1,674 @@
|
||||
const CACHE_INDEX_VERSION = 2
|
||||
const CACHE_FOLDER = 'tang-detective-assets-v2'
|
||||
const CACHE_INDEX_FILE = 'index.json'
|
||||
const DEFAULT_IMAGE_BUDGET = 28 * 1024 * 1024
|
||||
const DEFAULT_AUDIO_BUDGET = 32 * 1024 * 1024
|
||||
const DEFAULT_AUDIO_MAX_ENTRIES = 32
|
||||
|
||||
const SHA256_ROUND_CONSTANTS = [
|
||||
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
|
||||
0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
||||
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
|
||||
0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
||||
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
|
||||
0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
||||
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
|
||||
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
||||
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
|
||||
0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
||||
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
|
||||
0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
||||
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
|
||||
0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
||||
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
|
||||
0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
||||
]
|
||||
|
||||
function utf8Bytes(value) {
|
||||
const encoded = encodeURIComponent(String(value || ''))
|
||||
const bytes = []
|
||||
for (let index = 0; index < encoded.length; index += 1) {
|
||||
if (encoded[index] === '%') {
|
||||
bytes.push(parseInt(encoded.slice(index + 1, index + 3), 16))
|
||||
index += 2
|
||||
} else {
|
||||
bytes.push(encoded.charCodeAt(index))
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes)
|
||||
}
|
||||
|
||||
function toBytes(value) {
|
||||
if (typeof value === 'string') return utf8Bytes(value)
|
||||
if (value instanceof ArrayBuffer) return new Uint8Array(value)
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
|
||||
}
|
||||
throw new Error('unsupported SHA-256 input')
|
||||
}
|
||||
|
||||
function rotateRight(value, bits) {
|
||||
return (value >>> bits) | (value << (32 - bits))
|
||||
}
|
||||
|
||||
function sha256Hex(value) {
|
||||
const bytes = toBytes(value)
|
||||
const paddedLength = Math.ceil((bytes.length + 9) / 64) * 64
|
||||
const message = new Uint8Array(paddedLength)
|
||||
message.set(bytes)
|
||||
message[bytes.length] = 0x80
|
||||
const bitLength = bytes.length * 8
|
||||
const highBits = Math.floor(bitLength / 0x100000000)
|
||||
const lowBits = bitLength >>> 0
|
||||
const lengthOffset = paddedLength - 8
|
||||
message[lengthOffset] = (highBits >>> 24) & 0xff
|
||||
message[lengthOffset + 1] = (highBits >>> 16) & 0xff
|
||||
message[lengthOffset + 2] = (highBits >>> 8) & 0xff
|
||||
message[lengthOffset + 3] = highBits & 0xff
|
||||
message[lengthOffset + 4] = (lowBits >>> 24) & 0xff
|
||||
message[lengthOffset + 5] = (lowBits >>> 16) & 0xff
|
||||
message[lengthOffset + 6] = (lowBits >>> 8) & 0xff
|
||||
message[lengthOffset + 7] = lowBits & 0xff
|
||||
|
||||
const hash = [
|
||||
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
||||
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
||||
]
|
||||
const words = new Uint32Array(64)
|
||||
for (let offset = 0; offset < paddedLength; offset += 64) {
|
||||
for (let index = 0; index < 16; index += 1) {
|
||||
const start = offset + index * 4
|
||||
words[index] = (
|
||||
(message[start] << 24)
|
||||
| (message[start + 1] << 16)
|
||||
| (message[start + 2] << 8)
|
||||
| message[start + 3]
|
||||
) >>> 0
|
||||
}
|
||||
for (let index = 16; index < 64; index += 1) {
|
||||
const word15 = words[index - 15]
|
||||
const word2 = words[index - 2]
|
||||
const sigma0 = (
|
||||
rotateRight(word15, 7)
|
||||
^ rotateRight(word15, 18)
|
||||
^ (word15 >>> 3)
|
||||
)
|
||||
const sigma1 = (
|
||||
rotateRight(word2, 17)
|
||||
^ rotateRight(word2, 19)
|
||||
^ (word2 >>> 10)
|
||||
)
|
||||
words[index] = (
|
||||
words[index - 16]
|
||||
+ sigma0
|
||||
+ words[index - 7]
|
||||
+ sigma1
|
||||
) >>> 0
|
||||
}
|
||||
|
||||
let a = hash[0]
|
||||
let b = hash[1]
|
||||
let c = hash[2]
|
||||
let d = hash[3]
|
||||
let e = hash[4]
|
||||
let f = hash[5]
|
||||
let g = hash[6]
|
||||
let h = hash[7]
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25)
|
||||
const choice = (e & f) ^ (~e & g)
|
||||
const temp1 = (
|
||||
h + sum1 + choice + SHA256_ROUND_CONSTANTS[index] + words[index]
|
||||
) >>> 0
|
||||
const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22)
|
||||
const majority = (a & b) ^ (a & c) ^ (b & c)
|
||||
const temp2 = (sum0 + majority) >>> 0
|
||||
h = g
|
||||
g = f
|
||||
f = e
|
||||
e = (d + temp1) >>> 0
|
||||
d = c
|
||||
c = b
|
||||
b = a
|
||||
a = (temp1 + temp2) >>> 0
|
||||
}
|
||||
hash[0] = (hash[0] + a) >>> 0
|
||||
hash[1] = (hash[1] + b) >>> 0
|
||||
hash[2] = (hash[2] + c) >>> 0
|
||||
hash[3] = (hash[3] + d) >>> 0
|
||||
hash[4] = (hash[4] + e) >>> 0
|
||||
hash[5] = (hash[5] + f) >>> 0
|
||||
hash[6] = (hash[6] + g) >>> 0
|
||||
hash[7] = (hash[7] + h) >>> 0
|
||||
}
|
||||
return hash.map((word) => word.toString(16).padStart(8, '0')).join('')
|
||||
}
|
||||
|
||||
function constantTimeEqualHex(left, right) {
|
||||
const leftValue = String(left || '').toLowerCase()
|
||||
const rightValue = String(right || '').toLowerCase()
|
||||
const length = Math.max(leftValue.length, rightValue.length)
|
||||
let difference = leftValue.length ^ rightValue.length
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const leftCode = index < leftValue.length ? leftValue.charCodeAt(index) : 0
|
||||
const rightCode = index < rightValue.length ? rightValue.charCodeAt(index) : 0
|
||||
difference |= leftCode ^ rightCode
|
||||
}
|
||||
return difference === 0
|
||||
}
|
||||
|
||||
function trimSlash(value) {
|
||||
return String(value || '').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function joinPath(left, right) {
|
||||
return `${trimSlash(left)}/${String(right || '').replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
function normalizeIndex(value) {
|
||||
if (
|
||||
!value
|
||||
|| value.version !== CACHE_INDEX_VERSION
|
||||
|| !value.entries
|
||||
|| typeof value.entries !== 'object'
|
||||
) {
|
||||
return {
|
||||
version: CACHE_INDEX_VERSION,
|
||||
entries: {},
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function callFs(fs, method, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!fs || typeof fs[method] !== 'function') {
|
||||
reject(new Error(`fs.${method} unavailable`))
|
||||
return
|
||||
}
|
||||
fs[method]({
|
||||
...options,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function createAssetPlatformFacade() {
|
||||
if (typeof wx === 'undefined') return null
|
||||
const env = Object.freeze({
|
||||
USER_DATA_PATH: String(
|
||||
wx.env && wx.env.USER_DATA_PATH || '',
|
||||
),
|
||||
})
|
||||
return Object.freeze({
|
||||
downloadFile(options) {
|
||||
if (typeof wx.downloadFile !== 'function') {
|
||||
if (options && typeof options.fail === 'function') {
|
||||
options.fail(new Error('wx.downloadFile unavailable'))
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return wx.downloadFile(options)
|
||||
},
|
||||
getFileSystemManager() {
|
||||
if (typeof wx.getFileSystemManager !== 'function') return null
|
||||
return wx.getFileSystemManager()
|
||||
},
|
||||
env,
|
||||
})
|
||||
}
|
||||
|
||||
function download(assetPlatform, url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!assetPlatform || typeof assetPlatform.downloadFile !== 'function') {
|
||||
reject(new Error('wx.downloadFile unavailable'))
|
||||
return
|
||||
}
|
||||
assetPlatform.downloadFile({
|
||||
url,
|
||||
success(result) {
|
||||
if (
|
||||
result
|
||||
&& result.statusCode >= 200
|
||||
&& result.statusCode < 300
|
||||
&& result.tempFilePath
|
||||
) {
|
||||
resolve(result)
|
||||
return
|
||||
}
|
||||
reject(new Error(`download status ${result && result.statusCode}`))
|
||||
},
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function safeCacheName(assetId, asset) {
|
||||
const extensionMatch = String(asset.remotePath || '').match(/(\.[a-z0-9]+)$/i)
|
||||
const extension = extensionMatch ? extensionMatch[1].toLowerCase() : '.bin'
|
||||
const safeId = assetId.replace(/[^a-z0-9._-]/gi, '_')
|
||||
return `${safeId}.${asset.sha256.slice(0, 12)}${extension}`
|
||||
}
|
||||
|
||||
function createAssetManager(options = {}) {
|
||||
const assetPlatform = options.assetPlatform || createAssetPlatformFacade()
|
||||
const manifest = options.manifest || {}
|
||||
const cdnBaseUrl = trimSlash(options.cdnBaseUrl)
|
||||
const imageBudgetBytes = Number.isFinite(options.imageCacheBudgetBytes)
|
||||
? Math.max(0, options.imageCacheBudgetBytes)
|
||||
: DEFAULT_IMAGE_BUDGET
|
||||
const audioBudgetBytes = Number.isFinite(options.audioCacheBudgetBytes)
|
||||
? Math.max(0, options.audioCacheBudgetBytes)
|
||||
: DEFAULT_AUDIO_BUDGET
|
||||
const audioMaxEntries = Number.isFinite(options.audioCacheMaxEntries)
|
||||
? Math.max(0, Math.floor(options.audioCacheMaxEntries))
|
||||
: DEFAULT_AUDIO_MAX_ENTRIES
|
||||
const downloadConcurrency = Number.isFinite(options.downloadConcurrency)
|
||||
? Math.max(1, Math.floor(options.downloadConcurrency))
|
||||
: 2
|
||||
const now = typeof options.now === 'function' ? options.now : Date.now
|
||||
const fs = assetPlatform
|
||||
&& typeof assetPlatform.getFileSystemManager === 'function'
|
||||
? assetPlatform.getFileSystemManager()
|
||||
: null
|
||||
const userDataPath = (
|
||||
assetPlatform
|
||||
&& assetPlatform.env
|
||||
&& assetPlatform.env.USER_DATA_PATH
|
||||
) || ''
|
||||
const cacheRoot = userDataPath ? joinPath(userDataPath, CACHE_FOLDER) : ''
|
||||
const indexPath = cacheRoot ? joinPath(cacheRoot, CACHE_INDEX_FILE) : ''
|
||||
|
||||
let initialized = false
|
||||
let index = normalizeIndex(null)
|
||||
let activeDownloads = 0
|
||||
const inflight = new Map()
|
||||
const downloadWaiters = []
|
||||
|
||||
function fallback(assetId, reason) {
|
||||
return {
|
||||
assetId,
|
||||
available: false,
|
||||
uri: '',
|
||||
source: 'text-fallback',
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCacheFolder() {
|
||||
if (!cacheRoot) return false
|
||||
try {
|
||||
await callFs(fs, 'mkdir', {
|
||||
dirPath: cacheRoot,
|
||||
recursive: true,
|
||||
})
|
||||
} catch (error) {
|
||||
// EEXIST and older base-library mkdir failures are both safe to ignore.
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function persistIndex() {
|
||||
if (!indexPath) return
|
||||
try {
|
||||
await ensureCacheFolder()
|
||||
await callFs(fs, 'writeFile', {
|
||||
filePath: indexPath,
|
||||
data: JSON.stringify(index),
|
||||
encoding: 'utf8',
|
||||
})
|
||||
} catch (error) {
|
||||
// Cache metadata must never block the text-first game.
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(filePath) {
|
||||
if (!filePath) return false
|
||||
try {
|
||||
await callFs(fs, 'access', { path: filePath })
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fileMatchesSha256(filePath, expectedSha256) {
|
||||
try {
|
||||
const result = await callFs(fs, 'readFile', { filePath })
|
||||
return constantTimeEqualHex(
|
||||
sha256Hex(result.data),
|
||||
expectedSha256,
|
||||
)
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
if (!indexPath) return
|
||||
try {
|
||||
const result = await callFs(fs, 'readFile', {
|
||||
filePath: indexPath,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
index = normalizeIndex(JSON.parse(result.data))
|
||||
} catch (error) {
|
||||
index = normalizeIndex(null)
|
||||
}
|
||||
|
||||
let changed = false
|
||||
for (const [assetId, entry] of Object.entries(index.entries)) {
|
||||
if (!manifest[assetId] || !(await fileExists(entry.filePath))) {
|
||||
delete index.entries[assetId]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) await persistIndex()
|
||||
await enforceImageBudget()
|
||||
await enforceAudioBudget()
|
||||
}
|
||||
|
||||
function cacheBytes(kind) {
|
||||
return Object.values(index.entries).reduce(
|
||||
(total, entry) => (
|
||||
entry.kind === kind
|
||||
? total + Math.max(0, Number(entry.size) || 0)
|
||||
: total
|
||||
),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
function cacheEntries(kind) {
|
||||
return Object.values(index.entries)
|
||||
.filter((entry) => entry.kind === kind)
|
||||
.length
|
||||
}
|
||||
|
||||
function imageCacheBytes() {
|
||||
return cacheBytes('image')
|
||||
}
|
||||
|
||||
function audioCacheBytes() {
|
||||
return cacheBytes('audio')
|
||||
}
|
||||
|
||||
async function unlinkQuietly(filePath) {
|
||||
try {
|
||||
await callFs(fs, 'unlink', { filePath })
|
||||
} catch (error) {
|
||||
// A missing cache file is already evicted.
|
||||
}
|
||||
}
|
||||
|
||||
async function enforceImageBudget(protectedAssetId = '') {
|
||||
let total = imageCacheBytes()
|
||||
if (total <= imageBudgetBytes) return
|
||||
|
||||
const candidates = Object.entries(index.entries)
|
||||
.filter(([assetId, entry]) => (
|
||||
entry.kind === 'image' && assetId !== protectedAssetId
|
||||
))
|
||||
.sort((left, right) => (
|
||||
(Number(left[1].lastAccessedAt) || 0)
|
||||
- (Number(right[1].lastAccessedAt) || 0)
|
||||
))
|
||||
|
||||
for (const [assetId, entry] of candidates) {
|
||||
if (total <= imageBudgetBytes) break
|
||||
await unlinkQuietly(entry.filePath)
|
||||
total -= Math.max(0, Number(entry.size) || 0)
|
||||
delete index.entries[assetId]
|
||||
}
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
async function enforceAudioBudget(protectedAssetId = '') {
|
||||
let total = audioCacheBytes()
|
||||
let entries = cacheEntries('audio')
|
||||
if (total <= audioBudgetBytes && entries <= audioMaxEntries) return
|
||||
|
||||
const candidates = Object.entries(index.entries)
|
||||
.filter(([assetId, entry]) => (
|
||||
entry.kind === 'audio' && assetId !== protectedAssetId
|
||||
))
|
||||
.sort((left, right) => (
|
||||
(Number(left[1].lastAccessedAt) || 0)
|
||||
- (Number(right[1].lastAccessedAt) || 0)
|
||||
))
|
||||
|
||||
for (const [assetId, entry] of candidates) {
|
||||
if (total <= audioBudgetBytes && entries <= audioMaxEntries) break
|
||||
await unlinkQuietly(entry.filePath)
|
||||
total -= Math.max(0, Number(entry.size) || 0)
|
||||
entries -= 1
|
||||
delete index.entries[assetId]
|
||||
}
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
async function resolveCached(assetId, asset) {
|
||||
const entry = index.entries[assetId]
|
||||
if (!entry || entry.sha256 !== asset.sha256) return null
|
||||
if (!(await fileExists(entry.filePath))) {
|
||||
delete index.entries[assetId]
|
||||
await persistIndex()
|
||||
return null
|
||||
}
|
||||
if (!(await fileMatchesSha256(entry.filePath, asset.sha256))) {
|
||||
await unlinkQuietly(entry.filePath)
|
||||
delete index.entries[assetId]
|
||||
await persistIndex()
|
||||
return fallback(assetId, 'remote-integrity-failed')
|
||||
}
|
||||
entry.lastAccessedAt = now()
|
||||
await persistIndex()
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: entry.filePath,
|
||||
source: 'cache',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
|
||||
async function getTempFileSize(tempFilePath, response) {
|
||||
if (Number.isFinite(response.fileSize)) return response.fileSize
|
||||
try {
|
||||
const statResult = await callFs(fs, 'stat', {
|
||||
path: tempFilePath,
|
||||
})
|
||||
const stat = statResult && statResult.stats
|
||||
return Math.max(0, Number(stat && stat.size) || 0)
|
||||
} catch (error) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRemote(assetId, asset) {
|
||||
if (!cdnBaseUrl || !asset.remotePath) {
|
||||
return fallback(assetId, 'remote-disabled')
|
||||
}
|
||||
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const run = () => {
|
||||
activeDownloads += 1
|
||||
download(assetPlatform, joinPath(cdnBaseUrl, asset.remotePath))
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
activeDownloads -= 1
|
||||
const next = downloadWaiters.shift()
|
||||
if (next) next()
|
||||
})
|
||||
}
|
||||
if (activeDownloads < downloadConcurrency) run()
|
||||
else downloadWaiters.push(run)
|
||||
})
|
||||
if (!(await fileMatchesSha256(response.tempFilePath, asset.sha256))) {
|
||||
await unlinkQuietly(response.tempFilePath)
|
||||
return fallback(assetId, 'remote-integrity-failed')
|
||||
}
|
||||
const size = await getTempFileSize(response.tempFilePath, response)
|
||||
|
||||
const exceedsCachePolicy = (
|
||||
(asset.kind === 'image' && size > imageBudgetBytes)
|
||||
|| (
|
||||
asset.kind === 'audio'
|
||||
&& (size > audioBudgetBytes || audioMaxEntries < 1)
|
||||
)
|
||||
)
|
||||
if (!cacheRoot || exceedsCachePolicy) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: response.tempFilePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: false,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureCacheFolder()
|
||||
const filePath = joinPath(cacheRoot, safeCacheName(assetId, asset))
|
||||
await callFs(fs, 'saveFile', {
|
||||
tempFilePath: response.tempFilePath,
|
||||
filePath,
|
||||
})
|
||||
index.entries[assetId] = {
|
||||
assetId,
|
||||
filePath,
|
||||
kind: asset.kind,
|
||||
sha256: asset.sha256,
|
||||
size,
|
||||
lastAccessedAt: now(),
|
||||
}
|
||||
await enforceImageBudget(assetId)
|
||||
await enforceAudioBudget(assetId)
|
||||
await persistIndex()
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: filePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: true,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: response.tempFilePath,
|
||||
source: 'remote',
|
||||
kind: asset.kind,
|
||||
persistent: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolve(assetId, resolveOptions = {}) {
|
||||
await init()
|
||||
const asset = manifest[assetId]
|
||||
if (!asset) return fallback(assetId, 'unknown-asset')
|
||||
if (
|
||||
asset.kind === 'audio'
|
||||
&& String(asset.reviewStatus || '').toLowerCase() !== 'approved'
|
||||
) {
|
||||
return fallback(assetId, 'audio-unapproved')
|
||||
}
|
||||
|
||||
// 包内种子永远优先:首屏无需等网络,离线也能继续读和玩。
|
||||
if (asset.localSeed && resolveOptions.preferRemote !== true) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
|
||||
const cached = await resolveCached(assetId, asset)
|
||||
if (cached) return cached
|
||||
|
||||
if (resolveOptions.allowRemote === false) {
|
||||
if (asset.localSeed) {
|
||||
return {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
}
|
||||
return fallback(assetId, 'remote-disallowed')
|
||||
}
|
||||
|
||||
if (inflight.has(assetId)) return inflight.get(assetId)
|
||||
|
||||
const request = fetchRemote(assetId, asset)
|
||||
.catch(() => (
|
||||
asset.localSeed
|
||||
? {
|
||||
assetId,
|
||||
available: true,
|
||||
uri: asset.localSeed,
|
||||
source: 'local',
|
||||
kind: asset.kind,
|
||||
}
|
||||
: fallback(assetId, 'remote-failed')
|
||||
))
|
||||
.finally(() => inflight.delete(assetId))
|
||||
inflight.set(assetId, request)
|
||||
return request
|
||||
}
|
||||
|
||||
async function prefetch(assetIds) {
|
||||
const ids = Array.isArray(assetIds) ? assetIds : []
|
||||
return Promise.all(ids.map((assetId) => resolve(assetId)))
|
||||
}
|
||||
|
||||
async function clearCache() {
|
||||
await init()
|
||||
const entries = Object.values(index.entries)
|
||||
for (const entry of entries) await unlinkQuietly(entry.filePath)
|
||||
index = normalizeIndex(null)
|
||||
await persistIndex()
|
||||
}
|
||||
|
||||
function getCacheStats() {
|
||||
return {
|
||||
entries: Object.keys(index.entries).length,
|
||||
imageBytes: imageCacheBytes(),
|
||||
imageBudgetBytes,
|
||||
audioEntries: cacheEntries('audio'),
|
||||
audioBytes: audioCacheBytes(),
|
||||
audioBudgetBytes,
|
||||
audioMaxEntries,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
resolve,
|
||||
prefetch,
|
||||
clearCache,
|
||||
getCacheStats,
|
||||
getAsset(assetId) {
|
||||
return manifest[assetId] || null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CACHE_INDEX_VERSION,
|
||||
DEFAULT_AUDIO_BUDGET,
|
||||
DEFAULT_AUDIO_MAX_ENTRIES,
|
||||
constantTimeEqualHex,
|
||||
createAssetPlatformFacade,
|
||||
createAssetManager,
|
||||
sha256Hex,
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
const REVIEWED_AUDIO_STATUS = 'approved'
|
||||
const REVIEWED_VISUAL_STATUS = 'approved-readable'
|
||||
const PROVISIONAL_VISUAL_STATUS = 'experience-provisional'
|
||||
const PROVISIONAL_VISUAL_TIER = 'experience-provisional'
|
||||
const IMAGE_UNAVAILABLE_MESSAGE = '本页画面暂时没有打开,文字和互动仍可继续。'
|
||||
const AUDIO_FILE_PATTERN = /\.(?:aac|m4a|mp3|ogg|wav)$/i
|
||||
|
||||
function cleanPath(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function isPlayableVisualDeclaration(visual) {
|
||||
if (!visual || typeof visual !== 'object') return false
|
||||
const reviewStatus = cleanPath(visual.reviewStatus).toLowerCase()
|
||||
if (reviewStatus === REVIEWED_VISUAL_STATUS) return true
|
||||
return Boolean(
|
||||
visual.kind === 'page-art'
|
||||
&& cleanPath(visual.runtimeTier) === PROVISIONAL_VISUAL_TIER
|
||||
&& visual.formalReleaseEligible === false
|
||||
&& reviewStatus === PROVISIONAL_VISUAL_STATUS,
|
||||
)
|
||||
}
|
||||
|
||||
function isPackagedPath(value) {
|
||||
const path = cleanPath(value)
|
||||
return (
|
||||
path.startsWith('/assets/')
|
||||
|| /^\/package-[a-z0-9-]+\//i.test(path)
|
||||
)
|
||||
}
|
||||
|
||||
function actorSheetFocusPercent(person) {
|
||||
const era = String(person && person.eraLabel || '')
|
||||
const characterId = String(person && person.characterId || '')
|
||||
if (characterId === 'qin-xiaoman') {
|
||||
if (/2008/.test(era)) return 16
|
||||
if (/2026/.test(era)) return 65
|
||||
}
|
||||
if (characterId === 'tang-mingyuan') {
|
||||
if (/1995/.test(era)) return 11
|
||||
if (/2001/.test(era)) return 37
|
||||
if (/2003/.test(era)) return 63
|
||||
if (/2026/.test(era)) return 88
|
||||
}
|
||||
if (/1978|1980/.test(era)) return 11
|
||||
if (/1993|1995|1998/.test(era)) return 31
|
||||
if (/2001|2003/.test(era)) return 50
|
||||
if (/2008/.test(era)) return 69
|
||||
if (/2026/.test(era)) return 88
|
||||
return 50
|
||||
}
|
||||
|
||||
function isSinglePortraitFallback(person) {
|
||||
return String(person && person.characterId || '') === 'female-cook'
|
||||
}
|
||||
|
||||
function getComicPageImageMode(person, usingActorFallback) {
|
||||
return usingActorFallback && isSinglePortraitFallback(person)
|
||||
? 'aspectFit'
|
||||
: 'scaleToFill'
|
||||
}
|
||||
|
||||
function getComicPageImageStyle(person, usingActorFallback) {
|
||||
if (!usingActorFallback || !person) return ''
|
||||
if (isSinglePortraitFallback(person)) return ''
|
||||
const focusX = actorSheetFocusPercent(person)
|
||||
const translateX = 50 - (3.5 * focusX)
|
||||
return [
|
||||
'transform-origin:0 0',
|
||||
`transform:translate(${translateX}%,-30%) scale(3.5)`,
|
||||
].join(';')
|
||||
}
|
||||
|
||||
function getComicFocusImageStyle(person, usingActorFallback) {
|
||||
if (!usingActorFallback || !person) return ''
|
||||
if (isSinglePortraitFallback(person)) return ''
|
||||
const sourceFocusX = actorSheetFocusPercent(person)
|
||||
const focusX = 25 + (sourceFocusX * 0.5)
|
||||
return [
|
||||
'transform:scale(7)',
|
||||
`transform-origin:${focusX}% 12%`,
|
||||
].join(';')
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the first image shown by a comic page.
|
||||
*
|
||||
* A release-manifest localSeed is authoritative and must never wait for the
|
||||
* network. A remote-only illustration starts on the reviewed scene fallback,
|
||||
* so a slow or failed download cannot leave a blank reader.
|
||||
*/
|
||||
function buildComicImageState(page = {}, releaseAsset = null, previous = {}) {
|
||||
const illustrationAsset = cleanPath(page.illustrationAsset)
|
||||
const fallbackAsset = cleanPath(page.fallbackAsset)
|
||||
const playableVisual = isPlayableVisualDeclaration(page.playableVisual)
|
||||
? page.playableVisual
|
||||
: null
|
||||
const sharedVisualKind = playableVisual
|
||||
&& playableVisual.kind !== 'page-art'
|
||||
? cleanPath(playableVisual.kind)
|
||||
: ''
|
||||
const sharedVisualAsset = sharedVisualKind
|
||||
? cleanPath(playableVisual.asset)
|
||||
: ''
|
||||
const actorFallbackAsset = cleanPath(
|
||||
(
|
||||
sharedVisualKind === 'actor-portrait'
|
||||
|| sharedVisualKind === 'chapter-character'
|
||||
)
|
||||
? sharedVisualAsset
|
||||
: (page.actorFallbackAsset || page.actorPortrait),
|
||||
)
|
||||
const localSeed = (
|
||||
releaseAsset
|
||||
&& releaseAsset.kind === 'image'
|
||||
)
|
||||
? cleanPath(releaseAsset.localSeed)
|
||||
: ''
|
||||
const remotePath = (
|
||||
releaseAsset
|
||||
&& releaseAsset.kind === 'image'
|
||||
)
|
||||
? cleanPath(releaseAsset.remotePath)
|
||||
: ''
|
||||
const keepFallback = Boolean(
|
||||
previous.currentPageId === page.pageId
|
||||
&& previous.comicImageUsingFallback
|
||||
&& fallbackAsset
|
||||
)
|
||||
const keepActorFallback = Boolean(
|
||||
previous.currentPageId === page.pageId
|
||||
&& previous.comicImageUsingFallback
|
||||
&& previous.comicImageSource === 'actor-fallback'
|
||||
&& actorFallbackAsset
|
||||
)
|
||||
|
||||
if (
|
||||
sharedVisualAsset
|
||||
&& (
|
||||
sharedVisualKind === 'actor-portrait'
|
||||
|| sharedVisualKind === 'chapter-character'
|
||||
)
|
||||
) {
|
||||
return {
|
||||
src: sharedVisualAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: sharedVisualAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: true,
|
||||
source: 'shared-portrait',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
sharedVisualAsset
|
||||
&& sharedVisualKind === 'evidence-composite'
|
||||
) {
|
||||
return {
|
||||
src: sharedVisualAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: '',
|
||||
usingFallback: true,
|
||||
usingActorFallback: false,
|
||||
source: 'shared-evidence',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (keepActorFallback) {
|
||||
return {
|
||||
src: actorFallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: true,
|
||||
source: 'actor-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (keepFallback) {
|
||||
return {
|
||||
src: fallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: false,
|
||||
source: 'local-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (localSeed) {
|
||||
return {
|
||||
src: localSeed,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: false,
|
||||
usingActorFallback: false,
|
||||
source: 'local-seed',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (remotePath) {
|
||||
const loadingFallback = actorFallbackAsset || fallbackAsset
|
||||
const usingActorFallback = Boolean(actorFallbackAsset)
|
||||
return {
|
||||
src: loadingFallback,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: Boolean(loadingFallback),
|
||||
usingActorFallback,
|
||||
source: usingActorFallback
|
||||
? 'actor-fallback-loading'
|
||||
: (fallbackAsset ? 'fallback-loading' : 'text-fallback-loading'),
|
||||
error: loadingFallback ? '' : IMAGE_UNAVAILABLE_MESSAGE,
|
||||
shouldResolveRemote: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (releaseAsset && illustrationAsset) {
|
||||
return {
|
||||
src: illustrationAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: false,
|
||||
usingActorFallback: false,
|
||||
source: 'declared-local-path',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (actorFallbackAsset) {
|
||||
return {
|
||||
src: actorFallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: actorFallbackAsset,
|
||||
usingFallback: true,
|
||||
usingActorFallback: true,
|
||||
source: 'actor-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackAsset) {
|
||||
return {
|
||||
src: fallbackAsset,
|
||||
fallback: fallbackAsset,
|
||||
actorFallback: '',
|
||||
usingFallback: true,
|
||||
usingActorFallback: false,
|
||||
source: 'local-fallback',
|
||||
error: '',
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
src: '',
|
||||
fallback: '',
|
||||
actorFallback: '',
|
||||
usingFallback: false,
|
||||
usingActorFallback: false,
|
||||
source: 'text-fallback',
|
||||
error: IMAGE_UNAVAILABLE_MESSAGE,
|
||||
shouldResolveRemote: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio is intentionally fail-closed. A non-empty src alone is not enough:
|
||||
* both the story data and immutable release manifest must mark the file as
|
||||
* approved, and the playable src must be the packaged, hash-checked seed.
|
||||
*/
|
||||
function getReviewedAudioSrc(media = {}, manifest = {}) {
|
||||
const status = cleanPath(media.status || media.audioStatus).toLowerCase()
|
||||
const assetId = cleanPath(media.assetId || media.audioAssetId)
|
||||
const declaredSrc = cleanPath(media.src || media.audioSrc)
|
||||
if (
|
||||
status !== REVIEWED_AUDIO_STATUS
|
||||
|| !assetId
|
||||
|| !declaredSrc
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const releaseAsset = manifest[assetId]
|
||||
if (
|
||||
!releaseAsset
|
||||
|| releaseAsset.kind !== 'audio'
|
||||
|| cleanPath(releaseAsset.reviewStatus).toLowerCase()
|
||||
!== REVIEWED_AUDIO_STATUS
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const localSeed = cleanPath(releaseAsset.localSeed)
|
||||
if (
|
||||
!localSeed
|
||||
|| localSeed !== declaredSrc
|
||||
|| !isPackagedPath(localSeed)
|
||||
|| !AUDIO_FILE_PATTERN.test(localSeed)
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
return localSeed
|
||||
}
|
||||
|
||||
function buildReviewedAudioState(media = {}, manifest = {}) {
|
||||
const src = getReviewedAudioSrc(media, manifest)
|
||||
return {
|
||||
available: Boolean(src),
|
||||
src,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AUDIO_FILE_PATTERN,
|
||||
IMAGE_UNAVAILABLE_MESSAGE,
|
||||
REVIEWED_AUDIO_STATUS,
|
||||
buildComicImageState,
|
||||
buildReviewedAudioState,
|
||||
getComicFocusImageStyle,
|
||||
getComicPageImageMode,
|
||||
getComicPageImageStyle,
|
||||
getReviewedAudioSrc,
|
||||
isPackagedPath,
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
const COMIC_PAGE_TYPES = Object.freeze([
|
||||
'cover',
|
||||
'ensemble',
|
||||
'event',
|
||||
'event',
|
||||
'event',
|
||||
'event',
|
||||
'emotion',
|
||||
'memory',
|
||||
])
|
||||
|
||||
function cleanString(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function comicPages(model = {}) {
|
||||
return Array.isArray(model.pageSequence) ? model.pageSequence : []
|
||||
}
|
||||
|
||||
function pageIndex(model, pageId) {
|
||||
const requested = cleanString(pageId)
|
||||
if (!requested) return -1
|
||||
return comicPages(model).findIndex((page) => page.pageId === requested)
|
||||
}
|
||||
|
||||
function eventPages(model) {
|
||||
return comicPages(model).filter((page) => page.type === 'event')
|
||||
}
|
||||
|
||||
function isEightPageComicModel(model = {}) {
|
||||
const pages = comicPages(model)
|
||||
if (
|
||||
model.mode !== 'comic'
|
||||
|| !cleanString(model.chapterId)
|
||||
|| pages.length !== COMIC_PAGE_TYPES.length
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return pages.every((page, index) => (
|
||||
page
|
||||
&& page.pageId === `${model.chapterId}-P${String(index + 1).padStart(2, '0')}`
|
||||
&& page.type === COMIC_PAGE_TYPES[index]
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Only a contiguous prefix of P03-P06 is trusted.
|
||||
*
|
||||
* This prevents stale, foreign, duplicated or out-of-order ids from opening a
|
||||
* later page. It also keeps the locked 60-event source order unchanged.
|
||||
*/
|
||||
function normalizeCompletedEventIds(model, completedIds = []) {
|
||||
const supplied = new Set(
|
||||
(Array.isArray(completedIds) ? completedIds : [])
|
||||
.map(cleanString)
|
||||
.filter(Boolean),
|
||||
)
|
||||
const normalized = []
|
||||
for (const page of eventPages(model)) {
|
||||
if (!supplied.has(page.eventId)) break
|
||||
normalized.push(page.eventId)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function getUnlockedPageIndex(
|
||||
model,
|
||||
completedIds = [],
|
||||
chapterFinished = false,
|
||||
) {
|
||||
const pages = comicPages(model)
|
||||
if (!pages.length) return -1
|
||||
if (!isEightPageComicModel(model)) return 0
|
||||
if (chapterFinished) return pages.length - 1
|
||||
|
||||
const completed = normalizeCompletedEventIds(model, completedIds)
|
||||
const firstEventIndex = pages.findIndex((page) => page.type === 'event')
|
||||
const emotionIndex = pages.findIndex((page) => page.type === 'emotion')
|
||||
if (firstEventIndex < 0) return 0
|
||||
|
||||
return Math.min(
|
||||
emotionIndex >= 0 ? emotionIndex : pages.length - 1,
|
||||
firstEventIndex + completed.length,
|
||||
)
|
||||
}
|
||||
|
||||
function emptyReaderState() {
|
||||
return {
|
||||
valid: false,
|
||||
currentPage: null,
|
||||
currentPageId: '',
|
||||
currentPageIndex: -1,
|
||||
unlockedPageIndex: -1,
|
||||
unlockedPageIds: [],
|
||||
completedEventIds: [],
|
||||
allEventsComplete: false,
|
||||
chapterFinished: false,
|
||||
canGoPrevious: false,
|
||||
canGoNext: false,
|
||||
nextPageLocked: false,
|
||||
activeInteraction: null,
|
||||
activeHotspots: [],
|
||||
pageAccess: [],
|
||||
}
|
||||
}
|
||||
|
||||
function buildComicReaderState(model, progress = {}) {
|
||||
if (!isEightPageComicModel(model)) return emptyReaderState()
|
||||
|
||||
const pages = comicPages(model)
|
||||
const completedEventIds = normalizeCompletedEventIds(
|
||||
model,
|
||||
progress.completedEventIds || progress.completedIds,
|
||||
)
|
||||
const allEventsComplete = (
|
||||
completedEventIds.length === eventPages(model).length
|
||||
)
|
||||
const chapterFinished = Boolean(
|
||||
progress.chapterFinished && allEventsComplete,
|
||||
)
|
||||
const unlockedPageIndex = getUnlockedPageIndex(
|
||||
model,
|
||||
completedEventIds,
|
||||
chapterFinished,
|
||||
)
|
||||
const requestedPageId = (
|
||||
cleanString(progress.currentPageId)
|
||||
|| cleanString(progress.savedPageId)
|
||||
|| model.firstPageId
|
||||
|| pages[0].pageId
|
||||
)
|
||||
const requestedIndex = pageIndex(model, requestedPageId)
|
||||
const currentPageIndex = Math.min(
|
||||
unlockedPageIndex,
|
||||
Math.max(0, requestedIndex >= 0 ? requestedIndex : 0),
|
||||
)
|
||||
const currentPage = pages[currentPageIndex]
|
||||
const completed = new Set(completedEventIds)
|
||||
|
||||
let activeInteraction = null
|
||||
if (
|
||||
currentPage.type === 'event'
|
||||
&& !completed.has(currentPage.eventId)
|
||||
) {
|
||||
activeInteraction = {
|
||||
type: 'event',
|
||||
id: currentPage.eventId,
|
||||
eventId: currentPage.eventId,
|
||||
pageId: currentPage.pageId,
|
||||
actorInstanceId: currentPage.actorInstanceId || '',
|
||||
actorHotspot: currentPage.actorHotspot || null,
|
||||
}
|
||||
} else if (
|
||||
currentPage.type === 'emotion'
|
||||
&& allEventsComplete
|
||||
&& !chapterFinished
|
||||
) {
|
||||
activeInteraction = {
|
||||
type: 'emotion',
|
||||
id: currentPage.emotionMomentId,
|
||||
emotionMomentId: currentPage.emotionMomentId,
|
||||
pageId: currentPage.pageId,
|
||||
actorHotspot: currentPage.actorHotspot || null,
|
||||
}
|
||||
}
|
||||
|
||||
const activeHotspots = (
|
||||
activeInteraction
|
||||
&& activeInteraction.actorHotspot
|
||||
)
|
||||
? [{
|
||||
interactionId: activeInteraction.id,
|
||||
pageId: activeInteraction.pageId,
|
||||
actorHotspot: activeInteraction.actorHotspot,
|
||||
}]
|
||||
: []
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
currentPage,
|
||||
currentPageId: currentPage.pageId,
|
||||
currentPageIndex,
|
||||
unlockedPageIndex,
|
||||
unlockedPageIds: pages
|
||||
.slice(0, unlockedPageIndex + 1)
|
||||
.map((page) => page.pageId),
|
||||
completedEventIds,
|
||||
allEventsComplete,
|
||||
chapterFinished,
|
||||
canGoPrevious: currentPageIndex > 0,
|
||||
canGoNext: currentPageIndex < unlockedPageIndex,
|
||||
nextPageLocked: (
|
||||
currentPageIndex < pages.length - 1
|
||||
&& currentPageIndex >= unlockedPageIndex
|
||||
),
|
||||
activeInteraction,
|
||||
activeHotspots,
|
||||
pageAccess: pages.map((page, index) => ({
|
||||
pageId: page.pageId,
|
||||
pageIndex: index,
|
||||
type: page.type,
|
||||
unlocked: index <= unlockedPageIndex,
|
||||
current: index === currentPageIndex,
|
||||
interactionEnabled: Boolean(
|
||||
activeInteraction
|
||||
&& activeInteraction.pageId === page.pageId
|
||||
),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function progressFromState(state) {
|
||||
return {
|
||||
currentPageId: state.currentPageId,
|
||||
completedEventIds: [...state.completedEventIds],
|
||||
chapterFinished: state.chapterFinished,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one chapter from the shared v1 storage object.
|
||||
*
|
||||
* `comicReaderByChapter` is the canonical reader cursor. The older
|
||||
* completedHotspots/completedChapters/lastPageId fields remain mirrored for
|
||||
* catalog compatibility and safe migration from already-installed builds.
|
||||
*/
|
||||
function readComicReaderProgress(storageProgress = {}, model = {}, chapterNumber) {
|
||||
const chapterId = cleanString(model.chapterId)
|
||||
const storedByChapter = (
|
||||
storageProgress.comicReaderByChapter
|
||||
&& typeof storageProgress.comicReaderByChapter === 'object'
|
||||
)
|
||||
? storageProgress.comicReaderByChapter
|
||||
: {}
|
||||
const storedChapter = (
|
||||
chapterId
|
||||
&& storedByChapter[chapterId]
|
||||
&& typeof storedByChapter[chapterId] === 'object'
|
||||
)
|
||||
? storedByChapter[chapterId]
|
||||
: {}
|
||||
const legacyHotspots = (
|
||||
storageProgress.completedHotspots
|
||||
&& typeof storageProgress.completedHotspots === 'object'
|
||||
&& Array.isArray(storageProgress.completedHotspots[chapterId])
|
||||
)
|
||||
? storageProgress.completedHotspots[chapterId]
|
||||
: []
|
||||
const legacyFinished = (
|
||||
Array.isArray(storageProgress.completedChapters)
|
||||
&& storageProgress.completedChapters.includes(chapterId)
|
||||
)
|
||||
const chapterFinished = (
|
||||
typeof storedChapter.chapterFinished === 'boolean'
|
||||
? storedChapter.chapterFinished
|
||||
: legacyFinished
|
||||
)
|
||||
const isLastChapter = (
|
||||
Number(storageProgress.lastChapter) === Number(chapterNumber)
|
||||
)
|
||||
|
||||
return {
|
||||
currentPageId: (
|
||||
cleanString(storedChapter.currentPageId)
|
||||
|| (isLastChapter ? cleanString(storageProgress.lastPageId) : '')
|
||||
|| (chapterFinished ? cleanString(model.lastPageId) : '')
|
||||
|| cleanString(model.firstPageId)
|
||||
),
|
||||
completedEventIds: Array.isArray(storedChapter.completedEventIds)
|
||||
? [...storedChapter.completedEventIds]
|
||||
: [...legacyHotspots],
|
||||
chapterFinished,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge only the current chapter back into shared storage.
|
||||
*
|
||||
* Other chapters and unrelated product fields are copied through untouched.
|
||||
* The supplied reader progress is normalized again here so stale `finished`
|
||||
* flags and out-of-order future event ids cannot be persisted.
|
||||
*/
|
||||
function mergeComicReaderProgress(
|
||||
storageProgress = {},
|
||||
model = {},
|
||||
chapterNumber,
|
||||
readerProgress = {},
|
||||
) {
|
||||
const chapterId = cleanString(model.chapterId)
|
||||
if (!chapterId || !isEightPageComicModel(model)) {
|
||||
return { ...storageProgress }
|
||||
}
|
||||
const state = buildComicReaderState(model, readerProgress)
|
||||
const normalized = progressFromState(state)
|
||||
const completedHotspots = (
|
||||
storageProgress.completedHotspots
|
||||
&& typeof storageProgress.completedHotspots === 'object'
|
||||
)
|
||||
? { ...storageProgress.completedHotspots }
|
||||
: {}
|
||||
const comicReaderByChapter = (
|
||||
storageProgress.comicReaderByChapter
|
||||
&& typeof storageProgress.comicReaderByChapter === 'object'
|
||||
)
|
||||
? { ...storageProgress.comicReaderByChapter }
|
||||
: {}
|
||||
const completedChapters = Array.isArray(storageProgress.completedChapters)
|
||||
? storageProgress.completedChapters.filter(
|
||||
(storedChapterId) => storedChapterId !== chapterId,
|
||||
)
|
||||
: []
|
||||
|
||||
completedHotspots[chapterId] = [...normalized.completedEventIds]
|
||||
comicReaderByChapter[chapterId] = normalized
|
||||
if (normalized.chapterFinished) completedChapters.push(chapterId)
|
||||
|
||||
return {
|
||||
...storageProgress,
|
||||
completedHotspots,
|
||||
completedChapters,
|
||||
comicReaderByChapter,
|
||||
lastChapter: Number(chapterNumber) || model.chapterNumber || 1,
|
||||
lastPageId: normalized.currentPageId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure transition helper. Completing an interaction unlocks the following
|
||||
* page but never turns it automatically, so the reader still controls the
|
||||
* lianhuanhua rhythm.
|
||||
*/
|
||||
function applyComicReaderAction(model, progress = {}, action = {}) {
|
||||
const state = buildComicReaderState(model, progress)
|
||||
if (!state.valid) return progressFromState(state)
|
||||
const type = cleanString(action.type)
|
||||
let nextProgress = progressFromState(state)
|
||||
|
||||
if (type === 'previous-page' && state.canGoPrevious) {
|
||||
nextProgress.currentPageId = comicPages(model)[
|
||||
state.currentPageIndex - 1
|
||||
].pageId
|
||||
} else if (type === 'next-page' && state.canGoNext) {
|
||||
nextProgress.currentPageId = comicPages(model)[
|
||||
state.currentPageIndex + 1
|
||||
].pageId
|
||||
} else if (type === 'open-page') {
|
||||
const targetIndex = pageIndex(model, action.pageId)
|
||||
if (targetIndex >= 0 && targetIndex <= state.unlockedPageIndex) {
|
||||
nextProgress.currentPageId = comicPages(model)[targetIndex].pageId
|
||||
}
|
||||
} else if (
|
||||
type === 'complete-event'
|
||||
&& state.activeInteraction
|
||||
&& state.activeInteraction.type === 'event'
|
||||
&& state.activeInteraction.eventId === cleanString(action.eventId)
|
||||
) {
|
||||
nextProgress.completedEventIds = [
|
||||
...state.completedEventIds,
|
||||
state.activeInteraction.eventId,
|
||||
]
|
||||
} else if (
|
||||
type === 'complete-emotion'
|
||||
&& state.activeInteraction
|
||||
&& state.activeInteraction.type === 'emotion'
|
||||
&& state.activeInteraction.emotionMomentId
|
||||
=== cleanString(action.emotionMomentId)
|
||||
) {
|
||||
nextProgress.chapterFinished = true
|
||||
}
|
||||
|
||||
return progressFromState(buildComicReaderState(model, nextProgress))
|
||||
}
|
||||
|
||||
/**
|
||||
* A declared illustration path is not proof that a finished page exists.
|
||||
* Callers must explicitly confirm formalAvailable after package/manifest
|
||||
* validation. Otherwise the state is visibly a fallback, never "formal art".
|
||||
*/
|
||||
function buildComicArtAvailability(page = {}, options = {}) {
|
||||
const formalAsset = cleanString(
|
||||
options.formalAsset || page.illustrationAsset,
|
||||
)
|
||||
const fallbackAsset = cleanString(
|
||||
options.fallbackAsset || page.fallbackAsset,
|
||||
)
|
||||
if (options.formalAvailable === true && formalAsset) {
|
||||
return {
|
||||
mode: 'formal-art',
|
||||
src: formalAsset,
|
||||
formalAsset,
|
||||
fallbackAsset,
|
||||
isFormalArt: true,
|
||||
isFallback: false,
|
||||
}
|
||||
}
|
||||
if (fallbackAsset) {
|
||||
return {
|
||||
mode: 'scene-fallback',
|
||||
src: fallbackAsset,
|
||||
formalAsset,
|
||||
fallbackAsset,
|
||||
isFormalArt: false,
|
||||
isFallback: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
mode: 'text-only',
|
||||
src: '',
|
||||
formalAsset,
|
||||
fallbackAsset: '',
|
||||
isFormalArt: false,
|
||||
isFallback: true,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COMIC_PAGE_TYPES,
|
||||
applyComicReaderAction,
|
||||
buildComicArtAvailability,
|
||||
buildComicReaderState,
|
||||
getUnlockedPageIndex,
|
||||
isEightPageComicModel,
|
||||
mergeComicReaderProgress,
|
||||
normalizeCompletedEventIds,
|
||||
pageIndex,
|
||||
readComicReaderProgress,
|
||||
}
|
||||
Reference in New Issue
Block a user