This commit is contained in:
大哥大哥的大哥哥
2026-09-09 14:47:29 +08:00
parent 4d9da40abd
commit 65755c9e96
832 changed files with 412085 additions and 0 deletions
@@ -0,0 +1,30 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { listFiles, sha256 } from './tang-detective-native-plugin.mjs'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const sourceDirectory = process.argv[2]
if (!sourceDirectory) throw new Error('Usage: node build/import-tang-detective-native.mjs <source-miniprogram-directory>')
const targetDirectory = path.join(projectRoot, 'native/tang-detective')
const allowedRootFiles = new Set(['app.json', 'app.wxss'])
const allowedDirectories = /^(?:assets|data|utils|pages|package-[a-z0-9-]+)\//
const selectedFiles = listFiles(sourceDirectory).filter(relative => allowedRootFiles.has(relative) || allowedDirectories.test(relative))
if (!selectedFiles.includes('app.json') || !selectedFiles.includes('pages/home/home.js')) throw new Error('Source is not the expected native Tang Detective program')
const files = selectedFiles.map(relative => {
const bytes = fs.readFileSync(path.join(sourceDirectory, relative))
const destination = path.join(targetDirectory, relative)
const hash = sha256(bytes)
if (fs.existsSync(destination) && sha256(fs.readFileSync(destination)) !== hash) {
throw new Error(`Existing snapshot differs; refusing to overwrite: ${relative}`)
}
return { path: relative, bytes: bytes.length, sha256: hash }
})
for (const file of files) {
const destination = path.join(targetDirectory, file.path)
fs.mkdirSync(path.dirname(destination), { recursive: true })
fs.copyFileSync(path.join(sourceDirectory, file.path), destination)
}
const manifest = { version: 1, excludedRootFiles: ['app.js', 'sitemap.json'], files }
fs.writeFileSync(path.join(projectRoot, 'build/tang-detective-source-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
console.log(`Imported ${files.length} byte-preserved native files; no source App or project configuration was imported.`)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,169 @@
{
"schemaVersion": 1,
"uploadRunId": "b9ab703a-49a6-4b6b-ac34-979eca1d4828",
"baseUrl": "https://gz-1349751149.cos.ap-guangzhou.myqcloud.com",
"imageDirectory": "uploads/images/20260908/",
"audioDirectory": "uploads/voice/20260908/",
"firstImage": "202609081718480ee488780.jpg",
"images": [
"20260908172953806b83817.jpg",
"20260908172954a3c700294.jpg",
"202609081729547cbb37718.jpg",
"2026090817295416f2f2841.jpg",
"202609081729541a4a74577.jpg",
"20260908172954fef263003.jpg",
"20260908172954a75561985.jpg",
"20260908172954578d88427.jpg",
"2026090817295420b991364.jpg",
"20260908172954af5606377.jpg",
"2026090817303974c831720.jpg",
"20260908173040de54b5498.jpg",
"2026090817303983bc35301.jpg",
"20260908173039ad5bc2437.jpg",
"2026090817303985ee07761.jpg",
"2026090817303994b4b1401.jpg",
"20260908173039628910741.jpg",
"20260908173039668f11872.jpg",
"202609081730407a8a27957.jpg",
"202609081730409483f1003.jpg",
"2026090817310719bb62047.jpg",
"202609081731073a6bf7270.jpg",
"20260908173107b3c447822.jpg",
"202609081731074bb984948.jpg",
"202609081731073a9ad6102.jpg",
"202609081731076ef7d1167.jpg",
"202609081731075b3e30712.jpg",
"202609081731072dfe55233.jpg",
"202609081731078ac2d7004.jpg",
"20260908173107466700017.jpg",
"20260908173112ff8887242.jpg",
"20260908173112e49c90827.jpg",
"20260908173112365a59120.jpg",
"20260908173112d30437983.jpg",
"202609081731127140b3844.jpg",
"20260908173112353247130.jpg",
"20260908173112169de0256.jpg",
"20260908173112a505f4772.jpg",
"202609081731122b3399354.jpg",
"20260908173112c4da43174.jpg",
"2026090817311649e2a9550.jpg",
"20260908173116162c66104.jpg",
"20260908173116a53f24003.jpg",
"202609081731162a2021285.jpg",
"20260908173116813c87413.jpg",
"20260908173116bfec43397.jpg",
"20260908173116dbdc29125.jpg",
"20260908173116c5c618420.jpg",
"2026090817311649ffe3535.jpg",
"20260908173116ecae73791.jpg",
"202609081731352d7e82556.jpg",
"2026090817313559ab83276.jpg",
"2026090817313533e949160.jpg",
"202609081731350aa1d5788.jpg",
"202609081731357238e1677.jpg",
"20260908173135b00ff3493.jpg",
"20260908173135595af4252.jpg",
"202609081731352d9fb1212.jpg",
"2026090817313638f109229.jpg",
"2026090817313660b804375.jpg",
"20260908173138a61767853.jpg",
"20260908173138fa0762681.jpg",
"2026090817313839b279656.jpg",
"20260908173138dff5a5536.jpg",
"20260908173138191298369.jpg",
"20260908173138ec8e94150.jpg",
"20260908173138b2b761219.jpg",
"20260908173138c9d001251.jpg",
"20260908173139f46863978.jpg",
"2026090817313967a307026.jpg",
"202609081731411da1c2125.jpg",
"20260908173141cf0c01964.jpg",
"20260908173142053ed9914.jpg",
"20260908173142ef5784670.jpg",
"2026090817314274cfe2605.jpg",
"202609081731420bc315230.jpg",
"20260908173142d16d12935.jpg",
"202609081731425a0819698.jpg",
"202609081731429844c1305.jpg",
"20260908173142346106887.jpg",
"20260908173145f82762147.jpg",
"202609081731451a0a48902.jpg",
"20260908173145e43cc7774.jpg",
"20260908173145050809349.jpg",
"202609081731456267e0864.jpg",
"202609081731451e62d8082.jpg",
"2026090817314563ce19085.jpg",
"20260908173145234414957.jpg",
"2026090817314577b3d2877.jpg",
"20260908173145d06c49932.jpg",
"202609081732037cfc44506.jpg",
"202609081732034eb7f0368.jpg",
"202609081732039e6701414.jpg",
"2026090817320373f998007.jpg",
"20260908173203dace72023.jpg",
"202609081732035f2e17452.jpg",
"20260908173203e27952820.jpg",
"202609081732032db544840.jpg",
"202609081732035c9d61064.jpg",
"202609081732037c8583818.jpg",
"2026090817320602c476049.jpg",
"20260908173206dae776115.jpg",
"202609081732065676e2434.jpg",
"20260908173206a7ebe7583.jpg",
"20260908173206cb3721071.jpg",
"20260908173206b42891414.jpg",
"2026090817320615d6c3156.jpg",
"20260908173206946d11690.jpg",
"202609081732067211a5043.jpg",
"2026090817320628b5f1545.jpg",
"20260908173210f33956967.jpg",
"20260908173210dcc904301.jpg",
"202609081732104dda17591.jpg",
"202609081732103e1737129.jpg",
"20260908173210052871552.jpg",
"202609081732106acce6348.jpg",
"2026090817321063e243356.jpg",
"202609081732103c91a8938.jpg",
"20260908173210e3e400579.jpg",
"20260908173210a1bfa8588.jpg",
"2026090817321402ddc8471.jpg",
"20260908173214a4a8d4074.jpg",
"202609081732148dba50220.jpg",
"202609081732146ee292192.jpg",
"2026090817321455aa99619.jpg",
"20260908173214765e24253.jpg",
"20260908173214ac7b62068.jpg",
"202609081732143d4070553.jpg",
"2026090817321405dc63371.jpg",
"20260908173214896801232.jpg",
"20260908173218201a61275.jpg",
"20260908173218191279663.jpg",
"202609081732189a41f9598.jpg",
"202609081732189ef359918.jpg",
"20260908173218765170829.jpg"
],
"audio": [
"1788859559652-e90yvvdk.mp3", "1788859640935-23ixzy68.mp3",
"1788859681917-n7wb94nh.mp3", "1788859683117-o8y8c1ap.mp3",
"1788859684272-vx02qyha.mp3", "1788859685309-bq8hczp2.mp3",
"1788859686361-f4myg1jo.mp3", "1788859687423-at9k5kro.mp3",
"1788859688761-rv0vx59b.mp3", "1788859689813-2u5eyx28.mp3",
"1788859705645-v0njwhr6.mp3", "1788859706721-j2czunoh.mp3",
"1788859707781-5eif2sva.mp3", "1788859708857-lgkflk7r.mp3",
"1788859709963-w40dseme.mp3", "1788859710994-9ja52nj4.mp3",
"1788859712054-pqgtblz7.mp3", "1788859713116-rtg8ou4j.mp3",
"1788859714260-41ehj12t.mp3", "1788859715307-zvzm6c52.mp3",
"1788859716357-ddolkq84.mp3", "1788859717407-joxtjydj.mp3",
"1788859718453-7taqslda.mp3", "1788859719556-4h5g177p.mp3",
"1788859720601-tm2rratq.mp3", "1788859735790-h24dj5jw.mp3",
"1788859736846-tu8raupk.mp3", "1788859738725-lzs4obsj.mp3",
"1788859739954-0sdopsv6.mp3", "1788859741040-tnxabkmf.mp3",
"1788859742080-q1gq96gr.mp3", "1788859743113-kgit8i2r.mp3",
"1788859744174-pg41290p.mp3", "1788859745258-2u7m9wc0.mp3",
"1788859746348-qrqomq07.mp3", "1788859747502-csqqmkys.mp3",
"1788859748680-hx5gjcjt.mp3", "1788859749772-njzzx6f8.mp3",
"1788859750856-ubrs0bjv.mp3", "1788859751906-i84qzugn.mp3",
"1788859752961-k632st94.mp3", "1788859754110-nyj2b3le.mp3",
"1788859755676-8bbjnyhb.mp3", "1788859756823-r37bn776.mp3"
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,251 @@
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import vm from 'node:vm'
import { fileURLToPath } from 'node:url'
import { MEDIA_TYPES as TYPES, isMediaFile, readSourceManifest, validateSourceSnapshot, SOURCE_MANIFEST_PATH } from './tang-detective-source-validation.mjs'
export { isMediaFile, SOURCE_MANIFEST_PATH } from './tang-detective-source-validation.mjs'
const buildDirectory = path.dirname(fileURLToPath(import.meta.url))
export const DEFAULT_MEDIA_MANIFEST_PATH = path.join(buildDirectory, 'tang-detective-cos-manifest.json')
const RUNTIME_HELPER_PATH = path.resolve(buildDirectory, '../native-adapter/tang-detective/utils/cosMedia.js')
const SHA256 = /^[a-f0-9]{64}$/
const SERVER_DESTINATION = { bucket: 'gz-1349751149', region: 'ap-guangzhou',
baseUrl: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com' }
const hash = value => crypto.createHash('sha256').update(value).digest('hex')
const json = value => `${JSON.stringify(value, null, 2)}\n`
function assert(condition, message) {
if (!condition) throw new Error(`Invalid Tang Detective COS manifest: ${message}`)
}
function safeRelative(value) {
return typeof value === 'string' && value.length > 0 && !value.startsWith('/')
&& !/[\\?#\u0000-\u0020]/.test(value)
&& value.split('/').every(part => part && part !== '.' && part !== '..')
}
function httpsUrl(value) {
try {
assert(typeof value === 'string' && !/["'`<>\\\s]/.test(value), 'URL contains unsafe literal characters')
const parsed = new URL(value)
assert(parsed.protocol === 'https:' && !parsed.username && !parsed.password
&& !parsed.search && !parsed.hash, 'URLs must use unsigned HTTPS')
return parsed
} catch (error) {
throw new Error(`Invalid Tang Detective COS manifest: invalid HTTPS URL (${error.message})`)
}
}
function validUploadDate(value) {
if (!/^20\d{6}$/.test(value)) return false
const isoDate = `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`
const parsed = new Date(`${isoDate}T00:00:00.000Z`)
return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === isoDate
}
function confirmedServerObject(entry, kind) {
if (kind === 'image' && path.extname(entry.sourcePath) === '.jpg') {
// UploadService::image and storage/engine/Server::buildSaveName.
const match = /^uploads\/images\/(20\d{6})\/(20\d{6})([0-2]\d)([0-5]\d)([0-5]\d)[a-f0-9]{5}\d{4}\.jpg$/.exec(entry.objectKey)
return Boolean(match && validUploadDate(match[1]) && match[1] === match[2] && Number(match[3]) < 24)
}
if (kind === 'audio' && path.extname(entry.sourcePath) === '.mp3') {
// Actual authenticated voice upload, using the admin direct-uploader's
// Date.now() + Math.random().toString(36).slice(2, 10) naming.
const match = /^uploads\/voice\/(20\d{6})\/([1-9]\d{12})-[a-z0-9]{8}\.mp3$/.exec(entry.objectKey)
return Boolean(match && validUploadDate(match[1]))
}
return false
}
export function validateCosMediaManifest(mediaManifest, { sourceDirectory, sourceManifestPath = SOURCE_MANIFEST_PATH,
pruneReceipt, pruneReceiptPath } = {}) {
assert(mediaManifest && [1, 2].includes(mediaManifest.schemaVersion), 'schemaVersion must be 1 or 2')
const serverManaged = mediaManifest.schemaVersion === 2
if (serverManaged) {
assert(typeof mediaManifest.uploadRunId === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(mediaManifest.uploadRunId), 'uploadRunId is required')
}
const source = readSourceManifest(sourceManifestPath)
assert(mediaManifest.sourceManifestSha256 === source.manifestSha256, 'source manifest SHA-256 mismatch')
const sourceManifest = source.manifest
const expected = new Map(sourceManifest.files.filter(file => isMediaFile(file.path)).map(file => [file.path, file]))
const destination = mediaManifest.destination
assert(destination && typeof destination.bucket === 'string' && destination.bucket.trim()
&& typeof destination.region === 'string' && destination.region.trim(), 'destination bucket and region are required')
const base = httpsUrl(destination.baseUrl)
if (serverManaged) {
assert(Object.entries(SERVER_DESTINATION).every(([key, value]) => destination[key] === value),
'schema 2 destination must be the confirmed xuetang COS bucket, region and origin')
}
assert(Array.isArray(mediaManifest.entries) && mediaManifest.entries.length === expected.size, 'media coverage is incomplete')
const entries = new Map()
const objects = new Map()
for (const entry of mediaManifest.entries) {
assert(entry && safeRelative(entry.sourcePath) && expected.has(entry.sourcePath), 'unknown or unsafe sourcePath')
assert(!entries.has(entry.sourcePath), `duplicate sourcePath: ${entry.sourcePath}`)
const recorded = expected.get(entry.sourcePath)
const [kind, contentType] = TYPES[path.extname(entry.sourcePath).toLowerCase()]
assert(entry.kind === kind && entry.contentType === contentType, `media type mismatch: ${entry.sourcePath}`)
assert(SHA256.test(entry.sha256) && entry.sha256 === recorded.sha256
&& Number.isSafeInteger(entry.bytes) && entry.bytes === recorded.bytes, `source metadata mismatch: ${entry.sourcePath}`)
assert(entry.uploaded === true && entry.remoteVerifiedSha256 === entry.sha256, `remote verification missing: ${entry.sourcePath}`)
assert(entry.publicReadVerified === true, `public read verification missing: ${entry.sourcePath}`)
if (serverManaged) {
// Only these two media routes have current upload evidence. New kinds
// require their own verified route; schema 1 stays content-addressed.
assert(confirmedServerObject(entry, kind),
`object key does not match confirmed server upload contract: ${entry.sourcePath}`)
} else {
const expectedObjectKey = `tang-detective/season-01/media-v1/${entry.sha256}${path.extname(entry.sourcePath)}`
assert(entry.objectKey === expectedObjectKey, `object key does not match immutable media contract: ${entry.sourcePath}`)
}
if (kind === 'audio' || kind === 'video') {
assert(entry.rangeVerified === true, `media range verification missing: ${entry.sourcePath}`)
}
const url = httpsUrl(entry.url)
const expectedUrl = `${base.href.replace(/\/$/, '')}/${entry.objectKey.split('/').map(encodeURIComponent).join('/')}`
assert(url.href === expectedUrl && entry.url === url.href, `URL does not match destination and object key: ${entry.sourcePath}`)
const identity = `${entry.sha256}:${entry.bytes}:${entry.contentType}`
assert(!objects.has(entry.url) || objects.get(entry.url) === identity, `conflicting object contents: ${entry.sourcePath}`)
objects.set(entry.url, identity)
entries.set(entry.sourcePath, Object.freeze({ ...entry }))
}
const media = { entries, sourceManifestSha256: mediaManifest.sourceManifestSha256,
manifestSha256: hash(json(mediaManifest)), destination: { ...destination }, objectCount: objects.size,
uploadRunId: mediaManifest.uploadRunId }
// Validate all surviving bytes, and permit omissions only after validating a
// complete local prune receipt bound to this exact verified upload manifest.
media.sourceSnapshot = validateSourceSnapshot({ sourceDirectory, sourceManifestPath, media, pruneReceipt, pruneReceiptPath })
return media
}
export function loadCosMediaManifest({ mediaManifest, mediaManifestPath = DEFAULT_MEDIA_MANIFEST_PATH, sourceDirectory, sourceManifestPath,
pruneReceipt, pruneReceiptPath } = {}) {
// Explicit null is useful for a local/offline comparison without touching a
// verified manifest owned by another task. Undefined uses automatic discovery.
if (mediaManifest === null) return null
if (mediaManifest === undefined) {
if (!fs.existsSync(mediaManifestPath)) return null
const stat = fs.lstatSync(mediaManifestPath)
assert(stat.isFile() && !stat.isSymbolicLink(), 'media manifest must be a regular file')
mediaManifest = JSON.parse(fs.readFileSync(mediaManifestPath, 'utf8'))
}
return validateCosMediaManifest(mediaManifest, { sourceDirectory, sourceManifestPath, pruneReceipt, pruneReceiptPath })
}
function relativeHelper(relative, helper = 'utils/cosMedia.js') {
const result = path.posix.relative(path.posix.dirname(relative), helper)
return result.startsWith('.') ? result : `./${result}`
}
function replaceOnce(source, before, after, filename) {
assert(source.split(before).length === 2, `conversion anchor changed: ${filename}`)
return source.replace(before, after)
}
function evaluateData(source, filename) {
const context = { module: { exports: {} } }
vm.runInNewContext(source, context, { filename, timeout: 1000 })
return context.module.exports
}
export function applyCosMediaOutput(files, media, { namespace = 'tang-detective' } = {}) {
if (!media) return
const byUrl = new Map([...media.entries.values()].map(entry => [entry.url, entry]))
const lookup = value => {
if (byUrl.has(value)) return byUrl.get(value)
let relative = value.replace(/^\//, '')
if (relative.startsWith(`${namespace}/`)) relative = relative.slice(namespace.length + 1)
return media.entries.get(relative)
}
const staticPath = /(["'`])(\/(?:[a-z0-9-]+\/)?(?:assets|package-[a-z0-9-]+)\/[^"'`\n$]*\.(?:jpe?g|png|webp|gif|svg|avif|mp3|wav|aac|m4a|ogg|mp4|webm|mov))\1/gi
for (const [relative, bytes] of files) {
if (isMediaFile(relative)) { files.delete(relative); continue }
if (!/\.(?:js|json|wxml|wxss|wxs)$/.test(relative) || relative === 'utils/cosMedia.js') continue
let source = bytes.toString('utf8')
const helper = `require(${JSON.stringify(relativeHelper(relative))})`
if (relative.endsWith('/data/releaseAssetManifest.js')) {
const exported = evaluateData(source, relative)
for (const asset of Object.values(exported.releaseAssets)) {
if (!asset.localSeed) continue
const entry = lookup(asset.localSeed)
assert(entry && entry.sha256 === asset.sha256 && entry.kind === asset.kind, `release asset mismatch: ${relative}`)
asset.localSeed = ''
asset.remoteUrl = entry.url
}
const share = media.entries.get('assets/share/guixiang-story-share-preview-v1.jpg')
assert(share, 'share preview entry is missing')
exported.releaseAssets['image.tang.share-preview'] = {
kind: 'image', localSeed: '', remoteUrl: share.url,
remotePath: share.objectKey, sha256: share.sha256,
}
files.set(relative, Buffer.from(`module.exports = ${json(exported)}`))
continue
}
source = source.replace(staticPath, (match, quote, value) => {
const entry = lookup(value)
assert(entry, `unregistered static media: ${relative}: ${value}`)
return `${quote}${entry.url}${quote}`
})
if (relative.endsWith('/pages/chapter/chapterPages.js')) {
source = replaceOnce(source, ' pageSequence = attachPlayableVisuals(pageSequence, chapter)',
` pageSequence = ${helper}.mapMedia(pageSequence)\n pageSequence = attachPlayableVisuals(pageSequence, chapter)`, relative)
}
if (relative.endsWith('/data/playableVisualPolicy.js')) {
const previous = ' && clean(releaseAsset.localSeed)\n && clean(releaseAsset.localSeed) === clean(page.illustrationAsset),'
source = replaceOnce(source, previous,
` && ${helper}.verifiedUrl(releaseAsset.remoteUrl, releaseAsset.sha256, 'image')\n && clean(releaseAsset.remoteUrl) === clean(page.illustrationAsset),`, relative)
}
if (relative.endsWith('/utils/comicPageModel.js')) {
source = replaceOnce(source, ' if (localSeed) {\n return {\n src: localSeed,',
` const remoteUrl = releaseAsset && ${helper}.verifiedUrl(\n releaseAsset.remoteUrl, releaseAsset.sha256, 'image',\n )\n if (localSeed || remoteUrl) {\n return {\n src: localSeed || remoteUrl,`, relative)
source = replaceOnce(source, " source: 'local-seed',", " source: localSeed ? 'local-seed' : 'remote-url',", relative)
// Keep getReviewedAudioSrc and isPackagedPath fail-closed. Approved
// remote full-page audio uses the existing asynchronous verified player.
}
if (relative.endsWith('/utils/assetManager.js')) {
source = replaceOnce(source, ' if (!cdnBaseUrl || !asset.remotePath) {',
` const remoteUrl = asset.remoteUrl\n ? ${helper}.verifiedUrl(asset.remoteUrl, asset.sha256, asset.kind) : ''\n if (asset.remoteUrl && !remoteUrl) return fallback(assetId, 'remote-integrity-failed')\n if (!remoteUrl && (!cdnBaseUrl || !asset.remotePath)) {`, relative)
source = replaceOnce(source, 'download(assetPlatform, joinPath(cdnBaseUrl, asset.remotePath))',
'download(assetPlatform, remoteUrl || joinPath(cdnBaseUrl, asset.remotePath))', relative)
}
if (relative.endsWith('/pages/chapter/chapter.js')) {
const start = source.indexOf(' prepareSharePreview() {')
const end = source.indexOf(' loadChapter(', start)
assert(start !== -1 && end > start, `share conversion anchor changed: ${relative}`)
source = source.slice(0, start)
+ ` prepareSharePreview() {\n return ${helper}.prepareSharePreview(this, this.getAssetManager())\n },\n\n`
+ source.slice(end)
source = replaceOnce(source, " const localPath = String(this.data.sharePreviewLocalPath || '').trim()",
" const localPath = '' // Cached share bytes are revalidated in prepareSharePreview.", relative)
source = replaceOnce(source, ' if (!pageData.currentPage) return',
` if (!pageData.currentPage) return\n ${helper}.beginComicImage(this, pageData)`, relative)
source = replaceOnce(source, ' onComicImageLoad() {',
` onComicImageLoad(event) {\n if (!${helper}.isCurrentComicImageEvent(this, event)) return`, relative)
const errorStart = source.indexOf(' onComicImageError() {')
const errorEnd = source.indexOf(' applyLayoutMetrics(', errorStart)
assert(errorStart !== -1 && errorEnd > errorStart, `image error conversion anchor changed: ${relative}`)
let handler = source.slice(errorStart, errorEnd)
handler = replaceOnce(handler, ' onComicImageError() {',
` onComicImageError(event) {\n if (!${helper}.recordComicImageError(this, event)) return`, relative)
handler = replaceOnce(handler, ' actorFallback\n',
` actorFallback\n && ${helper}.canUseComicFallback(this, actorFallback)\n`, relative)
handler = replaceOnce(handler, ' fallback\n',
` fallback\n && ${helper}.canUseComicFallback(this, fallback)\n`, relative)
handler = handler.replaceAll('this.setData({', `${helper}.applyComicImageFallback(this, {`)
source = source.slice(0, errorStart) + handler + source.slice(errorEnd)
}
if (relative.endsWith('/pages/chapter/chapter.wxml')) {
assert(source.includes('binderror="onComicImageError"'), `image event conversion anchor changed: ${relative}`)
source = source.replaceAll('binderror="onComicImageError"',
'data-cos-page-id="{{currentPageId}}" data-cos-image-src="{{comicImageSrc}}" data-cos-image-generation="{{comicImageGeneration}}" binderror="onComicImageError"')
}
files.set(relative, Buffer.from(source))
}
files.set('utils/cosMedia.js', fs.readFileSync(RUNTIME_HELPER_PATH))
files.set('utils/cosMediaManifest.js', Buffer.from(`module.exports = ${json({
namespace,
entries: [...media.entries.values()].map(({ sourcePath, kind, bytes, sha256, url }) => ({ sourcePath, kind, bytes, sha256, url })),
})}`))
}
@@ -0,0 +1,559 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import vm from 'node:vm'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { copyNativeProgram, listFiles, sha256 } from './tang-detective-native-plugin.mjs'
import { isMediaFile, loadCosMediaManifest, validateCosMediaManifest, SOURCE_MANIFEST_PATH } from './tang-detective-cos-media.mjs'
import { validateNativeOutput } from './validate-tang-detective-output.mjs'
import { createSyntheticSource, fakeVerifiedManifest, fakePruneReceipt } from './tang-detective-test-fixtures.mjs'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const sourceDirectory = path.join(projectRoot, 'native/tang-detective')
const overlayDirectory = path.join(projectRoot, 'native-adapter/tang-detective')
const sourceBytes = fs.readFileSync(SOURCE_MANIFEST_PATH)
const sourceManifest = JSON.parse(sourceBytes)
const sourceRequire = createRequire(path.join(projectRoot, 'source-cos-test.cjs'))
const sourceMedia = sourceManifest.files.filter(file => isMediaFile(file.path))
function write(directory, relative, value) {
const filename = path.join(directory, relative)
fs.mkdirSync(path.dirname(filename), { recursive: true })
fs.writeFileSync(filename, value)
}
function fixture(t, { remote = true } = {}) {
const temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tang-cos-unit-')))
// Every path removed below was created exclusively by this test.
t.after(() => fs.rmSync(temporary, { recursive: true, force: true }))
const outputDirectory = path.join(temporary, 'output')
const source = createSyntheticSource(temporary)
write(outputDirectory, 'app.json', JSON.stringify({ pages: ['pages/index/index'], window: { navigationStyle: 'default' } }))
write(outputDirectory, 'app.js', '/* retained host entry */')
const mediaManifest = remote ? fakeVerifiedManifest(source) : null
const options = { sourceDirectory: source.sourceDirectory, sourceManifestPath: source.sourceManifestPath,
overlayDirectory, outputDirectory, mediaManifest }
const report = copyNativeProgram(options)
const root = path.join(outputDirectory, 'tang-detective')
const localRequire = createRequire(path.join(outputDirectory, 'test.cjs'))
return { ...source, temporary, root, options, report, outputDirectory, mediaManifest,
require: relative => localRequire(path.join(root, relative)) }
}
function mockPlatform(responses) {
const files = new Map()
const downloads = []
let sequence = 0
let failDownloads = false
const buffer = value => typeof value === 'string' ? Buffer.from(value) : Buffer.from(value)
const fsApi = {
mkdir: options => options.success({}),
access: options => files.has(options.path) ? options.success({}) : options.fail(new Error('missing')),
readFile(options) {
if (!files.has(options.filePath)) { options.fail(new Error('missing')); return }
const data = files.get(options.filePath)
options.success({ data: options.encoding === 'utf8' ? buffer(data).toString('utf8') : buffer(data) })
},
writeFile(options) { files.set(options.filePath, buffer(options.data)); options.success({}) },
saveFile(options) {
if (!files.has(options.tempFilePath)) { options.fail(new Error('missing')); return }
files.set(options.filePath, files.get(options.tempFilePath))
files.delete(options.tempFilePath)
options.success({ savedFilePath: options.filePath })
},
stat(options) { options.success({ stats: { size: buffer(files.get(options.path)).length } }) },
unlink(options) { files.delete(options.filePath); options.success({}) },
}
const platform = {
env: { USER_DATA_PATH: '/test-owned-user-data' },
getFileSystemManager: () => fsApi,
downloadFile(options) {
downloads.push(options.url)
if (failDownloads || !responses.has(options.url)) { options.fail(new Error('network unavailable')); return }
const body = responses.get(options.url)
const tempFilePath = `/test-owned-temp/${++sequence}`
files.set(tempFilePath, Buffer.from(body))
options.success({ statusCode: 200, tempFilePath, fileSize: body.length })
},
}
return { platform, files, downloads, failDownloads: value => { failDownloads = value } }
}
function managerFixture(context) {
const { releaseAssets } = context.require('package-game/data/releaseAssetManifest.js')
const managerApi = context.require('package-game/utils/assetManager.js')
const responses = new Map(context.mediaManifest.entries.map(entry => [entry.url, fs.readFileSync(path.join(context.sourceDirectory, entry.sourcePath))]))
const mock = mockPlatform(responses)
const manager = managerApi.createAssetManager({ manifest: releaseAssets, assetPlatform: mock.platform, cdnBaseUrl: '' })
return { ...mock, manager, managerApi, releaseAssets, responses }
}
test('verified manifest requires all 204 original paths, hashes, exact HTTPS objects and upload receipts', t => {
const context = fixture(t)
const media = validateCosMediaManifest(fakeVerifiedManifest(context), context.options)
assert.equal(media.entries.size, 204)
assert.equal(media.objectCount, 180)
const mutations = [
manifest => { manifest.entries.pop() },
manifest => { manifest.entries[1] = { ...manifest.entries[0] } },
manifest => { manifest.sourceManifestSha256 = '0'.repeat(64) },
manifest => { manifest.entries[0].sha256 = '0'.repeat(64) },
manifest => { manifest.entries[0].bytes += 1 },
manifest => { manifest.entries[0].remoteVerifiedSha256 = '0'.repeat(64) },
manifest => { manifest.entries[0].uploaded = false },
manifest => { manifest.entries[0].publicReadVerified = false },
manifest => { manifest.entries[0].url = manifest.entries[0].url.replace('https:', 'http:') },
manifest => { manifest.entries[0].url += '?signature=not-allowed' },
manifest => { manifest.entries[0].url = 'https://other.example.test/file.jpg' },
manifest => { manifest.destination.baseUrl = "https://tang-assets.example.test/quote'" },
manifest => { manifest.entries[0].sourcePath = '../outside.jpg' },
manifest => { manifest.entries[0].objectKey = 'mutable-name.jpg' },
manifest => {
const entry = manifest.entries[0]
entry.objectKey = `wrong-prefix/${entry.sha256}${path.extname(entry.sourcePath)}`
entry.url = `${manifest.destination.baseUrl}/${entry.objectKey}`
},
manifest => {
const entry = manifest.entries[0]
entry.objectKey = `tang-detective/season-01/media-v1/${entry.sha256.slice(0, 12)}${path.extname(entry.sourcePath)}`
entry.url = `${manifest.destination.baseUrl}/${entry.objectKey}`
},
manifest => { delete manifest.entries.find(entry => entry.kind === 'audio').rangeVerified },
manifest => { manifest.entries.find(entry => entry.kind === 'audio').rangeVerified = false },
manifest => { manifest.entries[0].contentType = 'text/html' },
]
for (const mutate of mutations) {
const manifest = fakeVerifiedManifest(context)
mutate(manifest)
assert.throws(() => validateCosMediaManifest(manifest, context.options), /Invalid Tang Detective COS manifest/)
}
})
test('schema 2 accepts only the confirmed COS origin and exact server image / voice object formats', t => {
const context = fixture(t)
const baseUrl = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com'
const manifest = { ...context.mediaManifest, schemaVersion: 2,
destination: { bucket: 'gz-1349751149', region: 'ap-guangzhou', baseUrl },
entries: context.mediaManifest.entries.map((entry, index) => {
const objectKey = entry.kind === 'image'
? `uploads/images/20260908/20260908171848abcde${String(index).padStart(4, '0')}.jpg`
: `uploads/voice/20260908/1788859559652-${String(index).padStart(8, '0')}.mp3`
return { ...entry, objectKey, url: `${baseUrl}/${objectKey}` }
}),
}
assert.equal(validateCosMediaManifest(manifest, context.options).entries.size, 204)
const mutations = [
value => { value.uploadRunId = '' },
value => { value.destination.bucket = 'other-bucket' },
value => { value.destination.region = 'ap-shanghai' },
value => { value.destination.baseUrl = 'https://different.example.test' },
value => { value.destination.baseUrl += '/uploads' },
value => { value.entries[0].objectKey = 'uploads/images/20260908/202609081718480ee488780.jpg?signature=x' },
value => { value.entries[0].objectKey = 'uploads/images/20260909/202609081718480ee488780.jpg' },
value => { value.entries[0].objectKey = 'uploads/images/20260230/202602301718480ee488780.jpg' },
value => { value.entries[0].objectKey = 'uploads/images/20260908/202609082918480ee488780.jpg' },
value => { value.entries[0].objectKey = 'uploads/images/20260908/mutable.jpg' },
value => { value.entries[0].objectKey = 'uploads/file/20260908/202609081718480ee488780.jpg' },
value => { value.entries[0].url = 'https://other.example.test/202609081718480ee488780.jpg' },
value => { value.entries[0].uploaded = false },
value => { value.entries[0].publicReadVerified = false },
value => { value.entries[0].remoteVerifiedSha256 = '0'.repeat(64) },
value => { value.entries.find(entry => entry.kind === 'audio').rangeVerified = false },
value => { value.entries.find(entry => entry.kind === 'audio').objectKey = 'uploads/images/20260908/1788859559652-e90yvvdk.mp3' },
value => { value.entries.find(entry => entry.kind === 'audio').objectKey = 'uploads/voice/20260908/1788859559652-e90yvvdk.mp4' },
value => { value.entries.find(entry => entry.kind === 'audio').objectKey = 'uploads/voice/20260908/1788859559652-unsafe_!.mp3' },
]
for (const mutate of mutations) {
const changed = structuredClone(manifest)
mutate(changed)
assert.throws(() => validateCosMediaManifest(changed, context.options), /Invalid Tang Detective COS manifest/)
}
// Confirm schema 1 is still unable to accept any server-managed object.
assert.throws(() => validateCosMediaManifest({ ...manifest, schemaVersion: 1 }, context.options), /immutable media contract/)
for (const entry of context.sourceMedia) fs.unlinkSync(path.join(context.sourceDirectory, entry.path))
const options = { ...context.options, mediaManifest: manifest, pruneReceipt: fakePruneReceipt(manifest) }
assert.equal(copyNativeProgram(options).source.omittedMediaFiles, 204)
assert.deepEqual(validateNativeOutput(context.outputDirectory, options).errors, [])
})
test('video manifest also requires a true range receipt and its exact immutable object key', t => {
const context = fixture(t)
const source = path.join(context.temporary, 'video-source')
const sourcePath = 'assets/video/test.mp4'
const bytes = Buffer.from('offline transport contract fixture')
write(source, sourcePath, bytes)
const record = { path: sourcePath, bytes: bytes.length, sha256: sha256(bytes) }
const smallSource = JSON.stringify({ files: [record] })
write(context.temporary, 'video-source-manifest.json', smallSource)
const sourceManifestPath = path.join(context.temporary, 'video-source-manifest.json')
const objectKey = `tang-detective/season-01/media-v1/${record.sha256}.mp4`
const manifest = { ...context.mediaManifest, sourceManifestSha256: sha256(smallSource), entries: [{
sourcePath, kind: 'video', contentType: 'video/mp4', bytes: record.bytes, sha256: record.sha256,
objectKey, url: `${context.mediaManifest.destination.baseUrl}/${objectKey}`,
uploaded: true, publicReadVerified: true, remoteVerifiedSha256: record.sha256, rangeVerified: true,
}] }
assert.equal(validateCosMediaManifest(manifest, { sourceDirectory: source, sourceManifestPath }).entries.size, 1)
for (const value of [undefined, false, 'true']) {
manifest.entries[0].rangeVerified = value
assert.throws(() => validateCosMediaManifest(manifest, { sourceDirectory: source, sourceManifestPath }), /range verification missing/)
}
})
test('source byte drift fails even when a manifest still claims the expected hash', t => {
const context = fixture(t)
const source = path.join(context.temporary, 'changed-source')
const original = context.mediaManifest.entries[0]
write(source, original.sourcePath, 'changed source bytes')
const smallSource = JSON.stringify({ files: [{ path: original.sourcePath, bytes: original.bytes, sha256: original.sha256 }] })
write(context.temporary, 'small-source-manifest.json', smallSource)
const manifest = { ...context.mediaManifest, sourceManifestSha256: sha256(smallSource), entries: [original] }
assert.throws(() => validateCosMediaManifest(manifest, { sourceDirectory: source,
sourceManifestPath: path.join(context.temporary, 'small-source-manifest.json') }), /source bytes changed/)
})
test('missing manifest stays local; a verified remote import removes only owned output media and remains deterministic', t => {
const context = fixture(t, { remote: false })
const missing = path.join(context.temporary, 'not-uploaded.json')
assert.equal(loadCosMediaManifest({ sourceDirectory, mediaManifestPath: missing }), null)
assert.equal(copyNativeProgram({ ...context.options, mediaManifest: undefined, mediaManifestPath: missing }).sizes.mediaFileCount, 204)
assert.equal(context.report.sizes.mediaFileCount, 204)
assert.equal(fs.existsSync(path.join(context.root, 'utils/cosMedia.js')), false)
const manifest = fakeVerifiedManifest(context)
write(context.temporary, 'verified-test-only.json', JSON.stringify(manifest))
const options = { ...context.options, mediaManifest: undefined, mediaManifestPath: path.join(context.temporary, 'verified-test-only.json') }
const report = copyNativeProgram(options)
assert.equal(report.sizes.mediaFileCount, 0)
assert.equal(report.media.sourceMediaFiles, 204)
assert.equal(listFiles(context.root).filter(isMediaFile).length, 0)
const state = fs.readFileSync(path.join(context.root, '.native-import-state.json'), 'utf8')
copyNativeProgram(options)
assert.equal(fs.readFileSync(path.join(context.root, '.native-import-state.json'), 'utf8'), state)
assert.equal(fs.readFileSync(path.join(context.outputDirectory, 'app.js'), 'utf8'), '/* retained host entry */')
for (const file of context.sourceManifest.files) {
assert.equal(sha256(fs.readFileSync(path.join(context.sourceDirectory, file.path))), file.sha256, file.path)
}
for (const file of ['platformCore.js', 'storage.js', 'tangPage.js']) {
assert.deepEqual(fs.readFileSync(path.join(context.root, 'utils', file)), fs.readFileSync(path.join(overlayDirectory, 'utils', file)), file)
}
})
test('invalid manifest and externally edited old media are rejected before output mutation', t => {
const context = fixture(t, { remote: false })
const before = fs.readFileSync(path.join(context.root, '.native-import-state.json'))
const invalid = fakeVerifiedManifest(context)
invalid.entries.pop()
assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: invalid }), /media coverage/)
assert.deepEqual(fs.readFileSync(path.join(context.root, '.native-import-state.json')), before)
const edited = sourceMedia.at(-1).path
write(context.root, edited, 'external user edit')
assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: fakeVerifiedManifest(context) }), /Stale native output has external edits/)
assert.equal(fs.readFileSync(path.join(context.root, edited), 'utf8'), 'external user edit')
assert.equal(sha256(fs.readFileSync(path.join(context.root, sourceMedia[0].path))), context.sourceMedia[0].sha256)
})
test('all 120 selected comic pages retain source content hashes and 112 formal / 8 provisional status', t => {
const context = fixture(t)
const season = context.require('data/season.js')
const originalSeason = sourceRequire(path.join(sourceDirectory, 'data/season.js'))
const originalPages = sourceRequire(path.join(sourceDirectory, 'package-game/pages/chapter/chapterPages.js'))
const media = context.require('utils/cosMedia.js')
const counts = { pages: 0, formal: 0, provisional: 0 }
for (let number = 1; number <= 15; number++) {
const packageRoot = number === 1 ? 'package-game' : `package-chapter-${String(number).padStart(2, '0')}`
const { buildComicPageModel } = context.require(`${packageRoot}/pages/chapter/chapterPages.js`)
const comic = context.require(`${packageRoot}/utils/comicPageModel.js`)
const { releaseAssets } = context.require(`${packageRoot}/data/releaseAssetManifest.js`)
const chapter = season.chapters[number - 1]
const original = originalPages.buildComicPageModel(originalSeason.chapters[number - 1], number)
const model = buildComicPageModel(chapter, number)
assert.equal(model.pageSequence.length, 8)
for (const [index, page] of model.pageSequence.entries()) {
const sourcePage = original.pageSequence[index]
const originalRecord = sourceManifest.files.find(file => file.path === sourcePage.illustrationAsset.replace(/^\//, ''))
assert.ok(originalRecord, `Original selected source record missing: ${sourcePage.illustrationAsset}`)
const expected = context.hashReplacements.get(originalRecord.sha256)
const assetId = `comic.${page.pageId.toLowerCase().replaceAll('-', '.')}`
const asset = releaseAssets[assetId]
const image = comic.buildComicImageState(page, asset, {})
assert.equal(media.entryFor(image.src).sha256, expected, page.pageId)
assert.equal(image.src, page.illustrationAsset, page.pageId)
assert.equal(asset.remoteUrl, image.src)
assert.equal(asset.localSeed, '')
assert.match(image.src, /^https:\/\//)
assert.equal(image.source, 'remote-url')
assert.equal(page.playableVisual.formalReleaseEligible, sourcePage.playableVisual.formalReleaseEligible)
assert.equal(page.playableVisual.reviewStatus, sourcePage.playableVisual.reviewStatus)
assert.equal(media.entryFor(image.fallback).kind, 'image')
assert.equal(comic.isPackagedPath(image.src), false)
counts.pages++
if (page.playableVisual.formalReleaseEligible) counts.formal++
if (page.playableVisual.runtimeTier === 'experience-provisional') counts.provisional++
}
}
assert.deepEqual(counts, { pages: 120, formal: 112, provisional: 8 })
assert.equal(media.resolve('/tang-detective/assets/unknown.jpg'), '')
assert.equal(media.mapMedia({ image: '/tang-detective/assets/unknown.jpg' }).image, '')
})
test('source and adapter static images, cast, C01 player art and all eight listening tracks use exact registry URLs', t => {
const context = fixture(t)
const media = context.require('utils/cosMedia.js')
const cover = media.resolve('/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg')
assert.ok(fs.readFileSync(path.join(context.root, 'pages/home/home.wxml'), 'utf8').includes(`src="${cover}"`))
for (const person of context.require('data/cast.js')) assert.equal(media.entryFor(person.asset).kind, 'image')
let count = 0
for (const packageRoot of ['package-audio-c01-a', 'package-audio-c01-b']) {
const pages = context.require(`${packageRoot}/data/audioPages.js`)
let player
vm.runInNewContext(fs.readFileSync(path.join(context.root, packageRoot, 'pages/player/player.js'), 'utf8'), {
require(specifier) {
if (specifier.endsWith('/utils/tangPage.js')) return definition => { player = definition }
if (specifier === '../../data/audioPages') return pages
throw new Error(`Unexpected player dependency: ${specifier}`)
},
})
assert.equal(player.data.reviewStatus, 'technical-qa-pass-human-listening-pending')
for (const page of Object.values(pages)) {
assert.equal(page.reviewStatus, 'technical-qa-pass-human-listening-pending')
assert.equal(media.entryFor(page.audioSrc).sha256, page.sha256)
assert.equal(media.entryFor(page.imageSrc).kind, 'image')
let prepared = ''
player.onReady.call({ _page: page, _unloaded: false, createAudioContext: src => { prepared = src } })
assert.equal(prepared, page.audioSrc)
count++
}
}
assert.equal(count, 8)
const sharePage = fs.readFileSync(path.join(context.root, 'pages/share/share.js'), 'utf8')
assert.ok(sharePage.includes(media.resolve('/assets/share/guixiang-story-share-preview-v1.jpg')))
})
test('remote transport does not open unreviewed audio, promote reserved cues or accept arbitrary HTTPS as approved', async t => {
const context = fixture(t)
const { manager, downloads, releaseAssets } = managerFixture(context)
const pages = context.require('package-game/data/remotePageAudioManifest.js')
const playerPages = context.require('package-audio-player/data/remotePageAudioManifest.js')
const comic = context.require('package-game/utils/comicPageModel.js')
assert.deepEqual(pages.remotePageAudioPages, {})
assert.deepEqual(playerPages.remotePageAudioPages, {})
for (let chapter = 2; chapter <= 15; chapter++) {
for (let page = 1; page <= 8; page++) {
const id = `S01-C${String(chapter).padStart(2, '0')}-P${String(page).padStart(2, '0')}`
assert.equal(pages.getApprovedRemotePageAudio(id, releaseAssets), null)
}
}
for (const id of ['audio.cast-audition.v1', 'audio.s01.c02.s01-c02-ms001', 'audio.s01.c03.s01-c03-ms001']) {
assert.equal((await manager.resolve(id)).reason, 'audio-unapproved')
}
const approvedId = 'audio.s01.c01.s01-c01-ms007'
assert.equal(releaseAssets[approvedId].reviewStatus, 'approved')
assert.equal(comic.getReviewedAudioSrc({ status: 'approved', assetId: approvedId, src: releaseAssets[approvedId].remoteUrl }, releaseAssets), '')
assert.equal(comic.getReviewedAudioSrc({ status: 'approved', assetId: approvedId, src: 'https://unknown.example.test/file.mp3' }, releaseAssets), '')
assert.equal(downloads.length, 0)
})
test('asset manager downloads exact URLs with an empty CDN base, verifies downloaded/cache bytes, and rejects substituted URLs', async t => {
const context = fixture(t)
const { manager, downloads, files, releaseAssets, managerApi, platform, responses } = managerFixture(context)
const id = 'comic.s01.c01.p01'
const first = await manager.resolve(id)
assert.equal(first.available, true)
assert.equal(first.persistent, true)
assert.equal(downloads[0], releaseAssets[id].remoteUrl)
assert.equal(sha256(files.get(first.uri)), releaseAssets[id].sha256)
assert.equal((await manager.resolve(id)).source, 'cache')
assert.equal(downloads.length, 1)
files.set(first.uri, Buffer.from('tampered cache'))
assert.equal((await manager.resolve(id)).reason, 'remote-integrity-failed')
assert.equal(files.has(first.uri), false)
const second = await manager.resolve(id)
assert.equal(second.available, true)
assert.equal(downloads.length, 2)
const replaced = { ...releaseAssets[id], remoteUrl: 'https://unknown.example.test/bad.jpg' }
const blocked = managerApi.createAssetManager({ manifest: { unregistered: replaced }, assetPlatform: platform })
assert.equal((await blocked.resolve('unregistered')).reason, 'remote-integrity-failed')
assert.equal(downloads.length, 2)
const badId = 'comic.s01.c01.p03'
responses.set(releaseAssets[badId].remoteUrl, Buffer.from('corrupt download'))
assert.equal((await manager.resolve(badId)).reason, 'remote-integrity-failed')
assert.equal([...files.keys()].some(name => name.startsWith('/test-owned-temp/')), false)
})
test('share preview rehashes cache, fails safely on corruption/network errors, and ignores completion after unload', async t => {
const context = fixture(t)
const { manager, files, downloads, failDownloads } = managerFixture(context)
const media = context.require('utils/cosMedia.js')
const page = { data: {}, __tangShowGeneration: 1, __tangVisible: true,
setData(update) { Object.assign(this.data, update) } }
const first = await media.prepareSharePreview(page, manager)
assert.match(first, /^\/test-owned-user-data\//)
assert.equal(page.data.sharePreviewLocalPath, first)
const cached = await media.prepareSharePreview(page, manager)
assert.equal(cached, first)
assert.equal(downloads.length, 1)
files.set(first, Buffer.from('corrupted thumbnail'))
assert.equal(await media.prepareSharePreview(page, manager), '')
assert.equal(page.data.sharePreviewLocalPath, '')
failDownloads(true)
assert.equal(await media.prepareSharePreview(page, manager), '')
failDownloads(false)
assert.ok(await media.prepareSharePreview(page, manager))
let finish
const delayed = { resolve: () => new Promise(resolve => { finish = resolve }) }
const pending = media.prepareSharePreview(page, delayed)
await Promise.resolve()
page.__tangDead = true
const prior = page.data.sharePreviewLocalPath
finish({ available: true, uri: '/should-not-be-shared' })
assert.equal(await pending, '')
assert.equal(page.data.sharePreviewLocalPath, prior)
const chapterScript = fs.readFileSync(path.join(context.root, 'package-game/pages/chapter/chapter.js'), 'utf8')
assert.ok(chapterScript.includes('.prepareSharePreview(this, this.getAssetManager())'))
assert.ok(chapterScript.includes("const localPath = '' // Cached share bytes are revalidated"))
assert.ok(!chapterScript.includes('filePath: SHARE_PREVIEW_IMAGE'))
})
test('real-output static validator supports COS mode and rejects extra packaged media', t => {
const context = fixture(t)
const report = validateNativeOutput(context.outputDirectory, context.options)
assert.deepEqual(report.errors, [])
assert.equal(report.passed, true)
assert.equal(report.mediaFiles, 204)
assert.equal(report.packagedMediaFiles, 0)
assert.equal(report.mediaMode, 'cos')
write(context.root, 'assets/unowned-video.mp4', 'unexpected packaged video')
const invalid = validateNativeOutput(context.outputDirectory, context.options)
assert.equal(invalid.passed, false)
assert.ok(invalid.errors.includes('Unexpected native packaged media in COS mode'))
})
test('actual converted chapter image handler exhausts fallbacks and ignores duplicate/late events across page visits', t => {
const context = fixture(t)
let definition
const modules = new Map()
function load(filename) {
if (!path.extname(filename)) filename += '.js'
if (filename.endsWith('/utils/tangPage.js')) return options => { definition = options }
if (filename.endsWith('/utils/storage.js')) return {}
if (modules.has(filename)) return modules.get(filename).exports
const module = { exports: {} }
modules.set(filename, module)
vm.runInNewContext(fs.readFileSync(filename, 'utf8'), { module,
require: specifier => load(path.resolve(path.dirname(filename), specifier)),
}, { filename })
return module.exports
}
load(path.join(context.root, 'package-game/pages/chapter/chapter.js'))
const chapter = context.require('data/season.js').chapters[0]
const model = context.require('package-game/pages/chapter/chapterPages.js').buildComicPageModel(chapter, 1)
let updates = 0
const page = { ...definition, data: { ...definition.data, chapter, chapterNumber: 1 }, _comicModel: model,
setData(patch) { updates++; Object.assign(this.data, patch) } }
const reader = index => ({ valid: true, currentPageIndex: index,
currentPageId: model.pageSequence[index].pageId, completedEventIds: [], chapterFinished: false })
const event = () => ({ currentTarget: { dataset: {
cosPageId: page.data.currentPageId, cosImageSrc: page.data.comicImageSrc,
cosImageGeneration: page.data.comicImageGeneration,
} } })
page.applyComicReaderState(reader(2), false)
assert.ok(page.data.comicImageActorFallback)
assert.ok(page.data.comicImageFallback)
const originalImage = page.data.comicImageSrc
const originalPage = JSON.stringify(page.data.currentPage)
const interactionEnabled = page.data.currentInteractionEnabled
const firstEvent = event()
page.onComicImageError(firstEvent)
assert.equal(page.data.comicImageSource, 'actor-fallback')
assert.equal(page.data.comicImageSrc, page.data.comicImageActorFallback)
const actorUpdates = updates
page.onComicImageError(firstEvent)
assert.equal(updates, actorUpdates, 'duplicate primary failure must not skip the actor fallback')
const actorEvent = event()
page.onComicImageError(actorEvent)
assert.equal(page.data.comicImageSrc, page.data.comicImageFallback)
const sceneEvent = event()
page.onComicImageError(sceneEvent)
assert.equal(page.data.comicImageSrc, '')
assert.equal(page.data.comicImageSource, 'text-fallback')
assert.ok(page.data.comicImageError)
assert.equal(JSON.stringify(page.data.currentPage), originalPage)
assert.equal(page.data.currentInteractionEnabled, interactionEnabled)
const terminalUpdates = updates
for (let index = 0; index < 10; index++) page.onComicImageError(event())
page.onComicImageLoad(sceneEvent)
assert.equal(updates, terminalUpdates, 'late events must leave terminal text intact')
page.applyComicReaderState(reader(2), false)
assert.equal(page.data.comicImageSrc, '', 'same-page interactions must not revive a failed URL')
page.applyComicReaderState(reader(3), false)
const nextImage = page.data.comicImageSrc
assert.ok(nextImage)
page.onComicImageError(firstEvent)
assert.equal(page.data.comicImageSrc, nextImage, 'previous-page event must be ignored')
page.applyComicReaderState(reader(2), false)
assert.equal(page.data.comicImageSrc, originalImage, 'revisiting a page starts a new attempt')
page.onComicImageError(firstEvent)
assert.equal(page.data.comicImageSrc, originalImage, 'same page ID from an earlier visit must be ignored')
page.onComicImageError(event())
assert.equal(page.data.comicImageSrc, page.data.comicImageActorFallback)
const template = fs.readFileSync(path.join(context.root, 'package-game/pages/chapter/chapter.wxml'), 'utf8')
assert.equal((template.match(/data-cos-image-generation=/g) || []).length,
(template.match(/binderror="onComicImageError"/g) || []).length)
})
test('output preflight rejects root, file, directory and stale-media symlinks without touching identical external targets', t => {
const context = fixture(t, { remote: false })
const snapshots = target => {
if (fs.lstatSync(target).isDirectory()) {
return listFiles(target).map(relative => [relative, sha256(fs.readFileSync(path.join(target, relative)))])
}
return sha256(fs.readFileSync(target))
}
const scenarios = [
{ target: context.outputDirectory },
{ target: context.root },
{ target: path.join(context.outputDirectory, 'app.json') },
{ target: path.join(context.root, '.native-import-state.json') },
{ target: path.join(context.root, 'utils/storage.js') },
{ target: path.join(context.root, 'utils') },
{ target: path.join(context.root, 'assets/scenes'), remote: true },
{ target: path.join(context.root, sourceMedia.at(-1).path), remote: true },
{ target: path.join(context.root, sourceMedia.at(-1).path), remote: true, dangling: true },
]
for (const [index, scenario] of scenarios.entries()) {
const external = path.join(context.temporary, `external-preserved-${index}`)
fs.renameSync(scenario.target, external)
const before = snapshots(external)
fs.symlinkSync(scenario.dangling ? `${external}-missing` : external, scenario.target)
try {
assert.throws(() => copyNativeProgram({ ...context.options,
mediaManifest: scenario.remote ? fakeVerifiedManifest(context) : null }), /Native output forbids symbolic links/)
assert.deepEqual(snapshots(external), before, scenario.target)
} finally {
fs.unlinkSync(scenario.target)
fs.renameSync(external, scenario.target)
}
// Even a cleanup conflict near the end must leave earlier media untouched.
assert.equal(sha256(fs.readFileSync(path.join(context.root, sourceMedia[0].path))), context.sourceMedia[0].sha256)
}
const externalNewFile = path.join(context.temporary, 'same-content-new-helper.js')
const newHelper = path.join(context.root, 'utils/cosMedia.js')
fs.copyFileSync(path.join(overlayDirectory, 'utils/cosMedia.js'), externalNewFile)
fs.symlinkSync(externalNewFile, newHelper)
const newHash = sha256(fs.readFileSync(externalNewFile))
try {
assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: fakeVerifiedManifest(context) }), /Native output forbids symbolic links/)
assert.equal(sha256(fs.readFileSync(externalNewFile)), newHash)
} finally { fs.unlinkSync(newHelper) }
const alias = path.join(context.temporary, 'ancestor-alias')
fs.symlinkSync(context.temporary, alias, 'dir')
assert.equal(copyNativeProgram({ ...context.options, outputDirectory: path.join(alias, 'output') }).sizes.mediaFileCount, 204)
const local = validateNativeOutput(context.outputDirectory, context.options)
assert.deepEqual(local.errors, [])
assert.equal(local.passed, true)
assert.equal(local.mediaMode, 'local')
assert.equal(local.packagedMediaFiles, 204)
assert.ok(local.relativeDependencies > 300)
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,392 @@
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { applyCosMediaOutput, DEFAULT_MEDIA_MANIFEST_PATH, isMediaFile, loadCosMediaManifest, SOURCE_MANIFEST_PATH } from './tang-detective-cos-media.mjs'
import { DEFAULT_PRUNE_RECEIPT_PATH, validateSourceSnapshot } from './tang-detective-source-validation.mjs'
export const NAMESPACE = 'tang-detective'
const TEXT_EXTENSIONS = new Set(['.js', '.json', '.wxml', '.wxss', '.wxs'])
const PAGE_WINDOW_KEYS = ['navigationStyle', 'pageOrientation', 'backgroundColor', 'backgroundTextStyle']
const STATE_FILE = '.native-import-state.json'
const BOOT_MASK_WXML = '<view wx:if="{{tangBootPending}}" class="tang-boot-mask" catchtap="tangIgnoreBootTap" catchtouchmove="tangIgnoreBootTap">正在读取阅读存档…</view>'
const BOOT_MASK_WXSS = `
.tang-boot-mask {
position: fixed;
inset: 0;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 2147483647;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 24px;
background: #201711;
color: #f3e5bd;
font-size: 18px;
text-align: center;
}
`
export function sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex')
}
export function listFiles(directory) {
if (!fs.existsSync(directory)) return []
const result = []
function visit(current, prefix = '') {
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
const relative = prefix ? `${prefix}/${entry.name}` : entry.name
if (entry.isSymbolicLink()) throw new Error(`Native import does not follow symbolic links: ${relative}`)
if (entry.isDirectory()) visit(path.join(current, entry.name), relative)
else if (entry.isFile()) result.push(relative)
}
}
visit(directory)
return result
}
function assertNamespace(namespace) {
if (!/^[a-z][a-z0-9-]*$/.test(namespace)) throw new Error(`Invalid native namespace: ${namespace}`)
}
function outputPath(directory, relative) {
if (typeof relative !== 'string' || !relative || path.isAbsolute(relative)
|| relative.includes('\\') || relative.split('/').some(part => !part || part === '.' || part === '..')) {
throw new Error(`Unsafe native output path: ${relative}`)
}
return path.join(directory, relative)
}
function lstatOrNull(filename) {
try { return fs.lstatSync(filename) } catch (error) {
if (error.code === 'ENOENT') return null
throw error
}
}
/** Ancestors such as macOS /tmp may be aliases, but the supplied output root
* itself and every component below it must be actual directories/files. */
export function createNativeOutputGuard(directory) {
const suppliedRoot = path.resolve(directory)
const suppliedStat = lstatOrNull(suppliedRoot)
if (suppliedStat?.isSymbolicLink()) throw new Error(`Native output forbids symbolic links: ${suppliedRoot}`)
if (!suppliedStat?.isDirectory()) throw new Error(`Native output root must be an existing directory: ${suppliedRoot}`)
const root = fs.realpathSync(suppliedRoot)
function check(relative, expectedType = 'file') {
const rootStat = lstatOrNull(root)
if (rootStat?.isSymbolicLink()) throw new Error(`Native output forbids symbolic links: ${root}`)
if (!rootStat?.isDirectory() || fs.realpathSync(root) !== root) throw new Error(`Native output root changed: ${root}`)
const destination = outputPath(root, relative)
const parts = relative.split('/')
let current = root
for (const [index, part] of parts.entries()) {
current = path.join(current, part)
const stat = lstatOrNull(current)
if (!stat) break
if (stat.isSymbolicLink()) throw new Error(`Native output forbids symbolic links: ${current}`)
const real = fs.realpathSync(current)
const inside = path.relative(root, real)
if (path.isAbsolute(inside) || inside === '..' || inside.startsWith(`..${path.sep}`)) {
throw new Error(`Native output escaped its real directory: ${current}`)
}
const needsDirectory = index < parts.length - 1 || expectedType === 'directory'
if (needsDirectory ? !stat.isDirectory() : !stat.isFile()) {
throw new Error(`Unexpected native output path type: ${current}`)
}
}
return destination
}
function write(relative, data) {
const destination = check(relative)
fs.mkdirSync(path.dirname(destination), { recursive: true })
check(relative)
// Re-check parent components after mkdir and prevent following a replaced
// final-file link between lstat and opening the destination.
const descriptor = fs.openSync(destination,
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_NOFOLLOW)
try { fs.writeFileSync(descriptor, data) } finally { fs.closeSync(descriptor) }
}
return { root, path: check, write }
}
function jsonBytes(value) {
return `${JSON.stringify(value, null, 2)}\n`
}
/** Transform only the imported copy. Remote URLs, hashes, and relative requires stay intact. */
export function transformNativeText(source, relativePath, { namespace = NAMESPACE } = {}) {
assertNamespace(namespace)
if (!TEXT_EXTENSIONS.has(path.extname(relativePath))) return source
return source
.replace(/(["'`])\/(?=(?:assets|pages|package-[a-z0-9-]+)\/)/g, `$1/${namespace}/`)
.replace(/(["'`])(?=package-(?:game|chapter-|audio-))/g, `$1${namespace}/`)
.replaceAll(
String.raw`/^\/package-[a-z0-9-]+\//i`,
String.raw`/^\/${namespace}\/package-[a-z0-9-]+\//i`,
)
}
export function createNativeManifest(sourceApp, { namespace = NAMESPACE } = {}) {
assertNamespace(namespace)
const sourcePackages = sourceApp.subPackages || sourceApp.subpackages || []
const packageNames = new Map(sourcePackages.map(item => [item.name || item.root, `${namespace}-${item.name || item.root}`]))
const pages = (sourceApp.pages || []).map(item => `${namespace}/${item}`)
const subPackages = sourcePackages.map(item => ({
...item,
root: `${namespace}/${item.root}`,
name: packageNames.get(item.name || item.root),
pages: [...item.pages],
}))
const preloadRule = Object.fromEntries(Object.entries(sourceApp.preloadRule || {}).map(([page, rule]) => [
`${namespace}/${page}`,
{ ...rule, packages: rule.packages.map(name => {
const mapped = packageNames.get(name)
|| subPackages.find(item => item.root === `${namespace}/${name}`)?.name
if (!mapped) throw new Error(`Unregistered native preload package: ${name}`)
return mapped
}) },
]))
const allPages = [...pages, ...subPackages.flatMap(item => item.pages.map(page => `${item.root}/${page}`))]
if (new Set(allPages).size !== allPages.length) throw new Error('Duplicate native page route')
return { namespace, pages, subPackages, preloadRule, allPages }
}
/** Append native routes without replacing the host's app settings or route ordering. */
export function mergeNativeAppManifest(hostApp, nativeManifest) {
const result = structuredClone(hostApp)
const packageKey = Object.hasOwn(hostApp, 'subpackages') && !Object.hasOwn(hostApp, 'subPackages')
? 'subpackages' : 'subPackages'
const hostPages = result.pages || []
const hostPackages = result[packageKey] || []
const nativePages = new Set(nativeManifest.allPages)
for (const item of hostPackages) {
const proposed = nativeManifest.subPackages.find(candidate => candidate.root === item.root)
if (proposed) {
if (JSON.stringify(item) !== JSON.stringify(proposed)) throw new Error(`Native subpackage conflicts with host: ${item.root}`)
continue
}
if ((item.pages || []).some(page => nativePages.has(`${item.root}/${page}`))) {
throw new Error(`Native page conflicts with host subpackage: ${item.root}`)
}
if (nativeManifest.subPackages.some(candidate => candidate.name === item.name)) {
throw new Error(`Native subpackage name conflicts with host: ${item.name}`)
}
}
for (const page of hostPages) {
if (nativePages.has(page) && !nativeManifest.pages.includes(page)) {
throw new Error(`Native subpackage page is already a host main page: ${page}`)
}
}
result.pages = [...hostPages, ...nativeManifest.pages.filter(page => !hostPages.includes(page))]
result[packageKey] = [...hostPackages, ...nativeManifest.subPackages.filter(item => !hostPackages.some(existing => existing.root === item.root))]
const preloadRule = { ...(result.preloadRule || {}) }
for (const [page, rule] of Object.entries(nativeManifest.preloadRule)) {
if (preloadRule[page] && JSON.stringify(preloadRule[page]) !== JSON.stringify(rule)) {
throw new Error(`Native preload conflicts with host: ${page}`)
}
preloadRule[page] = rule
}
result.preloadRule = preloadRule
return result
}
export function wrapNativePage(source, relativePath) {
const wrapperPath = path.posix.relative(path.posix.dirname(relativePath), 'utils/tangPage.js')
const topLevelCalls = [...source.matchAll(/^Page\(\{/gm)]
if (topLevelCalls.length !== 1) throw new Error(`Expected one top-level native Page registration: ${relativePath}`)
return source.replace(/^Page\(\{/m, `require(${JSON.stringify(wrapperPath)})({`)
}
function collectOutputFiles(sourceDirectory, overlayDirectory, sourceApp, nativeManifest, apiBaseUrl, media) {
const files = new Map()
const sourcePages = new Set(nativeManifest.allPages.map(page => page.slice(nativeManifest.namespace.length + 1)))
const pageDefaults = Object.fromEntries(PAGE_WINDOW_KEYS
.filter(key => sourceApp.window?.[key] !== undefined)
.map(key => [key, sourceApp.window[key]]))
for (const relative of listFiles(sourceDirectory)) {
if (['app.js', 'app.json', 'sitemap.json'].includes(relative)) continue
const original = fs.readFileSync(outputPath(sourceDirectory, relative))
if (relative === 'app.wxss') {
files.set('shared.wxss', original)
continue
}
const extension = path.extname(relative)
if (!TEXT_EXTENSIONS.has(extension)) {
files.set(relative, original)
continue
}
let content = transformNativeText(original.toString('utf8'), relative, nativeManifest)
const isPage = sourcePages.has(relative.slice(0, -extension.length))
if (isPage && extension === '.json') content = jsonBytes({ ...pageDefaults, ...JSON.parse(content) })
if (isPage && extension === '.wxss') {
content = `@import "/${nativeManifest.namespace}/shared.wxss";\n${content}`
}
files.set(relative, Buffer.from(content))
}
// Adapters contain final output paths. Do not transform them a second time.
for (const relative of listFiles(overlayDirectory)) {
// Remote helpers are activated together with a verified manifest only.
if (relative === 'utils/cosMedia.js') continue
if (['app.js', 'app.json', 'app.wxss', 'sitemap.json', STATE_FILE].includes(relative)
|| /(^|\/)project(?:\.private)?\.config\.json$/.test(relative)) {
throw new Error(`Adapter cannot replace the host app or import state: ${relative}`)
}
if (isMediaFile(relative)) {
throw new Error(`Adapter cannot replace source media: ${relative}`)
}
files.set(relative, fs.readFileSync(outputPath(overlayDirectory, relative)))
}
files.set('utils/platformConfig.js', Buffer.from(`module.exports = ${JSON.stringify({ apiBaseUrl })};\n`))
// Media conversion also covers the adapter's final paths, without repeating
// namespace conversion or changing the host/account lifecycle adapters.
applyCosMediaOutput(files, media, nativeManifest)
if (!files.has('utils/tangPage.js')) throw new Error('Native adapter is missing utils/tangPage.js')
files.set('shared.wxss', Buffer.from(`${files.get('shared.wxss').toString('utf8')}\n${BOOT_MASK_WXSS}`))
for (const page of sourcePages) {
for (const extension of ['.js', '.json', '.wxml', '.wxss']) {
if (!files.has(`${page}${extension}`)) throw new Error(`Native page file is missing: ${page}${extension}`)
}
const pageJson = JSON.parse(files.get(`${page}.json`).toString('utf8'))
if (pageJson.navigationStyle !== 'custom' || pageJson.pageOrientation !== 'landscape') {
throw new Error(`Adapter lost native page configuration: ${page}`)
}
files.set(`${page}.js`, Buffer.from(wrapNativePage(files.get(`${page}.js`).toString('utf8'), `${page}.js`)))
files.set(`${page}.wxml`, Buffer.from(`${files.get(`${page}.wxml`).toString('utf8')}\n${BOOT_MASK_WXML}\n`))
}
return files
}
export function summarizePackageSizes(files, nativeManifest) {
const result = { sourceFileBytes: 0, mainPackageBytes: 0, subPackages: {}, mediaFileCount: 0, mediaBytes: 0 }
for (const [relative, value] of files) {
const fullPath = `${nativeManifest.namespace}/${relative}`
const subpackage = nativeManifest.subPackages.find(item => fullPath.startsWith(`${item.root}/`))
result.sourceFileBytes += value.length
if (subpackage) result.subPackages[subpackage.root] = (result.subPackages[subpackage.root] || 0) + value.length
else result.mainPackageBytes += value.length
if (isMediaFile(relative)) {
result.mediaFileCount += 1
result.mediaBytes += value.length
}
}
return result
}
/** Reusable by the Vite hook and by static tests; no build or server is started here. */
export function copyNativeProgram({ sourceDirectory, outputDirectory, overlayDirectory, namespace = NAMESPACE, apiBaseUrl = '',
mediaManifest, mediaManifestPath, sourceManifestPath, pruneReceipt, pruneReceiptPath }) {
assertNamespace(namespace)
const outputGuard = createNativeOutputGuard(outputDirectory)
outputDirectory = outputGuard.root
const media = loadCosMediaManifest({ sourceDirectory, mediaManifest, mediaManifestPath, sourceManifestPath, pruneReceipt, pruneReceiptPath })
// This preflight must finish before collecting output or touching existing
// import ownership state, including when local mode was explicitly selected.
const sourceSnapshot = media?.sourceSnapshot || validateSourceSnapshot({ sourceDirectory, sourceManifestPath })
const sourceApp = JSON.parse(fs.readFileSync(path.join(sourceDirectory, 'app.json'), 'utf8'))
const hostAppPath = outputGuard.path('app.json')
const hostApp = JSON.parse(fs.readFileSync(hostAppPath, 'utf8'))
const nativeManifest = createNativeManifest(sourceApp, { namespace })
const mergedApp = mergeNativeAppManifest(hostApp, nativeManifest)
const files = collectOutputFiles(sourceDirectory, overlayDirectory, sourceApp, nativeManifest, apiBaseUrl, media)
const nativeOutputDirectory = outputGuard.path(namespace, 'directory')
const nativeTarget = relative => {
// Validate the untrusted old-state relative path before adding namespace.
const target = outputPath(nativeOutputDirectory, relative)
return outputGuard.path(path.relative(outputDirectory, target))
}
const statePath = nativeTarget(STATE_FILE)
const previousState = fs.existsSync(statePath) ? JSON.parse(fs.readFileSync(statePath, 'utf8')) : null
if (previousState && previousState.namespace !== namespace) throw new Error('Native output ownership mismatch')
const previousFiles = new Map((previousState?.files || []).map(item => [item.path, item.sha256]))
// Refuse to overwrite files whose ownership or later edits cannot be established.
for (const [relative, value] of files) {
const destination = nativeTarget(relative)
if (!fs.existsSync(destination)) continue
const existingHash = sha256(fs.readFileSync(destination))
if (existingHash !== previousFiles.get(relative) && existingHash !== sha256(value)) {
throw new Error(`Refusing to overwrite unowned native output: ${relative}`)
}
}
const staleFiles = []
for (const [relative, expectedHash] of previousFiles) {
if (files.has(relative)) continue
const stalePath = nativeTarget(relative)
if (fs.existsSync(stalePath)) {
if (sha256(fs.readFileSync(stalePath)) !== expectedHash) throw new Error(`Stale native output has external edits: ${relative}`)
staleFiles.push(relative)
}
}
// Check every stale file before removing any: a conflict late in the media
// list must not leave a previously usable local build partially stripped.
for (const relative of staleFiles) fs.unlinkSync(nativeTarget(relative))
for (const [relative, value] of files) outputGuard.write(`${namespace}/${relative}`, value)
const state = {
version: 1,
namespace,
pages: nativeManifest.allPages,
files: [...files].map(([relative, value]) => ({ path: relative, bytes: value.length, sha256: sha256(value) })),
sizes: summarizePackageSizes(files, nativeManifest),
source: { manifestSha256: sourceSnapshot.manifestSha256, originalFiles: sourceSnapshot.files.size,
presentFiles: sourceSnapshot.actual.size, omittedMediaFiles: sourceSnapshot.missingMedia.length,
...(sourceSnapshot.prune ? { prune: sourceSnapshot.prune } : {}) },
...(media ? { media: { mode: 'cos', sourceManifestSha256: media.sourceManifestSha256,
manifestSha256: media.manifestSha256, sourceMediaFiles: media.entries.size, objectCount: media.objectCount } } : {}),
validationBoundary: 'Static copy and route integration only; no device, upload, content review, or release approval.',
}
outputGuard.write(`${namespace}/${STATE_FILE}`, jsonBytes(state))
outputGuard.write('app.json', jsonBytes(mergedApp))
return { ...state, nativeManifest }
}
export default function tangDetectiveNativePlugin(options = {}) {
let root
let buildOutput
const sourceRelative = options.sourceDirectory || 'native/tang-detective'
const overlayRelative = options.overlayDirectory || 'native-adapter/tang-detective'
return {
name: 'tang-detective-native-pages',
enforce: 'post',
apply: () => process.env.UNI_PLATFORM === 'mp-weixin',
configResolved(config) {
root = config.root
buildOutput = path.resolve(root, config.build.outDir)
},
buildStart() {
const sourceDirectory = path.resolve(root, sourceRelative)
const media = loadCosMediaManifest({ sourceDirectory, mediaManifest: options.mediaManifest,
mediaManifestPath: options.mediaManifestPath, sourceManifestPath: options.sourceManifestPath,
pruneReceiptPath: options.pruneReceiptPath })
if (!media) validateSourceSnapshot({ sourceDirectory, sourceManifestPath: options.sourceManifestPath })
this.addWatchFile(options.mediaManifestPath || DEFAULT_MEDIA_MANIFEST_PATH)
this.addWatchFile(options.sourceManifestPath || SOURCE_MANIFEST_PATH)
this.addWatchFile(options.pruneReceiptPath || DEFAULT_PRUNE_RECEIPT_PATH)
for (const directory of [path.resolve(root, sourceRelative), path.resolve(root, overlayRelative)]) {
this.addWatchFile(directory)
for (const relative of listFiles(directory)) this.addWatchFile(path.join(directory, relative))
}
},
// Sequential post-order runs after normal write hooks and is repeated for watch rebuilds.
writeBundle: {
order: 'post',
sequential: true,
handler(outputOptions) {
const report = copyNativeProgram({
sourceDirectory: path.resolve(root, sourceRelative),
overlayDirectory: path.resolve(root, overlayRelative),
outputDirectory: outputOptions.dir ? path.resolve(root, outputOptions.dir) : buildOutput,
namespace: options.namespace || NAMESPACE,
apiBaseUrl: options.apiBaseUrl || '',
mediaManifest: options.mediaManifest,
mediaManifestPath: options.mediaManifestPath,
sourceManifestPath: options.sourceManifestPath,
pruneReceiptPath: options.pruneReceiptPath,
})
this.warn(`唐侦探原生页面已合并:${report.pages.length} 页,原生文件 ${report.sizes.sourceFileBytes} bytes,其中主包新增 ${report.sizes.mainPackageBytes} bytes;此结果不代表包体积或发布验收通过。`)
},
},
}
}
@@ -0,0 +1,265 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import vm from 'node:vm'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import nativePlugin, {
copyNativeProgram,
createNativeManifest,
listFiles,
mergeNativeAppManifest,
sha256,
transformNativeText,
wrapNativePage,
} from './tang-detective-native-plugin.mjs'
import { createSyntheticSource } from './tang-detective-test-fixtures.mjs'
import { loadCosMediaManifest } from './tang-detective-cos-media.mjs'
import { validateSourceSnapshot } from './tang-detective-source-validation.mjs'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const sourceDirectory = path.join(projectRoot, 'native/tang-detective')
const sourceApp = JSON.parse(fs.readFileSync(path.join(sourceDirectory, 'app.json'), 'utf8'))
const mediaPattern = /\.(?:jpg|jpeg|png|webp|mp3|wav|aac|m4a|ogg)$/i
function write(directory, relative, content) {
const filename = path.join(directory, relative)
fs.mkdirSync(path.dirname(filename), { recursive: true })
fs.writeFileSync(filename, content)
}
function fixture(t) {
const temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tang-native-test-')))
// This directory is created and exclusively owned by this individual test.
t.after(() => fs.rmSync(temporary, { recursive: true, force: true }))
const outputDirectory = path.join(temporary, 'output')
const source = createSyntheticSource(temporary)
const overlayDirectory = path.join(temporary, 'overlay')
const hostApp = {
pages: ['pages/index/index'],
subPackages: [{ root: 'tongji', pages: ['endless-game/index'] }],
window: { navigationStyle: 'default', pageOrientation: 'portrait', backgroundColor: '#ffffff' },
tabBar: { list: [{ pagePath: 'pages/index/index', text: '首页' }] },
permission: { 'scope.record': { desc: '通话' } },
preloadRule: { 'pages/index/index': { network: 'wifi', packages: ['tongji'] } },
}
write(outputDirectory, 'app.json', JSON.stringify(hostApp))
write(outputDirectory, 'app.js', '/* host App entry must stay unchanged */')
write(outputDirectory, 'app.wxss', '/* host global styles must stay unchanged */')
write(outputDirectory, 'project.config.json', '{"description":"host project"}')
write(outputDirectory, 'pages/index/index.js', '/* host home page */')
write(outputDirectory, 'tongji/endless-game/index.js', '/* existing game */')
write(overlayDirectory, 'utils/tangPage.js', 'module.exports = function (definition) { return Page(definition) }\n')
const options = { sourceDirectory: source.sourceDirectory, sourceManifestPath: source.sourceManifestPath,
outputDirectory, overlayDirectory, apiBaseUrl: 'https://api.example.test', mediaManifest: null }
return { ...options, options, hostApp }
}
function packageOwner(relative, manifest) {
return manifest.subPackages.find(item => relative.startsWith(`${item.root}/`))?.root || 'main'
}
test('original snapshot keeps 473 recorded paths and requires valid remote pruning evidence for any omitted media', () => {
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, 'build/tang-detective-source-manifest.json'), 'utf8'))
assert.equal(manifest.files.length, 473)
assert.equal(manifest.files.filter(item => mediaPattern.test(item.path)).length, 204)
const media = loadCosMediaManifest({ sourceDirectory })
const snapshot = media?.sourceSnapshot || validateSourceSnapshot({ sourceDirectory })
assert.equal(snapshot.actual.size + snapshot.missingMedia.length, 473)
assert.equal([...snapshot.actual.keys()].filter(file => !mediaPattern.test(file)).length, 269)
assert.equal(fs.existsSync(path.join(sourceDirectory, 'app.js')), false)
})
test('path conversion includes dynamic roots and packaged-path regex without changing external URLs or requires', () => {
const source = [
"const a = '/pages/share/share?x=1'",
'const b = "/assets/share/card.jpg"',
"const c = 'package-game'",
'const d = `package-chapter-${number}`',
'const e = `/${packageRoot}/assets/comic/page.jpg`',
"const f = require('../../../utils/storage')",
"const remote = 'https://cdn.example.test/assets/share/card.jpg'",
String.raw`const valid = /^\/package-[a-z0-9-]+\//i`,
].join('\n')
const result = transformNativeText(source, 'example.js')
assert.match(result, /'\/tang-detective\/pages\/share\/share\?x=1'/)
assert.match(result, /"\/tang-detective\/assets\/share\/card.jpg"/)
assert.match(result, /'tang-detective\/package-game'/)
assert.ok(result.includes('`tang-detective/package-chapter-${number}`'))
assert.ok(result.includes('`/${packageRoot}/assets/comic/page.jpg`'))
assert.ok(result.includes("require('../../../utils/storage')"))
assert.ok(result.includes("'https://cdn.example.test/assets/share/card.jpg'"))
assert.ok(result.includes(String.raw`/^\/tang-detective\/package-[a-z0-9-]+\//i`))
assert.equal(transformNativeText(result, 'example.js'), result)
new vm.Script(result)
})
test('manifest merge preserves host configuration, supports both package spellings, and rejects conflicting routes', () => {
const manifest = createNativeManifest(sourceApp)
assert.equal(manifest.pages.length, 6)
assert.equal(manifest.subPackages.length, 18)
assert.equal(manifest.allPages.length, 24)
const host = { pages: ['pages/index/index'], subpackages: [{ root: 'tongji', pages: ['pages/index'] }], window: { pageOrientation: 'portrait' } }
const merged = mergeNativeAppManifest(host, manifest)
assert.deepEqual(host, { pages: ['pages/index/index'], subpackages: [{ root: 'tongji', pages: ['pages/index'] }], window: { pageOrientation: 'portrait' } })
assert.deepEqual(merged.window, host.window)
assert.equal(merged.subPackages, undefined)
assert.deepEqual(mergeNativeAppManifest(merged, manifest), merged)
assert.deepEqual(merged.preloadRule['tang-detective/package-game/pages/chapter/chapter'].packages, ['tang-detective-audio-c01-a'])
assert.throws(() => mergeNativeAppManifest({ pages: [manifest.subPackages[0].root + '/pages/chapter/chapter'] }, manifest), /already a host main page/)
assert.throws(() => mergeNativeAppManifest({ subPackages: [{ root: manifest.subPackages[0].root, pages: ['wrong'] }] }, manifest), /conflicts with host/)
})
test('all native pages copy into owned output with legal relative requires and unchanged host/media bytes', t => {
const context = fixture(t)
const guardedFiles = ['app.js', 'app.wxss', 'project.config.json', 'pages/index/index.js', 'tongji/endless-game/index.js']
const before = new Map(guardedFiles.map(file => [file, sha256(fs.readFileSync(path.join(context.outputDirectory, file)))]))
// The final adapter path must be copied literally, without a second prefix pass.
const overlay = "module.exports = { home: '/tang-detective/pages/home/home', host: '/pages/index/index' }\n"
write(context.overlayDirectory, 'utils/overlayProbe.js', overlay)
// Final safety UI is added after adapters, including a fully replaced home template.
const homeTemplate = '<view>adapted home</view>'
write(context.overlayDirectory, 'pages/home/home.wxml', homeTemplate)
const report = copyNativeProgram(context.options)
assert.equal(report.pages.length, 24)
assert.equal(report.sizes.mediaFileCount, 204)
const nativeDirectory = path.join(context.outputDirectory, 'tang-detective')
assert.equal(fs.readFileSync(path.join(nativeDirectory, 'utils/overlayProbe.js'), 'utf8'), overlay)
assert.ok(fs.readFileSync(path.join(nativeDirectory, 'pages/home/home.wxml'), 'utf8').startsWith(homeTemplate))
const sharedStyle = fs.readFileSync(path.join(nativeDirectory, 'shared.wxss'), 'utf8')
assert.equal((sharedStyle.match(/\.tang-boot-mask\s*\{/g) || []).length, 1)
const maskStyle = sharedStyle.slice(sharedStyle.indexOf('.tang-boot-mask'))
for (const declaration of ['position: fixed', 'inset: 0', 'z-index: 2147483647', 'background: #201711', 'color: #f3e5bd', 'font-size: 18px', 'align-items: center', 'justify-content: center']) {
assert.ok(maskStyle.includes(declaration), declaration)
}
const localRequire = createRequire(path.join(context.outputDirectory, 'package.cjs'))
assert.deepEqual(localRequire(path.join(nativeDirectory, 'utils/platformConfig.js')), { apiBaseUrl: context.apiBaseUrl })
for (const file of guardedFiles) assert.equal(sha256(fs.readFileSync(path.join(context.outputDirectory, file))), before.get(file), file)
const merged = JSON.parse(fs.readFileSync(path.join(context.outputDirectory, 'app.json'), 'utf8'))
for (const key of ['window', 'tabBar', 'permission']) assert.deepEqual(merged[key], context.hostApp[key])
assert.deepEqual(merged.preloadRule['pages/index/index'], context.hostApp.preloadRule['pages/index/index'])
for (const relative of listFiles(context.sourceDirectory).filter(file => mediaPattern.test(file))) {
assert.equal(sha256(fs.readFileSync(path.join(nativeDirectory, relative))), sha256(fs.readFileSync(path.join(context.sourceDirectory, relative))), relative)
}
for (const page of report.pages) {
const config = JSON.parse(fs.readFileSync(path.join(context.outputDirectory, `${page}.json`), 'utf8'))
assert.equal(config.navigationStyle, 'custom', page)
assert.equal(config.pageOrientation, 'landscape', page)
const style = fs.readFileSync(path.join(context.outputDirectory, `${page}.wxss`), 'utf8')
assert.ok(style.startsWith('@import "/tang-detective/shared.wxss";'), page)
const script = fs.readFileSync(path.join(context.outputDirectory, `${page}.js`), 'utf8')
assert.doesNotMatch(script, /^Page\(\{/m, page)
assert.match(script, /require\("\.\.\/(?:\.\.\/)*utils\/tangPage\.js"\)\(\{/, page)
const template = fs.readFileSync(path.join(context.outputDirectory, `${page}.wxml`), 'utf8')
assert.equal((template.match(/class="tang-boot-mask"/g) || []).length, 1, page)
assert.ok(template.trimEnd().endsWith('<view wx:if="{{tangBootPending}}" class="tang-boot-mask" catchtap="tangIgnoreBootTap" catchtouchmove="tangIgnoreBootTap">正在读取阅读存档…</view>'), page)
}
let dependencyCount = 0
for (const relative of listFiles(nativeDirectory).filter(file => file.endsWith('.js'))) {
const filename = path.join(nativeDirectory, relative)
const script = fs.readFileSync(filename, 'utf8')
new vm.Script(script, { filename: relative })
for (const [, specifier] of script.matchAll(/require\(['"]([^'"]+)['"]\)/g)) {
assert.ok(specifier.startsWith('.'), `${relative}: native dependencies must be relative (${specifier})`)
const resolved = localRequire.resolve(path.resolve(path.dirname(filename), specifier))
const resolvedRelative = path.relative(context.outputDirectory, resolved).split(path.sep).join('/')
assert.ok(resolvedRelative.startsWith('tang-detective/'), `${relative}: dependency escaped namespace`)
const caller = packageOwner(`tang-detective/${relative}`, report.nativeManifest)
const dependency = packageOwner(resolvedRelative, report.nativeManifest)
assert.ok(dependency === 'main' || caller === dependency, `${relative} imports a sibling subpackage: ${specifier}`)
dependencyCount += 1
}
}
assert.ok(dependencyCount > 300)
assert.equal(fs.existsSync(path.join(nativeDirectory, 'app.js')), false)
assert.equal(fs.existsSync(path.join(nativeDirectory, 'app.json')), false)
assert.equal(fs.existsSync(path.join(nativeDirectory, 'sitemap.json')), false)
const firstState = fs.readFileSync(path.join(nativeDirectory, '.native-import-state.json'), 'utf8')
copyNativeProgram(context.options)
assert.equal(fs.readFileSync(path.join(nativeDirectory, '.native-import-state.json'), 'utf8'), firstState)
assert.equal(JSON.parse(fs.readFileSync(path.join(context.outputDirectory, 'app.json'), 'utf8')).pages.length, 7)
})
test('120 chapter page image selections and C01 full tracks remain in main or their own subpackage', t => {
const context = fixture(t)
const report = copyNativeProgram(context.options)
const localRequire = createRequire(path.join(context.outputDirectory, 'package.cjs'))
const root = path.join(context.outputDirectory, 'tang-detective')
const season = localRequire(path.join(root, 'data/season.js'))
const routing = localRequire(path.join(root, 'utils/chapterRoute.js'))
function assertLocalAsset(asset, owner) {
if (!asset) return
assert.ok(asset.startsWith('/tang-detective/'), asset)
const relative = asset.slice(1)
const targetOwner = packageOwner(relative, report.nativeManifest)
assert.ok(targetOwner === 'main' || targetOwner === owner, `Illegal sibling asset read: ${owner} -> ${asset}`)
assert.ok(fs.existsSync(path.join(context.outputDirectory, relative)), `Missing selected asset: ${asset}`)
}
let pages = 0
for (let number = 1; number <= 15; number += 1) {
const packageRoot = routing.chapterPackageRoot(number)
assert.ok(report.pages.includes(routing.chapterRoute(number).split('?')[0].slice(1)))
const chapter = season.chapters.find(item => item.chapterNumber === number)
const chapterPages = localRequire(path.join(context.outputDirectory, packageRoot, 'pages/chapter/chapterPages.js'))
const modelUtils = localRequire(path.join(context.outputDirectory, packageRoot, 'utils/comicPageModel.js'))
const { releaseAssets } = localRequire(path.join(context.outputDirectory, packageRoot, 'data/releaseAssetManifest.js'))
assert.equal(modelUtils.isPackagedPath(`/${packageRoot}/assets/example.jpg`), true)
assert.equal(modelUtils.isPackagedPath('/package-game/assets/example.jpg'), false)
const model = chapterPages.buildComicPageModel(chapter, number)
assert.equal(model.pageSequence.length, 8)
for (const page of model.pageSequence) {
const [, seasonNumber, chapterNumber, pageNumber] = page.pageId.match(/^S(\d+)-C(\d+)-P(\d+)$/)
const asset = releaseAssets[`comic.s${seasonNumber}.c${chapterNumber}.p${pageNumber}`]
const image = modelUtils.buildComicImageState(page, asset, {})
assertLocalAsset(image.src, packageRoot)
assertLocalAsset(image.fallback, packageRoot)
pages += 1
}
}
assert.equal(pages, 120)
for (const audioRoot of ['tang-detective/package-audio-c01-a', 'tang-detective/package-audio-c01-b']) {
const tracks = localRequire(path.join(context.outputDirectory, audioRoot, 'data/audioPages.js'))
for (const track of Object.values(tracks)) {
assertLocalAsset(track.audioSrc, audioRoot)
assertLocalAsset(track.imageSrc, audioRoot)
assert.equal(track.reviewStatus, 'technical-qa-pass-human-listening-pending')
}
}
})
test('overlay protection and owned cleanup preserve media, host state, and external edits', t => {
const context = fixture(t)
write(context.overlayDirectory, 'utils/obsolete.js', 'module.exports = 1')
copyNativeProgram(context.options)
fs.unlinkSync(path.join(context.overlayDirectory, 'utils/obsolete.js'))
copyNativeProgram(context.options)
assert.equal(fs.existsSync(path.join(context.outputDirectory, 'tang-detective/utils/obsolete.js')), false)
write(context.outputDirectory, 'tang-detective/utils/storage.js', '/* external user edit */')
assert.throws(() => copyNativeProgram(context.options), /unowned native output/)
write(context.overlayDirectory, 'app.js', 'App({})')
assert.throws(() => copyNativeProgram(context.options), /cannot replace the host app/)
fs.unlinkSync(path.join(context.overlayDirectory, 'app.js'))
write(context.overlayDirectory, 'assets/replacement.jpg', 'not an allowed media replacement')
assert.throws(() => copyNativeProgram(context.options), /cannot replace source media/)
})
test('page registration rejects ambiguous input and Vite hook remains post-sequential and WeChat-only', () => {
assert.equal(wrapNativePage('Page({\n})', 'pages/home/home.js'), 'require("../../utils/tangPage.js")({\n})')
assert.throws(() => wrapNativePage('Page({})\nPage({})', 'pages/home/home.js'), /one top-level/)
const plugin = nativePlugin()
assert.equal(plugin.enforce, 'post')
assert.equal(plugin.writeBundle.order, 'post')
assert.equal(plugin.writeBundle.sequential, true)
const previous = process.env.UNI_PLATFORM
try {
process.env.UNI_PLATFORM = 'h5'
assert.equal(plugin.apply(), false)
process.env.UNI_PLATFORM = 'mp-weixin'
assert.equal(plugin.apply(), true)
} finally {
if (previous === undefined) delete process.env.UNI_PLATFORM
else process.env.UNI_PLATFORM = previous
}
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,139 @@
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { fileURLToPath } from 'node:url'
const buildDirectory = path.dirname(fileURLToPath(import.meta.url))
export const SOURCE_MANIFEST_PATH = path.join(buildDirectory, 'tang-detective-source-manifest.json')
export const DEFAULT_PRUNE_RECEIPT_PATH = path.join(buildDirectory, 'tang-detective-media-prune-receipt.json')
export const ORIGINAL_SOURCE_MANIFEST_SHA256 = '3b203406a31fdbe17c770aeea0bab19755acfedf3fa1595641ee4e15a9712e20'
export const MEDIA_TYPES = {
'.jpg': ['image', 'image/jpeg'], '.jpeg': ['image', 'image/jpeg'],
'.png': ['image', 'image/png'], '.webp': ['image', 'image/webp'],
'.gif': ['image', 'image/gif'], '.svg': ['image', 'image/svg+xml'], '.avif': ['image', 'image/avif'],
'.mp3': ['audio', 'audio/mpeg'], '.wav': ['audio', 'audio/wav'],
'.aac': ['audio', 'audio/aac'], '.m4a': ['audio', 'audio/mp4'], '.ogg': ['audio', 'audio/ogg'],
'.mp4': ['video', 'video/mp4'], '.webm': ['video', 'video/webm'], '.mov': ['video', 'video/quicktime'],
}
export const isMediaFile = value => Boolean(MEDIA_TYPES[path.extname(value).toLowerCase()])
export const sha256 = value => crypto.createHash('sha256').update(value).digest('hex')
export const canonicalJsonSha256 = value => sha256(`${JSON.stringify(value, null, 2)}\n`)
const SHA256 = /^[a-f0-9]{64}$/
function assert(condition, message) {
if (!condition) throw new Error(`Invalid Tang Detective source snapshot: ${message}`)
}
export function safeSourcePath(value) {
return typeof value === 'string' && value.length > 0 && !path.isAbsolute(value)
&& !/[\\?#\u0000-\u0020]/.test(value)
&& value.split('/').every(part => part && part !== '.' && part !== '..')
}
function lstatOrNull(filename) {
try { return fs.lstatSync(filename) } catch (error) {
if (error.code === 'ENOENT') return null
throw error
}
}
export function readSourceManifest(sourceManifestPath = SOURCE_MANIFEST_PATH) {
const stat = lstatOrNull(sourceManifestPath)
assert(stat?.isFile() && !stat.isSymbolicLink(), 'source manifest must be a regular file')
const bytes = fs.readFileSync(sourceManifestPath)
const manifestSha256 = sha256(bytes)
if (path.resolve(sourceManifestPath) === SOURCE_MANIFEST_PATH) {
assert(manifestSha256 === ORIGINAL_SOURCE_MANIFEST_SHA256, 'original source manifest changed')
}
const manifest = JSON.parse(bytes)
assert(Array.isArray(manifest.files) && manifest.files.length > 0, 'source manifest files are required')
const files = new Map()
for (const record of manifest.files) {
assert(record && safeSourcePath(record.path) && !files.has(record.path), 'unsafe or duplicate source manifest path')
assert(Number.isSafeInteger(record.bytes) && record.bytes >= 0 && SHA256.test(record.sha256), `invalid source metadata: ${record.path}`)
files.set(record.path, record)
}
return { manifest, manifestSha256, files }
}
/** Inspect the entire tree, including dangling links and unexpected file types.
* No media omission may hide an extra file, source edit or symlink. */
export function inspectSourceFiles(sourceDirectory, source = readSourceManifest()) {
const root = path.resolve(sourceDirectory)
const rootStat = lstatOrNull(root)
assert(rootStat?.isDirectory() && !rootStat.isSymbolicLink(), 'source root must be a real directory, not a symbolic link')
const actual = new Map()
const expectedDirectories = new Set([...source.files.keys()].flatMap(relative => {
const parts = relative.split('/')
return parts.slice(0, -1).map((_, index) => parts.slice(0, index + 1).join('/'))
}))
function visit(directory, prefix = '') {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const relative = prefix ? `${prefix}/${entry.name}` : entry.name
const filename = path.join(directory, entry.name)
assert(!entry.isSymbolicLink(), `source symbolic links are forbidden: ${relative}`)
if (entry.isDirectory()) {
assert(expectedDirectories.has(relative), `unexpected source directory: ${relative}`)
visit(filename, relative)
continue
}
assert(entry.isFile(), `unexpected source file type: ${relative}`)
assert(source.files.has(relative), `unexpected source file: ${relative}`)
const record = source.files.get(relative)
const bytes = fs.readFileSync(filename)
assert(bytes.length === record.bytes && sha256(bytes) === record.sha256, `source bytes changed: ${relative}`)
actual.set(relative, record)
}
}
visit(root)
const missingMedia = []
for (const record of source.files.values()) {
if (actual.has(record.path)) continue
assert(isMediaFile(record.path), `missing non-media source: ${record.path}`)
missingMedia.push(record.path)
}
return { actual, missingMedia }
}
export function validatePruneReceipt(receipt, { source, media }) {
assert(receipt?.schemaVersion === 1 && receipt.status === 'completed', 'completed project-local media prune receipt is required')
assert(receipt.sourceManifestSha256 === source.manifestSha256, 'prune receipt source manifest hash mismatch')
assert(receipt.mediaManifestSha256 === media.manifestSha256, 'prune receipt media manifest hash mismatch')
assert(typeof media.uploadRunId === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(media.uploadRunId)
&& receipt.uploadRunId === media.uploadRunId, 'prune receipt upload run mismatch')
assert(receipt.backup?.format === 'tar.gz' && SHA256.test(receipt.backup.sha256)
&& Number.isSafeInteger(receipt.backup.bytes) && receipt.backup.bytes > 0, 'prune receipt verified backup hash and size are required')
const expectedMedia = [...source.files.values()].filter(record => isMediaFile(record.path))
assert(media.entries instanceof Map && media.entries.size === expectedMedia.length
&& expectedMedia.every(record => {
const entry = media.entries.get(record.path)
return entry?.sourcePath === record.path && entry.sha256 === record.sha256 && entry.bytes === record.bytes
&& entry.uploaded === true && entry.publicReadVerified === true && entry.remoteVerifiedSha256 === record.sha256
&& (MEDIA_TYPES[path.extname(record.path)][0] === 'image' || entry.rangeVerified === true)
}), 'prune receipt requires complete verified source media coverage')
assert(Array.isArray(receipt.entries) && receipt.entries.length === expectedMedia.length, 'prune receipt media coverage is incomplete')
const seen = new Set()
for (const entry of receipt.entries) {
assert(entry && safeSourcePath(entry.sourcePath) && !seen.has(entry.sourcePath), 'unsafe or duplicate prune receipt entry')
const expected = media.entries.get(entry.sourcePath)
assert(expected && entry.sha256 === expected.sha256 && entry.bytes === expected.bytes
&& entry.url === expected.url && entry.objectKey === expected.objectKey
&& entry.backupSha256 === receipt.backup.sha256, `prune receipt entry mismatch: ${entry.sourcePath}`)
seen.add(entry.sourcePath)
}
return { receiptSha256: canonicalJsonSha256(receipt), uploadRunId: receipt.uploadRunId, backupSha256: receipt.backup.sha256 }
}
export function validateSourceSnapshot({ sourceDirectory, sourceManifestPath = SOURCE_MANIFEST_PATH,
media = null, pruneReceipt, pruneReceiptPath = DEFAULT_PRUNE_RECEIPT_PATH } = {}) {
const source = readSourceManifest(sourceManifestPath)
const inspection = inspectSourceFiles(sourceDirectory, source)
let prune = null
if (inspection.missingMedia.length) {
assert(media, `local mode requires all media; ${inspection.missingMedia.length} source media files are missing`)
assert(media.sourceManifestSha256 === source.manifestSha256, 'remote source manifest binding mismatch')
if (pruneReceipt === undefined) {
const stat = lstatOrNull(pruneReceiptPath)
assert(stat?.isFile() && !stat.isSymbolicLink(), 'completed project-local media prune receipt is required')
pruneReceipt = JSON.parse(fs.readFileSync(pruneReceiptPath, 'utf8'))
}
prune = validatePruneReceipt(pruneReceipt, { source, media })
}
return { ...source, ...inspection, prune }
}
@@ -0,0 +1,184 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import nativePlugin, { copyNativeProgram, listFiles, sha256 } from './tang-detective-native-plugin.mjs'
import { validateCosMediaManifest } from './tang-detective-cos-media.mjs'
import { validateNativeOutput } from './validate-tang-detective-output.mjs'
import { readSourceManifest, validateSourceSnapshot } from './tang-detective-source-validation.mjs'
import { createSyntheticSource, fakeVerifiedManifest, fakePruneReceipt, writeFixture } from './tang-detective-test-fixtures.mjs'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
function fixture(t, { prune = false } = {}) {
const temporary = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'tang-source-test-')))
t.after(() => fs.rmSync(temporary, { recursive: true, force: true }))
const source = createSyntheticSource(temporary)
const mediaManifest = fakeVerifiedManifest(source)
const pruneReceipt = fakePruneReceipt(mediaManifest)
const pruneReceiptPath = path.join(temporary, 'test-only-prune-receipt.json')
writeFixture(temporary, 'test-only-prune-receipt.json', JSON.stringify(pruneReceipt))
if (prune) for (const file of source.sourceMedia) fs.unlinkSync(path.join(source.sourceDirectory, file.path))
const outputDirectory = path.join(temporary, 'output')
writeFixture(outputDirectory, 'app.json', JSON.stringify({ pages: ['pages/index/index'] }))
writeFixture(outputDirectory, 'app.js', '/* host unchanged */')
const options = { sourceDirectory: source.sourceDirectory, sourceManifestPath: source.sourceManifestPath,
overlayDirectory: path.join(projectRoot, 'native-adapter/tang-detective'), outputDirectory,
mediaManifest, pruneReceiptPath }
return { temporary, ...source, options, mediaManifest, pruneReceipt, pruneReceiptPath, outputDirectory }
}
const snapshot = directory => listFiles(directory).map(file => [file, sha256(fs.readFileSync(path.join(directory, file)))])
test('the pinned original manifest has 269 nonmedia and exactly 160 JPG / 44 MP3 files', () => {
const source = readSourceManifest()
assert.equal(source.files.size, 473)
assert.equal([...source.files.keys()].filter(file => file.endsWith('.jpg')).length, 160)
assert.equal([...source.files.keys()].filter(file => file.endsWith('.mp3')).length, 44)
})
test('a fully pruned clone builds and validates from 269 intact files with no external backup access', t => {
const context = fixture(t, { prune: true })
// A portable receipt records the archive hash. It never requires opening a
// prior machine's absolute path, and no archive exists in this offline test.
context.pruneReceipt.backup.originalPath = '/unavailable-on-another-machine/test-only.tar.gz'
writeFixture(context.temporary, 'test-only-prune-receipt.json', JSON.stringify(context.pruneReceipt))
const media = validateCosMediaManifest(context.mediaManifest, context.options)
assert.equal(media.sourceSnapshot.actual.size, 269)
assert.equal(media.sourceSnapshot.missingMedia.length, 204)
assert.equal(media.sourceSnapshot.prune.backupSha256, context.pruneReceipt.backup.sha256)
const report = copyNativeProgram(context.options)
assert.equal(report.source.omittedMediaFiles, 204)
assert.equal(report.source.presentFiles, 269)
assert.equal(report.sizes.mediaFileCount, 0)
const validation = validateNativeOutput(context.outputDirectory, context.options)
assert.deepEqual(validation.errors, [])
assert.equal(validation.passed, true)
const before = snapshot(context.outputDirectory)
copyNativeProgram(context.options)
assert.deepEqual(snapshot(context.outputDirectory), before)
})
test('missing local media and missing remote prune receipt fail before any output changes', t => {
const context = fixture(t)
copyNativeProgram({ ...context.options, mediaManifest: null })
const before = snapshot(context.outputDirectory)
fs.unlinkSync(path.join(context.sourceDirectory, context.sourceMedia[0].path))
assert.throws(() => copyNativeProgram({ ...context.options, mediaManifest: null }), /local mode requires all media/)
assert.deepEqual(snapshot(context.outputDirectory), before)
fs.unlinkSync(context.pruneReceiptPath)
assert.throws(() => copyNativeProgram(context.options), /completed project-local media prune receipt is required/)
assert.deepEqual(snapshot(context.outputDirectory), before)
const report = validateNativeOutput(context.outputDirectory, { ...context.options, mediaManifest: null })
assert.equal(report.passed, false)
assert.ok(report.errors.some(error => error.includes('local mode requires all media')))
})
test('the Vite buildStart preflight rejects missing local media before bundle output can begin', t => {
const context = fixture(t, { prune: true })
const plugin = nativePlugin({ ...context.options, mediaManifest: null })
plugin.configResolved({ root: context.temporary, build: { outDir: 'output' } })
const before = snapshot(context.outputDirectory)
assert.throws(() => plugin.buildStart.call({ addWatchFile() {} }), /local mode requires all media/)
assert.deepEqual(snapshot(context.outputDirectory), before)
})
test('pruning needs an exact complete receipt bound to the manifest, upload run, file records and archive hash', t => {
const context = fixture(t, { prune: true })
const mutations = [
receipt => { receipt.schemaVersion = 2 },
receipt => { receipt.status = 'planned' },
receipt => { receipt.sourceManifestSha256 = '0'.repeat(64) },
receipt => { receipt.mediaManifestSha256 = '0'.repeat(64) },
receipt => { receipt.uploadRunId = 'different-run' },
receipt => { receipt.backup.sha256 = 'not-a-hash' },
receipt => { receipt.backup.bytes = 0 },
receipt => { receipt.backup.format = 'zip' },
receipt => { receipt.entries.pop() },
receipt => { receipt.entries[1] = { ...receipt.entries[0] } },
receipt => { receipt.entries[0].sourcePath = '../escape.jpg' },
receipt => { receipt.entries[0].sourcePath = 'app.json' },
receipt => { receipt.entries[0].sha256 = '0'.repeat(64) },
receipt => { receipt.entries[0].bytes++ },
receipt => { receipt.entries[0].url += '?tampered' },
receipt => { receipt.entries[0].objectKey = 'wrong.jpg' },
receipt => { receipt.entries[0].backupSha256 = '0'.repeat(64) },
]
const before = snapshot(context.outputDirectory)
for (const mutate of mutations) {
const receipt = structuredClone(context.pruneReceipt)
mutate(receipt)
assert.throws(() => copyNativeProgram({ ...context.options, pruneReceipt: receipt }), /Invalid Tang Detective source snapshot/)
assert.deepEqual(snapshot(context.outputDirectory), before)
}
// Even a single omitted file needs evidence for the entire upload inventory.
const partial = fixture(t)
fs.unlinkSync(path.join(partial.sourceDirectory, partial.sourceMedia[0].path))
assert.equal(validateCosMediaManifest(partial.mediaManifest, partial.options).sourceSnapshot.missingMedia.length, 1)
const invalidManifest = { ...context.mediaManifest, uploadRunId: undefined }
assert.throws(() => validateCosMediaManifest(invalidManifest, {
...context.options, pruneReceipt: fakePruneReceipt(invalidManifest),
}), /upload run mismatch/)
})
test('remote pruning never allows missing nonmedia, changed surviving bytes or extra source files', t => {
const context = fixture(t, { prune: true })
const nonmedia = 'utils/storage.js'
const original = fs.readFileSync(path.join(context.sourceDirectory, nonmedia))
fs.unlinkSync(path.join(context.sourceDirectory, nonmedia))
assert.throws(() => copyNativeProgram(context.options), /missing non-media source: utils\/storage.js/)
writeFixture(context.sourceDirectory, nonmedia, 'changed code')
assert.throws(() => copyNativeProgram(context.options), /source bytes changed: utils\/storage.js/)
writeFixture(context.sourceDirectory, nonmedia, original)
for (const relative of ['assets/unexpected.jpg', 'package-game/data/unexpected.js']) {
writeFixture(context.sourceDirectory, relative, 'unexpected')
assert.throws(() => copyNativeProgram(context.options), /unexpected source file/)
fs.unlinkSync(path.join(context.sourceDirectory, relative))
}
fs.mkdirSync(path.join(context.sourceDirectory, 'unexpected-empty-directory'))
assert.throws(() => copyNativeProgram(context.options), /unexpected source directory/)
fs.rmdirSync(path.join(context.sourceDirectory, 'unexpected-empty-directory'))
const sourceMedia = context.sourceMedia[0]
writeFixture(context.sourceDirectory, sourceMedia.path, 'changed surviving media')
assert.throws(() => copyNativeProgram(context.options), /source bytes changed/)
assert.equal(listFiles(context.outputDirectory).length, 2)
})
test('source and evidence symlinks, including dangling media links, cannot authorize or hide omission', t => {
const context = fixture(t, { prune: true })
const target = path.join(context.sourceDirectory, context.sourceMedia[0].path)
fs.symlinkSync(path.join(context.temporary, 'missing-media.jpg'), target)
assert.throws(() => copyNativeProgram(context.options), /source symbolic links are forbidden/)
fs.unlinkSync(target)
const outside = path.join(context.temporary, 'original-source-directory')
fs.renameSync(context.sourceDirectory, outside)
fs.symlinkSync(outside, context.sourceDirectory, 'dir')
try { assert.throws(() => copyNativeProgram(context.options), /source root must be a real directory/) }
finally { fs.unlinkSync(context.sourceDirectory); fs.renameSync(outside, context.sourceDirectory) }
const savedReceipt = path.join(context.temporary, 'original-prune-receipt.json')
fs.renameSync(context.pruneReceiptPath, savedReceipt)
fs.symlinkSync(savedReceipt, context.pruneReceiptPath)
assert.throws(() => copyNativeProgram(context.options), /completed project-local media prune receipt is required/)
fs.unlinkSync(context.pruneReceiptPath)
fs.renameSync(savedReceipt, context.pruneReceiptPath)
const savedSource = path.join(context.temporary, 'original-source-manifest.json')
fs.renameSync(context.sourceManifestPath, savedSource)
fs.symlinkSync(savedSource, context.sourceManifestPath)
assert.throws(() => validateSourceSnapshot(context.options), /source manifest must be a regular file/)
})
test('remote output validation rejects later receipt drift or removal', t => {
const context = fixture(t, { prune: true })
copyNativeProgram(context.options)
const invalid = { ...context.pruneReceipt, uploadRunId: 'changed-run' }
const failed = validateNativeOutput(context.outputDirectory, { ...context.options, pruneReceipt: invalid })
assert.equal(failed.passed, false)
assert.ok(failed.errors.some(error => error.includes('upload run mismatch')))
const rebound = structuredClone(context.pruneReceipt)
rebound.backup.sha256 = '1'.repeat(64)
for (const entry of rebound.entries) entry.backupSha256 = rebound.backup.sha256
const changedReceipt = validateNativeOutput(context.outputDirectory, { ...context.options, pruneReceipt: rebound })
assert.ok(changedReceipt.errors.includes('Media prune receipt changed after import'))
fs.unlinkSync(context.pruneReceiptPath)
assert.equal(validateNativeOutput(context.outputDirectory, context.options).passed, false)
})
@@ -0,0 +1,63 @@
// Test-only synthetic transport bytes. Never use this module for a real
// activation manifest, upload evidence, prune receipt or source backup.
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { isMediaFile, readSourceManifest, sha256 } from './tang-detective-source-validation.mjs'
const originalSourceDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../native/tang-detective')
export function writeFixture(directory, relative, bytes) {
const filename = path.join(directory, relative)
fs.mkdirSync(path.dirname(filename), { recursive: true })
fs.writeFileSync(filename, bytes)
}
export function createSyntheticSource(temporary) {
const sourceDirectory = path.join(temporary, 'source')
const original = readSourceManifest()
const replacements = new Map([...original.files.values()].filter(file => isMediaFile(file.path))
.map(file => [file.sha256, Buffer.from(`Test-only media fixture for ${file.sha256}\n`)]))
const hashReplacements = new Map([...replacements].map(([originalHash, bytes]) => [originalHash, sha256(bytes)]))
const files = []
for (const file of original.files.values()) {
let bytes
if (isMediaFile(file.path)) bytes = replacements.get(file.sha256)
else {
const source = fs.readFileSync(path.join(originalSourceDirectory, file.path))
if (source.length !== file.bytes || sha256(source) !== file.sha256) throw new Error(`Original non-media source drift: ${file.path}`)
bytes = Buffer.from(source.toString('utf8').replace(/[a-f0-9]{64}/g, value => hashReplacements.get(value) || value))
}
writeFixture(sourceDirectory, file.path, bytes)
files.push({ path: file.path, bytes: bytes.length, sha256: sha256(bytes) })
}
const sourceManifest = { version: 1, files }
const sourceBytes = Buffer.from(`${JSON.stringify(sourceManifest, null, 2)}\n`)
const sourceManifestPath = path.join(temporary, 'synthetic-source-manifest.json')
fs.writeFileSync(sourceManifestPath, sourceBytes)
return { sourceDirectory, sourceManifest, sourceManifestPath, sourceBytes,
sourceMedia: files.filter(file => isMediaFile(file.path)), hashReplacements }
}
export function fakeVerifiedManifest({ sourceBytes, sourceMedia }) {
const baseUrl = 'https://tang-assets.example.test'
return {
schemaVersion: 1, uploadRunId: 'test-only-offline-run', sourceManifestSha256: sha256(sourceBytes),
destination: { bucket: 'test-bucket', region: 'test-region', baseUrl },
entries: sourceMedia.map(file => {
const kind = file.path.endsWith('.jpg') ? 'image' : 'audio'
const objectKey = `tang-detective/season-01/media-v1/${file.sha256}${path.extname(file.path)}`
return { sourcePath: file.path, kind, contentType: kind === 'image' ? 'image/jpeg' : 'audio/mpeg',
bytes: file.bytes, sha256: file.sha256, objectKey, url: `${baseUrl}/${objectKey}`,
uploaded: true, publicReadVerified: true, remoteVerifiedSha256: file.sha256,
...(kind === 'audio' ? { rangeVerified: true } : {}) }
}),
}
}
export function fakePruneReceipt(mediaManifest) {
const backup = { sha256: sha256('Test-only archive receipt; no real archive or upload'), bytes: 123, format: 'tar.gz' }
return { schemaVersion: 1, status: 'completed', sourceManifestSha256: mediaManifest.sourceManifestSha256,
mediaManifestSha256: sha256(`${JSON.stringify(mediaManifest, null, 2)}\n`), uploadRunId: mediaManifest.uploadRunId, backup,
entries: mediaManifest.entries.map(({ sourcePath, sha256: digest, bytes, url, objectKey }) => ({
sourcePath, sha256: digest, bytes, url, objectKey, backupSha256: backup.sha256,
})) }
}
@@ -0,0 +1,118 @@
import fs from 'node:fs'
import path from 'node:path'
import vm from 'node:vm'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { createNativeManifest, createNativeOutputGuard, listFiles, sha256 } from './tang-detective-native-plugin.mjs'
import { isMediaFile, loadCosMediaManifest } from './tang-detective-cos-media.mjs'
import { readSourceManifest, validateSourceSnapshot } from './tang-detective-source-validation.mjs'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
export function validateNativeOutput(outputDirectory, { mediaManifest, mediaManifestPath,
sourceDirectory = path.join(projectRoot, 'native/tang-detective'), sourceManifestPath, pruneReceipt, pruneReceiptPath } = {}) {
const outputGuard = createNativeOutputGuard(outputDirectory)
const output = outputGuard.root
const nativeRoot = outputGuard.path('tang-detective', 'directory')
const sourceRoot = sourceDirectory
const sourceApp = JSON.parse(fs.readFileSync(path.join(sourceRoot, 'app.json'), 'utf8'))
const manifest = createNativeManifest(sourceApp)
const hostApp = JSON.parse(fs.readFileSync(outputGuard.path('app.json'), 'utf8'))
const imported = JSON.parse(fs.readFileSync(outputGuard.path('tang-detective/.native-import-state.json'), 'utf8'))
const sourceManifest = readSourceManifest(sourceManifestPath).manifest
const localRequire = createRequire(path.join(output, 'native-validation.cjs'))
const errors = []
const check = (condition, message) => { if (!condition) errors.push(message) }
const remoteMode = imported.media?.mode === 'cos'
let media = null
if (remoteMode) {
try {
media = loadCosMediaManifest({ sourceDirectory: sourceRoot, mediaManifest, mediaManifestPath,
sourceManifestPath, pruneReceipt, pruneReceiptPath })
check(Boolean(media), 'COS output requires its verified media manifest')
if (media) {
check(imported.media.manifestSha256 === media.manifestSha256, 'COS manifest changed after import')
check(imported.media.sourceManifestSha256 === media.sourceManifestSha256, 'COS source manifest changed after import')
if (imported.source?.prune) {
check(imported.source.prune.receiptSha256 === media.sourceSnapshot.prune?.receiptSha256, 'Media prune receipt changed after import')
}
}
} catch (error) { errors.push(error.message) }
} else {
try { validateSourceSnapshot({ sourceDirectory: sourceRoot, sourceManifestPath }) }
catch (error) { errors.push(error.message) }
}
const allRoutes = [...hostApp.pages, ...(hostApp.subPackages || hostApp.subpackages || []).flatMap(item => item.pages.map(page => `${item.root}/${page}`))]
const packageOwner = relative => manifest.subPackages.find(item => relative.startsWith(`${item.root}/`))?.root || 'main'
let mediaFiles = 0
for (const file of sourceManifest.files) {
if (!isMediaFile(file.path)) continue
mediaFiles += 1
outputGuard.path(`tang-detective/${file.path}`)
if (remoteMode) {
check(!fs.existsSync(path.join(nativeRoot, file.path)), `Packaged media remains in COS mode: ${file.path}`)
check(media?.entries.get(file.path)?.sha256 === file.sha256, `Remote media mapping changed: ${file.path}`)
} else {
check(sha256(fs.readFileSync(path.join(nativeRoot, file.path))) === file.sha256, `Media changed: ${file.path}`)
}
}
for (const file of imported.files) {
check(sha256(fs.readFileSync(outputGuard.path(`tang-detective/${file.path}`))) === file.sha256, `Native output changed after import: ${file.path}`)
}
const packagedMediaFiles = listFiles(nativeRoot).filter(isMediaFile).length
if (remoteMode) check(packagedMediaFiles === 0, 'Unexpected native packaged media in COS mode')
for (const page of manifest.allPages) {
check(allRoutes.filter(route => route === page).length === 1, `Page registration missing or duplicated: ${page}`)
for (const extension of ['.js', '.json', '.wxml', '.wxss']) check(fs.existsSync(path.join(output, `${page}${extension}`)), `Missing page file: ${page}${extension}`)
const config = JSON.parse(fs.readFileSync(path.join(output, `${page}.json`), 'utf8'))
check(config.navigationStyle === 'custom' && config.pageOrientation === 'landscape', `Native display config missing: ${page}`)
const script = fs.readFileSync(path.join(output, `${page}.js`), 'utf8')
check(!/^Page\(\{/m.test(script) && /require\(["']\.\.\/(?:\.\.\/)*utils\/tangPage\.js["']\)\(\{/.test(script), `Page lifecycle wrapper missing: ${page}`)
const style = fs.readFileSync(path.join(output, `${page}.wxss`), 'utf8')
check(/@import\s+["']\/tang-detective\/shared\.wxss["'];/.test(style), `Native style import missing: ${page}`)
}
let dependencies = 0
for (const relative of listFiles(nativeRoot).filter(file => file.endsWith('.js'))) {
const filename = path.join(nativeRoot, relative)
const script = fs.readFileSync(filename, 'utf8')
try { new vm.Script(script, { filename: relative }) } catch (error) { errors.push(error.message) }
for (const [, specifier] of script.matchAll(/require\(['"]([^'"]+)['"]\)/g)) {
dependencies += 1
if (!specifier.startsWith('.')) { errors.push(`Non-relative native dependency: ${relative} -> ${specifier}`); continue }
try {
const resolved = localRequire.resolve(path.resolve(path.dirname(filename), specifier))
const dependency = path.relative(output, resolved).split(path.sep).join('/')
check(dependency.startsWith('tang-detective/'), `Dependency escaped namespace: ${relative} -> ${specifier}`)
check(packageOwner(dependency) === 'main' || packageOwner(dependency) === packageOwner(`tang-detective/${relative}`), `Illegal sibling package import: ${relative} -> ${specifier}`)
} catch (error) { errors.push(`Unresolved require: ${relative} -> ${specifier}: ${error.message}`) }
}
}
const outputSizes = { totalFileBytes: 0, mainPackageFileBytes: 0, subPackages: {} }
const outputPackages = hostApp.subPackages || hostApp.subpackages || []
for (const relative of listFiles(output)) {
const bytes = fs.statSync(path.join(output, relative)).size
outputSizes.totalFileBytes += bytes
const subpackage = outputPackages.find(item => relative.startsWith(`${item.root}/`))
if (subpackage) outputSizes.subPackages[subpackage.root] = (outputSizes.subPackages[subpackage.root] || 0) + bytes
else outputSizes.mainPackageFileBytes += bytes
}
return {
passed: errors.length === 0,
nativePages: manifest.allPages.length,
mediaFiles,
packagedMediaFiles,
mediaMode: remoteMode ? 'cos' : 'local',
relativeDependencies: dependencies,
nativeSizes: imported.sizes,
outputSizes,
errors,
boundary: 'Static source/asset/dependency/manifest verification only. Raw file sizes are not WeChat upload package sizes; no simulator, device, audio listening, upload, or release acceptance was performed.',
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const report = validateNativeOutput(path.resolve(projectRoot, process.argv[2] || 'dist/build/mp-weixin'))
if (process.argv[3]) fs.writeFileSync(path.resolve(projectRoot, process.argv[3]), `${JSON.stringify(report, null, 2)}\n`)
console.log(JSON.stringify(report, null, 2))
if (!report.passed) process.exitCode = 1
}
@@ -0,0 +1,37 @@
import fs from 'node:fs'
import path from 'node:path'
import vm from 'node:vm'
import assert from 'node:assert/strict'
import { fileURLToPath } from 'node:url'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const output = path.resolve(projectRoot, process.argv[2] || 'dist/build/mp-weixin')
const config = JSON.parse(fs.readFileSync(path.join(output, 'app.json'), 'utf8'))
const sourcePages = JSON.parse(fs.readFileSync(path.join(projectRoot, 'pages.json'), 'utf8'))
const allPages = new Set([...config.pages, ...(config.subPackages || config.subpackages || []).flatMap(pkg => pkg.pages.map(page => `${pkg.root}/${page}`))])
assert.equal(config.pages[0], 'tongji/pages/weekly', 'The standalone Tongji home must remain the startup page')
for (const page of sourcePages.pages) assert(allPages.has(page.path), `Missing existing host route: ${page.path}`)
for (const pkg of sourcePages.subPackages || []) {
for (const page of pkg.pages) assert(allPages.has(`${pkg.root}/${page.path}`), `Missing host subpackage route: ${pkg.root}/${page.path}`)
}
assert(allPages.has('tongji/tang-detective/index'))
assert(allPages.has('tang-detective/pages/home/home'))
assert(allPages.has('tongji/endless-game/index'))
const apiText = fs.readFileSync(path.join(projectRoot, 'config/api.js'), 'utf8')
const sourceApi = apiText.match(/export\s+const\s+API_BASE_URL\s*=\s*['"]([^'"]+)['"]/)
assert(sourceApi, 'Public API config must export API_BASE_URL')
const platform = { module: { exports: {} } }
vm.runInNewContext(fs.readFileSync(path.join(output, 'tang-detective/utils/platformConfig.js'), 'utf8'), platform, { timeout: 1000 })
assert.deepEqual(Object.keys(platform.module.exports), ['apiBaseUrl'], 'Only the public API base belongs in native platformConfig')
assert.equal(platform.module.exports.apiBaseUrl, sourceApi[1], 'Native progress calls must use this host API, not the previous project API')
const weekly = fs.readFileSync(path.join(output, 'tongji/pages/weekly.wxml'), 'utf8')
assert(weekly.includes('唐侦探'), 'The standalone weekly page must contain the game entry')
assert(weekly.includes('识糖小课堂'), 'Existing match-three entry must remain visible')
assert(!fs.existsSync(path.join(output, 'TUICallKit')), 'Do not import the unrelated call SDK into TongjiUniApp')
const home = fs.readFileSync(path.join(output, 'tang-detective/pages/home/home.js'), 'utf8')
assert(home.includes("wx.reLaunch({ url: '/tongji/pages/weekly' })"), 'Return must target the standalone host home')
console.log(JSON.stringify({ passed: true, project: 'TongjiUniApp', startupPage: config.pages[0],
sourceHostRoutes: sourcePages.pages.length + (sourcePages.subPackages || []).reduce((sum, pkg) => sum + pkg.pages.length, 0),
nativePages: [...allPages].filter(page => page.startsWith('tang-detective/')).length,
apiBaseUrl: sourceApi[1], unrelatedCallSdkImported: false,
boundary: 'Built output routing/configuration checks only; not a live backend or device test.' }, null, 2))
+3
View File
@@ -0,0 +1,3 @@
// Shared by the host API client and the native Tang Detective build adapter.
// This is a public endpoint, never a place for tokens or API secrets.
export const API_BASE_URL = 'https://xt.zhenyangtang.com.cn/'
@@ -0,0 +1,62 @@
# 唐侦探真实素材上云与清理
正确项目:`xuetang/TongjiUniApp`。上传批次:`b9ab703a-49a6-4b6b-ac34-979eca1d4828`
## 已完成上传与核验
用户自行登录 `https://xt.zhenyangtang.com.cn/admin/` 后,通过已有图片上传和语音直传界面上传;没有提取登录令牌或长期密钥,没有点击“确定发布”,没有改变桶权限。后台显示腾讯云 COS(对象存储)开启。原始文件仅复制为带序号的上传副本,没有生成、重编码、裁剪或压缩内容。
- 204 条唐侦探媒体路径:160 JPG、44 MP3、0 视频,总原始字节 24,052,082。
- 去重后 180 个实际对象:136 图片、44 音频。所有对象已完成不带凭据的 HTTPS(加密传输)完整下载,长度、类型和 SHA-256(摘要)均与原件一致。
- 全部 44 音频另通过 Range(分段下载)206 状态、范围头及首段原始字节一致性检查。
- 实际使用现有域名 `https://gz-1349751149.cos.ap-guangzhou.myqcloud.com`。图片位于 `uploads/images/20260908/`,语音位于 `uploads/voice/20260908/`;完整 URL(地址)逐条保存在真实清单。
证据文件:
- `build/tang-detective-source-manifest.json`:原始 473 项快照,保持原字节与固定摘要。
- `build/tang-detective-admin-upload-plan.json`:去重与上传副本对应关系;它本身不是成功证明。
- `build/tang-detective-admin-upload-observations.json`:管理界面实际返回地址,数组与上传副本序号对应。
- `build/tang-detective-admin-upload-receipt.json`180 个对象的真实完整回读结果,`complete=true`
- `build/tang-detective-cos-manifest.json`:完整 204 条路径的激活清单;仅全部验证通过后生成。
- `build/tang-detective-admin-audit-receipt.json`:清理后再次真实回读全部 180 对象,`complete=true`;不改写原激活清单。
- `build/tang-detective-cleanup-verification.json`:实际清理后源码、恢复副本、测试、构建与本机微信编译器结果和命令输出,`passed=true`
## 清理状态
独立代码审查通过后,已将 204 个被真实远端地址替代的媒体,以及 10 个没有运行时代码引用的旧图片移出项目,共 214 文件、24,145,441 字节。凭证为 `build/tang-detective-media-prune-receipt.json`,绑定源快照、真实远端清单、上传批次、备份摘要与每条移出记录。没有删除共享后端、此前误迁的 `TUICallKit-Vue3`、原唐侦探项目、图标字体或运行库。
保留 2 张个人中心导航图标和 5 张仍有业务数据引用的订单图片。10 个无引用旧图片的精确清单在 `scripts/tang-cos/prune-verified-media.mjs`,其范围不能扩大为按扩展名批量删除。
项目外备份:`/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/original-media.tar.gz`。归档含 221 个原媒体及源快照清单,222 项曾全部逐字节回读;清理预检查又独立回读了全部 214 个待移出条目。
归档 SHA-256`656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61`。归档没有密钥或登录配置。移出的原字节文件还保存在同目录 `pruned-originals/`,项目本身不需要访问此路径即可构建。
## 怎样运行和复核
`TongjiUniApp` 中执行:
```sh
npm run test:tang
npm run build:mp-weixin
npm run check:tang-output
node scripts/check-tang-native-compiler.cjs
node scripts/tang-cos/verify-admin-upload.mjs audit
```
最后一条只读取公开 COS 文件并写本地审计回执,不会再上传素材或改写激活清单。正常构建自动读取项目内清单;缺少媒体时必须同时提供完整、匹配的清理凭证,269 个非媒体源文件始终逐项校验。不应重新生成源快照、删除清单或用测试替身覆盖真实清单。
需要恢复本地原件时,应从上述项目外原件目录按精确路径恢复,并先核对备份及目标是否已有改动;不要覆盖现有代码。旧直连上传命令要求完整原件与可信数据库证书,此次没有绕过数据库证书错误,而是使用正常已登录上传通道。
## 已实测与限制
清理后的实际项目再次验证:81/81 自动测试通过;微信构建退出 0;24 个原生页、525 个相对依赖、原宿主路由与 API(接口)地址校验通过;唐侦探源目录和产物目录媒体数均为 0;本机微信 `wcc` / `wcsc` 对 24 页均编译通过。269 个非媒体源码及 214 个移出的恢复副本全部逐字节通过检查。另对原唐侦探项目的全部 473 个文件再次比对原快照,摘要与长度均一致。
实际产物已更新至 `TongjiUniApp/dist/build/mp-weixin`:原始构建文件总大小 7,438,230 字节,主包原始文件大小 1,412,653 字节。这些是磁盘文件字节统计,不是微信上传压缩包测量或发布通过证明。项目仅余 7 张仍有业务引用的宿主图片,无唐侦探本地图片、音频或视频。
老年友好小游戏验证规范用于保留原 120 页选图及 112 正式/8 临时状态,不提升音频试听、内容审核、可玩性或医疗审批状态。网络回读和编译不等于微信真机播放、账号云存档、老人体验、医生审签或公开发布。本次没有部署后端、提交数据库迁移、推送远端版本库、提交审核或发布小程序。
## 专业审查与资源收尾
微信小程序专业智能体完成严格删源后构建与清单校验,29/29 专项测试通过;代码审查专业智能体完成独立安全检查,44/44 隔离测试通过,未发现阻止本次清理的问题。主智能体负责真实上传、公开回读、备份核验、可恢复移出和实际项目删后复验。
上传/选择对话框已关闭,页面无音视频播放器;测试、编译、公开下载进程均已结束,没有新增监听服务。为保护用户工作,保留已登录后台、原有微信开发者工具及其他既有窗口;项目外归档和原字节恢复目录保留。无关闭失败资源。
@@ -0,0 +1,50 @@
# 素材上云与清理:历史阻塞和恢复点
本页记录用户登录后台之前的失败尝试,已被后续正常管理界面上传解除。当前真实结果请看 [2026-09-08 素材迁移记录](TANG-COS-MIGRATION-20260908.md) 与 `build/tang-detective-admin-upload-receipt.json`,不要把下面历史“上传 0”当作当前状态。数据库证书信任问题本身未被绕过或修复。
2026-09-08 用户再次明确:把图片、音频上传到 `xuetang/server` 配置的 COS(对象存储),随后删除 `TongjiUniApp` 中不必要的本地图音文件。上传、替换真实引用、校验和可恢复清理均已获授权;未授权绕过证书校验、改变桶权限、删除云端对象或发布小程序。
## 本次实际结果
- 安装了 `scripts/tang-cos` 锁定的 77 个工具依赖,未升级依赖、未改变小程序运行依赖。
- `node scripts/tang-cos/upload.mjs inspect` 退出码 1,脱敏错误码 `HANDSHAKE_SSL_ERROR`;仅进一步确认其属于数据库自签名证书信任失败,没有输出原始连接信息。
- 未取得实际 COS 配置或密钥;未发出 COS 写入请求;上传数为 0,删除数为 0,没有伪造真实激活清单。
- 后端专业智能体确认存储配置来自配置表中的 `storage/default``storage/qcloud`;现有仓库没有数据库专用可信 CA(证书颁发机构)文件。本机 SSH(加密远程连接)配置中没有匹配该数据库主机的入口。
- 已检查服务器现有管理入口 `https://xt.zhenyangtang.com.cn/admin/`,实际进入后台登录页,未登录。该标签页作为用户登录接续入口保留;未尝试账号或密码,也未读取浏览器凭据。
接续方式:请用户自行登录已打开的管理后台。登录后可检查现有图片/文件上传界面与配置驱动,通过正常已授权上传流程处理素材。不要从后台提取或输出长期密钥。若后台通道不可用,另一选项是管理员通过可信渠道提供数据库 CA 文件,再使用现有 `TANG_DB_CA_FILE` 配置;不能抓取对端自签名证书后自行建立信任。
## 已完成项目外备份
备份位置:`/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/original-media.tar.gz`
- 221 个图音素材,合计 24,179,649 字节,另含原唐侦探源文件摘要清单;排除了依赖、构建产物和运行库。
- 全部 222 个压缩包条目已逐条回读,与当前原件逐字节相等。
- 压缩包 SHA-256`656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61`
- 其中唐侦探为 204 条媒体路径:160 JPG、44 MP3,合计 24,052,082 字节;去重后 180 个对象。
- 未删除或移动任何原件。备份不包含 `.env`、账号、登录令牌或 API 密钥文件。
## 清理前还要补齐
当前构建的远程模式能移除产物媒体,但校验器仍要求项目源目录中的全部媒体存在,不能直接删除源图片/音频后宣布完成。
微信开发专业智能体已确认最小变更范围:共享严格源清单校验;完整且真实的远程登记表可以承接缺失媒体,但必须保留全部非媒体源文件,拒绝额外文件、原件漂移、符号链接和不完整清单。本地模式缺原件必须失败。上传工具继续要求完整原件,清理回执绑定上传、远程登记表和外部备份摘要。
10 个无运行时引用的图音候选共 93,359 字节,尚未删除:
- `static/background.svg`
- `static/calling-logo.png`
- `static/check.png`
- `static/user/home.png`
- `static/user/home_no.png`
- `static/wjw.png`
- `static/ys.png`
- `static/yy.png`
- `static/zs.jpg`
- `training/static/footprint.svg`(仅说明文件提及的备用素材)
保留仍被引用的个人中心图标、订单图、图标字体和不属于此次图音范围的运行库。实际删除前须重新核对引用、真实上传/公开回读与备份,不能把本清单当作已执行结果。
## 资源收尾
本次依赖安装、检查和归档命令均已结束,无临时监听服务或音视频播放。保留后台登录接续页和项目外备份;未操作或关闭已有小程序窗口、旧项目窗口及用户其他网页。无关闭失败。代码审查专业智能体未调用,因为尚未实施远程替换或删除。
@@ -0,0 +1,95 @@
# 唐侦探迁入 TongjiUniApp
核对日期:2026-09-08。本说明针对目录纠正后的 `xuetang/TongjiUniApp`,不是此前的 `xuetang/TUICallKit-Vue3`。用户登录后台后的素材上云结果见 [素材实测与清理记录](TANG-COS-MIGRATION-20260908.md);下方“迁移初始实测记录”保留历史阶段证据。
## 当前结果与边界
- 入口位于原首页 `tongji/pages/weekly`,作为与「识糖小课堂」并列的图标卡片。
- 微信端经 `tongji/tang-detective/index` 打开唐侦探原生首页;原 9 个主页面、5 个训练页面和登录/健康记录流程的源码保留。
- 迁入 24 个原生页面、15 回故事;保留原始 473 项源快照与摘要,其中 269 个非媒体文件必须完整。媒体上云不改变原始摘要,没有重新生成图片、音频或视频。
- `native-adapter/tang-detective` 隔离宿主与原生游戏的路径、页面生命周期、账号存档和媒体适配;不替换宿主 `App.vue`,不引入 TUICallKit 通话 SDK(软件开发工具包)。
- H5(网页端)可构建;唐侦探入口显示需从微信打开的提示,不宣称网页端已可游玩。
- 已通过正常登录后的管理界面上传素材,未提取长期密钥、改变桶权限、发布分发资源、部署后端、执行数据库迁移或推送 Git(版本库)。真机与发布验收仍未完成。
## 如何运行
在本项目目录执行:
```sh
cd /Users/dagedagededagege/Pictures/xuetang/TongjiUniApp
npm ci --ignore-scripts --no-audit --no-fund
npm run test:tang
npm run build:mp-weixin
npm run check:tang-output
node scripts/check-tang-native-compiler.cjs
```
微信开发者工具应导入本项目的 `dist/build/mp-weixin`,不要导入之前的 TUICallKit-Vue3 产物。首页进入「唐侦探」。命令行原生编译检查使用本机已安装的微信编译器;其他机器可用 `TANG_WECHAT_COMPILER_DIR` 指定编译器所在目录。该检查仅验证模板/样式语法,不会打开模拟器、登录、播放或上传。
网页端构建命令:
```sh
npm run build:h5
```
不升级原依赖版本。开发依赖沿用项目 `package-lock.json`COS 工具可选依赖单独锁定在 `scripts/tang-cos/package-lock.json`,正常构建与本地测试不需要安装该可选依赖。
## 接口对接
`config/api.js` 是公开接口地址的单一来源,保留此宿主原地址 `https://xt.zhenyangtang.com.cn/``main.js` 与原生构建插件共用它;生成的 `tang-detective/utils/platformConfig.js` 只包含公开地址,不包含密钥或登录令牌。
游戏复用宿主登录后的 `token` 请求头:
- `GET /api/tang/catalog`
- `GET /api/tang/progress`
- `POST /api/tang/saveProgress`
只同步受白名单约束的章节、事件、卡片 ID(标识)和阅读位置;不上传健康回答、诊疗数据、剧情文字快照或音频。访客存档本地保留,不在登录后自动合并上传。账号切换、断网、登录过期及冲突场景保留独立状态,不能把请求已发送当作云端确认。
接口实现位于同级 `../server`,沿用先前实现,目录纠正未再次改动后端。契约及部署前置条件见 [后端说明](../../server/docs/tang-detective-progress.md) 和 [接口契约](../../server/docs/tang-detective.openapi.yaml)。本轮未对 `xt.zhenyangtang.com.cn` 执行真实登录与存档联调;其他项目域名的历史结果不能用于判断此域名已成功或失败。
## 素材与 COS(对象存储)
源素材为 160 张图片、44 个音频、0 个视频,共 204 条路径,去重后 180 个对象,合计 24,052,082 字节。原始摘要清单不改写;清理后由真实远端清单和项目内清理凭证共同承接缺失媒体,非媒体源文件仍需逐项通过校验。
不需要任何密钥的远端回读复核命令(只发公开读取请求,生成本地审计回执):
```sh
node scripts/tang-cos/verify-admin-upload.mjs audit
```
本次使用 `xuetang/server` 已配置并开启的 COS,通过已有管理界面上传。实际地址位于 `gz-1349751149.cos.ap-guangzhou.myqcloud.com` 下的 `uploads/images/20260908/``uploads/voice/20260908/`;地址来自上传结果,不由客户端猜测。清单第二版严格约束已实测的域名与对象命名。
`build/tang-detective-cos-manifest.json` 已由完整公开回读验证后激活。180 个对象的长度、SHA-256(摘要)、媒体类型全部一致;44 个音频均通过 Range(分段下载)206 与片段字节校验。实际回执为 `build/tang-detective-admin-upload-receipt.json`;旧直连配置失败的历史回执不代表当前上传状态。
原直连上传工具仍保留,要求完整原件与可信数据库证书;清理后直接运行其 `inventory``upload` 会明确拒绝缺失原件。不要关闭证书校验、重新生成源摘要或用测试清单激活项目。正常构建不读取数据库、密钥或项目外备份路径。
## 迁移初始实测记录(素材上云前的历史阶段)
| 检查 | 本轮结果 | 不能据此证明 |
| --- | --- | --- |
| `npm run test:tang` | 72/72 通过 | 真实服务器与存储可用;COS 相关网络用例使用替身 |
| 微信构建 | 退出码 0 | 模拟器可操作、真机可用、上传通过 |
| `npm run check:tang-output` | 24 个原生页面、204 个媒体、328 个相对依赖通过;启动页与原宿主路由保留;原生接口地址与本宿主一致 | 云端存档成功 |
| 微信 `wcc` / `wcsc` 编译器 | 24 页均通过,退出码均为 0 | 界面、音质与播放体验验收 |
| `npm run build:h5` | 退出码 0 | 唐侦探支持网页游玩 |
| 原件与迁入快照 | 473/473 逐字节相同 | 内容、声音授权或医疗审签通过 |
| 原误迁目录及共享后端保护 | 515 个选定文件组合摘要与本轮开始时一致 | 整个仓库未被其他任务修改 |
仍待验证:目标部署接口与数据库、微信合法域名的当前配置、开发者工具完整交互与真机播放/存档、老年用户体验、医疗内容复核、发布验收。真实 COS 地址的公开字节回读已在后续阶段完成,但不等于微信真机验收。老年友好小游戏验证规范用于保持原玩法、素材与审核状态,不因迁移提升任何内容的验收等级。
## 文件保护与收尾
本轮没有删除此前误迁到 `TUICallKit-Vue3` 的内容;原唐侦探和共享后端均保留。新依赖、源码、构建产物保留在正确项目中供复核。测试与构建进程已结束,没有打开新窗口、网页、播放器或常驻服务;既有微信开发者工具窗口未操作、未关闭。
## 后续小程序同步复核(2026-09-08 16:26
用户继续要求「小程序也同步一下」后,重新执行微信构建和产物检查,均以退出码 0 结束。
开发者工具起初仍打开旧 TUICallKit-Vue3 产物;已通过导入选择框定位正确目录。后续复核时,工具已打开 `TongjiUniApp/dist/build/mp-weixin`,窗口名称为 `tongji-uniapp`,模拟器实际显示默认首页 `tongji/pages/weekly`,可见「唐侦探」和「识糖小课堂」两个入口。没有修改调试启动配置,没有清除存档、点击业务提交、播放音频或上传发布;没有执行完整唐侦探游玩与云存档验证。
微信开发专业智能体只读核对了配置、路由、接口及启动行为。需注意:宿主启动会自动登录并读取业务信息;默认首页在特定账号状态下可能自动创建或关联健康记录上下文,进入唐侦探也可能提交已有待同步进度。因此本次界面运行不能称为纯离线检查,也不能仅根据首页可见断言后台没有自动写入。
当时开发者工具代码质量面板显示主包尺寸、未使用脚本及本地图片/音频资源项目未通过。此处为上云前历史提示,不能作为后续远端产物的当前结论;后续体积变化见素材清理记录。线上接口联调、真机与发布验收边界不变。
收尾:构建和检查进程已结束,导入选择框已关闭。当前正确项目窗口保留供用户继续使用;原有窗口和应用未主动关闭。没有新增临时服务或本任务启动的音视频播放,也无关闭失败项。
@@ -0,0 +1,99 @@
const chapters = require('../../data/chapters')
const {
getProgress,
saveProgress,
resetStoryProgress,
getSettings,
} = require('../../utils/storage')
const { chapterRoute } = require('../../utils/chapterRoute')
const bridge = require('../../utils/platformBridge')
Page({
data: {
chapters: [],
fontScale: 'large',
catalogListEnded: false,
},
onShow() {
const progress = getProgress()
const settings = getSettings()
const completed = new Set(progress.completedChapters)
const lastChapter = Number(progress.lastChapter) || 1
const completedHotspots = progress.completedHotspots || {}
this.setData({
chapters: chapters.map((chapter) => ({
...chapter,
completed: completed.has(chapter.chapterId),
current: chapter.number === lastChapter,
readingLabel: completed.has(chapter.chapterId)
? '重新翻看这一回'
: chapter.number === lastChapter
? '上次读到这里'
: '翻开这一回',
eventCount: Array.isArray(completedHotspots[chapter.chapterId])
? completedHotspots[chapter.chapterId].length
: 0,
})),
lastChapter,
fontScale: settings.fontScale === 'xlarge' ? 'xlarge' : 'large',
catalogListEnded: false,
})
},
markListEnd() {
if (!this.data.catalogListEnded) {
this.setData({ catalogListEnded: true })
}
},
openChapter(event) {
const chapter = Number(event.currentTarget.dataset.chapter)
const progress = getProgress()
const chapterMeta = chapters.find((item) => item.number === chapter)
const replay = Boolean(
chapterMeta
&& progress.completedChapters.includes(chapterMeta.chapterId),
)
progress.lastChapter = chapter
saveProgress(progress)
wx.navigateTo({ url: chapterRoute(chapter, { replay }) })
},
restartStory() {
const expectedScope = bridge.getScope()
const visibleGeneration = this.__tangShowGeneration
wx.showModal({
title: '从第一回重新开始?',
content: '关卡进度会清空;已经收藏的“我的桂香岁月”和大字设置会保留。',
confirmText: '重新开始',
cancelText: '先不清空',
confirmColor: '#9f3028',
success: (result) => {
if (!result || !result.confirm || this.__tangDead || !this.__tangVisible
|| visibleGeneration !== this.__tangShowGeneration
|| expectedScope !== bridge.getScope()) return
if (!resetStoryProgress(expectedScope)) {
wx.showToast({ title: '进度暂时没有清空,请稍后再试', icon: 'none' })
return
}
wx.redirectTo({ url: chapterRoute(1) })
},
})
},
goBack() {
wx.reLaunch({ url: '/tang-detective/pages/home/home' })
},
onShareAppMessage() {
return {
title: '唐侦探:翻开十五回桂香故事',
path: '/tang-detective/pages/catalog/catalog',
}
},
onShareTimeline() {
return { title: '唐侦探:翻开十五回桂香故事', query: '' }
},
})
@@ -0,0 +1,184 @@
const memoryCards = require('../../data/memoryCards')
const { getProgress } = require('../../utils/storage')
const { getCollectedMemories } = require('../../utils/memoryCollection')
const { chapterRoute } = require('../../utils/chapterRoute')
const bridge = require('../../utils/platformBridge')
const STATUS_TEXT = {
guest: '游客阅读 · 仅存本机', offline: '离线存档 · 点此重试', pending: '本机已保存 · 等待同步',
connecting: '正在连接存档…', syncing: '本机已保存 · 正在同步', synced: '阅读存档已同步',
conflict: '存档有冲突 · 点此选择', 'auth-expired': '登录已失效 · 本机记录保留',
'storage-error': '本机未存成功 · 请检查空间', 'version-error': '存档版本不一致 · 仅本机阅读',
'sync-error': '存档未同步 · 请更新后重试',
}
const {
getHomeLayoutMetrics,
getHomeLayoutStyle,
hasReadingProgress,
} = require('./homeLayout')
Page({
data: {
tangStatusText: '正在读取存档…',
coverOpened: false,
completedCount: 0,
lastChapter: 1,
hasReadingProgress: false,
memoryCount: 0,
homeLayoutStyle: [
'height:390px',
'--home-top-inset:8px',
'--home-right-inset:10px',
'--home-bottom-inset:7px',
'--home-left-inset:10px',
].join(';'),
},
onLoad() {
this.unsubscribeTang = bridge.subscribe(() => this.refreshTangStatus())
this.refreshTangStatus()
this.applyHomeLayout()
},
onShow() {
this.refreshHomeProgress()
this.refreshTangStatus()
},
onUnload() {
if (this.unsubscribeTang) this.unsubscribeTang()
},
refreshHomeProgress() {
const progress = getProgress()
this.setData({
completedCount: progress.completedChapters.length,
lastChapter: progress.lastChapter || 1,
hasReadingProgress: hasReadingProgress(progress),
memoryCount: getCollectedMemories(progress, memoryCards).length,
})
this.applyHomeLayout()
},
refreshTangStatus() {
this.setData({ tangStatusText: STATUS_TEXT[bridge.getStatus()] || '本机阅读存档' })
},
async syncTang() {
const scope = bridge.getScope()
const visibleGeneration = this.__tangShowGeneration
const stillActive = () => !this.__tangDead && this.__tangVisible
&& this.__tangShowGeneration === visibleGeneration && bridge.getScope() === scope
if (!wx.getStorageSync('token')) {
wx.showToast({ title: '请先在学堂登录,游客记录不会自动上传', icon: 'none' })
return
}
await bridge.open(true)
if (!stillActive()) return
if (bridge.getStatus() !== 'conflict') { this.refreshHomeProgress(); return }
const conflictContext = bridge.getConflictContext()
wx.showActionSheet({
itemList: ['采用云端存档', '用本机存档覆盖云端'],
success: result => {
if (!stillActive() || bridge.getConflictContext() !== conflictContext) return
const choice = result.tapIndex === 0 ? 'cloud' : 'local'
wx.showModal({
title: '确认阅读存档',
content: choice === 'cloud' ? '将用云端进度替换当前本机进度。原记录会保留一份本机备份。'
: '将用本机进度覆盖云端,其他设备的未同步阅读可能不在其中。原记录会保留一份本机备份。',
confirmText: '确认使用',
success: async answer => {
if (answer.confirm && stillActive()) {
await bridge.resolveConflict(choice, conflictContext)
if (stillActive()) this.refreshHomeProgress()
}
},
})
},
})
},
returnToXuetang() {
bridge.flush()
wx.reLaunch({ url: '/tongji/pages/weekly' })
},
onResize(resizeInfo) {
this.applyHomeLayout(resizeInfo)
},
applyHomeLayout(resizeInfo = null) {
let windowInfo = {}
try {
windowInfo = wx.getWindowInfo
? wx.getWindowInfo()
: wx.getSystemInfoSync()
} catch (error) {
windowInfo = {}
}
const resizeSize = resizeInfo && resizeInfo.size
? resizeInfo.size
: resizeInfo
if (resizeSize) {
windowInfo = {
...windowInfo,
windowWidth: resizeSize.windowWidth || windowInfo.windowWidth,
windowHeight: resizeSize.windowHeight || windowInfo.windowHeight,
}
}
let menuRect = {}
try {
menuRect = wx.getMenuButtonBoundingClientRect
? wx.getMenuButtonBoundingClientRect()
: {}
} catch (error) {
menuRect = {}
}
const metrics = getHomeLayoutMetrics(windowInfo, menuRect)
this.setData({
homeLayoutStyle: getHomeLayoutStyle(metrics),
})
},
startGame() {
const chapter = this.data.lastChapter || 1
wx.navigateTo({
url: chapterRoute(chapter),
})
},
revealCover() {
if (this.data.coverOpened) return
this.setData({ coverOpened: true })
},
keepCoverOpen() {},
openCatalog() {
wx.navigateTo({ url: '/tang-detective/pages/catalog/catalog' })
},
openCast() {
wx.navigateTo({ url: '/tang-detective/pages/cast/cast' })
},
openMemories() {
wx.navigateTo({ url: '/tang-detective/pages/memories/memories' })
},
onShareAppMessage() {
return {
title: '唐侦探:在一桌饭里,看见被忽略的人',
path: '/tang-detective/pages/home/home',
}
},
onShareTimeline() {
return {
title: '唐侦探:在一桌饭里,看见被忽略的人',
query: '',
}
},
})
@@ -0,0 +1,69 @@
<view class="safe-shell home-shell" style="{{homeLayoutStyle}}">
<view class="tang-host-bar">
<button class="tang-host-button" catchtap="returnToXuetang" aria-label="退出唐侦探,返回学堂"> 返回学堂</button>
<button class="tang-sync-button" catchtap="syncTang">{{tangStatusText}}</button>
</view>
<view
class="home-comic-book {{coverOpened ? 'is-open' : 'is-closed'}}"
bindtap="revealCover"
aria-label="{{coverOpened ? '唐侦探漫画书已经翻开' : '唐侦探漫画封面,轻触翻开'}}"
>
<view class="home-cover-art" aria-label="桂香饭店人物群像漫画封面">
<image
class="home-cover-image"
src="/tang-detective/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg"
mode="aspectFill"
></image>
<view class="home-cover-shade"></view>
<view class="home-cover-spine"><text>甄养堂 · 中国健康连环画</text></view>
<view class="home-cover-title-block">
<text class="home-cover-series">第一季</text>
<text class="home-cover-title">唐侦探</text>
<text class="home-cover-subtitle">桂香里的第十五桌</text>
</view>
<view wx:if="{{!coverOpened}}" class="home-cover-touch">
<text class="home-cover-touch-mark"></text>
<text>翻开瞧瞧</text>
</view>
</view>
<view
wx:if="{{coverOpened}}"
class="home-flyleaf paper-panel"
catchtap="keepCoverOpen"
aria-label="漫画书扉页"
>
<view class="home-brand">
<view class="seal">甄</view>
<view class="home-brand-copy">
<text class="home-brand-name">甄养堂</text>
<text class="home-brand-kind">一本能看、能点、能带回家聊的中国健康连环画</text>
</view>
</view>
<text class="home-flyleaf-title">桂香里的第十五桌</text>
<text class="home-season">第一季 · 一桌饭里的三代人</text>
<text class="home-quote">“一桌饭,不应该只有坐下的人,还应该有被看见的人。”</text>
<button
class="home-primary"
catchtap="startGame"
aria-label="{{hasReadingProgress ? '继续阅读上次看到的地方' : '翻开第一回'}}"
>
{{hasReadingProgress ? '接着上回往下看' : '翻开第一回'}}
</button>
<view class="home-tabs">
<button class="home-tab" catchtap="openCatalog" aria-label="打开十五回目录">目录</button>
<button class="home-tab" catchtap="openCast" aria-label="打开人物画谱">人物</button>
<button class="home-tab home-memory-tab" catchtap="openMemories" aria-label="打开我的桂香岁月">
岁月册<text wx:if="{{memoryCount > 0}}"> · {{memoryCount}}页</text>
</button>
</view>
<text class="home-tagline">先看画、听故事;翻到背面,再聊聊这回事。</text>
</view>
</view>
<text wx:if="{{coverOpened}}" class="home-disclaimer">这里聊的是日常生活,不替代诊断、处方或个体化治疗建议。</text>
</view>
@@ -0,0 +1,683 @@
@import "/tang-detective/shared.wxss";
.tang-host-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex: 0 0 44px; padding-right: 92px; }
.tang-host-button, .tang-sync-button { margin: 0; min-height: 44px; padding: 0 12px; line-height: 44px; font-size: 16px; border-radius: 8px; color: #f3e5bd; background: #30251c; }
.tang-sync-button { font-size: 14px; }
.home-shell {
--home-top-inset: calc(8px + constant(safe-area-inset-top));
--home-right-inset: calc(10px + constant(safe-area-inset-right));
--home-bottom-inset: calc(7px + constant(safe-area-inset-bottom));
--home-left-inset: calc(10px + constant(safe-area-inset-left));
--home-top-inset: calc(8px + env(safe-area-inset-top));
--home-right-inset: calc(10px + env(safe-area-inset-right));
--home-bottom-inset: calc(7px + env(safe-area-inset-bottom));
--home-left-inset: calc(10px + env(safe-area-inset-left));
display: flex;
height: 100vh;
min-height: 0;
padding: var(--home-top-inset) var(--home-right-inset)
var(--home-bottom-inset) var(--home-left-inset);
gap: 6px;
overflow: hidden;
flex-direction: column;
}
.home-comic-book {
position: relative;
display: grid;
width: 100%;
min-height: 0;
flex: 1 1 auto;
overflow: hidden;
color: #33251b;
background: #1d130f;
border: 3px solid #9e845e;
border-radius: 10px;
box-shadow: 0 15px 34px rgba(0, 0, 0, 0.36);
grid-template-columns: minmax(0, 1fr);
}
.home-comic-book.is-open {
grid-template-columns: minmax(0, 1.45fr) minmax(280px, 1fr);
}
.home-cover-art {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
background: #241811;
}
.home-comic-book.is-open .home-cover-art {
border-right: 7px solid #6f2b23;
box-shadow: 10px 0 24px rgba(32, 19, 12, 0.34);
}
.home-cover-image,
.home-cover-shade {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.home-cover-shade {
pointer-events: none;
background:
linear-gradient(90deg, rgba(29, 18, 12, 0.68), transparent 24%, transparent 72%, rgba(29, 18, 12, 0.2)),
linear-gradient(0deg, rgba(31, 18, 12, 0.82), transparent 53%);
box-shadow: inset 0 0 70px rgba(30, 16, 9, 0.3);
}
.home-cover-spine {
position: absolute;
z-index: 3;
top: 0;
bottom: 0;
left: 0;
display: flex;
width: 46px;
align-items: center;
justify-content: center;
color: #f0d8a7;
background: rgba(74, 35, 26, 0.94);
border-right: 2px solid #c59a58;
font-family: "Songti SC", "STSong", serif;
font-size: 15px;
font-weight: 850;
letter-spacing: 3px;
writing-mode: vertical-rl;
}
.home-cover-title-block {
position: absolute;
z-index: 3;
left: 72px;
bottom: 54px;
display: flex;
max-width: 64%;
padding: 13px 19px 15px;
color: #f7e7c4;
background: rgba(43, 27, 18, 0.86);
border-left: 7px solid #a53a30;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
flex-direction: column;
}
.home-cover-series {
color: #e4c277;
font-size: 16px;
font-weight: 800;
letter-spacing: 4px;
}
.home-cover-title {
margin-top: 3px;
font-family: "Songti SC", "STSong", serif;
font-size: 43px;
font-weight: 900;
letter-spacing: 8px;
line-height: 1.05;
}
.home-cover-subtitle {
margin-top: 6px;
color: #f1d18c;
font-family: "Songti SC", "STSong", serif;
font-size: 25px;
font-weight: 900;
line-height: 1.2;
}
.home-cover-touch {
position: absolute;
z-index: 4;
right: 24px;
bottom: 22px;
display: flex;
min-height: 50px;
align-items: center;
gap: 9px;
padding: 8px 15px;
color: #fff0ca;
background: rgba(54, 34, 23, 0.9);
border: 2px solid #d7b36d;
border-radius: 999px;
font-size: 18px;
font-weight: 850;
box-shadow: 0 7px 18px rgba(0, 0, 0, 0.28);
}
.home-cover-touch-mark {
display: flex;
width: 31px;
height: 31px;
align-items: center;
justify-content: center;
color: #6c271f;
background: #f0d28c;
border-radius: 50%;
font-size: 28px;
line-height: 1;
}
.home-flyleaf {
display: flex;
box-sizing: border-box;
min-width: 0;
min-height: 0;
justify-content: center;
overflow: hidden;
padding: 16px 20px;
background:
repeating-linear-gradient(0deg, rgba(92, 61, 35, 0.03) 0, rgba(92, 61, 35, 0.03) 1px, transparent 1px, transparent 5px),
#f2e4bf;
flex-direction: column;
}
.home-flyleaf-title {
display: block;
margin-top: 9px;
color: #8d2d26;
font-family: "Songti SC", "STSong", serif;
font-size: 29px;
font-weight: 900;
line-height: 1.18;
}
.home-flyleaf .home-quote {
position: static;
display: block;
margin-top: 10px;
color: #4e3a2b;
font-size: 18px;
font-weight: 750;
line-height: 1.45;
}
.home-flyleaf .home-primary {
margin-top: 12px;
}
.home-flyleaf .home-tabs {
width: 100%;
min-width: 0;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.home-flyleaf .home-tab {
box-sizing: border-box;
width: 100%;
min-width: 0;
overflow: hidden;
padding: 5px 7px;
font-size: 16px;
white-space: nowrap;
}
.home-comic-book button::after {
border: 0;
}
.home-book {
display: grid;
width: 100%;
min-height: 0;
flex: 1;
overflow: hidden;
grid-template-columns: minmax(0, 3fr) minmax(285px, 2fr);
border: 2px solid #9e845e;
border-radius: 9px;
}
.home-art {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
background:
repeating-linear-gradient(0deg, rgba(65, 42, 25, 0.04) 0, rgba(65, 42, 25, 0.04) 1px, transparent 1px, transparent 5px),
#241811;
border-right: 5px solid var(--cinnabar);
}
.home-art-image,
.home-art-shade {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.home-art-shade {
pointer-events: none;
background: linear-gradient(0deg, rgba(35, 23, 16, 0.82), transparent 48%);
}
.home-memory-ribbon {
position: absolute;
z-index: 3;
top: 12px;
left: 12px;
display: flex;
min-height: 48px;
align-items: center;
gap: 7px;
padding: 6px 10px 6px 7px;
color: #4a3225;
background: rgba(241, 221, 178, 0.96);
border: 2px solid #9e754a;
border-radius: 5px;
box-shadow: 0 5px 14px rgba(25, 15, 8, 0.28);
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 17px;
font-weight: 850;
white-space: nowrap;
}
.home-memory-ribbon-mark {
display: flex;
width: 28px;
height: 35px;
align-items: center;
justify-content: center;
color: #ffe9bb;
background: #963128;
clip-path: polygon(0 0, 100% 0, 100% 100%, 50% 78%, 0 100%);
font-size: 14px;
font-weight: 900;
}
.home-quote {
position: absolute;
z-index: 2;
right: 22px;
bottom: 18px;
left: 22px;
color: #f5e5c0;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 20px;
font-weight: 800;
line-height: 1.5;
}
.home-copy {
display: flex;
box-sizing: border-box;
min-width: 0;
min-height: 0;
justify-content: center;
overflow-x: hidden;
overflow-y: auto;
padding: 18px 24px;
flex-direction: column;
}
.home-brand {
display: flex;
align-items: center;
gap: 10px;
}
.home-brand .seal {
width: 48px;
height: 48px;
flex: none;
font-size: 27px;
}
.home-brand-copy {
display: flex;
color: #7f2a23;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 18px;
font-weight: 850;
line-height: 1.35;
}
.home-brand-kind {
color: #765f45;
font-size: 16px;
font-weight: 700;
}
.home-title {
display: block;
margin-top: 10px;
font-size: 45px;
font-weight: 900;
letter-spacing: 7px;
line-height: 1.05;
}
.home-subtitle {
display: block;
margin-top: 4px;
color: #8d2d26;
font-size: 30px;
font-weight: 900;
line-height: 1.2;
}
.home-season {
display: block;
margin-top: 5px;
color: #6f5942;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 18px;
font-weight: 750;
}
.home-primary {
display: flex;
width: 100%;
min-height: 64px;
align-items: center;
justify-content: center;
margin-top: 16px;
padding: 8px 14px;
color: #fff0cb;
background: var(--cinnabar);
border: 3px solid #74231d;
border-radius: 8px;
box-shadow: 0 8px 18px rgba(111, 32, 27, 0.24);
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 21px;
font-weight: 900;
}
.home-tabs {
display: grid;
gap: 9px;
margin-top: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.home-tab {
position: relative;
display: flex;
min-width: 0;
min-height: 48px;
align-items: center;
justify-content: center;
padding: 6px 10px;
color: #4b3829;
background: #ead9b2;
border: 2px solid #997b54;
border-radius: 7px;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 18px;
font-weight: 850;
}
.home-memory-count {
display: flex;
min-width: 30px;
height: 26px;
align-items: center;
justify-content: center;
padding: 0 6px;
color: #f9edce;
background: #704b35;
border-radius: 999px;
font-size: 13px;
}
.home-tagline {
display: block;
margin-top: 10px;
color: #594431;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 18px;
font-weight: 700;
line-height: 1.48;
}
.home-disclaimer {
display: block;
flex: none;
color: #d7c49b;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 17px;
line-height: 1.35;
text-align: center;
}
@media (max-height: 620px) {
.home-copy {
justify-content: flex-start;
overflow-y: hidden;
padding: 12px 18px;
}
.home-brand .seal {
width: 42px;
height: 42px;
font-size: 24px;
}
.home-brand-copy {
font-size: 17px;
}
.home-brand-kind {
font-size: 15px;
}
.home-title {
margin-top: 7px;
font-size: 39px;
}
.home-subtitle {
font-size: 27px;
}
.home-season {
font-size: 17px;
}
.home-primary {
min-height: 60px;
margin-top: 11px;
font-size: 20px;
}
.home-tabs {
margin-top: 8px;
}
.home-tab {
min-height: 48px;
font-size: 17px;
}
.home-tagline {
display: block;
margin-top: 6px;
font-size: 16px;
line-height: 1.35;
}
.home-disclaimer {
font-size: 16px;
}
}
@media (max-width: 730px) {
.home-brand-kind {
display: none;
}
.home-brand {
max-width: calc(100% - 104px);
}
.home-title {
font-size: 39px;
letter-spacing: 5px;
}
.home-subtitle {
font-size: 26px;
}
.home-tagline {
display: block;
margin-top: 6px;
font-size: 16px;
line-height: 1.35;
}
.home-memory-ribbon {
top: 9px;
left: 9px;
min-height: 48px;
padding: 5px 8px 5px 6px;
font-size: 16px;
}
}
@media (max-height: 430px) {
.home-comic-book.is-open {
grid-template-columns: minmax(0, 1.3fr) minmax(292px, 1fr);
}
.home-cover-title-block {
left: 62px;
bottom: 34px;
padding: 9px 13px 10px;
}
.home-cover-series {
font-size: 13px;
}
.home-cover-title {
font-size: 34px;
letter-spacing: 6px;
}
.home-cover-subtitle {
font-size: 20px;
}
.home-cover-touch {
right: 16px;
bottom: 13px;
min-height: 48px;
font-size: 16px;
}
.home-flyleaf {
padding: 8px 12px;
}
.home-flyleaf .home-brand .seal {
width: 34px;
height: 34px;
font-size: 20px;
}
.home-flyleaf .home-brand-copy {
font-size: 14px;
}
.home-flyleaf-title {
margin-top: 5px;
font-size: 23px;
}
.home-flyleaf .home-season {
font-size: 14px;
}
.home-flyleaf .home-quote {
margin-top: 5px;
font-size: 15px;
line-height: 1.3;
}
.home-flyleaf .home-primary {
min-height: 50px;
margin-top: 6px;
font-size: 18px;
}
.home-flyleaf .home-tabs {
gap: 5px;
margin-top: 5px;
}
.home-flyleaf .home-tab {
min-height: 48px;
padding: 4px;
font-size: 14px;
}
.home-flyleaf .home-tagline {
margin-top: 4px;
font-size: 14px;
}
.home-copy {
justify-content: flex-start;
overflow-y: hidden;
padding: 8px 14px;
}
.home-brand .seal {
width: 36px;
height: 36px;
font-size: 22px;
}
.home-brand-copy {
font-size: 15px;
}
.home-brand-kind {
font-size: 13px;
}
.home-title {
margin-top: 4px;
font-size: 34px;
letter-spacing: 4px;
}
.home-subtitle {
margin-top: 2px;
font-size: 23px;
}
.home-season {
margin-top: 3px;
font-size: 15px;
}
.home-primary {
min-height: 60px;
margin-top: 7px;
padding: 6px 10px;
font-size: 19px;
}
.home-tabs {
margin-top: 5px;
}
.home-tagline {
display: block;
margin-top: 4px;
font-size: 15px;
line-height: 1.2;
}
.home-disclaimer {
font-size: 14px;
line-height: 1.2;
}
}
@@ -0,0 +1,125 @@
// Transport-only lookup. Review eligibility continues to belong to the story
// and release manifests; an HTTPS URL never confers audio approval.
const manifest = require('./cosMediaManifest')
const paths = Object.create(null)
const urls = Object.create(null)
const SHARE_ASSET_ID = 'image.tang.share-preview'
for (const entry of manifest.entries) {
paths[entry.sourcePath] = entry
urls[entry.url] = entry
}
function entryFor(value) {
if (typeof value !== 'string') return null
if (urls[value]) return urls[value]
let relative = value.replace(/^\//, '')
if (relative.startsWith(`${manifest.namespace}/`)) {
relative = relative.slice(manifest.namespace.length + 1)
}
return paths[relative] || null
}
function resolve(value) {
const entry = entryFor(value)
return entry ? entry.url : ''
}
function verifiedUrl(value, sha256, kind) {
const entry = urls[value]
return entry && entry.sha256 === sha256 && entry.kind === kind
? entry.url : ''
}
function mapMedia(value) {
if (typeof value === 'string') {
if (/^\/(?:[a-z0-9-]+\/)?(?:assets|package-[a-z0-9-]+)\/.*\.(?:jpe?g|png|webp|gif|svg|avif|mp3|wav|aac|m4a|ogg|mp4|webm|mov)$/i.test(value)) {
// Unregistered declarations remain text-only; never guess an object URL.
return resolve(value)
}
return value
}
if (Array.isArray(value)) return value.map(mapMedia)
if (!value || typeof value !== 'object') return value
const mapped = {}
Object.keys(value).forEach(key => { mapped[key] = mapMedia(value[key]) })
return mapped
}
function beginComicImage(page, pageData) {
const previous = page._cosComicImage
if (!previous || previous.pageId !== pageData.currentPageId) {
page._cosComicImage = {
pageId: pageData.currentPageId,
generation: (previous ? previous.generation : 0) + 1,
failed: Object.create(null),
lastPatch: null,
}
}
const state = page._cosComicImage
pageData.comicImageGeneration = state.generation
// Ordinary reader interactions re-render this page. They must not revive a
// URL that already failed or replace terminal text fallback with an image.
if (state.lastPatch) Object.assign(pageData, state.lastPatch)
}
function isCurrentComicImageEvent(page, event) {
const state = page._cosComicImage
const dataset = event && event.currentTarget && event.currentTarget.dataset
return Boolean(state && dataset && !page.__tangDead && !page.__tangOriginalUnloaded
&& page.__tangVisible !== false && page.data.comicImageSrc
&& state.pageId === page.data.currentPageId
&& dataset.cosPageId === state.pageId
&& Number(dataset.cosImageGeneration) === state.generation
&& dataset.cosImageSrc === page.data.comicImageSrc)
}
function recordComicImageError(page, event) {
if (!isCurrentComicImageEvent(page, event)) return false
const state = page._cosComicImage
const src = page.data.comicImageSrc
if (state.failed[src]) return false
state.failed[src] = true
return true
}
function canUseComicFallback(page, src) {
const state = page._cosComicImage
const entry = entryFor(src)
return Boolean(state && entry && entry.kind === 'image' && !state.failed[src])
}
function applyComicImageFallback(page, patch) {
if (page._cosComicImage) page._cosComicImage.lastPatch = { ...patch }
page.setData(patch)
}
function prepareSharePreview(page, assetManager) {
if (page._sharePreviewPromise) return page._sharePreviewPromise
const generation = page.__tangShowGeneration
const alive = () => !page.__tangDead && !page.__tangOriginalUnloaded
&& page.__tangVisible !== false && page.__tangShowGeneration === generation
// Resolve again for each share; the manager rehashes cached bytes before use.
const promise = Promise.resolve().then(() => assetManager.resolve(SHARE_ASSET_ID))
.then(result => {
if (!alive()) return ''
const localPath = result && result.available && result.uri || ''
page.setData({ sharePreviewLocalPath: localPath })
return localPath
})
.catch(() => {
if (alive()) page.setData({ sharePreviewLocalPath: '' })
return ''
})
.finally(() => {
if (page._sharePreviewPromise === promise) page._sharePreviewPromise = null
})
page._sharePreviewPromise = promise
return promise
}
module.exports = {
SHARE_ASSET_ID, entryFor, resolve, verifiedUrl, mapMedia, prepareSharePreview,
beginComicImage, isCurrentComicImageEvent, recordComicImageError,
canUseComicFallback, applyComicImageFallback,
}
@@ -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,3 @@
const { createPlatformBridge } = require('./platformCore')
const config = require('./platformConfig')
module.exports = createPlatformBridge(wx, config)
@@ -0,0 +1,218 @@
const { sha256Hex } = require('./identityHash')
const { CONTENT_VERSION, emptyProgress, projectProgress } = require('./progressContract')
const copy = value => JSON.parse(JSON.stringify(value))
const KEY = 'tang-xuetang-v1:'
// Dependency-injected for offline tests. Tokens remain in the existing host key
// and request header only; local slots use a SHA-256 fingerprint, not the token.
function createPlatformBridge(platform, config) {
let active = null
const listeners = new Set()
function token() { try { return String(platform.getStorageSync('token') || '') } catch (_) { return '' } }
function read(key, fallback) { try { return platform.getStorageSync(key) || fallback } catch (_) { return fallback } }
function write(key, value) { try { platform.setStorageSync(key, copy(value)); return true } catch (_) { return false } }
function context() {
const current = token()
if (active && active.token === current) return active
if (active && active.timer) clearTimeout(active.timer)
const scope = KEY + (current ? sha256Hex(current) : 'guest')
const mapped = current ? read(scope + ':user', null) : null
const key = Number.isSafeInteger(mapped) && mapped > 0 ? KEY + 'user:' + mapped : scope
const cached = read(key, {})
active = { token: current, scope, key, verified: false, opening: null, flushing: null, timer: null,
status: current ? 'offline' : 'guest', remote: null,
state: { progress: emptyProgress(), revision: 0, story_generation: 0, user_id: null,
dirty: false, resetPending: false, serial: 0, pending: null, ...cached } }
return active
}
const current = c => active === c && token() === c.token
function notify(c, status) {
if (!current(c)) return
c.status = status
listeners.forEach(listener => { try { listener(status) } catch (_) {} })
}
function persist(c) {
const ok = write(c.key, c.state)
if (!ok) notify(c, 'storage-error')
return ok
}
async function request(c, path, method = 'GET', data) {
if (!current(c) || !c.token) throw new Error('SESSION_CHANGED')
return new Promise((resolve, reject) => platform.request({
url: String(config.apiBaseUrl).replace(/\/+$/, '') + '/api/tang/' + path,
method, data, timeout: 8000,
header: { token: c.token, 'content-type': 'application/json' },
success(result) {
if (!current(c)) return reject(new Error('SESSION_CHANGED'))
const body = result.data
if (result.statusCode !== 200 || !body || typeof body !== 'object') return reject(new Error('API_UNAVAILABLE'))
if (body.code === -1) { c.verified = false; notify(c, 'auth-expired'); return reject(new Error('AUTH_EXPIRED')) }
if (body.code !== 1) {
const error = new Error(body.data && body.data.error_code || 'API_UNAVAILABLE')
return reject(error)
}
resolve(body.data)
}, fail() { reject(new Error('NETWORK_UNAVAILABLE')) },
}))
}
function validateRemote(value) {
if (!value || value.schema_version !== 1 || value.content_version !== CONTENT_VERSION
|| !Number.isSafeInteger(value.user_id) || value.user_id <= 0
|| !Number.isSafeInteger(value.revision) || value.revision < 0
|| !Number.isSafeInteger(value.story_generation) || value.story_generation < 0
|| !value.progress || typeof value.progress !== 'object') throw new Error('CONTENT_MISMATCH')
return { ...value, progress: projectProgress(value.progress) }
}
function hydrate(c, remote) {
c.state.progress = { ...c.state.progress, ...remote.progress }
c.state.revision = remote.revision
c.state.story_generation = remote.story_generation
c.state.user_id = remote.user_id
c.state.pending = null
c.state.resetPending = false
c.state.dirty = false
const saved = persist(c)
if (saved) notify(c, 'synced')
return saved
}
function failure(c, error) {
if (!current(c) || error.message === 'SESSION_CHANGED') return
if (['AUTH_EXPIRED', 'AUTH_REQUIRED'].includes(error.message)) c.verified = false
const permanent = ['INVALID_REQUEST', 'PAYLOAD_TOO_LARGE', 'IDEMPOTENCY_CONFLICT', 'UNSUPPORTED_MEDIA_TYPE', 'METHOD_NOT_ALLOWED']
if (c.status !== 'storage-error') notify(c, ['AUTH_EXPIRED', 'AUTH_REQUIRED'].includes(error.message) ? 'auth-expired'
: ['CONTENT_MISMATCH', 'UNSUPPORTED_CONTENT_VERSION'].includes(error.message) ? 'version-error'
: permanent.includes(error.message) ? 'sync-error' : 'offline')
}
async function open(force = false) {
const c = context()
if (!c.token) return c.status
if (c.opening) return c.opening
if (c.verified && !force) return c.status
c.opening = (async () => {
c.verified = false
notify(c, 'connecting')
try {
const [catalog, raw] = await Promise.all([request(c, 'catalog'), request(c, 'progress')])
if (!current(c)) return 'session-changed'
if (!catalog || catalog.schema_version !== 1 || catalog.content_version !== CONTENT_VERSION) throw new Error('CONTENT_MISMATCH')
const remote = validateRemote(raw)
if (c.state.user_id && c.state.user_id !== remote.user_id) throw new Error('CONTENT_MISMATCH')
// Only the authenticated server identity may select a shared user slot.
// A renewed token can recover the same user's unsynced local queue.
const userKey = KEY + 'user:' + remote.user_id
if (c.key !== userKey) {
const candidate = read(userKey, null)
const saved = candidate && typeof candidate === 'object' && !Array.isArray(candidate) ? candidate : null
if (saved && !c.state.dirty) c.state = saved
else if (saved && c.state.dirty && !write(c.scope + ':previous-user-backup', saved)) throw new Error('STORAGE_UNAVAILABLE')
c.key = userKey
if (!write(c.scope + ':user', remote.user_id)) { notify(c, 'storage-error'); return c.status }
}
c.verified = true
c.state.user_id = remote.user_id
if (!c.state.dirty) hydrate(c, remote)
else if (c.state.pending) await flushContext(c) // retry the exact idempotent request first
else if (c.state.revision !== remote.revision || c.state.story_generation !== remote.story_generation) {
c.remote = remote; notify(c, 'conflict')
} else await flushContext(c)
} catch (error) { failure(c, error) }
finally { c.opening = null }
return c.status
})()
return c.opening
}
function schedule(c) {
if (c.timer) clearTimeout(c.timer)
c.timer = setTimeout(() => { c.timer = null; flushContext(c) }, 600)
}
async function flushContext(c) {
if (!current(c) || !c.verified || !c.state.dirty || ['conflict', 'storage-error', 'sync-error', 'version-error'].includes(c.status)) return
if (c.flushing) return c.flushing
// Defer preparation so the promise is assigned before any early exit.
// The outer finally also covers a failed durable queue write.
c.flushing = Promise.resolve().then(async () => {
try {
if (!current(c)) return
if (!c.state.pending) {
const operation = c.state.resetPending ? 'reset_story' : 'replace'
const progress = projectProgress(c.state.progress)
c.state.pending = { serial: operation === 'reset_story' ? c.state.resetAt : c.state.serial, body: {
schema_version: 1, content_version: CONTENT_VERSION,
base_revision: c.state.revision, story_generation: c.state.story_generation,
request_id: 'tang-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 14),
operation, progress: operation === 'reset_story'
? { ...emptyProgress(), collectedMemoryCards: progress.collectedMemoryCards } : progress,
} }
}
if (!persist(c)) return
const pending = copy(c.state.pending)
notify(c, 'syncing')
const remote = validateRemote(await request(c, 'saveProgress', 'POST', pending.body))
if (!current(c)) return
if (remote.user_id !== c.state.user_id) throw new Error('CONTENT_MISMATCH')
c.state.revision = remote.revision
c.state.story_generation = remote.story_generation
c.state.pending = null
// A reset created while another request was in flight must not be lost.
if (pending.body.operation === 'reset_story' && (c.state.resetAt || 0) <= pending.serial) c.state.resetPending = false
if (pending.serial === c.state.serial) hydrate(c, remote)
else if (persist(c)) { notify(c, 'pending'); schedule(c) }
} catch (error) {
if (error.message === 'PROGRESS_CONFLICT' && current(c)) {
try { c.remote = validateRemote(await request(c, 'progress')); notify(c, 'conflict') }
catch (readError) { failure(c, readError) }
} else failure(c, error)
}
}).finally(() => { c.flushing = null })
return c.flushing
}
function saveProgress(value, reset = false) {
const c = context()
// A delayed callback from an old page/account must not enter the new slot.
if (!value || value.__tangLocalScope !== c.scope) return false
c.state.progress = copy(value)
delete c.state.progress.__tangLocalScope
c.state.dirty = true
c.state.serial += 1
if (reset) { c.state.resetPending = true; c.state.resetAt = c.state.serial }
if (!persist(c)) return false
if (!['conflict', 'sync-error', 'version-error', 'auth-expired'].includes(c.status)) notify(c, c.token ? 'pending' : 'guest')
if (c.verified) schedule(c)
return true
}
function getConflictContext() {
const c = context()
return c.status === 'conflict' && c.remote ? JSON.stringify([
c.scope, c.remote.revision, c.remote.story_generation, c.state.serial,
]) : ''
}
async function resolveConflict(choice, expectedContext) {
const c = context()
if (!expectedContext || expectedContext !== getConflictContext() || !c.verified) return false
// Recoverable, account-scoped backup before either explicit resolution.
if (!write(c.key + ':conflict-backup', { local: c.state, remote: c.remote })) { notify(c, 'storage-error'); return false }
if (choice === 'cloud') { const saved = hydrate(c, c.remote); if (saved) c.remote = null; return saved }
if (choice !== 'local') return false
c.state.revision = c.remote.revision
c.state.story_generation = c.remote.story_generation
c.state.pending = null
c.remote = null
if (!persist(c)) return false
notify(c, 'pending')
await flushContext(c)
return c.status === 'synced'
}
return {
open, saveProgress, resolveConflict, getConflictContext,
getProgress: () => { const c = context(); return { ...copy(c.state.progress || {}), ...projectProgress(c.state.progress), __tangLocalScope: c.scope } },
getScope: () => context().scope,
getStatus: () => context().status,
flush: () => { const c = context(); if (c.timer) { clearTimeout(c.timer); c.timer = null } return flushContext(c) },
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener) },
// Local preferences/audio are account-scoped and are never passed to request().
readLocal: (suffix, fallback) => copy(read(context().key + ':' + suffix, fallback)),
writeLocal: (suffix, value) => write(context().key + ':' + suffix, value),
dispose() { if (active && active.timer) clearTimeout(active.timer); listeners.clear(); active = null },
}
}
module.exports = { createPlatformBridge }
@@ -0,0 +1,41 @@
// Wire projection only: never send snapshots, answers, settings or audio state.
const CONTENT_VERSION = 'season-01'
const pad = value => String(value).padStart(2, '0')
const record = value => value && typeof value === 'object' && !Array.isArray(value) ? value : {}
const list = value => Array.isArray(value) ? value : []
function emptyProgress() {
return { completedHotspots: {}, completedChapters: [], lastChapter: 1,
collectedMemoryCards: [], comicReaderByChapter: {}, lastPageId: '' }
}
function projectProgress(input) {
const value = record(input)
const result = emptyProgress()
result.lastChapter = Number.isInteger(value.lastChapter) && value.lastChapter >= 1 && value.lastChapter <= 15 ? value.lastChapter : 1
for (let n = 1; n <= 15; n++) {
const id = `S01-C${pad(n)}`
const saved = record(record(value.comicReaderByChapter)[id])
const supplied = new Set(list(saved.completedEventIds || record(value.completedHotspots)[id]))
const events = []
for (let i = 1; i <= 4; i++) {
const event = `S01-H${pad((n - 1) * 4 + i)}`
if (!supplied.has(event)) break
events.push(event)
}
const finished = events.length === 4 && (typeof saved.chapterFinished === 'boolean'
? saved.chapterFinished : list(value.completedChapters).includes(id))
if (Object.keys(saved).length || events.length || Object.prototype.hasOwnProperty.call(record(value.completedHotspots), id)) {
const requested = String(saved.currentPageId || (n === result.lastChapter ? value.lastPageId : '') || '')
const match = requested.match(new RegExp(`^${id}-P(0[1-8])$`))
const page = Math.max(1, Math.min(finished ? 8 : 3 + events.length, match ? Number(match[1]) : 1))
const currentPageId = `${id}-P${pad(page)}`
result.completedHotspots[id] = events
result.comicReaderByChapter[id] = { currentPageId, completedEventIds: events.slice(), chapterFinished: finished }
if (finished) result.completedChapters.push(id)
if (n === result.lastChapter) result.lastPageId = currentPageId
}
const card = `${id}-MC01`
if (list(value.collectedMemoryCards).includes(card)) result.collectedMemoryCards.push(card)
}
return result
}
module.exports = { CONTENT_VERSION, emptyProgress, projectProgress }
@@ -0,0 +1,22 @@
const bridge = require('./platformBridge')
const { emptyProgress } = require('./progressContract')
function normalizeSettings(value = {}) {
return { ...value, fontScale: value.fontScale === 'xlarge' ? 'xlarge' : 'large', sound: value.sound !== false }
}
function resetStoryProgress(expectedScope) {
if (!expectedScope || bridge.getScope() !== expectedScope) return false
const current = bridge.getProgress()
const next = { ...current, ...emptyProgress(), collectedMemoryCards: current.collectedMemoryCards || [] }
if (!bridge.saveProgress(next, true)) return false
bridge.writeLocal('audio', {})
return true
}
module.exports = {
getProgress: bridge.getProgress,
saveProgress: value => bridge.saveProgress(value),
resetStoryProgress,
getSettings: () => normalizeSettings(bridge.readLocal('settings', {})),
saveSettings: value => bridge.writeLocal('settings', normalizeSettings(value)),
getAudioProgress: () => bridge.readLocal('audio', {}),
saveAudioProgress: value => bridge.writeLocal('audio', value),
}
@@ -0,0 +1,145 @@
// Keep the native Page lifecycle, but hydrate the correct account before any
// story page reads/writes storage. This also covers cold starts from shares.
const bridge = require('./platformBridge')
const LIFECYCLE_METHODS = new Set(['onLoad', 'onShow', 'onReady', 'onHide', 'onUnload'])
const CLEANUP_METHOD = /^(?:destroy|clear|unbind|pause|invalidate|beginPauseLock)/
module.exports = function registerTangPage(options) {
function isCurrent(page) {
return !page.__tangDead && !page.__tangOriginalUnloaded
&& page.__tangVisible && page.__tangScope === bridge.getScope()
}
function returnHome(page) {
if (page.__tangRedirected || page.__tangDead) return
page.__tangRedirected = true
wx.reLaunch({ url: '/tang-detective/pages/home/home' })
}
function cleanUp(page, name) {
if (!page.__tangLoaded || typeof options[name] !== 'function') return
if (name !== 'onUnload' && page.__tangOriginalUnloaded) return
if (name === 'onUnload') {
if (page.__tangOriginalUnloaded) return
page.__tangOriginalUnloaded = true
}
page.__tangCleaning = true
try {
options[name].call(page)
} finally {
// This exception is synchronous only; pending timers and audio callbacks
// regain the normal visible/account/dead guards as soon as cleanup ends.
page.__tangCleaning = false
}
}
function deliverReady(page) {
if (!isCurrent(page) || !page.__tangLoaded || !page.__tangHasShown
|| !page.__tangReadyRequested || page.__tangReadyDelivered) return
page.__tangReadyDelivered = true
if (typeof options.onReady === 'function') options.onReady.call(page)
}
const guarded = { ...options }
Object.keys(options).forEach(key => {
// Event handlers such as onAudioTimeUpdate are ordinary guarded methods,
// even though their names begin with "on".
if (typeof options[key] !== 'function' || LIFECYCLE_METHODS.has(key)) return
guarded[key] = function (...args) {
if (this.__tangCleaning) {
// Release resources across an account change, but do not run helpers
// such as saveCurrentAudioTime against the newly selected account.
if (this.__tangScope === bridge.getScope() || CLEANUP_METHOD.test(key)) {
return options[key].apply(this, args)
}
return
}
if (this.__tangDead || this.__tangOriginalUnloaded || !this.__tangLoaded || !this.__tangVisible) return
if (this.__tangScope && this.__tangScope !== bridge.getScope()) {
returnHome(this)
return
}
return options[key].apply(this, args)
}
})
Page({
...guarded,
data: { ...(options.data || {}), tangBootPending: true },
tangIgnoreBootTap() {},
onLoad(query) {
this.__tangQuery = query
this.__tangScope = bridge.getScope()
this.__tangDead = false
this.__tangVisible = false
this.__tangLoaded = false
this.__tangCleaning = false
this.__tangOriginalUnloaded = false
this.__tangRedirected = false
this.__tangShowGeneration = 0
this.__tangReadyRequested = false
this.__tangReadyDelivered = false
this.__tangHasShown = false
const nativeSetData = this.setData
this.setData = function (...args) {
if (this.__tangScope !== bridge.getScope()) return
if (!this.__tangCleaning && !isCurrent(this)) return
return nativeSetData.apply(this, args)
}
this.__tangBoot = bridge.open()
},
onShow() {
if (this.__tangDead) return
this.__tangVisible = true
const generation = ++this.__tangShowGeneration
const scope = bridge.getScope()
if (this.__tangOriginalUnloaded || scope !== this.__tangScope) {
returnHome(this)
return
}
Promise.resolve(this.__tangBoot).then(() => {
if (this.__tangDead || !this.__tangVisible || generation !== this.__tangShowGeneration) return
if (bridge.getScope() !== scope) {
returnHome(this)
return
}
if (!this.__tangLoaded) {
this.__tangLoaded = true
if (options.onLoad) options.onLoad.call(this, this.__tangQuery)
if (!isCurrent(this)) return
this.setData({ tangBootPending: false })
}
if (options.onShow) options.onShow.call(this)
this.__tangHasShown = true
deliverReady(this)
}).catch(() => {
if (isCurrent(this) && generation === this.__tangShowGeneration) {
wx.showToast({ title: '故事暂时未能打开,请返回重试', icon: 'none' })
}
})
},
onReady() {
this.__tangReadyRequested = true
deliverReady(this)
},
onHide() {
this.__tangVisible = false
++this.__tangShowGeneration
try {
cleanUp(this, 'onHide')
} finally {
// A hidden page from an obsolete account must not retain contexts with
// callbacks that directly reference storage instead of guarded methods.
try {
if (this.__tangScope !== bridge.getScope()) cleanUp(this, 'onUnload')
} finally {
bridge.flush()
}
}
},
onUnload() {
this.__tangDead = true
this.__tangVisible = false
++this.__tangShowGeneration
try {
cleanUp(this, 'onUnload')
} finally {
bridge.flush()
}
},
})
}
+161
View File
@@ -0,0 +1,161 @@
{
"pages": [
"pages/home/home",
"pages/catalog/catalog",
"pages/cast/cast",
"pages/memories/memories",
"pages/share/share",
"pages/report/report"
],
"subPackages": [
{
"root": "package-game",
"name": "game",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-audio-c01-a",
"name": "audio-c01-a",
"pages": [
"pages/player/player"
]
},
{
"root": "package-audio-c01-b",
"name": "audio-c01-b",
"pages": [
"pages/player/player"
]
},
{
"root": "package-audio-player",
"name": "audio-player",
"pages": [
"pages/player/player"
]
},
{
"root": "package-chapter-04",
"name": "chapter-04",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-02",
"name": "chapter-02",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-03",
"name": "chapter-03",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-05",
"name": "chapter-05",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-06",
"name": "chapter-06",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-07",
"name": "chapter-07",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-08",
"name": "chapter-08",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-09",
"name": "chapter-09",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-10",
"name": "chapter-10",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-11",
"name": "chapter-11",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-12",
"name": "chapter-12",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-13",
"name": "chapter-13",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-14",
"name": "chapter-14",
"pages": [
"pages/chapter/chapter"
]
},
{
"root": "package-chapter-15",
"name": "chapter-15",
"pages": [
"pages/chapter/chapter"
]
}
],
"preloadRule": {
"package-game/pages/chapter/chapter": {
"network": "all",
"packages": [
"audio-c01-a"
]
},
"package-audio-c01-a/pages/player/player": {
"network": "all",
"packages": [
"audio-c01-b"
]
}
},
"window": {
"navigationStyle": "custom",
"pageOrientation": "landscape",
"backgroundColor": "#201711",
"backgroundTextStyle": "light"
},
"lazyCodeLoading": "requiredComponents",
"style": "v2",
"sitemapLocation": "sitemap.json"
}
+167
View File
@@ -0,0 +1,167 @@
page {
--ink: #30251c;
--muted: #745f48;
--paper: #f3e5bd;
--paper-deep: #dfc995;
--cinnabar: #9f3028;
--cinnabar-dark: #6f201b;
--jade: #275f4e;
--jade-soft: #d7e4d5;
--danger-soft: #ead2c7;
--night: #211812;
min-height: 100%;
color: var(--ink);
background: var(--night);
font-family: "Songti SC", "STSong", "Noto Serif CJK SC", serif;
}
view,
text,
button,
image,
scroll-view {
box-sizing: border-box;
}
button {
margin: 0;
padding: 0;
color: inherit;
background: none;
border: 0;
border-radius: 0;
font: inherit;
line-height: inherit;
}
button::after {
border: 0;
}
.safe-shell {
width: 100vw;
min-height: 100vh;
padding:
calc(24rpx + constant(safe-area-inset-top))
calc(34rpx + constant(safe-area-inset-right))
calc(24rpx + constant(safe-area-inset-bottom))
calc(34rpx + constant(safe-area-inset-left));
padding:
calc(24rpx + env(safe-area-inset-top))
calc(34rpx + env(safe-area-inset-right))
calc(24rpx + env(safe-area-inset-bottom))
calc(34rpx + env(safe-area-inset-left));
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.035), transparent 30%),
#211812;
}
.paper-panel {
color: var(--ink);
background:
repeating-linear-gradient(0deg, rgba(85, 54, 31, 0.025) 0, rgba(85, 54, 31, 0.025) 1px, transparent 1px, transparent 6px),
var(--paper);
border: 2rpx solid #9e845e;
box-shadow: 0 18rpx 42rpx rgba(0, 0, 0, 0.24);
}
.seal {
display: flex;
width: 64rpx;
height: 64rpx;
align-items: center;
justify-content: center;
color: #fff0cd;
background: var(--cinnabar);
border: 3rpx solid #d99b72;
border-radius: 10rpx;
font-size: 38rpx;
font-weight: 800;
}
.eyebrow {
color: var(--cinnabar);
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 20rpx;
font-weight: 700;
letter-spacing: 4rpx;
}
.primary-button,
.secondary-button,
.quiet-button {
display: flex;
min-height: 76rpx;
align-items: center;
justify-content: center;
padding: 0 30rpx;
border-radius: 12rpx;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 28rpx;
font-weight: 750;
}
.primary-button {
color: #fff1ce;
background: var(--cinnabar);
box-shadow: 0 10rpx 22rpx rgba(111, 32, 27, 0.25);
}
.secondary-button {
color: #fff0cf;
background: #3b2a20;
border: 2rpx solid #856646;
}
.quiet-button {
min-height: 60rpx;
padding: 0 22rpx;
color: var(--muted);
background: rgba(255, 248, 229, 0.62);
border: 2rpx solid #a78f68;
font-size: 24rpx;
}
.status-pill {
display: inline-flex;
align-items: center;
padding: 8rpx 16rpx;
border-radius: 999rpx;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
font-size: 20rpx;
}
.status-pill.internal {
color: #7b231d;
background: #ead2b4;
border: 2rpx solid #b8744e;
}
@media (max-height: 620px) {
.safe-shell {
padding:
calc(10px + constant(safe-area-inset-top))
calc(14px + constant(safe-area-inset-right))
calc(10px + constant(safe-area-inset-bottom))
calc(14px + constant(safe-area-inset-left));
padding:
calc(10px + env(safe-area-inset-top))
calc(14px + env(safe-area-inset-right))
calc(10px + env(safe-area-inset-bottom))
calc(14px + env(safe-area-inset-left));
}
.primary-button,
.secondary-button,
.quiet-button {
min-height: 44px;
padding: 0 16px;
border-radius: 8px;
font-size: 18px;
}
.quiet-button {
min-height: 48px;
font-size: 16px;
}
}
@@ -0,0 +1,74 @@
module.exports = [
{
id: 'tang-shouan',
name: '唐守安',
title: '唐侦探/唐大夫',
eras: '1978—2026',
asset: '/assets/characters/tang-shouan.jpg',
role: '从厂卫生员到受邻里敬重的中医从业者;始终先观察、后开口,最后才真正入席。',
visual: '素色深靛蓝日常外套,旧棕布卫生包只暗示家学,不画成古装神医。',
},
{
id: 'qin-zhicheng',
name: '秦志成',
title: '秦师傅',
eras: '1978—2026',
asset: '/assets/characters/qin-zhicheng.jpg',
role: '从集体食堂小炊事员到桂香饭店老师傅,第十五桌的留下、挪走和回来都经过他的手。',
visual: '宽头、粗颈、宽肩、厚实前臂;中式粗棉厨工褂、白围裙和低矮布帽。',
},
{
id: 'lin-xiulan',
name: '林秀兰',
title: '票夹保管人',
eras: '1978—2026',
asset: '/assets/characters/lin-xiulan.jpg',
role: '守住饭票、账本和钥匙,也守住每个人把旧事慢慢讲清楚的权利。',
visual: '枣红或深棕工作外套;用动作摆事实,不竖指训人。',
},
{
id: 'zhao-jianguo',
name: '赵建国',
title: '赵伯/劳动骨干',
eras: '1978—2026',
asset: '/assets/characters/zhao-jianguo.jpg',
role: '年轻时把饭量当本事,后来学会不拿“为你好”替别人作决定。',
visual: '青年工装结实,老年深蓝便帽、盖碗茶;豪爽但不被画成莽汉或病号。',
},
{
id: 'tang-mingyuan',
name: '唐明远',
title: '中年家人',
eras: '1995—2026',
asset: '/assets/characters/tang-mingyuan.jpg',
role: '唐守安之子、乐乐之父,用久坐、应酬、晚吃饭和家庭责任接住现代中年人的困境。',
visual: '随年代逐渐出现腰腹与颈围变化;发福是生活轨迹,不作笑料或疾病诊断。',
},
{
id: 'qin-xiaoman',
name: '秦小满',
title: '饭店经营者',
eras: '2026',
asset: '/assets/characters/qin-xiaoman.jpg',
role: '把祖辈想留人的心意,转成纸单点餐、可选择份量和真实可用的座位。',
visual: '简洁现代中式工作装,软尺、纸质座位单和调台记录随身。',
},
{
id: 'lele',
name: '乐乐',
title: '会直问的小孙子',
eras: '2026',
asset: '/assets/characters/lele.jpg',
role: '从儿童视角照见大人的生活习惯;教育对象是家庭环境,不把责任推给孩子。',
visual: '圆润、活泼、讨人喜欢;不贴疾病、懒惰或减重标签。',
},
{
id: 'xiaozhen',
name: '小甄',
title: '甄养堂健康客服',
eras: '2026',
asset: '/assets/characters/xiaozhen.jpg',
role: '负责画外引导和知识回顾;需要时提醒联系专业医护,不替医生调整治疗。',
visual: '灰绿色开衫、白衬衫、低马尾与记录夹;不作广告式主角。',
},
]
@@ -0,0 +1,25 @@
const chapterTitles = [
['2026', '开席前,少了一张桌'],
['2026 → 1978', '铜牌背后的两张旧票'],
['1978', '厂铃一响,饭盆就响'],
['1978', '劳动骨干的第三碗饭'],
['1978', '第十五桌给谁留'],
['1993—1995', '木牌翻面的那一天'],
['1995', '桂香饭馆开张'],
['1998', '四凉八热才叫客气'],
['2001', '一杯酒绕了三圈'],
['2003', '后厨里的员工桌'],
['2008', '旧房子要拆了'],
['2026', '扫码点出一整桌'],
['2026', '三代人都说“我是为你好”'],
['2026', '秦师傅最后一道老菜'],
['2026', '第十五桌重新开席'],
]
module.exports = chapterTitles.map(([year, title], index) => ({
number: index + 1,
chapterId: `S01-C${String(index + 1).padStart(2, '0')}`,
year,
title,
audioStatus: index === 0 ? 'audition' : 'reserved',
}))
@@ -0,0 +1,246 @@
// Compact, main-package registry for the personal storybook.
// The wording mirrors each chapter's reviewed memory page. Keep this file free
// of gameplay logic so old collections can be restored without loading a game
// subpackage.
module.exports = [
{
cardId: 'S01-C01-MC01',
reportId: 'S01-C01-RP01',
chapterId: 'S01-C01',
chapterNumber: 1,
chapterTitle: '开席前,少了一张桌',
characterName: '赵建国',
eraLine: '2026 · 桂香大饭店宴会前厅',
tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。',
lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”',
observation: '替家人安排得再周到,也别忘了问他的意思。',
familySupport: '先问赵伯想坐哪里,再一起安排座位。',
todayAction: '替家里人张罗之前,先问一句:“您想怎么来?”',
familyLine: '下回别急着替我安排,先叫我一起商量。',
eraObject: '手写请柬与十五号旧铜牌',
},
{
cardId: 'S01-C02-MC01',
reportId: 'S01-C02-RP01',
chapterId: 'S01-C02',
chapterNumber: 2,
chapterTitle: '铜牌背后的两张旧票',
characterName: '林秀兰',
eraLine: '2026 → 1978 · 桂香旧物展柜',
tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。',
lifeAction: '保留近照和尺寸,等待旧桌板出现。',
observation: '旧物和旧话都可能记错,慢一点核对更稳妥。',
familySupport: '听林秀兰把旧事说完,再一起找照片和尺寸。',
todayAction: '保留近照和尺寸,等待旧桌板出现。',
familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。',
eraObject: '十五号铜牌与两枚破损饭菜票',
},
{
cardId: 'S01-C03-MC01',
reportId: 'S01-C03-RP01',
chapterId: 'S01-C03',
chapterNumber: 3,
chapterTitle: '厂铃一响,饭盆就响',
characterName: '唐守安',
eraLine: '1978 · 桂香机械厂集体食堂',
tableEcho: '看见一个人,不只看他能干多少,也问他累不累。',
lifeAction: '进食前按条件把手清洁并擦干',
observation: '忙着干活的人,也需要被问一句累不累。',
familySupport: '开饭前互相提醒洗手,也问问今天累不累。',
todayAction: '进食前按条件把手清洁并擦干',
familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?',
eraObject: '饭票与搪瓷饭盒',
},
{
cardId: 'S01-C04-MC01',
reportId: 'S01-C04-RP01',
chapterId: 'S01-C04',
chapterNumber: 4,
chapterTitle: '劳动骨干的第三碗饭',
characterName: '赵建国',
eraLine: '1978 · 桂香机械厂集体食堂',
tableEcho: '饭能添,面子也要留;劝人停一停,先给他一张凳。',
lifeAction: '停止继续添饭,先坐下休息并说出不适;必要时停止工作并求助。',
observation: '劝人少添一碗,先留住他的体面和座位。',
familySupport: '发现他不舒服,先让他坐下,再一起想办法。',
todayAction: '停止继续添饭,先坐下休息并说出不适;必要时停止工作并求助。',
familyLine: '下回我嘴硬时,先给我拉把凳子。',
eraObject: '铝饭勺与长条凳',
},
{
cardId: 'S01-C05-MC01',
reportId: 'S01-C05-RP01',
chapterId: 'S01-C05',
chapterNumber: 5,
chapterTitle: '第十五桌给谁留',
characterName: '秦志成',
eraLine: '1978 · 熄灯后的桂香食堂',
tableEcho: '有人为晚归的人留了一盏灯。',
lifeAction: '尽快说明情况,按单位安排和个人需要解决进食',
observation: '晚归的人需要的不只是一口热饭,还有一个位置。',
familySupport: '家里有人晚归,留句消息,也留一份合适的饭。',
todayAction: '尽快说明情况,按单位安排和个人需要解决进食',
familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?',
eraObject: '第十五桌木牌与留饭灯',
},
{
cardId: 'S01-C06-MC01',
reportId: 'S01-C06-RP01',
chapterId: 'S01-C06',
chapterNumber: 6,
chapterTitle: '木牌翻面的那一天',
characterName: '秦志成',
eraLine: '1995 · 从厂食堂改成的桂香饭馆',
tableEcho: '门牌可以翻面,留给人的座位不能翻没。',
lifeAction: '同时查看合同期限和资产清单',
observation: '生意往前走,人情和规矩也要一起留下。',
familySupport: '谈变化时,把期限、物件和家人的意见都摆明白。',
todayAction: '同时查看合同期限和资产清单',
familyLine: '回家聊一聊:饭馆要往前走,第十五桌怎样继续留下来?',
eraObject: '翻面木牌与承包合同',
},
{
cardId: 'S01-C07-MC01',
reportId: 'S01-C07-RP01',
chapterId: 'S01-C07',
chapterNumber: 7,
chapterTitle: '桂香饭馆开张',
characterName: '唐明远',
eraLine: '1995 · 开张后的桂香饭馆',
tableEcho: '问出口的关心,要等到对方把话说完。',
lifeAction: '一开始少盛,不够再添',
observation: '问了“吃不吃”,也要等对方把话说完。',
familySupport: '盛饭前先少一点,问清楚不够再添。',
todayAction: '一开始少盛,不够再添',
familyLine: '回家聊一聊:你愿意怎样把这句话接下去?',
eraObject: '手写菜单与现金盒',
},
{
cardId: 'S01-C08-MC01',
reportId: 'S01-C08-RP01',
chapterId: 'S01-C08',
chapterNumber: 8,
chapterTitle: '四凉八热才叫客气',
characterName: '女炊事员',
eraLine: '1998 · 桂香饭馆第一场大婚宴',
tableEcho: '热闹照得到客人,也该照得到端菜的人。',
lifeAction: '按人数、份量和菜品结构确认是否足够',
observation: '热闹席面里,忙着端菜的人也值得被看见。',
familySupport: '聚餐时问问照料大家的人有没有坐下吃饭。',
todayAction: '按人数、份量和菜品结构确认是否足够',
familyLine: '回家聊一聊:在最忙的时候,你想怎样让她知道自己没有被忘记?',
eraObject: '红纸菜单与婚宴席单',
},
{
cardId: 'S01-C09-MC01',
reportId: 'S01-C09-RP01',
chapterId: 'S01-C09',
chapterNumber: 9,
chapterTitle: '一杯酒绕了三圈',
characterName: '赵建国',
eraLine: '2001 · 桂香饭馆宴席',
tableEcho: '照顾不是把人摘出去,是陪他把自己的选择说出来。',
lifeAction: '明确告诉同桌自己要开车,直接换成茶或白水',
observation: '照顾不是替他决定,而是让他把选择说出来。',
familySupport: '不劝酒、不起哄,给赵伯茶或白水,也留住座位。',
todayAction: '明确告诉同桌自己要开车,直接换成茶或白水',
familyLine: '回家聊一聊:你愿意怎样让赵伯先说出自己的担心?',
eraObject: '茶杯与小酒盅',
},
{
cardId: 'S01-C10-MC01',
reportId: 'S01-C10-RP01',
chapterId: 'S01-C10',
chapterNumber: 10,
chapterTitle: '后厨里的员工桌',
characterName: '唐明远',
eraLine: '2003 · 桂香饭馆后厨',
tableEcho: '桌上腾不出一只碗,忙碌就已经挤到人身上了。',
lifeAction: '清出座位并排出轮班,让员工坐下完成一餐',
observation: '忙到没地方坐下吃饭,已经不是小事。',
familySupport: '一家人再忙,也给吃饭留出座位和时间。',
todayAction: '清出座位并排出轮班,让员工坐下完成一餐',
familyLine: '回家聊一聊:你想怎样帮他把这一顿饭重新放回生活里?',
eraObject: '员工饭盒与后厨长桌',
},
{
cardId: 'S01-C11-MC01',
reportId: 'S01-C11-RP01',
chapterId: 'S01-C11',
chapterNumber: 11,
chapterTitle: '旧房子要拆了',
characterName: '秦志成',
eraLine: '2008 · 翻建前的桂香旧房',
tableEcho: '东西收进柜里叫保存,规矩留在人间才叫传下去。',
lifeAction: '把理念落实到座位、菜单和服务动作',
observation: '旧物保存下来,照顾人的规矩更要继续做。',
familySupport: '把好意写成全家都能做到的小规矩。',
todayAction: '把理念落实到座位、菜单和服务动作',
familyLine: '回家聊一聊:除了保存旧物,你想请秦师傅再留下些什么?',
eraObject: '旧桌板与暗红铁包边',
},
{
cardId: 'S01-C12-MC01',
reportId: 'S01-C12-RP01',
chapterId: 'S01-C12',
chapterNumber: 12,
chapterTitle: '扫码点出一整桌',
characterName: '赵建国',
eraLine: '2026 · 重聚宴服务台与餐桌',
tableEcho: '让人看懂、让人开口,才算把选择递到手里。',
lifeAction: '为每项证据拍照并记录来源',
observation: '信息看得懂、话说得出,选择才真正回到自己手里。',
familySupport: '点餐时放慢一点,陪着看,不替老人直接下单。',
todayAction: '为每项证据拍照并记录来源',
familyLine: '回家聊一聊:信息都在了,最后一步怎样交还给赵伯?',
eraObject: '手机点餐页与纸菜单',
},
{
cardId: 'S01-C13-MC01',
reportId: 'S01-C13-RP01',
chapterId: 'S01-C13',
chapterNumber: 13,
chapterTitle: '三代人都说“我是为你好”',
characterName: '乐乐',
eraLine: '2026 · 重聚宴共同桌与侧桌',
tableEcho: '这一次,大人终于听孩子把话说完。',
lifeAction: '先介绍菜,让乐乐自己夹',
observation: '关心孩子,也要先让孩子说出自己的感受。',
familySupport: '介绍菜以后,先问乐乐想吃什么、想吃多少。',
todayAction: '先介绍菜,让乐乐自己夹',
familyLine: '回家聊一聊:这一回,家里人怎样让乐乐自己选?',
eraObject: '公筷与儿童小碗',
},
{
cardId: 'S01-C14-MC01',
reportId: 'S01-C14-RP01',
chapterId: 'S01-C14',
chapterNumber: 14,
chapterTitle: '秦师傅最后一道老菜',
characterName: '唐守安',
eraLine: '2026 · 重聚宴主桌',
tableEcho: '会解决问题的人,也可以学着先听一会儿。',
lifeAction: '只介绍食材、做法和份量信息',
observation: '会给办法之前,先听完一句话。',
familySupport: '家人开口时先不打断,听完再一起商量。',
todayAction: '只介绍食材、做法和份量信息',
familyLine: '回家聊一聊:唐守安怎样让儿子把那句旧话真正说完?',
eraObject: '秦师傅的手写老菜谱',
},
{
cardId: 'S01-C15-MC01',
reportId: 'S01-C15-RP01',
chapterId: 'S01-C15',
chapterNumber: 15,
chapterTitle: '第十五桌重新开席',
characterName: '老吕',
eraLine: '2026 · 桂香大饭店原址',
tableEcho: '桌子回来了,人也都坐回来了。',
lifeAction: '点餐前先看份量和人数',
observation: '桌子回来只是开始,重要的是每个人都坐得自在。',
familySupport: '点菜前先问人数、份量和每个人的想法。',
todayAction: '点餐前先看份量和人数',
familyLine: '回家聊一聊:这只旧碗怎样留在今天的第十五桌边?',
eraObject: '缺口搪瓷碗与第十五桌铜牌',
},
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
/**
* This is the in-app build identity, not the version entered in WeChat
* Developer Tools during upload. Keep semanticVersion aligned with the root
* package.json so support screenshots can be traced back to source.
*/
module.exports = Object.freeze({
productName: '唐侦探:桂香里的第十五桌',
semanticVersion: '0.1.0',
releaseChannel: 'release-candidate',
contentVersion: 'season-01',
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
// First-chapter page tracks are packaged for an internal listening candidate.
// They passed automated technical checks, but have not passed human listening,
// elder-device listening or medical-content sign-off. Do not relabel approved.
module.exports = Object.freeze({
'S01-C01-P01': Object.freeze({
reviewStatus: 'technical-qa-pass-human-listening-pending',
pageNumber: 1,
title: '开席前,少了一张桌',
caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。',
imageSrc: '/package-audio-c01-a/assets/player-art/S01-C01-P01-title-v1.jpg',
audioSrc: '/package-audio-c01-a/assets/audio/S01-C01-P01.daa8f04a2627.mp3',
durationSeconds: 33.679388,
sha256: 'daa8f04a262767109f3a0621f178e3c58f169507e8ea09fa8d583258cb713282',
}),
'S01-C01-P02': Object.freeze({
reviewStatus: 'technical-qa-pass-human-listening-pending',
pageNumber: 2,
title: '十五桌,到底去哪儿了?',
caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。',
imageSrc: '/package-audio-c01-a/assets/player-art/S01-C01-P02-missing-table-ensemble-v1.jpg',
audioSrc: '/package-audio-c01-a/assets/audio/S01-C01-P02.7774981b1878.mp3',
durationSeconds: 49.49424,
sha256: '7774981b1878f6ad882cb7a4e3f923d67f43c12c2ea068982df1cab63820d8c2',
}),
'S01-C01-P03': Object.freeze({
reviewStatus: 'technical-qa-pass-human-listening-pending',
pageNumber: 3,
title: '老人站在扫码牌前',
caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。',
imageSrc: '/package-audio-c01-a/assets/player-art/S01-C01-P03-H01-scan-only-v1.jpg',
audioSrc: '/package-audio-c01-a/assets/audio/S01-C01-P03.0f21daa7eccb.mp3',
durationSeconds: 30.534762,
sha256: '0f21daa7eccbaac9139317e8209570fe025fb2403ea5cc794e12586764365885',
}),
'S01-C01-P04': Object.freeze({
reviewStatus: 'technical-qa-pass-human-listening-pending',
pageNumber: 4,
title: '桌上已经够吃了',
caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”',
imageSrc: '/package-audio-c01-a/assets/player-art/S01-C01-P04-H02-extra-dish-v1.jpg',
audioSrc: '/package-audio-c01-a/assets/audio/S01-C01-P04.ed65c367d761.mp3',
durationSeconds: 46.79424,
sha256: 'ed65c367d76125e1404d76bb86faa0629b599f6853a39e58821617f854bfa167',
}),
})
@@ -0,0 +1,713 @@
const audioPages = require('../../data/audioPages')
const SEEK_TIMEOUT_MS = 1600
const PAUSE_LOCK_TIMEOUT_MS = 1200
const RATE_VALUES = Object.freeze([0.8, 1, 1.2])
const RATE_OPTIONS = Object.freeze([
Object.freeze({ value: 0.8, label: '0.8倍' }),
Object.freeze({ value: 1, label: '1.0倍' }),
Object.freeze({ value: 1.2, label: '1.2倍' }),
])
const FALLBACK_RATE_OPTIONS = Object.freeze([
Object.freeze({ value: 1, label: '1.0倍' }),
])
function formatTime(value) {
const seconds = Math.max(0, Math.floor(Number(value) || 0))
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, Number(value) || 0))
}
function eventValue(event) {
return Number(event && event.detail && event.detail.value) || 0
}
Page({
data: {
pageId: '',
pageNumber: 1,
title: '',
caption: '',
imageSrc: '',
playing: false,
loading: false,
audioReady: false,
hasStarted: false,
seekLocked: false,
error: '',
currentLabel: '0:00',
durationLabel: '0:00',
durationSeconds: 1,
sliderValue: 0,
progressStyle: 'width:0%',
playbackRate: 1,
rateOptions: FALLBACK_RATE_OPTIONS,
rateControlsVisible: false,
reviewStatus: 'technical-qa-pass-human-listening-pending',
},
onLoad(options = {}) {
this._unloaded = false
this._lifecycleGeneration = (Number(this._lifecycleGeneration) || 0) + 1
this._contextGeneration = 0
this._intentGeneration = 0
this._seekOperationGeneration = 0
this._controlOperationGeneration = 0
this._desiredPlayback = false
this._activePlayIntentGeneration = 0
this._controlLocked = false
this._scrubbing = false
this._pendingSeek = null
this._seekTimer = null
this._pendingPauseLock = null
this._pauseLockTimer = null
this._page = null
this.bindAudioInterruptionHandlers()
const pageId = String(options.pageId || '')
const page = audioPages[pageId]
if (!page) {
this.setData({ error: '这一页的声音没有找到,请返回连环画。' })
return
}
this._page = page
const durationSeconds = Math.max(1, Number(page.durationSeconds) || 1)
this.setData({
pageId,
pageNumber: page.pageNumber,
title: page.title,
caption: page.caption,
imageSrc: page.imageSrc,
durationLabel: formatTime(durationSeconds),
durationSeconds,
})
},
onReady() {
if (!this._page || this._unloaded) return
this.createAudioContext(this._page.audioSrc)
},
isCurrentContext(context, generation) {
return !this._unloaded
&& this.audioContext === context
&& this._contextGeneration === generation
},
setProgress(value, durationValue) {
const duration = Math.max(
1,
Number(durationValue) || Number(this.data.durationSeconds) || 1,
)
const current = clamp(value, 0, duration)
const percent = Math.min(100, current / duration * 100)
this.setData({
currentLabel: formatTime(current),
durationLabel: formatTime(duration),
durationSeconds: duration,
sliderValue: current,
progressStyle: `width:${percent}%`,
})
},
disableRateControls(context) {
try {
if (context && typeof context.playbackRate === 'number') {
context.playbackRate = 1
}
} catch (_) {
// A runtime that rejects playbackRate remains safely at normal speed.
}
this.setData({
playbackRate: 1,
rateOptions: FALLBACK_RATE_OPTIONS,
rateControlsVisible: false,
})
},
configureRateControls(context, generation) {
try {
if (typeof context.playbackRate !== 'number') {
this.disableRateControls(context)
return
}
for (const rate of RATE_VALUES) {
context.playbackRate = rate
if (Math.abs(Number(context.playbackRate) - rate) > 0.001) {
this.disableRateControls(context)
return
}
}
context.playbackRate = 1
if (
!this.isCurrentContext(context, generation)
|| Math.abs(Number(context.playbackRate) - 1) > 0.001
) {
this.disableRateControls(context)
return
}
this.setData({
playbackRate: 1,
rateOptions: RATE_OPTIONS,
rateControlsVisible: true,
})
} catch (_) {
this.disableRateControls(context)
}
},
createAudioContext(src) {
if (this._unloaded) return null
if (this.audioContext) this.destroyAudioContext()
const context = wx.createInnerAudioContext()
const generation = this._contextGeneration + 1
this._contextGeneration = generation
this.audioContext = context
context.autoplay = false
context.src = src
this.setData({
audioReady: true,
loading: false,
playing: false,
error: '',
})
this.configureRateControls(context, generation)
context.onCanplay(() => {
if (!this.isCurrentContext(context, generation)) return
this.setData({ audioReady: true, error: '' })
})
context.onPlay(() => {
if (!this.isCurrentContext(context, generation)) return
if (
!this._desiredPlayback
|| this._activePlayIntentGeneration !== this._intentGeneration
) {
context.pause()
this._controlLocked = false
this.setData({ playing: false, loading: false })
return
}
this._controlLocked = false
this.setData({
playing: true,
loading: false,
hasStarted: true,
error: '',
})
})
context.onPause(() => {
if (!this.isCurrentContext(context, generation)) return
if (this._desiredPlayback) return
this.releasePauseLockFromEvent(context, generation)
this.setData({ playing: false, loading: false })
})
context.onStop(() => {
if (!this.isCurrentContext(context, generation)) return
this.invalidatePlaybackIntent(false)
this.setData({ playing: false, loading: false })
})
context.onWaiting(() => {
if (
!this.isCurrentContext(context, generation)
|| !this._desiredPlayback
) return
this.setData({ playing: false, loading: true })
})
context.onEnded(() => {
if (!this.isCurrentContext(context, generation)) return
this.invalidatePlaybackIntent(false)
this.setProgress(this.data.durationSeconds, this.data.durationSeconds)
this.setData({ playing: false, loading: false })
})
context.onTimeUpdate(() => {
if (
!this.isCurrentContext(context, generation)
|| this._scrubbing
|| this._pendingSeek
) return
const duration = Number(context.duration) || this.data.durationSeconds
const current = Number(context.currentTime) || 0
this.setProgress(current, duration)
})
if (typeof context.onSeeked === 'function') {
context.onSeeked(() => {
if (!this.isCurrentContext(context, generation)) return
this.finishSeekFromEvent(context, generation)
})
}
context.onError(() => {
if (!this.isCurrentContext(context, generation)) return
this.invalidatePlaybackIntent(false)
this.setData({
playing: false,
loading: false,
error: '声音暂时没打开,您可以返回继续看连环画。',
})
})
this.bindAudioInterruptionHandlers()
return context
},
beginPlaybackIntent() {
this._intentGeneration += 1
this._desiredPlayback = true
this._activePlayIntentGeneration = this._intentGeneration
return this._intentGeneration
},
clearSeekState(updateData = true) {
if (this._seekTimer !== null) {
clearTimeout(this._seekTimer)
this._seekTimer = null
}
this._seekOperationGeneration += 1
this._pendingSeek = null
this._scrubbing = false
if (updateData && !this._unloaded) this.setData({ seekLocked: false })
},
clearPauseLock(unlock = true) {
if (this._pauseLockTimer !== null) {
clearTimeout(this._pauseLockTimer)
this._pauseLockTimer = null
}
this._controlOperationGeneration += 1
this._pendingPauseLock = null
if (unlock) this._controlLocked = false
},
beginPauseLock(context) {
this.clearPauseLock(true)
const operationGeneration = this._controlOperationGeneration + 1
this._controlOperationGeneration = operationGeneration
const pending = {
operationGeneration,
contextGeneration: this._contextGeneration,
intentGeneration: this._intentGeneration,
lifecycleGeneration: this._lifecycleGeneration,
context,
}
this._pendingPauseLock = pending
this._controlLocked = true
this._pauseLockTimer = setTimeout(() => {
this.releasePauseLock(
operationGeneration,
pending.contextGeneration,
pending.intentGeneration,
pending.lifecycleGeneration,
context,
)
}, PAUSE_LOCK_TIMEOUT_MS)
},
releasePauseLock(
operationGeneration,
contextGeneration,
intentGeneration,
lifecycleGeneration,
context,
) {
const pending = this._pendingPauseLock
if (
!pending
|| pending.operationGeneration !== operationGeneration
|| pending.contextGeneration !== contextGeneration
|| pending.intentGeneration !== intentGeneration
|| pending.lifecycleGeneration !== lifecycleGeneration
|| pending.context !== context
|| !this.isCurrentContext(context, contextGeneration)
|| this._intentGeneration !== intentGeneration
|| this._lifecycleGeneration !== lifecycleGeneration
|| this._desiredPlayback
) return false
if (this._pauseLockTimer !== null) {
clearTimeout(this._pauseLockTimer)
this._pauseLockTimer = null
}
this._pendingPauseLock = null
this._controlLocked = false
return true
},
releasePauseLockFromEvent(context, contextGeneration) {
const pending = this._pendingPauseLock
if (!pending) {
if (this.isCurrentContext(context, contextGeneration)) {
this._controlLocked = false
}
return
}
this.releasePauseLock(
pending.operationGeneration,
contextGeneration,
pending.intentGeneration,
pending.lifecycleGeneration,
context,
)
},
invalidatePlaybackIntent(pauseContext = true) {
this._intentGeneration += 1
this._activePlayIntentGeneration = 0
this._desiredPlayback = false
this.clearPauseLock(true)
this.clearSeekState(!this._unloaded)
if (
pauseContext
&& this.audioContext
&& typeof this.audioContext.pause === 'function'
) {
this.audioContext.pause()
}
},
startPlaybackWithIntent(intentGeneration) {
const context = this.audioContext
if (
!context
|| this._unloaded
|| intentGeneration !== this._intentGeneration
|| !this._desiredPlayback
) return false
this._controlLocked = true
this.setData({
loading: true,
playing: false,
hasStarted: true,
error: '',
})
context.play()
return true
},
bindAudioInterruptionHandlers() {
if (this._audioInterruptionBeginHandler) return
const generation = this._lifecycleGeneration
this._audioInterruptionBeginHandler = () => {
if (this._unloaded || this._lifecycleGeneration !== generation) return
this.pauseWithoutAutoResume()
}
this._audioInterruptionEndHandler = () => {
if (this._unloaded || this._lifecycleGeneration !== generation) return
this.invalidatePlaybackIntent(false)
this.setData({ playing: false, loading: false })
}
if (typeof wx.onAudioInterruptionBegin === 'function') {
wx.onAudioInterruptionBegin(this._audioInterruptionBeginHandler)
}
if (typeof wx.onAudioInterruptionEnd === 'function') {
wx.onAudioInterruptionEnd(this._audioInterruptionEndHandler)
}
},
unbindAudioInterruptionHandlers() {
if (
this._audioInterruptionBeginHandler
&& typeof wx.offAudioInterruptionBegin === 'function'
) {
wx.offAudioInterruptionBegin(this._audioInterruptionBeginHandler)
}
if (
this._audioInterruptionEndHandler
&& typeof wx.offAudioInterruptionEnd === 'function'
) {
wx.offAudioInterruptionEnd(this._audioInterruptionEndHandler)
}
this._audioInterruptionBeginHandler = null
this._audioInterruptionEndHandler = null
},
pauseWithoutAutoResume() {
const context = this.audioContext
this.invalidatePlaybackIntent(false)
if (context && typeof context.pause === 'function') {
this.beginPauseLock(context)
try {
context.pause()
} catch (_) {
this.clearPauseLock(true)
}
}
if (!this._unloaded) {
this.setData({ playing: false, loading: false })
}
},
onHide() {
this.pauseWithoutAutoResume()
},
onShow() {
if (this._unloaded) return
this.clearPauseLock(true)
this.setData({ playing: false, loading: false, seekLocked: false })
},
togglePlayback() {
if (this.data.loading || this._controlLocked || this._unloaded) return
if (!this.audioContext && this._page) {
this.createAudioContext(this._page.audioSrc)
}
if (!this.audioContext) return
if (this.data.playing || this._desiredPlayback) {
this.pauseWithoutAutoResume()
return
}
const intentGeneration = this.beginPlaybackIntent()
this.startPlaybackWithIntent(intentGeneration)
},
requestSeek(targetValue, options = {}) {
const context = this.audioContext
if (
!context
|| this._unloaded
|| this.data.loading
|| this._pendingSeek
) return false
const duration = Math.max(
1,
Number(context.duration) || Number(this.data.durationSeconds) || 1,
)
const target = clamp(targetValue, 0, duration)
const actualBeforeSeek = Number(context.currentTime)
const previousValue = clamp(
Number.isFinite(actualBeforeSeek)
? actualBeforeSeek
: Number(this.data.sliderValue) || 0,
0,
duration,
)
const operationGeneration = this._seekOperationGeneration + 1
this._seekOperationGeneration = operationGeneration
const pending = {
operationGeneration,
contextGeneration: this._contextGeneration,
intentGeneration: this._intentGeneration,
context,
target,
previousValue,
playAfterSeek: Boolean(options.playAfterSeek),
}
this._pendingSeek = pending
this._scrubbing = false
this.setProgress(target, duration)
this.setData({ seekLocked: true, error: '' })
this._seekTimer = setTimeout(() => {
this.failSeek(
operationGeneration,
pending.contextGeneration,
pending.intentGeneration,
context,
)
}, SEEK_TIMEOUT_MS)
try {
context.seek(target)
} catch (_) {
this.failSeek(
operationGeneration,
pending.contextGeneration,
pending.intentGeneration,
context,
)
return false
}
return true
},
finishSeekFromEvent(context, contextGeneration) {
const pending = this._pendingSeek
if (!pending) return
const actual = Number(context.currentTime)
if (Number.isFinite(actual) && Math.abs(actual - pending.target) > 1.25) return
this.finishSeek(
pending.operationGeneration,
contextGeneration,
pending.intentGeneration,
context,
)
},
finishSeek(
operationGeneration,
contextGeneration,
intentGeneration,
context,
) {
const pending = this._pendingSeek
if (
!pending
|| pending.operationGeneration !== operationGeneration
|| pending.contextGeneration !== contextGeneration
|| pending.intentGeneration !== intentGeneration
|| pending.context !== context
|| !this.isCurrentContext(context, contextGeneration)
|| this._intentGeneration !== intentGeneration
) return
if (this._seekTimer !== null) {
clearTimeout(this._seekTimer)
this._seekTimer = null
}
this._pendingSeek = null
this.setData({ seekLocked: false })
if (
pending.playAfterSeek
&& this._desiredPlayback
&& this._activePlayIntentGeneration === intentGeneration
) {
this.startPlaybackWithIntent(intentGeneration)
}
},
failSeek(
operationGeneration,
contextGeneration,
intentGeneration,
context,
) {
const pending = this._pendingSeek
if (
!pending
|| pending.operationGeneration !== operationGeneration
|| pending.contextGeneration !== contextGeneration
|| pending.intentGeneration !== intentGeneration
|| pending.context !== context
|| !this.isCurrentContext(context, contextGeneration)
|| this._intentGeneration !== intentGeneration
) return false
if (this._seekTimer !== null) {
clearTimeout(this._seekTimer)
this._seekTimer = null
}
this._pendingSeek = null
this._scrubbing = false
const actual = Number(context.currentTime)
const restoredValue = Number.isFinite(actual)
? actual
: pending.previousValue
if (pending.playAfterSeek) this.invalidatePlaybackIntent(false)
this.setProgress(restoredValue, this.data.durationSeconds)
this.setData({
playing: pending.playAfterSeek ? false : this.data.playing,
loading: false,
seekLocked: false,
error: '进度没有调好,请再试一次。',
})
return true
},
previewSeek(event) {
if (!this.audioContext || this.data.loading || this._pendingSeek) return
this._scrubbing = true
this.setProgress(eventValue(event), this.data.durationSeconds)
},
commitSeek(event) {
if (!this.audioContext || this.data.loading || this._pendingSeek) return
this._scrubbing = false
this.requestSeek(eventValue(event))
},
rewindTenSeconds() {
if (!this.audioContext || this.data.loading || this._pendingSeek) return
const current = Number(this.audioContext.currentTime)
const fallback = Number(this.data.sliderValue) || 0
this.requestSeek(Math.max(0, (Number.isFinite(current) ? current : fallback) - 10))
},
replay() {
if (
!this.audioContext
|| this.data.loading
|| this._controlLocked
|| this._pendingSeek
|| this._unloaded
) return
const alreadyPlaying = this.data.playing && this._desiredPlayback
const intentGeneration = alreadyPlaying
? this._intentGeneration
: this.beginPlaybackIntent()
this.setData({ hasStarted: true, error: '' })
this.requestSeek(0, {
playAfterSeek: !alreadyPlaying,
intentGeneration,
})
},
changePlaybackRate(event) {
const requested = Number(
event && event.currentTarget && event.currentTarget.dataset.rate,
)
if (
!RATE_VALUES.includes(requested)
|| !this.audioContext
|| !this.data.rateControlsVisible
) return
const allowed = this.data.rateOptions.some((item) => item.value === requested)
if (!allowed) return
try {
this.audioContext.playbackRate = requested
if (Math.abs(Number(this.audioContext.playbackRate) - requested) > 0.001) {
this.disableRateControls(this.audioContext)
return
}
this.setData({ playbackRate: requested })
} catch (_) {
this.disableRateControls(this.audioContext)
}
},
goBack() {
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
const returnToChapter = () => {
if (typeof wx.reLaunch === 'function') {
wx.reLaunch({
url: '/package-game/pages/chapter/chapter?chapter=1',
})
}
}
if (pages.length > 1 && typeof wx.navigateBack === 'function') {
wx.navigateBack({ delta: 1, fail: returnToChapter })
return
}
returnToChapter()
},
destroyAudioContext() {
const context = this.audioContext
if (!context) return
this.invalidatePlaybackIntent(false)
this._contextGeneration += 1
this.audioContext = null
context.destroy()
if (!this._unloaded) {
this.setData({
audioReady: false,
playing: false,
loading: false,
rateOptions: FALLBACK_RATE_OPTIONS,
rateControlsVisible: false,
playbackRate: 1,
})
}
},
onUnload() {
this._unloaded = true
this._lifecycleGeneration += 1
this._intentGeneration += 1
this._activePlayIntentGeneration = 0
this._desiredPlayback = false
this._controlLocked = true
this.clearPauseLock(false)
this.clearSeekState(false)
this.unbindAudioInterruptionHandlers()
const context = this.audioContext
this._contextGeneration += 1
this.audioContext = null
if (context) context.destroy()
},
})
@@ -0,0 +1,3 @@
{
"disableScroll": true
}
@@ -0,0 +1,63 @@
<view class="audio-leaf">
<view class="audio-leaf-topbar">
<button class="back-top" bindtap="goBack" aria-label="返回刚才的连环画"> 返回连环画</button>
<view class="leaf-heading">
<text class="leaf-kicker">第01回 · 有声夹页</text>
<text class="leaf-count">{{pageNumber}}8</text>
</view>
</view>
<view class="audio-leaf-spread">
<view class="leaf-art-frame">
<image class="leaf-art" src="{{imageSrc}}" mode="aspectFit"></image>
<text class="leaf-stamp">桂香故事</text>
</view>
<view class="leaf-copy">
<text class="leaf-title">{{title}}</text>
<text class="leaf-caption">{{caption}}</text>
<view class="leaf-seek-row">
<slider
class="leaf-slider"
aria-label="播放进度,可左右拖动"
min="0"
max="{{durationSeconds}}"
step="1"
value="{{sliderValue}}"
activeColor="#9b3328"
backgroundColor="#d6c6a1"
block-color="#8f271f"
block-size="24"
disabled="{{loading || !audioReady || seekLocked}}"
bindchanging="previewSeek"
bindchange="commitSeek"
></slider>
<text class="leaf-time">{{currentLabel}} / {{durationLabel}}</text>
</view>
<view class="leaf-controls">
<button class="leaf-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="rewindTenSeconds">后退10秒</button>
<button class="leaf-button primary" disabled="{{loading || !audioReady || seekLocked}}" bindtap="togglePlayback">
{{loading ? '正在准备' : playing ? '暂停' : hasStarted ? '继续听' : '开始听'}}
</button>
<button class="leaf-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="replay">从头重听</button>
</view>
<view wx:if="{{rateControlsVisible}}" class="leaf-rate-row" aria-label="播放速度">
<text class="leaf-rate-label">语速</text>
<button
wx:for="{{rateOptions}}"
wx:key="value"
class="leaf-rate-button {{playbackRate == item.value ? 'active' : ''}}"
data-rate="{{item.value}}"
disabled="{{loading || seekLocked}}"
bindtap="changePlaybackRate"
>{{item.label}}</button>
</view>
<text wx:if="{{error}}" class="leaf-error">{{error}}</text>
<button class="back-bottom" bindtap="goBack">看完,回到连环画</button>
</view>
</view>
</view>
@@ -0,0 +1,171 @@
page {
width: 100%;
min-height: 100%;
background: #1c130f;
color: #2f241d;
}
button::after { border: 0; }
.audio-leaf {
box-sizing: border-box;
width: 100vw;
height: 100vh;
min-height: 320px;
padding: max(8px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left));
display: flex;
flex-direction: column;
gap: 8px;
background: radial-gradient(circle at 48% 45%, #423024 0, #211611 70%);
}
.audio-leaf-topbar {
height: 52px;
flex: 0 0 52px;
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 10px;
align-items: center;
padding-right: max(92px, env(safe-area-inset-right));
color: #f5e6b7;
}
.back-top {
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-height: 48px;
border: 1px solid #9b7a51;
border-radius: 10px;
background: #38251b;
color: #f7e8bd;
font-size: 20px;
line-height: 48px;
text-align: center;
padding: 0 12px;
}
.leaf-heading { display: flex; align-items: baseline; gap: 12px; }
.leaf-kicker { font-size: 20px; font-weight: 700; }
.leaf-count { color: #d9bb78; font-size: 18px; }
.audio-leaf-spread {
min-height: 0;
flex: 1;
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(300px, 2fr);
border: 2px solid #b99762;
background: #efe2bd;
box-shadow: 0 10px 34px rgba(0, 0, 0, .4);
}
.leaf-art-frame {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
background: #17100c;
border-right: 4px solid #8d271f;
}
.leaf-art { width: 100%; height: 100%; display: block; }
.leaf-stamp {
position: absolute;
left: 18px;
bottom: 16px;
padding: 6px 12px;
background: rgba(86, 24, 20, .9);
color: #f5e6bd;
font-size: 17px;
letter-spacing: 2px;
}
.leaf-copy {
min-width: 0;
min-height: 0;
padding: 18px 20px 14px;
display: flex;
flex-direction: column;
background: repeating-linear-gradient(0deg, rgba(119, 87, 46, .04) 0, rgba(119, 87, 46, .04) 1px, transparent 1px, transparent 4px), #f6edcf;
}
.leaf-title { color: #8f271f; font-size: 28px; font-weight: 800; line-height: 1.25; }
.leaf-caption { margin-top: 10px; font-size: 22px; font-weight: 600; line-height: 1.48; }
.leaf-seek-row {
min-width: 0;
min-height: 48px;
margin-top: auto;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
}
.leaf-slider { min-width: 0; width: 100%; margin: 0; }
.leaf-time { min-width: 82px; color: #6d5b48; font-size: 17px; text-align: right; }
.leaf-controls {
min-width: 0;
margin-top: 6px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.leaf-button, .back-bottom {
box-sizing: border-box;
min-height: 48px;
border: 2px solid #7e6648;
border-radius: 10px;
background: #eadbb5;
color: #37291f;
font-size: 18px;
font-weight: 700;
line-height: 46px;
min-width: 0;
margin: 0;
padding: 0 6px;
}
.leaf-button { width: 100%; max-width: 100%; }
.leaf-button.primary { border-color: #8f271f; background: #9e3027; color: #fff2cf; }
.leaf-button[disabled] { opacity: .72; }
.leaf-rate-row {
min-width: 0;
min-height: 48px;
margin-top: 6px;
display: grid;
grid-template-columns: auto repeat(3, minmax(48px, 1fr));
gap: 6px;
align-items: center;
}
.leaf-rate-label { color: #6d5b48; font-size: 18px; font-weight: 700; }
.leaf-rate-button {
box-sizing: border-box;
min-width: 48px;
min-height: 48px;
margin: 0;
padding: 0 4px;
border: 2px solid #9d896a;
border-radius: 9px;
background: #f3e7c8;
color: #4d3b2d;
font-size: 17px;
font-weight: 700;
line-height: 46px;
}
.leaf-rate-button { width: 100%; max-width: 100%; }
.leaf-rate-button.active { border-color: #8f271f; background: #8f271f; color: #fff2cf; }
.leaf-rate-button[disabled] { opacity: .72; }
.leaf-error { margin-top: 8px; color: #8f271f; font-size: 17px; line-height: 1.35; }
.back-bottom { margin-top: 8px; width: 100%; }
@media (max-height: 430px) {
.audio-leaf { gap: 4px; padding-top: 4px; padding-bottom: 4px; }
.audio-leaf-topbar { height: 48px; flex-basis: 48px; }
.leaf-copy { padding: 8px 12px; overflow-y: auto; }
.leaf-title { font-size: 23px; }
.leaf-caption { margin-top: 4px; font-size: 17px; line-height: 1.3; }
.leaf-seek-row { min-height: 48px; }
.leaf-controls, .leaf-rate-row { margin-top: 3px; gap: 5px; }
.leaf-button, .back-bottom { min-height: 48px; line-height: 46px; font-size: 17px; }
.leaf-rate-button { min-height: 48px; line-height: 46px; font-size: 16px; }
.leaf-rate-label { font-size: 16px; }
.back-bottom { display: none; }
}
@@ -0,0 +1,45 @@
// First-chapter page tracks are packaged for an internal listening candidate.
// They passed automated technical checks, but have not passed human listening,
// elder-device listening or medical-content sign-off. Do not relabel approved.
module.exports = Object.freeze({
'S01-C01-P05': Object.freeze({
reviewStatus: 'technical-qa-pass-human-listening-pending',
pageNumber: 5,
title: '地上留下四个新压痕',
caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。',
imageSrc: '/package-audio-c01-b/assets/player-art/S01-C01-P05-H03-four-imprints-v1.jpg',
audioSrc: '/package-audio-c01-b/assets/audio/S01-C01-P05.210005d24e32.mp3',
durationSeconds: 32.880567,
sha256: '210005d24e3276e2689e38b0bb3a9d6b6e900ca3cc5ca874278a0b89a50def6a',
}),
'S01-C01-P06': Object.freeze({
reviewStatus: 'technical-qa-pass-human-listening-pending',
pageNumber: 6,
title: '秦师傅把话咽了回去',
caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。',
imageSrc: '/package-audio-c01-b/assets/player-art/S01-C01-P06-H04-unsaid-speech-v1.jpg',
audioSrc: '/package-audio-c01-b/assets/audio/S01-C01-P06.5e69ed4c17f3.mp3',
durationSeconds: 13.328889,
sha256: '5e69ed4c17f3d5c4168546c0b76a6d5bfb13ecbecdd982c5be1c27259b9ce1a4',
}),
'S01-C01-P07': Object.freeze({
reviewStatus: 'technical-qa-pass-human-listening-pending',
pageNumber: 7,
title: '把赵伯叫进来',
caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。',
imageSrc: '/package-audio-c01-b/assets/player-art/S01-C01-P07-EM01-call-zhao-in-v1.jpg',
audioSrc: '/package-audio-c01-b/assets/audio/S01-C01-P07.a68bc0cb8ecc.mp3',
durationSeconds: 10.466644,
sha256: 'a68bc0cb8ecc6d53b7c877567d6c21fdd894c914e84c072357116429d05d0cc5',
}),
'S01-C01-P08': Object.freeze({
reviewStatus: 'technical-qa-pass-human-listening-pending',
pageNumber: 8,
title: '锅盖响了一声',
caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。',
imageSrc: '/package-audio-c01-b/assets/player-art/S01-C01-P08-cliffhanger-v1.jpg',
audioSrc: '/package-audio-c01-b/assets/audio/S01-C01-P08.1c44eff9d71f.mp3',
durationSeconds: 39.534467,
sha256: '1c44eff9d71fcd004e53050e0727ee37fa3560509d3579c0e2cd004e80e246ec',
}),
})
@@ -0,0 +1,713 @@
const audioPages = require('../../data/audioPages')
const SEEK_TIMEOUT_MS = 1600
const PAUSE_LOCK_TIMEOUT_MS = 1200
const RATE_VALUES = Object.freeze([0.8, 1, 1.2])
const RATE_OPTIONS = Object.freeze([
Object.freeze({ value: 0.8, label: '0.8倍' }),
Object.freeze({ value: 1, label: '1.0倍' }),
Object.freeze({ value: 1.2, label: '1.2倍' }),
])
const FALLBACK_RATE_OPTIONS = Object.freeze([
Object.freeze({ value: 1, label: '1.0倍' }),
])
function formatTime(value) {
const seconds = Math.max(0, Math.floor(Number(value) || 0))
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, Number(value) || 0))
}
function eventValue(event) {
return Number(event && event.detail && event.detail.value) || 0
}
Page({
data: {
pageId: '',
pageNumber: 1,
title: '',
caption: '',
imageSrc: '',
playing: false,
loading: false,
audioReady: false,
hasStarted: false,
seekLocked: false,
error: '',
currentLabel: '0:00',
durationLabel: '0:00',
durationSeconds: 1,
sliderValue: 0,
progressStyle: 'width:0%',
playbackRate: 1,
rateOptions: FALLBACK_RATE_OPTIONS,
rateControlsVisible: false,
reviewStatus: 'technical-qa-pass-human-listening-pending',
},
onLoad(options = {}) {
this._unloaded = false
this._lifecycleGeneration = (Number(this._lifecycleGeneration) || 0) + 1
this._contextGeneration = 0
this._intentGeneration = 0
this._seekOperationGeneration = 0
this._controlOperationGeneration = 0
this._desiredPlayback = false
this._activePlayIntentGeneration = 0
this._controlLocked = false
this._scrubbing = false
this._pendingSeek = null
this._seekTimer = null
this._pendingPauseLock = null
this._pauseLockTimer = null
this._page = null
this.bindAudioInterruptionHandlers()
const pageId = String(options.pageId || '')
const page = audioPages[pageId]
if (!page) {
this.setData({ error: '这一页的声音没有找到,请返回连环画。' })
return
}
this._page = page
const durationSeconds = Math.max(1, Number(page.durationSeconds) || 1)
this.setData({
pageId,
pageNumber: page.pageNumber,
title: page.title,
caption: page.caption,
imageSrc: page.imageSrc,
durationLabel: formatTime(durationSeconds),
durationSeconds,
})
},
onReady() {
if (!this._page || this._unloaded) return
this.createAudioContext(this._page.audioSrc)
},
isCurrentContext(context, generation) {
return !this._unloaded
&& this.audioContext === context
&& this._contextGeneration === generation
},
setProgress(value, durationValue) {
const duration = Math.max(
1,
Number(durationValue) || Number(this.data.durationSeconds) || 1,
)
const current = clamp(value, 0, duration)
const percent = Math.min(100, current / duration * 100)
this.setData({
currentLabel: formatTime(current),
durationLabel: formatTime(duration),
durationSeconds: duration,
sliderValue: current,
progressStyle: `width:${percent}%`,
})
},
disableRateControls(context) {
try {
if (context && typeof context.playbackRate === 'number') {
context.playbackRate = 1
}
} catch (_) {
// A runtime that rejects playbackRate remains safely at normal speed.
}
this.setData({
playbackRate: 1,
rateOptions: FALLBACK_RATE_OPTIONS,
rateControlsVisible: false,
})
},
configureRateControls(context, generation) {
try {
if (typeof context.playbackRate !== 'number') {
this.disableRateControls(context)
return
}
for (const rate of RATE_VALUES) {
context.playbackRate = rate
if (Math.abs(Number(context.playbackRate) - rate) > 0.001) {
this.disableRateControls(context)
return
}
}
context.playbackRate = 1
if (
!this.isCurrentContext(context, generation)
|| Math.abs(Number(context.playbackRate) - 1) > 0.001
) {
this.disableRateControls(context)
return
}
this.setData({
playbackRate: 1,
rateOptions: RATE_OPTIONS,
rateControlsVisible: true,
})
} catch (_) {
this.disableRateControls(context)
}
},
createAudioContext(src) {
if (this._unloaded) return null
if (this.audioContext) this.destroyAudioContext()
const context = wx.createInnerAudioContext()
const generation = this._contextGeneration + 1
this._contextGeneration = generation
this.audioContext = context
context.autoplay = false
context.src = src
this.setData({
audioReady: true,
loading: false,
playing: false,
error: '',
})
this.configureRateControls(context, generation)
context.onCanplay(() => {
if (!this.isCurrentContext(context, generation)) return
this.setData({ audioReady: true, error: '' })
})
context.onPlay(() => {
if (!this.isCurrentContext(context, generation)) return
if (
!this._desiredPlayback
|| this._activePlayIntentGeneration !== this._intentGeneration
) {
context.pause()
this._controlLocked = false
this.setData({ playing: false, loading: false })
return
}
this._controlLocked = false
this.setData({
playing: true,
loading: false,
hasStarted: true,
error: '',
})
})
context.onPause(() => {
if (!this.isCurrentContext(context, generation)) return
if (this._desiredPlayback) return
this.releasePauseLockFromEvent(context, generation)
this.setData({ playing: false, loading: false })
})
context.onStop(() => {
if (!this.isCurrentContext(context, generation)) return
this.invalidatePlaybackIntent(false)
this.setData({ playing: false, loading: false })
})
context.onWaiting(() => {
if (
!this.isCurrentContext(context, generation)
|| !this._desiredPlayback
) return
this.setData({ playing: false, loading: true })
})
context.onEnded(() => {
if (!this.isCurrentContext(context, generation)) return
this.invalidatePlaybackIntent(false)
this.setProgress(this.data.durationSeconds, this.data.durationSeconds)
this.setData({ playing: false, loading: false })
})
context.onTimeUpdate(() => {
if (
!this.isCurrentContext(context, generation)
|| this._scrubbing
|| this._pendingSeek
) return
const duration = Number(context.duration) || this.data.durationSeconds
const current = Number(context.currentTime) || 0
this.setProgress(current, duration)
})
if (typeof context.onSeeked === 'function') {
context.onSeeked(() => {
if (!this.isCurrentContext(context, generation)) return
this.finishSeekFromEvent(context, generation)
})
}
context.onError(() => {
if (!this.isCurrentContext(context, generation)) return
this.invalidatePlaybackIntent(false)
this.setData({
playing: false,
loading: false,
error: '声音暂时没打开,您可以返回继续看连环画。',
})
})
this.bindAudioInterruptionHandlers()
return context
},
beginPlaybackIntent() {
this._intentGeneration += 1
this._desiredPlayback = true
this._activePlayIntentGeneration = this._intentGeneration
return this._intentGeneration
},
clearSeekState(updateData = true) {
if (this._seekTimer !== null) {
clearTimeout(this._seekTimer)
this._seekTimer = null
}
this._seekOperationGeneration += 1
this._pendingSeek = null
this._scrubbing = false
if (updateData && !this._unloaded) this.setData({ seekLocked: false })
},
clearPauseLock(unlock = true) {
if (this._pauseLockTimer !== null) {
clearTimeout(this._pauseLockTimer)
this._pauseLockTimer = null
}
this._controlOperationGeneration += 1
this._pendingPauseLock = null
if (unlock) this._controlLocked = false
},
beginPauseLock(context) {
this.clearPauseLock(true)
const operationGeneration = this._controlOperationGeneration + 1
this._controlOperationGeneration = operationGeneration
const pending = {
operationGeneration,
contextGeneration: this._contextGeneration,
intentGeneration: this._intentGeneration,
lifecycleGeneration: this._lifecycleGeneration,
context,
}
this._pendingPauseLock = pending
this._controlLocked = true
this._pauseLockTimer = setTimeout(() => {
this.releasePauseLock(
operationGeneration,
pending.contextGeneration,
pending.intentGeneration,
pending.lifecycleGeneration,
context,
)
}, PAUSE_LOCK_TIMEOUT_MS)
},
releasePauseLock(
operationGeneration,
contextGeneration,
intentGeneration,
lifecycleGeneration,
context,
) {
const pending = this._pendingPauseLock
if (
!pending
|| pending.operationGeneration !== operationGeneration
|| pending.contextGeneration !== contextGeneration
|| pending.intentGeneration !== intentGeneration
|| pending.lifecycleGeneration !== lifecycleGeneration
|| pending.context !== context
|| !this.isCurrentContext(context, contextGeneration)
|| this._intentGeneration !== intentGeneration
|| this._lifecycleGeneration !== lifecycleGeneration
|| this._desiredPlayback
) return false
if (this._pauseLockTimer !== null) {
clearTimeout(this._pauseLockTimer)
this._pauseLockTimer = null
}
this._pendingPauseLock = null
this._controlLocked = false
return true
},
releasePauseLockFromEvent(context, contextGeneration) {
const pending = this._pendingPauseLock
if (!pending) {
if (this.isCurrentContext(context, contextGeneration)) {
this._controlLocked = false
}
return
}
this.releasePauseLock(
pending.operationGeneration,
contextGeneration,
pending.intentGeneration,
pending.lifecycleGeneration,
context,
)
},
invalidatePlaybackIntent(pauseContext = true) {
this._intentGeneration += 1
this._activePlayIntentGeneration = 0
this._desiredPlayback = false
this.clearPauseLock(true)
this.clearSeekState(!this._unloaded)
if (
pauseContext
&& this.audioContext
&& typeof this.audioContext.pause === 'function'
) {
this.audioContext.pause()
}
},
startPlaybackWithIntent(intentGeneration) {
const context = this.audioContext
if (
!context
|| this._unloaded
|| intentGeneration !== this._intentGeneration
|| !this._desiredPlayback
) return false
this._controlLocked = true
this.setData({
loading: true,
playing: false,
hasStarted: true,
error: '',
})
context.play()
return true
},
bindAudioInterruptionHandlers() {
if (this._audioInterruptionBeginHandler) return
const generation = this._lifecycleGeneration
this._audioInterruptionBeginHandler = () => {
if (this._unloaded || this._lifecycleGeneration !== generation) return
this.pauseWithoutAutoResume()
}
this._audioInterruptionEndHandler = () => {
if (this._unloaded || this._lifecycleGeneration !== generation) return
this.invalidatePlaybackIntent(false)
this.setData({ playing: false, loading: false })
}
if (typeof wx.onAudioInterruptionBegin === 'function') {
wx.onAudioInterruptionBegin(this._audioInterruptionBeginHandler)
}
if (typeof wx.onAudioInterruptionEnd === 'function') {
wx.onAudioInterruptionEnd(this._audioInterruptionEndHandler)
}
},
unbindAudioInterruptionHandlers() {
if (
this._audioInterruptionBeginHandler
&& typeof wx.offAudioInterruptionBegin === 'function'
) {
wx.offAudioInterruptionBegin(this._audioInterruptionBeginHandler)
}
if (
this._audioInterruptionEndHandler
&& typeof wx.offAudioInterruptionEnd === 'function'
) {
wx.offAudioInterruptionEnd(this._audioInterruptionEndHandler)
}
this._audioInterruptionBeginHandler = null
this._audioInterruptionEndHandler = null
},
pauseWithoutAutoResume() {
const context = this.audioContext
this.invalidatePlaybackIntent(false)
if (context && typeof context.pause === 'function') {
this.beginPauseLock(context)
try {
context.pause()
} catch (_) {
this.clearPauseLock(true)
}
}
if (!this._unloaded) {
this.setData({ playing: false, loading: false })
}
},
onHide() {
this.pauseWithoutAutoResume()
},
onShow() {
if (this._unloaded) return
this.clearPauseLock(true)
this.setData({ playing: false, loading: false, seekLocked: false })
},
togglePlayback() {
if (this.data.loading || this._controlLocked || this._unloaded) return
if (!this.audioContext && this._page) {
this.createAudioContext(this._page.audioSrc)
}
if (!this.audioContext) return
if (this.data.playing || this._desiredPlayback) {
this.pauseWithoutAutoResume()
return
}
const intentGeneration = this.beginPlaybackIntent()
this.startPlaybackWithIntent(intentGeneration)
},
requestSeek(targetValue, options = {}) {
const context = this.audioContext
if (
!context
|| this._unloaded
|| this.data.loading
|| this._pendingSeek
) return false
const duration = Math.max(
1,
Number(context.duration) || Number(this.data.durationSeconds) || 1,
)
const target = clamp(targetValue, 0, duration)
const actualBeforeSeek = Number(context.currentTime)
const previousValue = clamp(
Number.isFinite(actualBeforeSeek)
? actualBeforeSeek
: Number(this.data.sliderValue) || 0,
0,
duration,
)
const operationGeneration = this._seekOperationGeneration + 1
this._seekOperationGeneration = operationGeneration
const pending = {
operationGeneration,
contextGeneration: this._contextGeneration,
intentGeneration: this._intentGeneration,
context,
target,
previousValue,
playAfterSeek: Boolean(options.playAfterSeek),
}
this._pendingSeek = pending
this._scrubbing = false
this.setProgress(target, duration)
this.setData({ seekLocked: true, error: '' })
this._seekTimer = setTimeout(() => {
this.failSeek(
operationGeneration,
pending.contextGeneration,
pending.intentGeneration,
context,
)
}, SEEK_TIMEOUT_MS)
try {
context.seek(target)
} catch (_) {
this.failSeek(
operationGeneration,
pending.contextGeneration,
pending.intentGeneration,
context,
)
return false
}
return true
},
finishSeekFromEvent(context, contextGeneration) {
const pending = this._pendingSeek
if (!pending) return
const actual = Number(context.currentTime)
if (Number.isFinite(actual) && Math.abs(actual - pending.target) > 1.25) return
this.finishSeek(
pending.operationGeneration,
contextGeneration,
pending.intentGeneration,
context,
)
},
finishSeek(
operationGeneration,
contextGeneration,
intentGeneration,
context,
) {
const pending = this._pendingSeek
if (
!pending
|| pending.operationGeneration !== operationGeneration
|| pending.contextGeneration !== contextGeneration
|| pending.intentGeneration !== intentGeneration
|| pending.context !== context
|| !this.isCurrentContext(context, contextGeneration)
|| this._intentGeneration !== intentGeneration
) return
if (this._seekTimer !== null) {
clearTimeout(this._seekTimer)
this._seekTimer = null
}
this._pendingSeek = null
this.setData({ seekLocked: false })
if (
pending.playAfterSeek
&& this._desiredPlayback
&& this._activePlayIntentGeneration === intentGeneration
) {
this.startPlaybackWithIntent(intentGeneration)
}
},
failSeek(
operationGeneration,
contextGeneration,
intentGeneration,
context,
) {
const pending = this._pendingSeek
if (
!pending
|| pending.operationGeneration !== operationGeneration
|| pending.contextGeneration !== contextGeneration
|| pending.intentGeneration !== intentGeneration
|| pending.context !== context
|| !this.isCurrentContext(context, contextGeneration)
|| this._intentGeneration !== intentGeneration
) return false
if (this._seekTimer !== null) {
clearTimeout(this._seekTimer)
this._seekTimer = null
}
this._pendingSeek = null
this._scrubbing = false
const actual = Number(context.currentTime)
const restoredValue = Number.isFinite(actual)
? actual
: pending.previousValue
if (pending.playAfterSeek) this.invalidatePlaybackIntent(false)
this.setProgress(restoredValue, this.data.durationSeconds)
this.setData({
playing: pending.playAfterSeek ? false : this.data.playing,
loading: false,
seekLocked: false,
error: '进度没有调好,请再试一次。',
})
return true
},
previewSeek(event) {
if (!this.audioContext || this.data.loading || this._pendingSeek) return
this._scrubbing = true
this.setProgress(eventValue(event), this.data.durationSeconds)
},
commitSeek(event) {
if (!this.audioContext || this.data.loading || this._pendingSeek) return
this._scrubbing = false
this.requestSeek(eventValue(event))
},
rewindTenSeconds() {
if (!this.audioContext || this.data.loading || this._pendingSeek) return
const current = Number(this.audioContext.currentTime)
const fallback = Number(this.data.sliderValue) || 0
this.requestSeek(Math.max(0, (Number.isFinite(current) ? current : fallback) - 10))
},
replay() {
if (
!this.audioContext
|| this.data.loading
|| this._controlLocked
|| this._pendingSeek
|| this._unloaded
) return
const alreadyPlaying = this.data.playing && this._desiredPlayback
const intentGeneration = alreadyPlaying
? this._intentGeneration
: this.beginPlaybackIntent()
this.setData({ hasStarted: true, error: '' })
this.requestSeek(0, {
playAfterSeek: !alreadyPlaying,
intentGeneration,
})
},
changePlaybackRate(event) {
const requested = Number(
event && event.currentTarget && event.currentTarget.dataset.rate,
)
if (
!RATE_VALUES.includes(requested)
|| !this.audioContext
|| !this.data.rateControlsVisible
) return
const allowed = this.data.rateOptions.some((item) => item.value === requested)
if (!allowed) return
try {
this.audioContext.playbackRate = requested
if (Math.abs(Number(this.audioContext.playbackRate) - requested) > 0.001) {
this.disableRateControls(this.audioContext)
return
}
this.setData({ playbackRate: requested })
} catch (_) {
this.disableRateControls(this.audioContext)
}
},
goBack() {
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
const returnToChapter = () => {
if (typeof wx.reLaunch === 'function') {
wx.reLaunch({
url: '/package-game/pages/chapter/chapter?chapter=1',
})
}
}
if (pages.length > 1 && typeof wx.navigateBack === 'function') {
wx.navigateBack({ delta: 1, fail: returnToChapter })
return
}
returnToChapter()
},
destroyAudioContext() {
const context = this.audioContext
if (!context) return
this.invalidatePlaybackIntent(false)
this._contextGeneration += 1
this.audioContext = null
context.destroy()
if (!this._unloaded) {
this.setData({
audioReady: false,
playing: false,
loading: false,
rateOptions: FALLBACK_RATE_OPTIONS,
rateControlsVisible: false,
playbackRate: 1,
})
}
},
onUnload() {
this._unloaded = true
this._lifecycleGeneration += 1
this._intentGeneration += 1
this._activePlayIntentGeneration = 0
this._desiredPlayback = false
this._controlLocked = true
this.clearPauseLock(false)
this.clearSeekState(false)
this.unbindAudioInterruptionHandlers()
const context = this.audioContext
this._contextGeneration += 1
this.audioContext = null
if (context) context.destroy()
},
})
@@ -0,0 +1,3 @@
{
"disableScroll": true
}
@@ -0,0 +1,63 @@
<view class="audio-leaf">
<view class="audio-leaf-topbar">
<button class="back-top" bindtap="goBack" aria-label="返回刚才的连环画"> 返回连环画</button>
<view class="leaf-heading">
<text class="leaf-kicker">第01回 · 有声夹页</text>
<text class="leaf-count">{{pageNumber}}8</text>
</view>
</view>
<view class="audio-leaf-spread">
<view class="leaf-art-frame">
<image class="leaf-art" src="{{imageSrc}}" mode="aspectFit"></image>
<text class="leaf-stamp">桂香故事</text>
</view>
<view class="leaf-copy">
<text class="leaf-title">{{title}}</text>
<text class="leaf-caption">{{caption}}</text>
<view class="leaf-seek-row">
<slider
class="leaf-slider"
aria-label="播放进度,可左右拖动"
min="0"
max="{{durationSeconds}}"
step="1"
value="{{sliderValue}}"
activeColor="#9b3328"
backgroundColor="#d6c6a1"
block-color="#8f271f"
block-size="24"
disabled="{{loading || !audioReady || seekLocked}}"
bindchanging="previewSeek"
bindchange="commitSeek"
></slider>
<text class="leaf-time">{{currentLabel}} / {{durationLabel}}</text>
</view>
<view class="leaf-controls">
<button class="leaf-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="rewindTenSeconds">后退10秒</button>
<button class="leaf-button primary" disabled="{{loading || !audioReady || seekLocked}}" bindtap="togglePlayback">
{{loading ? '正在准备' : playing ? '暂停' : hasStarted ? '继续听' : '开始听'}}
</button>
<button class="leaf-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="replay">从头重听</button>
</view>
<view wx:if="{{rateControlsVisible}}" class="leaf-rate-row" aria-label="播放速度">
<text class="leaf-rate-label">语速</text>
<button
wx:for="{{rateOptions}}"
wx:key="value"
class="leaf-rate-button {{playbackRate == item.value ? 'active' : ''}}"
data-rate="{{item.value}}"
disabled="{{loading || seekLocked}}"
bindtap="changePlaybackRate"
>{{item.label}}</button>
</view>
<text wx:if="{{error}}" class="leaf-error">{{error}}</text>
<button class="back-bottom" bindtap="goBack">看完,回到连环画</button>
</view>
</view>
</view>
@@ -0,0 +1,171 @@
page {
width: 100%;
min-height: 100%;
background: #1c130f;
color: #2f241d;
}
button::after { border: 0; }
.audio-leaf {
box-sizing: border-box;
width: 100vw;
height: 100vh;
min-height: 320px;
padding: max(8px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left));
display: flex;
flex-direction: column;
gap: 8px;
background: radial-gradient(circle at 48% 45%, #423024 0, #211611 70%);
}
.audio-leaf-topbar {
height: 52px;
flex: 0 0 52px;
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 10px;
align-items: center;
padding-right: max(92px, env(safe-area-inset-right));
color: #f5e6b7;
}
.back-top {
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-height: 48px;
border: 1px solid #9b7a51;
border-radius: 10px;
background: #38251b;
color: #f7e8bd;
font-size: 20px;
line-height: 48px;
text-align: center;
padding: 0 12px;
}
.leaf-heading { display: flex; align-items: baseline; gap: 12px; }
.leaf-kicker { font-size: 20px; font-weight: 700; }
.leaf-count { color: #d9bb78; font-size: 18px; }
.audio-leaf-spread {
min-height: 0;
flex: 1;
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(300px, 2fr);
border: 2px solid #b99762;
background: #efe2bd;
box-shadow: 0 10px 34px rgba(0, 0, 0, .4);
}
.leaf-art-frame {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
background: #17100c;
border-right: 4px solid #8d271f;
}
.leaf-art { width: 100%; height: 100%; display: block; }
.leaf-stamp {
position: absolute;
left: 18px;
bottom: 16px;
padding: 6px 12px;
background: rgba(86, 24, 20, .9);
color: #f5e6bd;
font-size: 17px;
letter-spacing: 2px;
}
.leaf-copy {
min-width: 0;
min-height: 0;
padding: 18px 20px 14px;
display: flex;
flex-direction: column;
background: repeating-linear-gradient(0deg, rgba(119, 87, 46, .04) 0, rgba(119, 87, 46, .04) 1px, transparent 1px, transparent 4px), #f6edcf;
}
.leaf-title { color: #8f271f; font-size: 28px; font-weight: 800; line-height: 1.25; }
.leaf-caption { margin-top: 10px; font-size: 22px; font-weight: 600; line-height: 1.48; }
.leaf-seek-row {
min-width: 0;
min-height: 48px;
margin-top: auto;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
}
.leaf-slider { min-width: 0; width: 100%; margin: 0; }
.leaf-time { min-width: 82px; color: #6d5b48; font-size: 17px; text-align: right; }
.leaf-controls {
min-width: 0;
margin-top: 6px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.leaf-button, .back-bottom {
box-sizing: border-box;
min-height: 48px;
border: 2px solid #7e6648;
border-radius: 10px;
background: #eadbb5;
color: #37291f;
font-size: 18px;
font-weight: 700;
line-height: 46px;
min-width: 0;
margin: 0;
padding: 0 6px;
}
.leaf-button { width: 100%; max-width: 100%; }
.leaf-button.primary { border-color: #8f271f; background: #9e3027; color: #fff2cf; }
.leaf-button[disabled] { opacity: .72; }
.leaf-rate-row {
min-width: 0;
min-height: 48px;
margin-top: 6px;
display: grid;
grid-template-columns: auto repeat(3, minmax(48px, 1fr));
gap: 6px;
align-items: center;
}
.leaf-rate-label { color: #6d5b48; font-size: 18px; font-weight: 700; }
.leaf-rate-button {
box-sizing: border-box;
min-width: 48px;
min-height: 48px;
margin: 0;
padding: 0 4px;
border: 2px solid #9d896a;
border-radius: 9px;
background: #f3e7c8;
color: #4d3b2d;
font-size: 17px;
font-weight: 700;
line-height: 46px;
}
.leaf-rate-button { width: 100%; max-width: 100%; }
.leaf-rate-button.active { border-color: #8f271f; background: #8f271f; color: #fff2cf; }
.leaf-rate-button[disabled] { opacity: .72; }
.leaf-error { margin-top: 8px; color: #8f271f; font-size: 17px; line-height: 1.35; }
.back-bottom { margin-top: 8px; width: 100%; }
@media (max-height: 430px) {
.audio-leaf { gap: 4px; padding-top: 4px; padding-bottom: 4px; }
.audio-leaf-topbar { height: 48px; flex-basis: 48px; }
.leaf-copy { padding: 8px 12px; overflow-y: auto; }
.leaf-title { font-size: 23px; }
.leaf-caption { margin-top: 4px; font-size: 17px; line-height: 1.3; }
.leaf-seek-row { min-height: 48px; }
.leaf-controls, .leaf-rate-row { margin-top: 3px; gap: 5px; }
.leaf-button, .back-bottom { min-height: 48px; line-height: 46px; font-size: 17px; }
.leaf-rate-button { min-height: 48px; line-height: 46px; font-size: 16px; }
.leaf-rate-label { font-size: 16px; }
.back-bottom { display: none; }
}
@@ -0,0 +1,14 @@
/**
* 发布环境可覆盖这些值。
*
* cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接
* 使用包内种子图或返回文字回退,不会发起网络请求。
*/
module.exports = {
cdnBaseUrl: '',
imageCacheBudgetBytes: 28 * 1024 * 1024,
audioCacheBudgetBytes: 32 * 1024 * 1024,
audioCacheMaxEntries: 32,
prefetchAhead: 2,
downloadConcurrency: 2,
}
@@ -0,0 +1,911 @@
/**
* 发布固定资产清单 v1
*
* 约束:
* 1. 页面只保存稳定的 assetId,不直接拼 CDN 地址。
* 2. remotePath 必须包含对应文件内容哈希的前 12 位,发布后不可覆盖。
* 3. 医学结论、判断答案和行动建议必须随代码包发布,不得放进远端资产。
* 4. localSeed 是当前小程序包内的安全回退;以后迁移 CDN 时可按资产逐项移除。
*/
const RELEASE_ASSET_MANIFEST_VERSION = 1
const releaseAssets = {
'comic.s01.c01.p01': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P01-title-v1.12163b7f4667.jpg',
sha256: '12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a',
},
'comic.s01.c01.p02': {
kind: 'image',
localSeed: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.f34fe1faf167.jpg',
sha256: 'f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0',
},
'comic.s01.c01.p03': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P03-H01-scan-only-v1.6e0d20c4055f.jpg',
sha256: '6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80',
},
'comic.s01.c01.p04': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.bcef17dda041.jpg',
sha256: 'bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce',
},
'comic.s01.c01.p05': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.d2e1becd9c12.jpg',
sha256: 'd2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f',
},
'comic.s01.c01.p06': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.7bc229ef1f1c.jpg',
sha256: '7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750',
},
'comic.s01.c01.p07': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.4a19c49a53ea.jpg',
sha256: '4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31',
},
'comic.s01.c01.p08': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P08-cliffhanger-v1.7db63c3725ee.jpg',
sha256: '7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69',
},
'comic.s01.c02.p01': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P01-title-v1.c32093483c58.jpg',
sha256: 'c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552',
},
'comic.s01.c02.p02': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.892de611f097.jpg',
sha256: '892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea',
},
'comic.s01.c02.p03': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.e725c333849e.jpg',
sha256: 'e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c',
},
'comic.s01.c02.p04': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.ac89e35c3692.jpg',
sha256: 'ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593',
},
'comic.s01.c02.p05': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.cda630c13f31.jpg',
sha256: 'cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d',
},
'comic.s01.c02.p06': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.61d413562200.jpg',
sha256: '61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541',
},
'comic.s01.c02.p07': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.c7a28411b1d7.jpg',
sha256: 'c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539',
},
'comic.s01.c02.p08': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P08-clock-to-1978-v1.6c0eed6b964e.jpg',
sha256: '6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703',
},
'comic.s01.c03.p01': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P01-title-v1.7aaefa1ff157.jpg',
sha256: '7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4',
},
'comic.s01.c03.p02': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.feb9f3243262.jpg',
sha256: 'feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5',
},
'comic.s01.c03.p03': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.841146ea446b.jpg',
sha256: '841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e',
},
'comic.s01.c03.p04': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.8a838a7d0bac.jpg',
sha256: '8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727',
},
'comic.s01.c03.p05': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.cbd7d426ee42.jpg',
sha256: 'cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79',
},
'comic.s01.c03.p06': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P06-H12-two-clips-v1.00e5034f636a.jpg',
sha256: '00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1',
},
'comic.s01.c03.p07': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.787bf794fd53.jpg',
sha256: '787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602',
},
'comic.s01.c03.p08': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.056ef8b3017c.jpg',
sha256: '056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5',
},
'comic.s01.c04.p01': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P01-title-v1.e20d8bd83bfb.jpg',
sha256: 'e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30',
},
'comic.s01.c04.p02': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P02-ensemble-v1.d83c35c61a7c.jpg',
sha256: 'd83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505',
},
'comic.s01.c04.p03': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.c90b76c43463.jpg',
sha256: 'c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4',
},
'comic.s01.c04.p04': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P04-H14-belt-table-v1.70da80c5dd1d.jpg',
sha256: '70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7',
},
'comic.s01.c04.p05': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.4f4212b949b9.jpg',
sha256: '4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a',
},
'comic.s01.c04.p06': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.25834d3e9082.jpg',
sha256: '25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2',
},
'comic.s01.c04.p07': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.3f741e4ac31c.jpg',
sha256: '3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d',
},
'comic.s01.c04.p08': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P08-cliffhanger-v1.9558ee49f2e2.jpg',
sha256: '9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61',
},
'comic.s01.c05.p01': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P01-title-v1.52a79ac178e6.jpg',
sha256: '52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef',
},
'comic.s01.c05.p02': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.897aa558fe7d.jpg',
sha256: '897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb',
},
'comic.s01.c05.p03': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P03-H17-empty-window-v1.76ac18af26da.jpg',
sha256: '76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22',
},
'comic.s01.c05.p04': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P04-H18-half-bun-v1.c9506ed1fe98.jpg',
sha256: 'c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c',
},
'comic.s01.c05.p05': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.a88bbf597302.jpg',
sha256: 'a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8',
},
'comic.s01.c05.p06': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.63adf03d0d30.jpg',
sha256: '63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca',
},
'comic.s01.c05.p07': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.172fc27a2252.jpg',
sha256: '172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567',
},
'comic.s01.c05.p08': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P08-first-photo-v1.b85b2852a7dc.jpg',
sha256: 'b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f',
},
'comic.s01.c06.p01': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P01-title-v1.f7039560e49d.jpg',
sha256: 'f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40',
},
'comic.s01.c06.p02': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.0b4519763ba5.jpg',
sha256: '0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae',
},
'comic.s01.c06.p03': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.caf57f377be3.jpg',
sha256: 'caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75',
},
'comic.s01.c06.p04': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.f05b92fd22c2.jpg',
sha256: 'f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469',
},
'comic.s01.c06.p05': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.408f43b54b2b.jpg',
sha256: '408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d',
},
'comic.s01.c06.p06': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P06-H24-protect-table-v1.84156df566a6.jpg',
sha256: '84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53',
},
'comic.s01.c06.p07': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.c61d898fd982.jpg',
sha256: 'c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b',
},
'comic.s01.c06.p08': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.bcb8ec7e4720.jpg',
sha256: 'bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5',
},
'comic.s01.c07.p01': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P01-title-v1.42031fff6b37.jpg',
sha256: '42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720',
},
'comic.s01.c07.p02': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P02-opening-ensemble-v1.81e28334e743.jpg',
sha256: '81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2',
},
'comic.s01.c07.p03': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.fb9d1cd9910d.jpg',
sha256: 'fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5',
},
'comic.s01.c07.p04': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.8e12ab04091a.jpg',
sha256: '8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3',
},
'comic.s01.c07.p05': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.5d65745996d6.jpg',
sha256: '5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4',
},
'comic.s01.c07.p06': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P06-H28-shift-table-v1.7365643f439b.jpg',
sha256: '7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a',
},
'comic.s01.c07.p07': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.19ea61163655.jpg',
sha256: '19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5',
},
'comic.s01.c07.p08': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.f7f84ebecba2.jpg',
sha256: 'f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e',
},
'comic.s01.c08.p01': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P01-title-v1.b292709d6f22.jpg',
sha256: 'b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc',
},
'comic.s01.c08.p02': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P02-opening-ensemble-v1.4b755190ba3d.jpg',
sha256: '4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562',
},
'comic.s01.c08.p03': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.2a5bc840cc2d.jpg',
sha256: '2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c',
},
'comic.s01.c08.p04': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.737e9996a8f3.jpg',
sha256: '737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935',
},
'comic.s01.c08.p05': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.41da420b4e7b.jpg',
sha256: '41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618',
},
'comic.s01.c08.p06': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.fb66a9f2234c.jpg',
sha256: 'fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111',
},
'comic.s01.c08.p07': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.6c748cf7c1a6.jpg',
sha256: '6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea',
},
'comic.s01.c08.p08': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.2deaee916f92.jpg',
sha256: '2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743',
},
'comic.s01.c09.p01': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P01-opening-memory-v1.59db94b9c5dd.jpg',
sha256: '59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f',
},
'comic.s01.c09.p02': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P02-opening-ensemble-v1.0584053b2e87.jpg',
sha256: '0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d',
},
'comic.s01.c09.p03': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.9081d9c71e8f.jpg',
sha256: '9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af',
},
'comic.s01.c09.p04': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.cf00e6434a90.jpg',
sha256: 'cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff',
},
'comic.s01.c09.p05': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg',
remotePath: 'comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.5de6f5b46666.jpg',
sha256: '5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a',
},
'comic.s01.c09.p06': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.5bb10be0addb.jpg',
sha256: '5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443',
},
'comic.s01.c09.p07': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.9f4b4b8b1aa9.jpg',
sha256: '9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f',
},
'comic.s01.c09.p08': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg',
remotePath: 'comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.b3628d1471f2.jpg',
sha256: 'b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724',
},
'comic.s01.c10.p01': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P01-one-palm-space-v1.42d2b657a20f.jpg',
sha256: '42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8',
},
'comic.s01.c10.p02': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P02-opening-ensemble-v1.30c7fa3f8e9a.jpg',
sha256: '30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55',
},
'comic.s01.c10.p03': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.23e14923a5fd.jpg',
sha256: '23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9',
},
'comic.s01.c10.p04': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg',
remotePath: 'comic/s01-c10/S01-C10-P04-H38-open-palm-v3.be147c9d7c0c.jpg',
sha256: 'be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde',
},
'comic.s01.c10.p05': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.a21bec0d89b6.jpg',
sha256: 'a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd',
},
'comic.s01.c10.p06': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.6fa3594b0e5a.jpg',
sha256: '6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446',
},
'comic.s01.c10.p07': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.139439432818.jpg',
sha256: '13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99',
},
'comic.s01.c10.p08': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.2499342b7c3c.jpg',
sha256: '2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0',
},
'comic.s01.c11.p01': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P01-pen-above-paper-v1.840b82256bca.jpg',
sha256: '840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a',
},
'comic.s01.c11.p02': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P02-opening-ensemble-v1.112f4fe7b4f9.jpg',
sha256: '112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1',
},
'comic.s01.c11.p03': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.23ef59f3f23d.jpg',
sha256: '23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51',
},
'comic.s01.c11.p04': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.3e93fafafe75.jpg',
sha256: '3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50',
},
'comic.s01.c11.p05': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.c0c8c01f4e37.jpg',
sha256: 'c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5',
},
'comic.s01.c11.p06': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.4a805d19bb22.jpg',
sha256: '4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c',
},
'comic.s01.c11.p07': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.9fb66ad32712.jpg',
sha256: '9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a',
},
'comic.s01.c11.p08': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P08-can-we-open-now-v1.b80149fb0407.jpg',
sha256: 'b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de',
},
'comic.s01.c12.p01': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.e7f7138b21bb.jpg',
sha256: 'e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a',
},
'comic.s01.c12.p02': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.6d2ba8cf6cfe.jpg',
sha256: '6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602',
},
'comic.s01.c12.p03': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.c07a27557685.jpg',
sha256: 'c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9',
},
'comic.s01.c12.p04': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.1a5c07f21eae.jpg',
sha256: '1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e',
},
'comic.s01.c12.p05': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.695c7ebf0573.jpg',
sha256: '695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0',
},
'comic.s01.c12.p06': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.9b332e2a0d6d.jpg',
sha256: '9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76',
},
'comic.s01.c12.p07': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.338240b0bc54.jpg',
sha256: '338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5',
},
'comic.s01.c12.p08': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.a6aeee055eed.jpg',
sha256: 'a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f',
},
'comic.s01.c13.p01': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.2184f354e70f.jpg',
sha256: '2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e',
},
'comic.s01.c13.p02': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P02-opening-ensemble-v1.a9abf81e15a6.jpg',
sha256: 'a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198',
},
'comic.s01.c13.p03': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.13813c3961f7.jpg',
sha256: '13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b',
},
'comic.s01.c13.p04': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.d4002bf11c74.jpg',
sha256: 'd4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d',
},
'comic.s01.c13.p05': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.2c5923ca40e2.jpg',
sha256: '2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff',
},
'comic.s01.c13.p06': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.8ccbb50e6342.jpg',
sha256: '8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d',
},
'comic.s01.c13.p07': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.edac7d0ccb9f.jpg',
sha256: 'edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b',
},
'comic.s01.c13.p08': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.f40996a2f7d0.jpg',
sha256: 'f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc',
},
'comic.s01.c14.p01': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.6609c450b024.jpg',
sha256: '6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2',
},
'comic.s01.c14.p02': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.15678b028566.jpg',
sha256: '15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48',
},
'comic.s01.c14.p03': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.9d1a6e4c130a.jpg',
sha256: '9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365',
},
'comic.s01.c14.p04': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.899c26b37f6a.jpg',
sha256: '899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e',
},
'comic.s01.c14.p05': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.b87841f55cc9.jpg',
sha256: 'b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801',
},
'comic.s01.c14.p06': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.c17546b0c7e9.jpg',
sha256: 'c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd',
},
'comic.s01.c14.p07': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.d0bcbbb428bc.jpg',
sha256: 'd0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d',
},
'comic.s01.c14.p08': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.09f5a22c8a7f.jpg',
sha256: '09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c',
},
'comic.s01.c15.p01': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.194fda1fa427.jpg',
sha256: '194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab',
},
'comic.s01.c15.p02': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.b0e82af85954.jpg',
sha256: 'b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b',
},
'comic.s01.c15.p03': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.b3838eb91bea.jpg',
sha256: 'b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173',
},
'comic.s01.c15.p04': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.2471d7dd78aa.jpg',
sha256: '2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea',
},
'comic.s01.c15.p05': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.215691878476.jpg',
sha256: '21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a',
},
'comic.s01.c15.p06': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.3f1cd9abb08e.jpg',
sha256: '3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50',
},
'comic.s01.c15.p07': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.98a32b939746.jpg',
sha256: '98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98',
},
'comic.s01.c15.p08': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg',
remotePath: 'comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.520d68ce0a72.jpg',
sha256: '520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113',
},
'character.female-cook': {
kind: 'image',
localSeed: '/assets/characters/female-cook.jpg',
remotePath: 'characters/female-cook.c56420b2c632.jpg',
sha256: 'c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f',
},
'character.lele': {
kind: 'image',
localSeed: '/assets/characters/lele.jpg',
remotePath: 'characters/lele.807f5232b1e8.jpg',
sha256: '807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168',
},
'character.lin-xiulan': {
kind: 'image',
localSeed: '/assets/characters/lin-xiulan.jpg',
remotePath: 'characters/lin-xiulan.56e84abec4e5.jpg',
sha256: '56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361',
},
'character.qin-xiaoman': {
kind: 'image',
localSeed: '/assets/characters/qin-xiaoman.jpg',
remotePath: 'characters/qin-xiaoman.22fa229d5b09.jpg',
sha256: '22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5',
},
'character.qin-zhicheng': {
kind: 'image',
localSeed: '/assets/characters/qin-zhicheng.jpg',
remotePath: 'characters/qin-zhicheng.7f4be48ece92.jpg',
sha256: '7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2',
},
'character.tang-mingyuan': {
kind: 'image',
localSeed: '/assets/characters/tang-mingyuan.jpg',
remotePath: 'characters/tang-mingyuan.5ca2fa88f885.jpg',
sha256: '5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb',
},
'character.tang-shouan': {
kind: 'image',
localSeed: '/assets/characters/tang-shouan.jpg',
remotePath: 'characters/tang-shouan.4ed8d25ca911.jpg',
sha256: '4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89',
},
'character.xiaozhen': {
kind: 'image',
localSeed: '/assets/characters/xiaozhen.jpg',
remotePath: 'characters/xiaozhen.d5e9614f6006.jpg',
sha256: 'd5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb',
},
'character.zhao-jianguo': {
kind: 'image',
localSeed: '/assets/characters/zhao-jianguo.jpg',
remotePath: 'characters/zhao-jianguo.1c393fb68b02.jpg',
sha256: '1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0',
},
'scene.gui-xiang.1978': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-1978.jpg',
remotePath: 'scenes/gui-xiang-1978.bb21d9342e37.jpg',
sha256: 'bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5',
},
'scene.gui-xiang.1995': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-1995.jpg',
remotePath: 'scenes/gui-xiang-1995.a9701e2d55ad.jpg',
sha256: 'a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f',
},
'scene.gui-xiang.1998': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-1998.jpg',
remotePath: 'scenes/gui-xiang-1998.d4d7bebfd04a.jpg',
sha256: 'd4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14',
},
'scene.gui-xiang.2001': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-2001.jpg',
remotePath: 'scenes/gui-xiang-2001.9143455b349d.jpg',
sha256: '9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8',
},
'scene.gui-xiang.2003': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-2003.jpg',
remotePath: 'scenes/gui-xiang-2003.68349c74c916.jpg',
sha256: '68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431',
},
'scene.gui-xiang.2008': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-2008.jpg',
remotePath: 'scenes/gui-xiang-2008.c0fd9418aec2.jpg',
sha256: 'c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5',
},
'scene.gui-xiang.2026': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-2026.jpg',
remotePath: 'scenes/gui-xiang-2026.55e63e85388e.jpg',
sha256: '55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7',
},
'audio.cast-audition.v1': {
kind: 'audio',
reviewStatus: 'audition',
localSeed: '',
remotePath: 'audio/cast-audition-v1.7129d884f445.mp3',
sha256: '7129d884f44542434e131efb86f2f5619bedf188a1d8b21e7d0864131b58dd18',
},
}
// Legacy C01 cue-level seeds are retained as immutable backup assets only.
// The reader no longer maps them as page narration; C01 uses full-page tracks
// through the two package-local audio player pages instead.
for (const cueId of [
'S01-C01-MS007','S01-C01-MS010',
]) {
const file = `${cueId}.mp3`
const sha256 = {
'S01-C01-MS007': '1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8', 'S01-C01-MS010': '628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597',
}[cueId]
releaseAssets[`audio.s01.c01.${cueId.toLowerCase()}`] = { kind: 'audio', reviewStatus: 'approved', localSeed: `/package-game/assets/audio/${file}`, remotePath: `audio/${file}.${sha256.slice(0, 12)}.mp3`, sha256 }
}
const audioC02Sha256 = {
'S01-C02-MS001': '580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233',
'S01-C02-MS002-ATTR': 'aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8',
'S01-C02-MS002': 'ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe',
'S01-C02-MS003': '3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f',
'S01-C02-MS004': 'e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c',
'S01-C02-MS005': '6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e',
'S01-C02-MS006': '067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88',
'S01-C02-MS007': '7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c',
'S01-C02-MS008': '288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f',
'S01-C02-MS009': '5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2',
'S01-C02-MS010': 'd371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d',
'S01-C02-MS011': '2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb',
'S01-C02-MS012': '33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a',
'S01-C02-MT000': '5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e',
'S01-C02-TE900': 'd57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1',
}
const audioC03Sha256 = {
'S01-C03-MS001': 'b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43',
'S01-C03-MS002-ATTR': 'ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd',
'S01-C03-MS002': 'c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c',
'S01-C03-MS003': 'af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184',
'S01-C03-MS004': 'c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da',
'S01-C03-MS005-ATTR': '4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e',
'S01-C03-MS005': '46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442',
'S01-C03-MS006': '2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5',
'S01-C03-MS007-ATTR': 'f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0',
'S01-C03-MS007': '68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038',
'S01-C03-MS008': 'd9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7',
'S01-C03-MS009': '97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed',
'S01-C03-MS010': '49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0',
'S01-C03-MS011': '6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25',
'S01-C03-MS013': '8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a',
'S01-C03-MS014': '8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0',
'S01-C03-MS015': '661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb',
'S01-C03-MT000': '374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65',
'S01-C03-TE900': 'f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993',
}
// C02/C03 cue files are retained only as rollback/source material. A single
// cue is not a complete page track, so these assets must not pass the runtime
// audio gate or appear as “试听本页”.
for (const [chapterNumber, audioHashes] of Object.entries({ '02': audioC02Sha256, '03': audioC03Sha256 })) {
for (const [cueId, sha256] of Object.entries(audioHashes)) {
const file = `${cueId}.mp3`
releaseAssets[`audio.s01.c${chapterNumber}.${cueId.toLowerCase()}`] = {
kind: 'audio',
reviewStatus: 'reserved',
localSeed: `/package-chapter-${chapterNumber}/assets/audio/${file}`,
remotePath: `audio/${cueId}.${sha256.slice(0, 12)}.mp3`,
sha256,
}
}
}
module.exports = {
RELEASE_ASSET_MANIFEST_VERSION,
releaseAssets,
}
@@ -0,0 +1,97 @@
/**
* C02-C15 remote full-page audio manifest v1.
*
* Import contract for a future reviewed batch:
*
* 'S01-C09-P05': Object.freeze({
* pageId: 'S01-C09-P05',
* chapterNumber: 9,
* pageNumber: 5,
* assetId: 'audio.page.s01.c09.p05',
* durationSeconds: 42.1,
* reviewStatus: 'approved',
* })
*
* The matching releaseAssetManifest entry must independently be an approved,
* remote-only, immutable hash-named audio asset. Automated QA, pending human
* listening, and missing CDN records must never be relabelled as approved.
*/
const REMOTE_PAGE_AUDIO_MANIFEST_VERSION = 1
const REMOTE_PAGE_AUDIO_ID_PATTERN = /^S01-C(0[2-9]|1[0-5])-P(0[1-8])$/
const SHA256_PATTERN = /^[a-f0-9]{64}$/
// Deliberately empty until full-page tracks pass the required human reviews
// and their immutable CDN objects are available. This is a release gate, not
// a placeholder that the UI may treat as playable.
const remotePageAudioPages = Object.freeze({})
function clean(value) {
return typeof value === 'string' ? value.trim() : ''
}
function remotePageAudioAssetId(pageId) {
const stablePageId = clean(pageId)
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
if (!match) return ''
return `audio.page.s01.c${match[1]}.p${match[2]}`
}
function getApprovedRemotePageAudio(
pageId,
releaseAssets = {},
pageManifest = remotePageAudioPages,
) {
const stablePageId = clean(pageId)
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
if (!match) return null
const entry = pageManifest && pageManifest[stablePageId]
if (!entry || typeof entry !== 'object') return null
const chapterNumber = Number(match[1])
const pageNumber = Number(match[2])
const assetId = remotePageAudioAssetId(stablePageId)
const durationSeconds = Number(entry.durationSeconds)
if (
clean(entry.reviewStatus).toLowerCase() !== 'approved'
|| entry.pageId !== stablePageId
|| Number(entry.chapterNumber) !== chapterNumber
|| Number(entry.pageNumber) !== pageNumber
|| entry.assetId !== assetId
|| !Number.isFinite(durationSeconds)
|| durationSeconds <= 0
) {
return null
}
const asset = releaseAssets && releaseAssets[assetId]
const sha256 = asset && typeof asset.sha256 === 'string'
? asset.sha256
: ''
const remotePath = asset && asset.remotePath
const expectedRemotePath = (
`audio/${stablePageId.slice(0, 7).toLowerCase()}`
+ `/${stablePageId}.${sha256.slice(0, 12)}.mp3`
)
if (
!asset
|| asset.kind !== 'audio'
|| clean(asset.reviewStatus).toLowerCase() !== 'approved'
|| asset.localSeed !== ''
|| !SHA256_PATTERN.test(sha256)
|| typeof remotePath !== 'string'
|| remotePath !== expectedRemotePath
) {
return null
}
return { ...entry, assetId }
}
module.exports = {
REMOTE_PAGE_AUDIO_ID_PATTERN,
REMOTE_PAGE_AUDIO_MANIFEST_VERSION,
getApprovedRemotePageAudio,
remotePageAudioAssetId,
remotePageAudioPages,
}
@@ -0,0 +1,872 @@
const {
getApprovedRemotePageAudio,
} = require('../../data/remotePageAudioManifest')
const {
releaseAssets,
} = require('../../data/releaseAssetManifest')
const assetReleaseConfig = require('../../data/assetReleaseConfig')
const {
createAssetPlatformFacade,
createAssetManager,
} = require('../../utils/assetManager')
const {
chapterRoute,
normalizeChapterNumber,
} = require('../../utils/chapterRoute')
const REMOTE_PAGE_ID_PATTERN = /^S01-C(0[2-9]|1[0-5])-P(0[1-8])$/
const SEEK_TIMEOUT_MS = 1600
const PAUSE_LOCK_TIMEOUT_MS = 1200
const RATE_VALUES = Object.freeze([0.8, 1, 1.2])
const RATE_OPTIONS = Object.freeze([
Object.freeze({ value: 0.8, label: '0.8倍' }),
Object.freeze({ value: 1, label: '1.0倍' }),
Object.freeze({ value: 1.2, label: '1.2倍' }),
])
const FALLBACK_RATE_OPTIONS = Object.freeze([
Object.freeze({ value: 1, label: '1.0倍' }),
])
function formatTime(value) {
const seconds = Math.max(0, Math.floor(Number(value) || 0))
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
}
function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, Number(value) || 0))
}
function eventValue(event) {
return Number(event && event.detail && event.detail.value) || 0
}
function resolveErrorMessage(reason) {
if (reason === 'remote-disabled') {
return '声音下载地址还没配置好,请返回连环画,稍后再试。'
}
if (reason === 'remote-failed') {
return '网络不太稳,声音没有下载下来。请稍后再试。'
}
if (reason === 'remote-integrity-failed') {
return '声音文件下载不完整,安全校验没有通过。请稍后重试。'
}
if (reason === 'audio-unapproved') {
return '这一页的声音还在审核,暂时不能播放。'
}
return '这一页的声音暂时没有打开,请返回连环画,稍后再试。'
}
Page({
data: {
pageId: '',
chapterNumber: 2,
pageNumber: 1,
chapterLabel: '第02回',
pageLabel: '第1页',
playing: false,
loading: false,
audioReady: false,
hasStarted: false,
seekLocked: false,
retryAvailable: false,
error: '',
currentLabel: '0:00',
durationLabel: '0:00',
durationSeconds: 1,
sliderValue: 0,
progressStyle: 'width:0%',
playbackRate: 1,
rateOptions: FALLBACK_RATE_OPTIONS,
rateControlsVisible: false,
},
onLoad(options = {}) {
this._unloaded = false
this._lifecycleGeneration = (Number(this._lifecycleGeneration) || 0) + 1
this._contextGeneration = 0
this._intentGeneration = 0
this._seekOperationGeneration = 0
this._controlOperationGeneration = 0
this._prepareGeneration = 0
this._desiredPlayback = false
this._activePlayIntentGeneration = 0
this._preparePlayIntentGeneration = 0
this._controlLocked = false
this._scrubbing = false
this._pendingSeek = null
this._seekTimer = null
this._pendingPauseLock = null
this._pauseLockTimer = null
this._preparePromise = null
this._pageAudio = null
this.bindAudioInterruptionHandlers()
const pageId = String(options.pageId || '').trim()
const match = REMOTE_PAGE_ID_PATTERN.exec(pageId)
const chapterNumber = normalizeChapterNumber(options.chapterNumber)
const pageChapterNumber = match ? Number(match[1]) : 0
const pageNumber = match ? Number(match[2]) : 0
this.setData({
pageId,
chapterNumber,
pageNumber: pageNumber || 1,
chapterLabel: `${String(chapterNumber).padStart(2, '0')}`,
pageLabel: `${pageNumber || 1}`,
})
if (
!match
|| chapterNumber < 2
|| pageChapterNumber !== chapterNumber
) {
this.setData({ error: '页面地址不对,请返回连环画重新打开。' })
return
}
const pageAudio = getApprovedRemotePageAudio(pageId, releaseAssets)
if (!pageAudio) {
this.setData({ error: '这一页的声音还在审核,暂时不能播放。' })
return
}
this._pageAudio = pageAudio
const durationSeconds = Math.max(1, Number(pageAudio.durationSeconds) || 1)
this.setData({
durationLabel: formatTime(durationSeconds),
durationSeconds,
})
},
onReady() {
// Opening the leaf is text-only. Downloading starts after an explicit tap.
return Promise.resolve(null)
},
isCurrentContext(context, generation) {
return !this._unloaded
&& this.audioContext === context
&& this._contextGeneration === generation
},
setProgress(value, durationValue) {
const duration = Math.max(
1,
Number(durationValue) || Number(this.data.durationSeconds) || 1,
)
const current = clamp(value, 0, duration)
const percent = Math.min(100, current / duration * 100)
this.setData({
currentLabel: formatTime(current),
durationLabel: formatTime(duration),
durationSeconds: duration,
sliderValue: current,
progressStyle: `width:${percent}%`,
})
},
disableRateControls(context) {
try {
if (context && typeof context.playbackRate === 'number') {
context.playbackRate = 1
}
} catch (_) {
// A runtime that rejects playbackRate remains safely at normal speed.
}
this.setData({
playbackRate: 1,
rateOptions: FALLBACK_RATE_OPTIONS,
rateControlsVisible: false,
})
},
configureRateControls(context, generation) {
try {
if (typeof context.playbackRate !== 'number') {
this.disableRateControls(context)
return
}
for (const rate of RATE_VALUES) {
context.playbackRate = rate
if (Math.abs(Number(context.playbackRate) - rate) > 0.001) {
this.disableRateControls(context)
return
}
}
context.playbackRate = 1
if (
!this.isCurrentContext(context, generation)
|| Math.abs(Number(context.playbackRate) - 1) > 0.001
) {
this.disableRateControls(context)
return
}
this.setData({
playbackRate: 1,
rateOptions: RATE_OPTIONS,
rateControlsVisible: true,
})
} catch (_) {
this.disableRateControls(context)
}
},
getAssetManager() {
if (!this._assetManager) {
this._assetManager = createAssetManager({
assetPlatform: createAssetPlatformFacade(),
manifest: releaseAssets,
...assetReleaseConfig,
})
}
return this._assetManager
},
prepareAudio(intentGeneration) {
if (
!this._pageAudio
|| this._unloaded
|| intentGeneration !== this._intentGeneration
|| !this._desiredPlayback
) return Promise.resolve(null)
this._preparePlayIntentGeneration = intentGeneration
if (this._preparePromise) return this._preparePromise
const prepareGeneration = this._prepareGeneration + 1
this._prepareGeneration = prepareGeneration
this._controlLocked = true
this.setData({
loading: true,
playing: false,
hasStarted: true,
retryAvailable: false,
error: '',
})
const assetId = this._pageAudio.assetId
const promise = this.getAssetManager().resolve(assetId)
.then((result) => {
if (
this._unloaded
|| this._prepareGeneration !== prepareGeneration
) return null
const activeIntent = this._preparePlayIntentGeneration
if (!result || !result.available || !result.uri) {
if (
activeIntent === this._intentGeneration
&& this._desiredPlayback
) {
this.invalidatePlaybackIntent(false)
this.setData({
playing: false,
loading: false,
retryAvailable: true,
error: resolveErrorMessage(result && result.reason),
})
}
return result || null
}
if (
!activeIntent
|| activeIntent !== this._intentGeneration
|| !this._desiredPlayback
) return result
this.createAudioContext(result.uri)
if (
activeIntent === this._intentGeneration
&& this._desiredPlayback
) {
this.startPlaybackWithIntent(activeIntent)
}
return result
})
.catch(() => {
if (
!this._unloaded
&& this._prepareGeneration === prepareGeneration
&& this._preparePlayIntentGeneration === this._intentGeneration
&& this._desiredPlayback
) {
this.invalidatePlaybackIntent(false)
this.setData({
playing: false,
loading: false,
retryAvailable: true,
error: resolveErrorMessage('remote-failed'),
})
}
return null
})
.finally(() => {
if (this._preparePromise === promise) this._preparePromise = null
})
this._preparePromise = promise
return promise
},
createAudioContext(src) {
if (this._unloaded) return null
if (this.audioContext) this.destroyAudioContext()
const context = wx.createInnerAudioContext()
const generation = this._contextGeneration + 1
this._contextGeneration = generation
this.audioContext = context
context.autoplay = false
context.src = src
this.setData({
audioReady: true,
loading: false,
playing: false,
retryAvailable: false,
error: '',
})
this.configureRateControls(context, generation)
context.onCanplay(() => {
if (!this.isCurrentContext(context, generation)) return
this.setData({ audioReady: true, error: '' })
})
context.onPlay(() => {
if (!this.isCurrentContext(context, generation)) return
if (
!this._desiredPlayback
|| this._activePlayIntentGeneration !== this._intentGeneration
) {
context.pause()
this._controlLocked = false
this.setData({ playing: false, loading: false })
return
}
this._controlLocked = false
this.setData({
playing: true,
loading: false,
hasStarted: true,
retryAvailable: false,
error: '',
})
})
context.onPause(() => {
if (!this.isCurrentContext(context, generation)) return
if (this._desiredPlayback) return
this.releasePauseLockFromEvent(context, generation)
this.setData({ playing: false, loading: false })
})
context.onStop(() => {
if (!this.isCurrentContext(context, generation)) return
this.invalidatePlaybackIntent(false)
this.setData({ playing: false, loading: false })
})
context.onWaiting(() => {
if (
!this.isCurrentContext(context, generation)
|| !this._desiredPlayback
) return
this.setData({ playing: false, loading: true })
})
context.onEnded(() => {
if (!this.isCurrentContext(context, generation)) return
this.invalidatePlaybackIntent(false)
this.setProgress(this.data.durationSeconds, this.data.durationSeconds)
this.setData({ playing: false, loading: false })
})
context.onTimeUpdate(() => {
if (
!this.isCurrentContext(context, generation)
|| this._scrubbing
|| this._pendingSeek
) return
const duration = Number(context.duration) || this.data.durationSeconds
const current = Number(context.currentTime) || 0
this.setProgress(current, duration)
})
if (typeof context.onSeeked === 'function') {
context.onSeeked(() => {
if (!this.isCurrentContext(context, generation)) return
this.finishSeekFromEvent(context, generation)
})
}
context.onError(() => {
if (!this.isCurrentContext(context, generation)) return
this.invalidatePlaybackIntent(false)
this.setData({
playing: false,
loading: false,
retryAvailable: true,
error: '声音播放失败了。您可以重试,或返回连环画继续看。',
})
})
this.bindAudioInterruptionHandlers()
return context
},
beginPlaybackIntent() {
this._intentGeneration += 1
this._desiredPlayback = true
this._activePlayIntentGeneration = this._intentGeneration
return this._intentGeneration
},
clearSeekState(updateData = true) {
if (this._seekTimer !== null) {
clearTimeout(this._seekTimer)
this._seekTimer = null
}
this._seekOperationGeneration += 1
this._pendingSeek = null
this._scrubbing = false
if (updateData && !this._unloaded) this.setData({ seekLocked: false })
},
clearPauseLock(unlock = true) {
if (this._pauseLockTimer !== null) {
clearTimeout(this._pauseLockTimer)
this._pauseLockTimer = null
}
this._controlOperationGeneration += 1
this._pendingPauseLock = null
if (unlock) this._controlLocked = false
},
beginPauseLock(context) {
this.clearPauseLock(true)
const operationGeneration = this._controlOperationGeneration + 1
this._controlOperationGeneration = operationGeneration
const pending = {
operationGeneration,
contextGeneration: this._contextGeneration,
intentGeneration: this._intentGeneration,
lifecycleGeneration: this._lifecycleGeneration,
context,
}
this._pendingPauseLock = pending
this._controlLocked = true
this._pauseLockTimer = setTimeout(() => {
this.releasePauseLock(
operationGeneration,
pending.contextGeneration,
pending.intentGeneration,
pending.lifecycleGeneration,
context,
)
}, PAUSE_LOCK_TIMEOUT_MS)
},
releasePauseLock(
operationGeneration,
contextGeneration,
intentGeneration,
lifecycleGeneration,
context,
) {
const pending = this._pendingPauseLock
if (
!pending
|| pending.operationGeneration !== operationGeneration
|| pending.contextGeneration !== contextGeneration
|| pending.intentGeneration !== intentGeneration
|| pending.lifecycleGeneration !== lifecycleGeneration
|| pending.context !== context
|| !this.isCurrentContext(context, contextGeneration)
|| this._intentGeneration !== intentGeneration
|| this._lifecycleGeneration !== lifecycleGeneration
|| this._desiredPlayback
) return false
if (this._pauseLockTimer !== null) {
clearTimeout(this._pauseLockTimer)
this._pauseLockTimer = null
}
this._pendingPauseLock = null
this._controlLocked = false
return true
},
releasePauseLockFromEvent(context, contextGeneration) {
const pending = this._pendingPauseLock
if (!pending) {
if (this.isCurrentContext(context, contextGeneration)) {
this._controlLocked = false
}
return
}
this.releasePauseLock(
pending.operationGeneration,
contextGeneration,
pending.intentGeneration,
pending.lifecycleGeneration,
context,
)
},
invalidatePlaybackIntent(pauseContext = true) {
this._intentGeneration += 1
this._activePlayIntentGeneration = 0
this._preparePlayIntentGeneration = 0
this._desiredPlayback = false
this.clearPauseLock(true)
this.clearSeekState(!this._unloaded)
if (
pauseContext
&& this.audioContext
&& typeof this.audioContext.pause === 'function'
) {
this.audioContext.pause()
}
},
startPlaybackWithIntent(intentGeneration) {
const context = this.audioContext
if (
!context
|| this._unloaded
|| intentGeneration !== this._intentGeneration
|| !this._desiredPlayback
) return false
this._controlLocked = true
this.setData({
loading: true,
playing: false,
hasStarted: true,
retryAvailable: false,
error: '',
})
context.play()
return true
},
bindAudioInterruptionHandlers() {
if (this._audioInterruptionBeginHandler) return
const generation = this._lifecycleGeneration
this._audioInterruptionBeginHandler = () => {
if (this._unloaded || this._lifecycleGeneration !== generation) return
this.pauseWithoutAutoResume()
}
this._audioInterruptionEndHandler = () => {
if (this._unloaded || this._lifecycleGeneration !== generation) return
this.invalidatePlaybackIntent(false)
this.setData({ playing: false, loading: false })
}
if (typeof wx.onAudioInterruptionBegin === 'function') {
wx.onAudioInterruptionBegin(this._audioInterruptionBeginHandler)
}
if (typeof wx.onAudioInterruptionEnd === 'function') {
wx.onAudioInterruptionEnd(this._audioInterruptionEndHandler)
}
},
unbindAudioInterruptionHandlers() {
if (
this._audioInterruptionBeginHandler
&& typeof wx.offAudioInterruptionBegin === 'function'
) {
wx.offAudioInterruptionBegin(this._audioInterruptionBeginHandler)
}
if (
this._audioInterruptionEndHandler
&& typeof wx.offAudioInterruptionEnd === 'function'
) {
wx.offAudioInterruptionEnd(this._audioInterruptionEndHandler)
}
this._audioInterruptionBeginHandler = null
this._audioInterruptionEndHandler = null
},
pauseWithoutAutoResume() {
const context = this.audioContext
this.invalidatePlaybackIntent(false)
if (context && typeof context.pause === 'function') {
this.beginPauseLock(context)
try {
context.pause()
} catch (_) {
this.clearPauseLock(true)
}
}
if (!this._unloaded) {
this.setData({ playing: false, loading: false })
}
},
onHide() {
this.pauseWithoutAutoResume()
},
onShow() {
if (this._unloaded) return
this.clearPauseLock(true)
this.setData({ playing: false, loading: false, seekLocked: false })
},
togglePlayback() {
if (this.data.loading || this._controlLocked || this._unloaded) return
if (!this.audioContext) {
const intentGeneration = this.beginPlaybackIntent()
return this.prepareAudio(intentGeneration)
}
if (this.data.playing || this._desiredPlayback) {
this.pauseWithoutAutoResume()
return
}
const intentGeneration = this.beginPlaybackIntent()
this.startPlaybackWithIntent(intentGeneration)
},
requestSeek(targetValue, options = {}) {
const context = this.audioContext
if (
!context
|| this._unloaded
|| this.data.loading
|| this._pendingSeek
) return false
const duration = Math.max(
1,
Number(context.duration) || Number(this.data.durationSeconds) || 1,
)
const target = clamp(targetValue, 0, duration)
const actualBeforeSeek = Number(context.currentTime)
const previousValue = clamp(
Number.isFinite(actualBeforeSeek)
? actualBeforeSeek
: Number(this.data.sliderValue) || 0,
0,
duration,
)
const operationGeneration = this._seekOperationGeneration + 1
this._seekOperationGeneration = operationGeneration
const pending = {
operationGeneration,
contextGeneration: this._contextGeneration,
intentGeneration: this._intentGeneration,
context,
target,
previousValue,
playAfterSeek: Boolean(options.playAfterSeek),
}
this._pendingSeek = pending
this._scrubbing = false
this.setProgress(target, duration)
this.setData({ seekLocked: true, error: '' })
this._seekTimer = setTimeout(() => {
this.failSeek(
operationGeneration,
pending.contextGeneration,
pending.intentGeneration,
context,
)
}, SEEK_TIMEOUT_MS)
try {
context.seek(target)
} catch (_) {
this.failSeek(
operationGeneration,
pending.contextGeneration,
pending.intentGeneration,
context,
)
return false
}
return true
},
finishSeekFromEvent(context, contextGeneration) {
const pending = this._pendingSeek
if (!pending) return
const actual = Number(context.currentTime)
if (Number.isFinite(actual) && Math.abs(actual - pending.target) > 1.25) return
this.finishSeek(
pending.operationGeneration,
contextGeneration,
pending.intentGeneration,
context,
)
},
finishSeek(
operationGeneration,
contextGeneration,
intentGeneration,
context,
) {
const pending = this._pendingSeek
if (
!pending
|| pending.operationGeneration !== operationGeneration
|| pending.contextGeneration !== contextGeneration
|| pending.intentGeneration !== intentGeneration
|| pending.context !== context
|| !this.isCurrentContext(context, contextGeneration)
|| this._intentGeneration !== intentGeneration
) return
if (this._seekTimer !== null) {
clearTimeout(this._seekTimer)
this._seekTimer = null
}
this._pendingSeek = null
this.setData({ seekLocked: false })
if (
pending.playAfterSeek
&& this._desiredPlayback
&& this._activePlayIntentGeneration === intentGeneration
) {
this.startPlaybackWithIntent(intentGeneration)
}
},
failSeek(
operationGeneration,
contextGeneration,
intentGeneration,
context,
) {
const pending = this._pendingSeek
if (
!pending
|| pending.operationGeneration !== operationGeneration
|| pending.contextGeneration !== contextGeneration
|| pending.intentGeneration !== intentGeneration
|| pending.context !== context
|| !this.isCurrentContext(context, contextGeneration)
|| this._intentGeneration !== intentGeneration
) return false
if (this._seekTimer !== null) {
clearTimeout(this._seekTimer)
this._seekTimer = null
}
this._pendingSeek = null
this._scrubbing = false
const actual = Number(context.currentTime)
const restoredValue = Number.isFinite(actual)
? actual
: pending.previousValue
if (pending.playAfterSeek) this.invalidatePlaybackIntent(false)
this.setProgress(restoredValue, this.data.durationSeconds)
this.setData({
playing: pending.playAfterSeek ? false : this.data.playing,
loading: false,
seekLocked: false,
error: '进度没有调好,请再试一次。',
})
return true
},
previewSeek(event) {
if (!this.audioContext || this.data.loading || this._pendingSeek) return
this._scrubbing = true
this.setProgress(eventValue(event), this.data.durationSeconds)
},
commitSeek(event) {
if (!this.audioContext || this.data.loading || this._pendingSeek) return
this._scrubbing = false
this.requestSeek(eventValue(event))
},
rewindTenSeconds() {
if (!this.audioContext || this.data.loading || this._pendingSeek) return
const current = Number(this.audioContext.currentTime)
const fallback = Number(this.data.sliderValue) || 0
this.requestSeek(Math.max(0, (Number.isFinite(current) ? current : fallback) - 10))
},
replay() {
if (
!this.audioContext
|| this.data.loading
|| this._controlLocked
|| this._pendingSeek
|| this._unloaded
) return
const alreadyPlaying = this.data.playing && this._desiredPlayback
const intentGeneration = alreadyPlaying
? this._intentGeneration
: this.beginPlaybackIntent()
this.setData({ hasStarted: true, error: '' })
this.requestSeek(0, {
playAfterSeek: !alreadyPlaying,
intentGeneration,
})
},
changePlaybackRate(event) {
const requested = Number(
event && event.currentTarget && event.currentTarget.dataset.rate,
)
if (
!RATE_VALUES.includes(requested)
|| !this.audioContext
|| !this.data.rateControlsVisible
) return
const allowed = this.data.rateOptions.some((item) => item.value === requested)
if (!allowed) return
try {
this.audioContext.playbackRate = requested
if (Math.abs(Number(this.audioContext.playbackRate) - requested) > 0.001) {
this.disableRateControls(this.audioContext)
return
}
this.setData({ playbackRate: requested })
} catch (_) {
this.disableRateControls(this.audioContext)
}
},
retryLoad() {
if (this.data.loading || this._controlLocked || this._unloaded) return
if (this.audioContext) {
const intentGeneration = this.beginPlaybackIntent()
this.startPlaybackWithIntent(intentGeneration)
return
}
const intentGeneration = this.beginPlaybackIntent()
return this.prepareAudio(intentGeneration)
},
goBack() {
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
const returnToChapter = () => {
if (typeof wx.reLaunch === 'function') {
wx.reLaunch({
url: chapterRoute(this.data.chapterNumber),
})
}
}
if (pages.length > 1 && typeof wx.navigateBack === 'function') {
wx.navigateBack({ delta: 1, fail: returnToChapter })
return
}
returnToChapter()
},
destroyAudioContext() {
const context = this.audioContext
if (!context) return
this.invalidatePlaybackIntent(false)
this._contextGeneration += 1
this.audioContext = null
context.destroy()
if (!this._unloaded) {
this.setData({
audioReady: false,
playing: false,
loading: false,
rateOptions: FALLBACK_RATE_OPTIONS,
rateControlsVisible: false,
playbackRate: 1,
})
}
},
onUnload() {
this._unloaded = true
this._lifecycleGeneration += 1
this._prepareGeneration += 1
this._preparePlayIntentGeneration = 0
this._intentGeneration += 1
this._activePlayIntentGeneration = 0
this._desiredPlayback = false
this._controlLocked = true
this.clearPauseLock(false)
this.clearSeekState(false)
this.unbindAudioInterruptionHandlers()
const context = this.audioContext
this._contextGeneration += 1
this.audioContext = null
if (context) context.destroy()
},
})
@@ -0,0 +1,3 @@
{
"disableScroll": true
}
@@ -0,0 +1,73 @@
<view class="audio-page">
<view class="audio-topbar">
<button class="back-top" bindtap="goBack" aria-label="返回刚才的连环画"> 返回连环画</button>
<view class="page-heading">
<text class="page-kicker">{{chapterLabel}} · 有声夹页</text>
<text class="page-count">{{pageNumber}}8</text>
</view>
</view>
<view class="audio-spread">
<view class="story-card" aria-label="本页文字封面">
<view class="story-card-border">
<text class="story-card-kicker">桂香里的有声夹页</text>
<text class="story-card-title">桂香里的这一页</text>
<view class="story-card-rule"></view>
<text class="story-card-chapter">{{chapterLabel}}</text>
<text class="story-card-page">{{pageLabel}}</text>
<text class="story-card-note">声音单独下载,画面仍留在原来的连环画里。</text>
<text class="story-card-stamp">桂香故事</text>
</view>
</view>
<view class="audio-copy">
<text class="audio-title">听这一页</text>
<text class="audio-caption">点“开始听”后才下载并播放;来电话或离开页面会暂停,不会自己续播。</text>
<view class="audio-seek-row">
<slider
class="audio-slider"
aria-label="播放进度,可左右拖动"
min="0"
max="{{durationSeconds}}"
step="1"
value="{{sliderValue}}"
activeColor="#9b3328"
backgroundColor="#d6c6a1"
block-color="#8f271f"
block-size="24"
disabled="{{loading || !audioReady || seekLocked}}"
bindchanging="previewSeek"
bindchange="commitSeek"
></slider>
<text class="audio-time">{{currentLabel}} / {{durationLabel}}</text>
</view>
<view class="audio-controls">
<button class="audio-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="rewindTenSeconds">后退10秒</button>
<button class="audio-button primary" disabled="{{loading || seekLocked || error}}" bindtap="togglePlayback">
{{loading ? '正在准备' : playing ? '暂停' : hasStarted ? '继续听' : '开始听'}}
</button>
<button class="audio-button" disabled="{{loading || !audioReady || seekLocked}}" bindtap="replay">从头重听</button>
</view>
<view wx:if="{{rateControlsVisible}}" class="audio-rate-row" aria-label="播放速度">
<text class="audio-rate-label">语速</text>
<button
wx:for="{{rateOptions}}"
wx:key="value"
class="audio-rate-button {{playbackRate == item.value ? 'active' : ''}}"
data-rate="{{item.value}}"
disabled="{{loading || seekLocked}}"
bindtap="changePlaybackRate"
>{{item.label}}</button>
</view>
<view wx:if="{{error}}" class="audio-error" role="alert">
<text>{{error}}</text>
<button wx:if="{{retryAvailable}}" class="retry-button" disabled="{{loading}}" bindtap="retryLoad">再试一次</button>
</view>
<button class="back-bottom" bindtap="goBack">回到连环画</button>
</view>
</view>
</view>
@@ -0,0 +1,192 @@
page {
width: 100%;
min-height: 100%;
background: #1c130f;
color: #2f241d;
}
button::after { border: 0; }
.audio-page {
box-sizing: border-box;
width: 100vw;
height: 100vh;
min-height: 320px;
padding: max(8px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left));
display: flex;
flex-direction: column;
gap: 8px;
background: radial-gradient(circle at 48% 45%, #443125 0, #211611 70%);
}
.audio-topbar {
height: 52px;
flex: 0 0 52px;
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 10px;
align-items: center;
padding-right: max(92px, env(safe-area-inset-right));
color: #f5e6b7;
}
.back-top {
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-height: 48px;
border: 1px solid #9b7a51;
border-radius: 10px;
background: #38251b;
color: #f7e8bd;
font-size: 20px;
line-height: 48px;
text-align: center;
padding: 0 12px;
}
.page-heading { display: flex; align-items: baseline; gap: 12px; }
.page-kicker { font-size: 20px; font-weight: 700; }
.page-count { color: #d9bb78; font-size: 18px; }
.audio-spread {
min-height: 0;
flex: 1;
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(300px, 2fr);
border: 2px solid #b99762;
background: #efe2bd;
box-shadow: 0 10px 34px rgba(0, 0, 0, .4);
}
.story-card {
box-sizing: border-box;
min-width: 0;
min-height: 0;
padding: 18px;
background: repeating-linear-gradient(0deg, rgba(245, 225, 177, .035) 0, rgba(245, 225, 177, .035) 1px, transparent 1px, transparent 5px), #251813;
border-right: 4px solid #8d271f;
}
.story-card-border {
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: 0;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2px solid #b99762;
outline: 1px solid rgba(185, 151, 98, .55);
outline-offset: -8px;
color: #f4e3b8;
text-align: center;
padding: 28px;
}
.story-card-kicker { color: #d1b274; font-size: 17px; letter-spacing: 3px; }
.story-card-title { margin-top: 10px; font-size: 32px; font-weight: 800; letter-spacing: 5px; }
.story-card-rule { width: 58%; height: 2px; margin: 16px 0; background: #8f3027; }
.story-card-chapter { color: #f8eac8; font-size: 28px; font-weight: 800; }
.story-card-page { margin-top: 6px; color: #d8bd83; font-size: 22px; }
.story-card-note { max-width: 430px; margin-top: 20px; color: #d8c8a5; font-size: 18px; line-height: 1.55; }
.story-card-stamp { position: absolute; right: 22px; bottom: 18px; padding: 6px 10px; border: 2px solid #9d3a2e; color: #d98673; font-size: 16px; letter-spacing: 2px; }
.audio-copy {
min-width: 0;
min-height: 0;
padding: 18px 20px 14px;
display: flex;
flex-direction: column;
background: repeating-linear-gradient(0deg, rgba(119, 87, 46, .04) 0, rgba(119, 87, 46, .04) 1px, transparent 1px, transparent 4px), #f6edcf;
}
.audio-title { color: #8f271f; font-size: 28px; font-weight: 800; line-height: 1.25; }
.audio-caption { margin-top: 10px; font-size: 20px; font-weight: 600; line-height: 1.48; }
.audio-seek-row {
min-width: 0;
min-height: 48px;
margin-top: auto;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
align-items: center;
}
.audio-slider { min-width: 0; width: 100%; margin: 0; }
.audio-time { min-width: 82px; color: #6d5b48; font-size: 17px; text-align: right; }
.audio-controls {
min-width: 0;
margin-top: 6px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.audio-button,
.retry-button,
.back-bottom {
box-sizing: border-box;
min-height: 48px;
border: 2px solid #7e6648;
border-radius: 10px;
background: #eadbb5;
color: #37291f;
font-size: 18px;
font-weight: 700;
line-height: 46px;
min-width: 0;
margin: 0;
padding: 0 6px;
}
.audio-button { width: 100%; max-width: 100%; }
.audio-button.primary { border-color: #8f271f; background: #9e3027; color: #fff2cf; }
.audio-button[disabled], .retry-button[disabled] { opacity: .72; }
.audio-rate-row {
min-width: 0;
min-height: 48px;
margin-top: 6px;
display: grid;
grid-template-columns: auto repeat(3, minmax(48px, 1fr));
gap: 6px;
align-items: center;
}
.audio-rate-label { color: #6d5b48; font-size: 18px; font-weight: 700; }
.audio-rate-button {
box-sizing: border-box;
min-width: 48px;
min-height: 48px;
margin: 0;
padding: 0 4px;
border: 2px solid #9d896a;
border-radius: 9px;
background: #f3e7c8;
color: #4d3b2d;
font-size: 17px;
font-weight: 700;
line-height: 46px;
}
.audio-rate-button { width: 100%; max-width: 100%; }
.audio-rate-button.active { border-color: #8f271f; background: #8f271f; color: #fff2cf; }
.audio-rate-button[disabled] { opacity: .72; }
.audio-error { margin-top: 8px; color: #8f271f; font-size: 17px; line-height: 1.35; }
.retry-button { width: 100%; min-height: 48px; margin-top: 8px; line-height: 46px; font-size: 18px; }
.back-bottom { margin-top: 8px; width: 100%; }
@media (max-height: 430px) {
.audio-page { gap: 4px; padding-top: 4px; padding-bottom: 4px; }
.audio-topbar { height: 48px; flex-basis: 48px; }
.story-card { padding: 10px; }
.story-card-border { padding: 16px; }
.story-card-title { font-size: 26px; }
.story-card-note { margin-top: 10px; font-size: 16px; }
.audio-copy { padding: 8px 12px; overflow-y: auto; }
.audio-title { font-size: 23px; }
.audio-caption { margin-top: 4px; font-size: 17px; line-height: 1.3; }
.audio-seek-row { min-height: 48px; }
.audio-controls, .audio-rate-row { margin-top: 3px; gap: 5px; }
.audio-button, .retry-button, .back-bottom { min-height: 48px; line-height: 46px; font-size: 17px; }
.audio-rate-button { min-height: 48px; line-height: 46px; font-size: 16px; }
.audio-rate-label { font-size: 16px; }
.back-bottom { display: none; }
}
@@ -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,25 @@
const SHARED_GAME_CHAPTERS = new Set([1])
function normalizeChapterNumber(value) {
const number = Number(value)
if (!Number.isFinite(number)) return 1
return Math.min(15, Math.max(1, Math.floor(number)))
}
function chapterPackageRoot(value) {
const chapter = normalizeChapterNumber(value)
if (SHARED_GAME_CHAPTERS.has(chapter)) return 'package-game'
return `package-chapter-${String(chapter).padStart(2, '0')}`
}
function chapterRoute(value, options = {}) {
const chapter = normalizeChapterNumber(value)
const replay = options && options.replay === true ? '&replay=1' : ''
return `/${chapterPackageRoot(chapter)}/pages/chapter/chapter?chapter=${chapter}${replay}`
}
module.exports = {
normalizeChapterNumber,
chapterPackageRoot,
chapterRoute,
}
@@ -0,0 +1,14 @@
/**
* 发布环境可覆盖这些值。
*
* cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接
* 使用包内种子图或返回文字回退,不会发起网络请求。
*/
module.exports = {
cdnBaseUrl: '',
imageCacheBudgetBytes: 28 * 1024 * 1024,
audioCacheBudgetBytes: 32 * 1024 * 1024,
audioCacheMaxEntries: 32,
prefetchAhead: 2,
downloadConcurrency: 2,
}
@@ -0,0 +1,298 @@
const { releaseAssets } = require('./releaseAssetManifest')
const PLAYABLE_VISUAL_REVIEW_STATUS = 'approved-readable'
const PROVISIONAL_VISUAL_REVIEW_STATUS = 'experience-provisional'
const RUNTIME_VISUAL_TIERS = Object.freeze({
FORMAL: 'formal',
PROVISIONAL: 'experience-provisional',
FALLBACK: 'fallback',
})
// This set deliberately mirrors `usable` in
// docs/production/art-release-gate-s01.json. Registered page art outside this
// set may be shown only as experience-provisional and never counts as formal.
const FORMAL_PAGE_ART_IDS = new Set([
'S01-C01-P01',
'S01-C01-P02',
'S01-C01-P03',
'S01-C01-P04',
'S01-C01-P05',
'S01-C01-P07',
'S01-C01-P08',
'S01-C02-P01',
'S01-C02-P02',
'S01-C02-P03',
'S01-C02-P04',
'S01-C02-P05',
'S01-C02-P06',
'S01-C02-P07',
'S01-C02-P08',
'S01-C03-P01',
'S01-C03-P02',
'S01-C03-P03',
'S01-C03-P04',
'S01-C03-P05',
'S01-C03-P07',
'S01-C03-P08',
'S01-C04-P01',
'S01-C04-P02',
'S01-C04-P03',
'S01-C04-P05',
'S01-C04-P06',
'S01-C04-P07',
'S01-C04-P08',
'S01-C05-P01',
'S01-C05-P02',
'S01-C05-P03',
'S01-C05-P04',
'S01-C05-P05',
'S01-C05-P06',
'S01-C05-P07',
'S01-C05-P08',
'S01-C06-P01',
'S01-C06-P02',
'S01-C06-P03',
'S01-C06-P04',
'S01-C06-P05',
'S01-C06-P06',
'S01-C06-P07',
'S01-C06-P08',
'S01-C07-P01',
'S01-C07-P02',
'S01-C07-P03',
'S01-C07-P04',
'S01-C07-P05',
'S01-C07-P06',
'S01-C07-P07',
'S01-C07-P08',
'S01-C08-P02',
'S01-C08-P03',
'S01-C08-P04',
'S01-C08-P05',
'S01-C08-P06',
'S01-C08-P07',
'S01-C08-P08',
'S01-C09-P02',
'S01-C09-P03',
'S01-C09-P04',
'S01-C09-P07',
'S01-C09-P08',
'S01-C10-P01',
'S01-C10-P02',
'S01-C10-P03',
'S01-C10-P04',
'S01-C10-P05',
'S01-C10-P06',
'S01-C10-P07',
'S01-C10-P08',
'S01-C11-P01',
'S01-C11-P02',
'S01-C11-P03',
'S01-C11-P04',
'S01-C11-P05',
'S01-C11-P06',
'S01-C11-P07',
'S01-C11-P08',
'S01-C12-P01',
'S01-C12-P02',
'S01-C12-P03',
'S01-C12-P04',
'S01-C12-P05',
'S01-C12-P06',
'S01-C12-P07',
'S01-C12-P08',
'S01-C13-P01',
'S01-C13-P02',
'S01-C13-P03',
'S01-C13-P05',
'S01-C13-P06',
'S01-C13-P07',
'S01-C13-P08',
'S01-C14-P01',
'S01-C14-P02',
'S01-C14-P03',
'S01-C14-P04',
'S01-C14-P05',
'S01-C14-P06',
'S01-C14-P07',
'S01-C14-P08',
'S01-C15-P01',
'S01-C15-P02',
'S01-C15-P03',
'S01-C15-P04',
'S01-C15-P05',
'S01-C15-P06',
'S01-C15-P07',
'S01-C15-P08',
])
function clean(value) {
return typeof value === 'string' ? value.trim() : ''
}
function comicAssetIdForPageId(pageId) {
const match = clean(pageId).match(/^S(\d+)-C(\d+)-P(\d+)$/)
if (!match) return ''
return `comic.s${match[1]}.c${match[2]}.p${match[3]}`
}
function registeredPageArtFor(page) {
const assetId = comicAssetIdForPageId(page && page.pageId)
const releaseAsset = assetId ? releaseAssets[assetId] : null
return Boolean(
releaseAsset
&& releaseAsset.kind === 'image'
&& clean(releaseAsset.localSeed)
&& clean(releaseAsset.localSeed) === clean(page.illustrationAsset),
)
}
function createPageArtVisual(page, chapter, runtimeTier) {
const formal = runtimeTier === RUNTIME_VISUAL_TIERS.FORMAL
return {
kind: 'page-art',
asset: clean(page.illustrationAsset),
subject: clean(page.actorName || page.headline || chapter.title),
runtimeTier,
formalReleaseEligible: formal,
reviewStatus: formal
? PLAYABLE_VISUAL_REVIEW_STATUS
: PROVISIONAL_VISUAL_REVIEW_STATUS,
reviewBasis: formal
? 'art-release-gate-s01-usable'
: 'registered-immutable-asset-experience-only',
}
}
function personByInstanceId(chapter, instanceId) {
return (chapter.people || []).find(
(person) => (
person.instanceId === instanceId
&& clean(person.portrait)
),
) || null
}
function personByName(chapter, name) {
const cleanName = clean(name)
if (!cleanName) return null
return (chapter.people || []).find(
(person) => person.name === cleanName && clean(person.portrait),
) || null
}
function featuredPersonForPage(page, chapter) {
const direct = personByInstanceId(chapter, page.actorInstanceId)
if (direct) return direct
if (page.type === 'memory') {
const memoryPerson = personByName(
chapter,
page.memoryCard && page.memoryCard.characterName,
)
if (memoryPerson) return memoryPerson
}
if (page.type === 'emotion') {
const emotionPerson = personByName(
chapter,
chapter.emotionMoment
&& chapter.emotionMoment.character
&& chapter.emotionMoment.character.name,
)
if (emotionPerson) return emotionPerson
}
return (chapter.people || []).find(
(person) => clean(person.portrait),
) || null
}
function createPlayableVisual(page, chapter) {
if (FORMAL_PAGE_ART_IDS.has(page.pageId)) {
return createPageArtVisual(page, chapter, RUNTIME_VISUAL_TIERS.FORMAL)
}
if (registeredPageArtFor(page)) {
return createPageArtVisual(
page,
chapter,
RUNTIME_VISUAL_TIERS.PROVISIONAL,
)
}
if (page.type === 'event') {
const portrait = clean(page.actorPortrait)
if (portrait) {
return {
kind: 'actor-portrait',
asset: portrait,
subject: clean(page.actorName),
actorInstanceId: clean(page.actorInstanceId),
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
formalReleaseEligible: false,
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
reviewBasis: 'season-character-continuity-sheet',
}
}
return {
kind: 'evidence-composite',
asset: clean(page.fallbackAsset),
subject: clean(page.actorName),
evidenceLabel: clean(page.headline || '看看手边证据'),
evidenceDetail: clean(
page.secondaryCaption || page.caption || page.question,
),
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
formalReleaseEligible: false,
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
reviewBasis: 'chapter-era-scene-with-reviewed-evidence-copy',
}
}
const featuredPerson = featuredPersonForPage(page, chapter)
if (featuredPerson) {
return {
kind: 'chapter-character',
asset: clean(featuredPerson.portrait),
subject: clean(featuredPerson.name),
actorInstanceId: clean(featuredPerson.instanceId),
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
formalReleaseEligible: false,
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
reviewBasis: 'season-character-continuity-sheet',
}
}
// Defensive last resort. The season currently never reaches this branch,
// but if a future chapter lacks a portrait it still gets explicit evidence
// instead of an unexplained empty room.
return {
kind: 'evidence-composite',
asset: clean(page.fallbackAsset),
subject: clean(page.headline || chapter.title),
evidenceLabel: clean(page.headline || chapter.title),
evidenceDetail: clean(page.caption || chapter.narration),
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
formalReleaseEligible: false,
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
reviewBasis: 'chapter-era-scene-with-reviewed-evidence-copy',
}
}
function attachPlayableVisuals(pageSequence, chapter) {
return (Array.isArray(pageSequence) ? pageSequence : []).map((page) => ({
...page,
playableVisual: createPlayableVisual(page, chapter),
}))
}
module.exports = {
FORMAL_PAGE_ART_IDS,
PLAYABLE_VISUAL_REVIEW_STATUS,
PROVISIONAL_VISUAL_REVIEW_STATUS,
RUNTIME_VISUAL_TIERS,
attachPlayableVisuals,
createPlayableVisual,
}
@@ -0,0 +1,2 @@
// Generated package-local production comic data. Do not hand-edit.
module.exports = {}
@@ -0,0 +1,911 @@
/**
* 发布固定资产清单 v1
*
* 约束:
* 1. 页面只保存稳定的 assetId,不直接拼 CDN 地址。
* 2. remotePath 必须包含对应文件内容哈希的前 12 位,发布后不可覆盖。
* 3. 医学结论、判断答案和行动建议必须随代码包发布,不得放进远端资产。
* 4. localSeed 是当前小程序包内的安全回退;以后迁移 CDN 时可按资产逐项移除。
*/
const RELEASE_ASSET_MANIFEST_VERSION = 1
const releaseAssets = {
'comic.s01.c01.p01': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P01-title-v1.12163b7f4667.jpg',
sha256: '12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a',
},
'comic.s01.c01.p02': {
kind: 'image',
localSeed: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.f34fe1faf167.jpg',
sha256: 'f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0',
},
'comic.s01.c01.p03': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P03-H01-scan-only-v1.6e0d20c4055f.jpg',
sha256: '6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80',
},
'comic.s01.c01.p04': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.bcef17dda041.jpg',
sha256: 'bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce',
},
'comic.s01.c01.p05': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.d2e1becd9c12.jpg',
sha256: 'd2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f',
},
'comic.s01.c01.p06': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.7bc229ef1f1c.jpg',
sha256: '7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750',
},
'comic.s01.c01.p07': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.4a19c49a53ea.jpg',
sha256: '4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31',
},
'comic.s01.c01.p08': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P08-cliffhanger-v1.7db63c3725ee.jpg',
sha256: '7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69',
},
'comic.s01.c02.p01': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P01-title-v1.c32093483c58.jpg',
sha256: 'c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552',
},
'comic.s01.c02.p02': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.892de611f097.jpg',
sha256: '892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea',
},
'comic.s01.c02.p03': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.e725c333849e.jpg',
sha256: 'e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c',
},
'comic.s01.c02.p04': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.ac89e35c3692.jpg',
sha256: 'ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593',
},
'comic.s01.c02.p05': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.cda630c13f31.jpg',
sha256: 'cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d',
},
'comic.s01.c02.p06': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.61d413562200.jpg',
sha256: '61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541',
},
'comic.s01.c02.p07': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.c7a28411b1d7.jpg',
sha256: 'c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539',
},
'comic.s01.c02.p08': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P08-clock-to-1978-v1.6c0eed6b964e.jpg',
sha256: '6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703',
},
'comic.s01.c03.p01': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P01-title-v1.7aaefa1ff157.jpg',
sha256: '7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4',
},
'comic.s01.c03.p02': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.feb9f3243262.jpg',
sha256: 'feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5',
},
'comic.s01.c03.p03': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.841146ea446b.jpg',
sha256: '841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e',
},
'comic.s01.c03.p04': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.8a838a7d0bac.jpg',
sha256: '8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727',
},
'comic.s01.c03.p05': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.cbd7d426ee42.jpg',
sha256: 'cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79',
},
'comic.s01.c03.p06': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P06-H12-two-clips-v1.00e5034f636a.jpg',
sha256: '00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1',
},
'comic.s01.c03.p07': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.787bf794fd53.jpg',
sha256: '787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602',
},
'comic.s01.c03.p08': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.056ef8b3017c.jpg',
sha256: '056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5',
},
'comic.s01.c04.p01': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P01-title-v1.e20d8bd83bfb.jpg',
sha256: 'e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30',
},
'comic.s01.c04.p02': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P02-ensemble-v1.d83c35c61a7c.jpg',
sha256: 'd83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505',
},
'comic.s01.c04.p03': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.c90b76c43463.jpg',
sha256: 'c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4',
},
'comic.s01.c04.p04': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P04-H14-belt-table-v1.70da80c5dd1d.jpg',
sha256: '70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7',
},
'comic.s01.c04.p05': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.4f4212b949b9.jpg',
sha256: '4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a',
},
'comic.s01.c04.p06': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.25834d3e9082.jpg',
sha256: '25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2',
},
'comic.s01.c04.p07': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.3f741e4ac31c.jpg',
sha256: '3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d',
},
'comic.s01.c04.p08': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P08-cliffhanger-v1.9558ee49f2e2.jpg',
sha256: '9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61',
},
'comic.s01.c05.p01': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P01-title-v1.52a79ac178e6.jpg',
sha256: '52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef',
},
'comic.s01.c05.p02': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.897aa558fe7d.jpg',
sha256: '897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb',
},
'comic.s01.c05.p03': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P03-H17-empty-window-v1.76ac18af26da.jpg',
sha256: '76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22',
},
'comic.s01.c05.p04': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P04-H18-half-bun-v1.c9506ed1fe98.jpg',
sha256: 'c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c',
},
'comic.s01.c05.p05': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.a88bbf597302.jpg',
sha256: 'a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8',
},
'comic.s01.c05.p06': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.63adf03d0d30.jpg',
sha256: '63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca',
},
'comic.s01.c05.p07': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.172fc27a2252.jpg',
sha256: '172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567',
},
'comic.s01.c05.p08': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P08-first-photo-v1.b85b2852a7dc.jpg',
sha256: 'b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f',
},
'comic.s01.c06.p01': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P01-title-v1.f7039560e49d.jpg',
sha256: 'f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40',
},
'comic.s01.c06.p02': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.0b4519763ba5.jpg',
sha256: '0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae',
},
'comic.s01.c06.p03': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.caf57f377be3.jpg',
sha256: 'caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75',
},
'comic.s01.c06.p04': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.f05b92fd22c2.jpg',
sha256: 'f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469',
},
'comic.s01.c06.p05': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.408f43b54b2b.jpg',
sha256: '408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d',
},
'comic.s01.c06.p06': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P06-H24-protect-table-v1.84156df566a6.jpg',
sha256: '84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53',
},
'comic.s01.c06.p07': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.c61d898fd982.jpg',
sha256: 'c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b',
},
'comic.s01.c06.p08': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.bcb8ec7e4720.jpg',
sha256: 'bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5',
},
'comic.s01.c07.p01': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P01-title-v1.42031fff6b37.jpg',
sha256: '42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720',
},
'comic.s01.c07.p02': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P02-opening-ensemble-v1.81e28334e743.jpg',
sha256: '81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2',
},
'comic.s01.c07.p03': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.fb9d1cd9910d.jpg',
sha256: 'fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5',
},
'comic.s01.c07.p04': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.8e12ab04091a.jpg',
sha256: '8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3',
},
'comic.s01.c07.p05': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.5d65745996d6.jpg',
sha256: '5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4',
},
'comic.s01.c07.p06': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P06-H28-shift-table-v1.7365643f439b.jpg',
sha256: '7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a',
},
'comic.s01.c07.p07': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.19ea61163655.jpg',
sha256: '19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5',
},
'comic.s01.c07.p08': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.f7f84ebecba2.jpg',
sha256: 'f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e',
},
'comic.s01.c08.p01': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P01-title-v1.b292709d6f22.jpg',
sha256: 'b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc',
},
'comic.s01.c08.p02': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P02-opening-ensemble-v1.4b755190ba3d.jpg',
sha256: '4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562',
},
'comic.s01.c08.p03': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.2a5bc840cc2d.jpg',
sha256: '2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c',
},
'comic.s01.c08.p04': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.737e9996a8f3.jpg',
sha256: '737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935',
},
'comic.s01.c08.p05': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.41da420b4e7b.jpg',
sha256: '41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618',
},
'comic.s01.c08.p06': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.fb66a9f2234c.jpg',
sha256: 'fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111',
},
'comic.s01.c08.p07': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.6c748cf7c1a6.jpg',
sha256: '6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea',
},
'comic.s01.c08.p08': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.2deaee916f92.jpg',
sha256: '2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743',
},
'comic.s01.c09.p01': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P01-opening-memory-v1.59db94b9c5dd.jpg',
sha256: '59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f',
},
'comic.s01.c09.p02': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P02-opening-ensemble-v1.0584053b2e87.jpg',
sha256: '0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d',
},
'comic.s01.c09.p03': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.9081d9c71e8f.jpg',
sha256: '9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af',
},
'comic.s01.c09.p04': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.cf00e6434a90.jpg',
sha256: 'cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff',
},
'comic.s01.c09.p05': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg',
remotePath: 'comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.5de6f5b46666.jpg',
sha256: '5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a',
},
'comic.s01.c09.p06': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.5bb10be0addb.jpg',
sha256: '5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443',
},
'comic.s01.c09.p07': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.9f4b4b8b1aa9.jpg',
sha256: '9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f',
},
'comic.s01.c09.p08': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg',
remotePath: 'comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.b3628d1471f2.jpg',
sha256: 'b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724',
},
'comic.s01.c10.p01': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P01-one-palm-space-v1.42d2b657a20f.jpg',
sha256: '42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8',
},
'comic.s01.c10.p02': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P02-opening-ensemble-v1.30c7fa3f8e9a.jpg',
sha256: '30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55',
},
'comic.s01.c10.p03': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.23e14923a5fd.jpg',
sha256: '23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9',
},
'comic.s01.c10.p04': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg',
remotePath: 'comic/s01-c10/S01-C10-P04-H38-open-palm-v3.be147c9d7c0c.jpg',
sha256: 'be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde',
},
'comic.s01.c10.p05': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.a21bec0d89b6.jpg',
sha256: 'a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd',
},
'comic.s01.c10.p06': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.6fa3594b0e5a.jpg',
sha256: '6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446',
},
'comic.s01.c10.p07': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.139439432818.jpg',
sha256: '13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99',
},
'comic.s01.c10.p08': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.2499342b7c3c.jpg',
sha256: '2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0',
},
'comic.s01.c11.p01': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P01-pen-above-paper-v1.840b82256bca.jpg',
sha256: '840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a',
},
'comic.s01.c11.p02': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P02-opening-ensemble-v1.112f4fe7b4f9.jpg',
sha256: '112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1',
},
'comic.s01.c11.p03': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.23ef59f3f23d.jpg',
sha256: '23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51',
},
'comic.s01.c11.p04': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.3e93fafafe75.jpg',
sha256: '3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50',
},
'comic.s01.c11.p05': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.c0c8c01f4e37.jpg',
sha256: 'c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5',
},
'comic.s01.c11.p06': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.4a805d19bb22.jpg',
sha256: '4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c',
},
'comic.s01.c11.p07': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.9fb66ad32712.jpg',
sha256: '9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a',
},
'comic.s01.c11.p08': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P08-can-we-open-now-v1.b80149fb0407.jpg',
sha256: 'b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de',
},
'comic.s01.c12.p01': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.e7f7138b21bb.jpg',
sha256: 'e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a',
},
'comic.s01.c12.p02': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.6d2ba8cf6cfe.jpg',
sha256: '6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602',
},
'comic.s01.c12.p03': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.c07a27557685.jpg',
sha256: 'c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9',
},
'comic.s01.c12.p04': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.1a5c07f21eae.jpg',
sha256: '1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e',
},
'comic.s01.c12.p05': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.695c7ebf0573.jpg',
sha256: '695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0',
},
'comic.s01.c12.p06': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.9b332e2a0d6d.jpg',
sha256: '9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76',
},
'comic.s01.c12.p07': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.338240b0bc54.jpg',
sha256: '338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5',
},
'comic.s01.c12.p08': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.a6aeee055eed.jpg',
sha256: 'a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f',
},
'comic.s01.c13.p01': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.2184f354e70f.jpg',
sha256: '2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e',
},
'comic.s01.c13.p02': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P02-opening-ensemble-v1.a9abf81e15a6.jpg',
sha256: 'a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198',
},
'comic.s01.c13.p03': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.13813c3961f7.jpg',
sha256: '13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b',
},
'comic.s01.c13.p04': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.d4002bf11c74.jpg',
sha256: 'd4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d',
},
'comic.s01.c13.p05': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.2c5923ca40e2.jpg',
sha256: '2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff',
},
'comic.s01.c13.p06': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.8ccbb50e6342.jpg',
sha256: '8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d',
},
'comic.s01.c13.p07': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.edac7d0ccb9f.jpg',
sha256: 'edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b',
},
'comic.s01.c13.p08': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.f40996a2f7d0.jpg',
sha256: 'f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc',
},
'comic.s01.c14.p01': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.6609c450b024.jpg',
sha256: '6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2',
},
'comic.s01.c14.p02': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.15678b028566.jpg',
sha256: '15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48',
},
'comic.s01.c14.p03': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.9d1a6e4c130a.jpg',
sha256: '9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365',
},
'comic.s01.c14.p04': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.899c26b37f6a.jpg',
sha256: '899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e',
},
'comic.s01.c14.p05': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.b87841f55cc9.jpg',
sha256: 'b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801',
},
'comic.s01.c14.p06': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.c17546b0c7e9.jpg',
sha256: 'c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd',
},
'comic.s01.c14.p07': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.d0bcbbb428bc.jpg',
sha256: 'd0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d',
},
'comic.s01.c14.p08': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.09f5a22c8a7f.jpg',
sha256: '09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c',
},
'comic.s01.c15.p01': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.194fda1fa427.jpg',
sha256: '194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab',
},
'comic.s01.c15.p02': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.b0e82af85954.jpg',
sha256: 'b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b',
},
'comic.s01.c15.p03': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.b3838eb91bea.jpg',
sha256: 'b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173',
},
'comic.s01.c15.p04': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.2471d7dd78aa.jpg',
sha256: '2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea',
},
'comic.s01.c15.p05': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.215691878476.jpg',
sha256: '21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a',
},
'comic.s01.c15.p06': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.3f1cd9abb08e.jpg',
sha256: '3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50',
},
'comic.s01.c15.p07': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.98a32b939746.jpg',
sha256: '98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98',
},
'comic.s01.c15.p08': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg',
remotePath: 'comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.520d68ce0a72.jpg',
sha256: '520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113',
},
'character.female-cook': {
kind: 'image',
localSeed: '/assets/characters/female-cook.jpg',
remotePath: 'characters/female-cook.c56420b2c632.jpg',
sha256: 'c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f',
},
'character.lele': {
kind: 'image',
localSeed: '/assets/characters/lele.jpg',
remotePath: 'characters/lele.807f5232b1e8.jpg',
sha256: '807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168',
},
'character.lin-xiulan': {
kind: 'image',
localSeed: '/assets/characters/lin-xiulan.jpg',
remotePath: 'characters/lin-xiulan.56e84abec4e5.jpg',
sha256: '56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361',
},
'character.qin-xiaoman': {
kind: 'image',
localSeed: '/assets/characters/qin-xiaoman.jpg',
remotePath: 'characters/qin-xiaoman.22fa229d5b09.jpg',
sha256: '22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5',
},
'character.qin-zhicheng': {
kind: 'image',
localSeed: '/assets/characters/qin-zhicheng.jpg',
remotePath: 'characters/qin-zhicheng.7f4be48ece92.jpg',
sha256: '7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2',
},
'character.tang-mingyuan': {
kind: 'image',
localSeed: '/assets/characters/tang-mingyuan.jpg',
remotePath: 'characters/tang-mingyuan.5ca2fa88f885.jpg',
sha256: '5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb',
},
'character.tang-shouan': {
kind: 'image',
localSeed: '/assets/characters/tang-shouan.jpg',
remotePath: 'characters/tang-shouan.4ed8d25ca911.jpg',
sha256: '4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89',
},
'character.xiaozhen': {
kind: 'image',
localSeed: '/assets/characters/xiaozhen.jpg',
remotePath: 'characters/xiaozhen.d5e9614f6006.jpg',
sha256: 'd5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb',
},
'character.zhao-jianguo': {
kind: 'image',
localSeed: '/assets/characters/zhao-jianguo.jpg',
remotePath: 'characters/zhao-jianguo.1c393fb68b02.jpg',
sha256: '1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0',
},
'scene.gui-xiang.1978': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-1978.jpg',
remotePath: 'scenes/gui-xiang-1978.bb21d9342e37.jpg',
sha256: 'bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5',
},
'scene.gui-xiang.1995': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-1995.jpg',
remotePath: 'scenes/gui-xiang-1995.a9701e2d55ad.jpg',
sha256: 'a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f',
},
'scene.gui-xiang.1998': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-1998.jpg',
remotePath: 'scenes/gui-xiang-1998.d4d7bebfd04a.jpg',
sha256: 'd4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14',
},
'scene.gui-xiang.2001': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-2001.jpg',
remotePath: 'scenes/gui-xiang-2001.9143455b349d.jpg',
sha256: '9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8',
},
'scene.gui-xiang.2003': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-2003.jpg',
remotePath: 'scenes/gui-xiang-2003.68349c74c916.jpg',
sha256: '68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431',
},
'scene.gui-xiang.2008': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-2008.jpg',
remotePath: 'scenes/gui-xiang-2008.c0fd9418aec2.jpg',
sha256: 'c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5',
},
'scene.gui-xiang.2026': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-2026.jpg',
remotePath: 'scenes/gui-xiang-2026.55e63e85388e.jpg',
sha256: '55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7',
},
'audio.cast-audition.v1': {
kind: 'audio',
reviewStatus: 'audition',
localSeed: '',
remotePath: 'audio/cast-audition-v1.7129d884f445.mp3',
sha256: '7129d884f44542434e131efb86f2f5619bedf188a1d8b21e7d0864131b58dd18',
},
}
// Legacy C01 cue-level seeds are retained as immutable backup assets only.
// The reader no longer maps them as page narration; C01 uses full-page tracks
// through the two package-local audio player pages instead.
for (const cueId of [
'S01-C01-MS007','S01-C01-MS010',
]) {
const file = `${cueId}.mp3`
const sha256 = {
'S01-C01-MS007': '1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8', 'S01-C01-MS010': '628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597',
}[cueId]
releaseAssets[`audio.s01.c01.${cueId.toLowerCase()}`] = { kind: 'audio', reviewStatus: 'approved', localSeed: `/package-game/assets/audio/${file}`, remotePath: `audio/${file}.${sha256.slice(0, 12)}.mp3`, sha256 }
}
const audioC02Sha256 = {
'S01-C02-MS001': '580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233',
'S01-C02-MS002-ATTR': 'aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8',
'S01-C02-MS002': 'ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe',
'S01-C02-MS003': '3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f',
'S01-C02-MS004': 'e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c',
'S01-C02-MS005': '6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e',
'S01-C02-MS006': '067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88',
'S01-C02-MS007': '7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c',
'S01-C02-MS008': '288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f',
'S01-C02-MS009': '5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2',
'S01-C02-MS010': 'd371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d',
'S01-C02-MS011': '2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb',
'S01-C02-MS012': '33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a',
'S01-C02-MT000': '5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e',
'S01-C02-TE900': 'd57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1',
}
const audioC03Sha256 = {
'S01-C03-MS001': 'b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43',
'S01-C03-MS002-ATTR': 'ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd',
'S01-C03-MS002': 'c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c',
'S01-C03-MS003': 'af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184',
'S01-C03-MS004': 'c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da',
'S01-C03-MS005-ATTR': '4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e',
'S01-C03-MS005': '46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442',
'S01-C03-MS006': '2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5',
'S01-C03-MS007-ATTR': 'f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0',
'S01-C03-MS007': '68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038',
'S01-C03-MS008': 'd9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7',
'S01-C03-MS009': '97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed',
'S01-C03-MS010': '49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0',
'S01-C03-MS011': '6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25',
'S01-C03-MS013': '8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a',
'S01-C03-MS014': '8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0',
'S01-C03-MS015': '661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb',
'S01-C03-MT000': '374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65',
'S01-C03-TE900': 'f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993',
}
// C02/C03 cue files are retained only as rollback/source material. A single
// cue is not a complete page track, so these assets must not pass the runtime
// audio gate or appear as “试听本页”.
for (const [chapterNumber, audioHashes] of Object.entries({ '02': audioC02Sha256, '03': audioC03Sha256 })) {
for (const [cueId, sha256] of Object.entries(audioHashes)) {
const file = `${cueId}.mp3`
releaseAssets[`audio.s01.c${chapterNumber}.${cueId.toLowerCase()}`] = {
kind: 'audio',
reviewStatus: 'reserved',
localSeed: `/package-chapter-${chapterNumber}/assets/audio/${file}`,
remotePath: `audio/${cueId}.${sha256.slice(0, 12)}.mp3`,
sha256,
}
}
}
module.exports = {
RELEASE_ASSET_MANIFEST_VERSION,
releaseAssets,
}
@@ -0,0 +1,97 @@
/**
* C02-C15 remote full-page audio manifest v1.
*
* Import contract for a future reviewed batch:
*
* 'S01-C09-P05': Object.freeze({
* pageId: 'S01-C09-P05',
* chapterNumber: 9,
* pageNumber: 5,
* assetId: 'audio.page.s01.c09.p05',
* durationSeconds: 42.1,
* reviewStatus: 'approved',
* })
*
* The matching releaseAssetManifest entry must independently be an approved,
* remote-only, immutable hash-named audio asset. Automated QA, pending human
* listening, and missing CDN records must never be relabelled as approved.
*/
const REMOTE_PAGE_AUDIO_MANIFEST_VERSION = 1
const REMOTE_PAGE_AUDIO_ID_PATTERN = /^S01-C(0[2-9]|1[0-5])-P(0[1-8])$/
const SHA256_PATTERN = /^[a-f0-9]{64}$/
// Deliberately empty until full-page tracks pass the required human reviews
// and their immutable CDN objects are available. This is a release gate, not
// a placeholder that the UI may treat as playable.
const remotePageAudioPages = Object.freeze({})
function clean(value) {
return typeof value === 'string' ? value.trim() : ''
}
function remotePageAudioAssetId(pageId) {
const stablePageId = clean(pageId)
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
if (!match) return ''
return `audio.page.s01.c${match[1]}.p${match[2]}`
}
function getApprovedRemotePageAudio(
pageId,
releaseAssets = {},
pageManifest = remotePageAudioPages,
) {
const stablePageId = clean(pageId)
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
if (!match) return null
const entry = pageManifest && pageManifest[stablePageId]
if (!entry || typeof entry !== 'object') return null
const chapterNumber = Number(match[1])
const pageNumber = Number(match[2])
const assetId = remotePageAudioAssetId(stablePageId)
const durationSeconds = Number(entry.durationSeconds)
if (
clean(entry.reviewStatus).toLowerCase() !== 'approved'
|| entry.pageId !== stablePageId
|| Number(entry.chapterNumber) !== chapterNumber
|| Number(entry.pageNumber) !== pageNumber
|| entry.assetId !== assetId
|| !Number.isFinite(durationSeconds)
|| durationSeconds <= 0
) {
return null
}
const asset = releaseAssets && releaseAssets[assetId]
const sha256 = asset && typeof asset.sha256 === 'string'
? asset.sha256
: ''
const remotePath = asset && asset.remotePath
const expectedRemotePath = (
`audio/${stablePageId.slice(0, 7).toLowerCase()}`
+ `/${stablePageId}.${sha256.slice(0, 12)}.mp3`
)
if (
!asset
|| asset.kind !== 'audio'
|| clean(asset.reviewStatus).toLowerCase() !== 'approved'
|| asset.localSeed !== ''
|| !SHA256_PATTERN.test(sha256)
|| typeof remotePath !== 'string'
|| remotePath !== expectedRemotePath
) {
return null
}
return { ...entry, assetId }
}
module.exports = {
REMOTE_PAGE_AUDIO_ID_PATTERN,
REMOTE_PAGE_AUDIO_MANIFEST_VERSION,
getApprovedRemotePageAudio,
remotePageAudioAssetId,
remotePageAudioPages,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
{
"navigationBarTitleText": "唐侦探",
"pageOrientation": "landscape",
"disableScroll": true
}
@@ -0,0 +1,932 @@
<view
wx:if="{{chapter}}"
class="chapter-page font-{{fontScale}} {{compactHeight ? 'compact-height' : ''}}"
style="{{pageStyle}}"
>
<view class="comic-topbar {{storyFirstComicMode ? ((chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct')) ? 'c1-book-topbar is-back' : ('c1-book-topbar is-front ' + (frontControlsRevealed ? 'is-controls-revealed' : 'is-controls-hidden'))) : ''}}" style="{{topbarStyle}}">
<block wx:if="{{!storyFirstComicMode || chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct') || frontControlsRevealed}}">
<button class="comic-catalog-button" bindtap="goCatalog" aria-label="返回目录"> 目录</button>
<button
wx:if="{{storyFirstComicMode && !comicPreviousDisabled}}"
class="comic-top-previous-button"
bindtap="previousComicPage"
aria-label="上一张画"
></button>
<view class="comic-bookmark">
<text class="comic-bookmark-label">
第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}}
</text>
<text class="comic-page-count">{{currentPageNumber}}{{comicPageCount}}</text>
</view>
<button
wx:if="{{!storyFirstComicMode && !currentPage.audioAvailable && !currentPage.audioCompanionAvailable}}"
class="comic-page-audio-slot is-text-mode {{storyFirstComicMode ? 'c1-audio-slot' : ''}}"
bindtap="openTextReading"
aria-label="本页可以直接看文字"
>
<text class="comic-page-audio-icon" aria-hidden="true">阅</text>
<view class="comic-page-audio-copy">
<text class="comic-page-audio-label">直接看文字</text>
<text class="comic-page-audio-meta">按页慢慢看</text>
</view>
</button>
<button
wx:elif="{{currentPage.audioAvailable || currentPage.audioCompanionAvailable}}"
class="comic-page-audio-slot {{storyFirstComicMode ? 'c1-audio-slot' : ''}} {{audioLoading ? 'is-loading' : audioPlaying ? 'is-playing' : audioPercent > 0 ? 'is-replay' : 'is-ready'}}"
disabled="{{audioLoading}}"
bindtap="toggleComicPageAudio"
data-audio-cue-count="{{currentPage.audioCueCount}}"
aria-label="本页语音播放控制"
>
<text class="comic-page-audio-icon" aria-hidden="true">
{{audioPlaying ? 'Ⅱ' : '▶'}}
</text>
<view class="comic-page-audio-copy">
<text wx:if="{{audioLoading}}" class="comic-page-audio-label">准备中</text>
<text wx:elif="{{audioPlaying}}" class="comic-page-audio-label">暂停</text>
<text wx:elif="{{audioPercent > 0}}" class="comic-page-audio-label">重听</text>
<text wx:else class="comic-page-audio-label">听一听</text>
<text class="comic-page-audio-meta">
{{audioCurrent}} / {{audioDuration === '0:00' ? currentPage.audioDurationLabel : audioDuration}}
</text>
</view>
</button>
<button
wx:if="{{!storyFirstComicMode || chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct')}}"
class="comic-font-button"
bindtap="toggleFont"
aria-label="{{fontScale === 'large' ? '把整页文字再放大' : '恢复标准大字'}}"
>
{{fontScale === 'large' ? '大字' : '标准字'}}
</button>
<button
wx:if="{{storyFirstComicMode && !chapterOneBackOpen && !decisionOpen && !chapterOneEmotionOpen}}"
class="c1-front-action-button"
bindtap="openCurrentFrontAction"
aria-label="{{currentPage.type === 'event'
? (currentEventCompleted ? '点一下,再看背面' : '点点人物,翻到背面')
: currentPage.type === 'emotion'
? (chapterFinished ? '点一下,看看章末' : '点一下,听听心里话')
: '点一下,翻到背面'}}"
>
{{currentPage.type === 'event'
? (currentEventCompleted ? '再看背面' : '点点人物')
: currentPage.type === 'emotion'
? (chapterFinished ? '看看章末' : '听听心里话')
: '翻到背面'}}
</button>
</block>
</view>
<view class="comic-reader">
<block wx:if="{{storyFirstComicMode}}">
<view class="c1-comic-page {{chapterOneBackOpen ? 'is-back' : 'is-front'}}">
<block wx:if="{{chapterOneBackOpen}}">
<view class="c1-back-paper">
<view class="c1-back-evidence">
<image
wx:if="{{comicImageSrc}}"
class="c1-back-image"
src="{{comicImageSrc}}"
mode="aspectFit"
></image>
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
<text class="c1-back-stamp">
{{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}}
</text>
</view>
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
<view wx:if="{{currentPage.type === 'memory'}}" class="c1-back-copy-inner">
<text class="c1-back-kicker">第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆</text>
<text class="c1-back-title">{{currentPage.headline}}</text>
<text class="c1-back-body">{{currentPage.caption}}</text>
<view class="c1-back-callout">
<text class="c1-back-label">桌边回声</text>
<text>{{currentPage.memoryCard.tableEcho}}</text>
</view>
<view class="c1-back-action">
<text class="c1-back-label">带回生活里</text>
<text>{{currentPage.memoryCard.lifeAction}}</text>
</view>
<text class="c1-back-family">“{{currentPage.memoryCard.familyLine}}”</text>
<view class="c1-memory-secondary-actions">
<button
class="c1-memory-secondary-action"
bindtap="openLifeReport"
aria-label="查看本回桂香生活观察"
>看看生活观察</button>
<button
class="c1-memory-secondary-action"
open-type="share"
aria-label="发给家人,一起聊聊这一页"
>发给家人聊聊</button>
</view>
</view>
<view wx:elif="{{currentPage.type === 'event' && currentEventCompleted && chapterOneReviewEvent}}" class="c1-back-copy-inner">
<text class="c1-back-kicker">{{currentPage.actorName}} · 刚才看见的</text>
<text class="c1-back-title">{{currentPage.headline}}</text>
<text class="c1-back-body">{{chapterOneReviewEvent.actionDescription}}</text>
<view class="c1-back-callout">
<text class="c1-back-label">您刚才瞧见的</text>
<text>{{chapterOneReviewEvent.evidence}}</text>
</view>
<view class="c1-back-action">
<text class="c1-back-label">下回遇到这事</text>
<text>{{chapterOneReviewEvent.actionAdvice}}</text>
</view>
</view>
<view wx:elif="{{currentPage.type === 'event' && chapterOneReviewEvent}}" class="c1-back-copy-inner">
<text class="c1-back-kicker">{{currentPage.actorName}} · 这回事</text>
<text class="c1-back-title">{{currentPage.headline}}</text>
<text class="c1-back-body">{{currentPage.caption}}</text>
<view wx:if="{{currentPage.shortDialogue}}" class="c1-back-callout">
<text class="c1-back-label">桌边一句话</text>
<text>“{{currentPage.shortDialogue}}”</text>
</view>
</view>
<view wx:else class="c1-back-copy-inner">
<text class="c1-back-kicker">{{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}}</text>
<text class="c1-back-title">{{currentPage.headline}}</text>
<text class="c1-back-body">{{currentPage.displayCaption}}</text>
<text wx:if="{{currentPage.secondaryCaption}}" class="c1-back-secondary">{{currentPage.secondaryCaption}}</text>
</view>
</scroll-view>
<text
wx:if="{{currentPage.type === 'memory' || (currentPage.type === 'event' && currentEventCompleted)}}"
class="c1-scroll-hint"
>上划接着看 ↑</text>
<view wx:if="{{currentPage.type === 'memory'}}" class="c1-back-footer c1-memory-footer">
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneBack">回去看画</button>
<button wx:if="{{chapterNumber >= chapterCount}}" class="c1-back-button primary" bindtap="goCatalog">回到目录</button>
<button wx:else class="c1-back-button primary" bindtap="nextChapter">翻下一回</button>
</view>
<view wx:else class="c1-back-footer">
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneBack">回去看画</button>
<button
wx:if="{{currentPage.type === 'event' && !currentEventCompleted}}"
class="c1-back-button primary"
bindtap="startChapterOneDecisionFromBack"
>聊聊这事</button>
<button
wx:else
class="c1-back-button primary"
bindtap="nextChapterOnePageFromBack"
>翻下一张</button>
</view>
</view>
</block>
<block wx:elif="{{currentPage.type === 'emotion' && chapterOneEmotionOpen && emotionResult}}">
<view class="c1-back-paper">
<view class="c1-back-evidence">
<image
wx:if="{{comicImageSrc}}"
class="c1-back-image"
src="{{comicImageSrc}}"
mode="aspectFit"
></image>
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
<text class="c1-back-stamp">桌边回声</text>
</view>
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
<view class="c1-back-copy-inner">
<text class="c1-back-kicker">没有标准答案</text>
<text class="c1-back-title">{{currentPage.headline}}</text>
<text class="c1-back-body">{{emotionResult.feedback}}</text>
<view class="c1-back-callout">
<text class="c1-back-label">这一桌记住了</text>
<text>{{emotionResult.echo}}</text>
</view>
</view>
</scroll-view>
<view class="c1-back-footer">
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneEmotion">回去看画</button>
<button class="c1-back-button primary" bindtap="finishChapterOneEmotionAndTurn">收下这句话</button>
</view>
</view>
</block>
<block wx:elif="{{currentPage.type === 'emotion' && chapterOneEmotionOpen && !emotionResult}}">
<view class="c1-interaction-paper c1-emotion-interaction-paper">
<view class="c1-interaction-evidence">
<image
wx:if="{{comicImageSrc}}"
class="c1-interaction-image"
src="{{comicImageSrc}}"
mode="aspectFit"
></image>
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
<text class="c1-back-stamp">桌边一句话</text>
</view>
<view class="c1-interaction-panel">
<button class="c1-tray-close" bindtap="closeChapterOneEmotion" aria-label="收起选择,先回画面">先看画</button>
<view class="c1-tray-heading">
<text class="c1-tray-kicker">这一刻,您想怎么说?</text>
<text class="c1-tray-question">{{currentPage.prompt}}</text>
</view>
<view class="c1-choice-stack">
<button
wx:for="{{chapter.emotionMoment.playerChoices}}"
wx:key="choiceId"
class="c1-natural-choice"
data-choice="{{item.choiceId}}"
bindtap="chooseEmotion"
>{{item.text}}</button>
</view>
</view>
</view>
</block>
<block wx:else>
<view class="c1-front-stage">
<image
wx:if="{{comicImageSrc}}"
class="c1-front-image"
src="{{comicImageSrc}}"
mode="aspectFit"
bindload="onComicImageLoad"
binderror="onComicImageError"
></image>
<view wx:else class="c1-front-fallback">
<text>{{comicImageError}}</text>
<text>{{currentPage.sceneAlt}}</text>
</view>
<view class="c1-front-grain"></view>
<button
wx:if="{{!frontControlsRevealed}}"
class="c1-front-reveal-gate"
bindtap="revealCurrentFrontControls"
aria-label="轻点画面,显示阅读按钮"
></button>
<block wx:else>
<button
wx:if="{{frontControlsRevealed && currentInteractionEnabled && currentPage.type === 'event' && !comicImageUsingFallback}}"
class="c1-person-hotspot"
style="{{currentPage.actorHotspot.style}}"
bindtap="openComicActor"
aria-label="点点{{currentPage.actorName}},翻到这一幕背面"
><view class="c1-hotspot-ring"></view></button>
<button
wx:elif="{{frontControlsRevealed && currentPage.type === 'event' && currentEventCompleted}}"
class="c1-full-page-tap"
bindtap="openChapterOneBack"
aria-label="翻到这一幕的背面"
></button>
<button
wx:elif="{{frontControlsRevealed && currentPage.type === 'emotion'}}"
class="c1-full-page-tap"
bindtap="openChapterOneEmotion"
aria-label="{{chapterFinished ? '轻触画面,翻到章末' : '轻触画面,听听这一桌心里话'}}"
></button>
<button
wx:else
class="c1-full-page-tap"
bindtap="openChapterOneBack"
aria-label="轻触画面,翻到这一页背面"
></button>
</block>
<text wx:if="{{frontHintVisible}}" class="c1-front-first-hint">轻点画面,按钮就出来了</text>
</view>
</block>
</view>
</block>
<block wx:elif="{{currentPage.type === 'emotion'}}">
<view class="comic-special-page emotion-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}">
<view class="special-art">
<image
wx:if="{{comicImageSrc}}"
class="special-art-image"
src="{{comicImageSrc}}"
mode="aspectFit"
bindload="onComicImageLoad"
binderror="onComicImageError"
></image>
<view wx:else class="special-art-fallback">
<text>{{comicImageError}}</text>
<text>{{currentPage.sceneAlt}}</text>
</view>
<view class="comic-paper-grain"></view>
</view>
<view class="emotion-panel">
<scroll-view class="emotion-paper" scroll-y enhanced show-scrollbar="{{true}}">
<view class="emotion-context">
<text class="emotion-context-kicker">听听这一桌心里话</text>
<text class="emotion-context-title">{{currentPage.headline}}</text>
<text class="emotion-context-caption">{{currentPage.caption}}</text>
</view>
<text class="emotion-prompt">{{currentPage.prompt}}</text>
<text
wx:if="{{!emotionResult && !chapterFinished}}"
class="emotion-note"
>没有标准答案,选您更愿意说的话。</text>
<view wx:if="{{emotionResult || chapterFinished}}" class="comic-emotion-result">
<text wx:if="{{emotionResult}}" class="feedback">{{emotionResult.feedback}}</text>
<view class="table-echo">
<text class="echo-label">桌边回声</text>
<text>{{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}}</text>
</view>
</view>
</scroll-view>
<view
wx:if="{{!emotionResult && !chapterFinished}}"
class="emotion-choice-dock"
>
<button
wx:for="{{chapter.emotionMoment.playerChoices}}"
wx:key="choiceId"
class="emotion-choice"
data-choice="{{item.choiceId}}"
bindtap="chooseEmotion"
>
<text class="choice-number">{{index + 1}}</text>
<text>{{item.text}}</text>
</button>
</view>
<view class="special-page-footer emotion-page-footer {{emotionResult || chapterFinished ? 'has-primary' : 'single-action'}}">
<button
class="special-page-button secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
bindtap="previousComicPage"
aria-label="返回前一页"
aria-disabled="{{comicPreviousDisabled}}"
>
<text class="comic-control-visible">上页</text>
</button>
<button
wx:if="{{emotionResult || chapterFinished}}"
class="special-page-button primary"
bindtap="finishComicEmotion"
aria-label="{{chapterFinished ? '查看记忆卡' : '收下这一桌的回声'}}"
>
<text class="comic-control-visible">{{chapterFinished ? '查看记忆卡' : '收下这一桌的回声'}}</text>
</button>
</view>
</view>
</view>
</block>
<block wx:elif="{{currentPage.isSeasonClose}}">
<view class="season-close-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}">
<view class="season-close-body">
<view class="season-close-art">
<image
wx:if="{{comicImageSrc}}"
class="season-close-main-image"
src="{{comicImageSrc}}"
mode="aspectFit"
bindload="onComicImageLoad"
binderror="onComicImageError"
></image>
<view wx:else class="season-close-art-fallback">
<text>{{comicImageError}}</text>
<text>{{currentPage.sceneAlt}}</text>
</view>
<view
wx:if="{{comicImageSrc && !comicImageUsingFallback && currentPage.detailInset}}"
class="season-close-detail-inset {{currentPage.detailInset.anchorClass}}"
aria-label="{{currentPage.detailInset.label}}近景"
>
<image
class="season-close-detail-image"
src="{{comicImageSrc}}"
mode="{{currentPage.detailInset.imageMode}}"
style="{{currentPage.detailInset.imageStyle}}"
></image>
<text class="season-close-detail-label">{{currentPage.detailInset.label}}</text>
</view>
</view>
<scroll-view
class="season-close-copy"
scroll-y
enhanced
show-scrollbar="{{true}}"
>
<view class="season-close-copy-sheet">
<text class="season-close-kicker">第一季 · 季终</text>
<text class="season-close-caption">{{currentPage.seasonCloseCaption}}</text>
<view class="season-close-memory-card">
<text class="season-close-card-label">桂香记忆卡</text>
<text class="season-close-era">{{currentPage.memoryCard.eraLine}}</text>
<text class="season-close-quote">{{currentPage.memoryCard.tableEcho}}</text>
<view class="season-close-life-line">
<text class="season-close-line-label">今天可以这样做</text>
<text>{{currentPage.memoryCard.lifeAction}}</text>
</view>
<view class="season-close-family-line">
<text class="season-close-line-label">带回家聊一聊</text>
<text>“{{currentPage.memoryCard.familyLine}}”</text>
</view>
</view>
<text class="season-close-tail">{{currentPage.seasonCloseTail}}</text>
</view>
</scroll-view>
</view>
<view class="memory-actions season-close-actions">
<button
class="memory-action-cell secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
bindtap="previousComicPage"
aria-label="返回前一页"
aria-disabled="{{comicPreviousDisabled}}"
>
<text class="memory-action-visible">上页</text>
</button>
<button
class="memory-action-cell report"
bindtap="openLifeReport"
aria-label="查看本回桂香生活观察"
>
<text class="memory-action-visible">生活观察</text>
</button>
<button
class="memory-action-cell share"
open-type="share"
aria-label="发给家人,一起聊聊这一页"
>
<text class="memory-action-visible">发给家人</text>
</button>
<button
class="memory-action-cell primary"
bindtap="goCatalog"
aria-label="第一季读完,回到目录"
>
<text class="memory-action-visible">回到目录</text>
</button>
</view>
</view>
</block>
<block wx:elif="{{currentPage.type === 'memory'}}">
<view class="comic-special-page memory-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}} {{currentPage.pageId === 'S01-C09-P08' ? 'c09-p08' : ''}}">
<view class="memory-art">
<image
wx:if="{{comicImageSrc}}"
class="memory-art-image"
src="{{comicImageSrc}}"
mode="aspectFit"
bindload="onComicImageLoad"
binderror="onComicImageError"
></image>
<view wx:else class="special-art-fallback">
<text>{{comicImageError}}</text>
<text>{{currentPage.sceneAlt}}</text>
</view>
<view class="comic-paper-grain"></view>
</view>
<view class="memory-panel">
<scroll-view class="memory-paper {{currentPage.pageId === 'S01-C09-P08' ? 'is-complete' : ''}}" scroll-y="{{currentPage.pageId !== 'S01-C09-P08'}}" enhanced show-scrollbar="{{currentPage.pageId !== 'S01-C09-P08'}}">
<view class="memory-paper-content">
<view class="memory-cliffhanger-copy"><text class="memory-ending-label">下一回的门缝</text><text>{{currentPage.caption}}</text></view>
<view class="memory-card-sheet"><text class="memory-stamp">桂香记忆卡</text>
<text class="memory-era">{{currentPage.memoryCard.eraLine}}</text><text class="memory-person">{{currentPage.memoryCard.characterName}}</text>
<view wx:if="{{currentPage.pageId === 'S01-C09-P08'}}" class="memory-short-lines">
<text wx:for="{{currentPage.memoryCard.displayLines}}" wx:key="*this" class="memory-short-line">{{item}}</text>
</view>
<block wx:else>
<text class="memory-quote">{{currentPage.memoryCard.tableEcho}}</text>
<view class="memory-action"><text class="memory-label">今天可以这样做</text><text>{{currentPage.memoryCard.lifeAction}}</text></view>
<button class="memory-family-toggle" bindtap="toggleMemoryFamily" aria-label="打开带回家聊一聊">
{{memoryFamilyOpen ? '收起这一句' : '带回家聊一聊'}}
</button>
<view wx:if="{{memoryFamilyOpen}}" class="memory-family"><text class="memory-label">带回家聊一聊</text><text>“{{currentPage.memoryCard.familyLine}}”</text></view>
</block>
</view>
</view>
</scroll-view>
<view class="memory-actions">
<button
class="memory-action-cell secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
bindtap="previousComicPage"
aria-label="返回前一页"
aria-disabled="{{comicPreviousDisabled}}"
>
<text class="memory-action-visible">上页</text>
</button>
<button
class="memory-action-cell report"
bindtap="openLifeReport"
aria-label="查看本回桂香生活观察"
>
<text class="memory-action-visible">生活观察</text>
</button>
<button
class="memory-action-cell share"
open-type="share"
aria-label="发给家人,一起聊聊这一页"
>
<text class="memory-action-visible">发给家人</text>
</button>
<button
wx:if="{{chapterNumber >= chapterCount}}"
class="memory-action-cell primary"
bindtap="goCatalog"
aria-label="全书读完,回到目录"
>
<text class="memory-action-visible">回到目录</text>
</button>
<button
wx:else
class="memory-action-cell primary"
bindtap="nextChapter"
aria-label="翻到下一回"
>
<text class="memory-action-visible">翻到下一回</text>
</button>
</view>
</view>
</view>
</block>
<block wx:else>
<view
class="comic-normal-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}"
bindtouchstart="onComicTouchStart"
bindtouchend="onComicTouchEnd"
>
<view class="comic-art-column">
<view class="comic-art-stage" style="{{comicArtStageStyle}}">
<image
wx:if="{{comicImageSrc}}"
class="comic-art-image"
src="{{comicImageSrc}}"
mode="{{comicPageImageMode}}"
style="{{comicPageImageStyle}}"
bindload="onComicImageLoad"
binderror="onComicImageError"
></image>
<view wx:else class="comic-art-fallback">
<text>{{comicImageError}}</text>
<text>{{currentPage.sceneAlt}}</text>
</view>
<view class="comic-paper-grain"></view>
<view
wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}"
class="comic-evidence-composite"
aria-label="{{currentPage.playableVisual.subject}}的手边证据"
>
<text class="comic-evidence-kicker">手边证据</text>
<text class="comic-evidence-label">{{currentPage.playableVisual.evidenceLabel}}</text>
<text class="comic-evidence-detail">{{currentPage.playableVisual.evidenceDetail}}</text>
</view>
<button
wx:if="{{currentInteractionEnabled && !comicImageUsingFallback}}"
class="comic-actor-hotspot is-quiet"
style="{{currentPage.actorHotspot.style}}"
bindtap="openComicActor"
aria-label="看看{{currentPage.actorName}}的手边,了解这一页发生了什么"
></button>
</view>
</view>
<view class="comic-caption-strip {{currentInteractionEnabled && compactHeight ? 'has-outside-actor-guide' : ''}}">
<scroll-view class="comic-caption-copy" scroll-y enhanced show-scrollbar="{{false}}">
<view wx:if="{{currentPage.type === 'cover'}}" class="comic-cover-caption-layout">
<view class="comic-cover-caption-heading">
<text class="comic-caption-kicker">{{chapter.year}}</text>
<text class="comic-caption-heading">{{currentPage.headline}}</text>
</view>
<text class="comic-caption comic-cover-caption-text">{{currentPage.displayCaption}}</text>
</view>
<view wx:elif="{{currentPage.type === 'ensemble'}}" class="comic-ensemble-caption-layout">
<text class="comic-caption-heading">{{currentPage.headline}}</text>
<view class="comic-ensemble-caption-copy">
<text class="comic-caption">{{currentPage.displayCaption}}</text>
<text wx:if="{{currentPage.secondaryCaption}}" class="comic-secondary-caption">{{currentPage.secondaryCaption}}</text>
</view>
</view>
<view wx:elif="{{currentPage.type === 'event'}}" class="comic-event-caption-layout {{currentEventCompleted ? 'is-completed' : ''}}">
<view class="comic-event-caption-copy">
<text class="comic-caption">{{currentPage.displayCaption}}</text>
<text wx:if="{{currentPage.secondaryCaption}}" class="comic-secondary-caption">{{currentPage.secondaryCaption}}</text>
</view>
<view class="comic-event-cue">
<text
wx:if="{{currentInteractionEnabled && !compactHeight && currentPage.artTapHint}}"
class="comic-art-tap-note"
>再看看{{currentPage.actorName}}的手边</text>
<button
wx:if="{{currentInteractionEnabled && !compactHeight}}"
class="comic-outside-actor-button"
bindtap="openComicActor"
aria-label="{{currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上的东西' : (currentPage.playableVisual.kind === 'actor-portrait' ? '看看' + currentPage.actorName + '的手边' : '看看发生了什么')}}"
>
<text wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}">看看桌上的东西</text>
<text wx:elif="{{currentPage.artTapHint}}">再看看{{currentPage.actorName}}的手边</text>
<text wx:elif="{{currentPage.playableVisual.kind === 'actor-portrait'}}">看看{{currentPage.actorName}}的手边</text>
<text wx:else>看看发生了什么</text>
</button>
<view wx:elif="{{currentEventCompleted}}" class="comic-seen-row">
<text class="seen-chip">看见了</text>
<text class="comic-tap-prompt is-seen">可以翻到下一页。</text>
</view>
</view>
</view>
</scroll-view>
<view
wx:if="{{currentInteractionEnabled && compactHeight}}"
class="comic-outside-action-dock {{currentPage.artTapHint ? 'has-art-tap-note' : ''}}"
>
<text
wx:if="{{currentPage.artTapHint}}"
class="comic-art-tap-note"
>再看看{{currentPage.actorName}}的手边</text>
<button
class="comic-outside-actor-button comic-outside-actor-dock"
bindtap="openComicActor"
aria-label="{{currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上的东西' : (currentPage.playableVisual.kind === 'actor-portrait' ? '看看' + currentPage.actorName + '的手边' : '看看发生了什么')}}"
>
<text wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}">看看桌上的东西</text>
<text wx:elif="{{currentPage.artTapHint}}">再看看{{currentPage.actorName}}的手边</text>
<text wx:elif="{{currentPage.playableVisual.kind === 'actor-portrait'}}">看看{{currentPage.actorName}}的手边</text>
<text wx:else>看看发生了什么</text>
</button>
</view>
<view class="comic-page-nav">
<button
class="page-turn-button {{comicPreviousDisabled ? 'is-disabled' : ''}}"
bindtap="previousComicPage"
aria-label="上一页"
aria-disabled="{{comicPreviousDisabled}}"
>
<text class="comic-control-visible">上页</text>
</button>
<text wx:if="{{!compactHeight}}" class="folio">{{currentPageNumber}}{{comicPageCount}}</text>
<button
class="page-turn-button next {{comicNextLocked ? 'is-locked' : ''}} {{comicNextDisabled ? 'is-disabled' : ''}}"
bindtap="nextComicPage"
aria-label="{{comicNextLocked ? '先看看这一页的画面' : '下一页'}}"
aria-disabled="{{comicNextLocked || comicNextDisabled}}"
>
<text class="comic-control-visible">
{{comicNextLocked ? (currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上' : '看看画面') : '下页'}}
</text>
</button>
</view>
</view>
</view>
</block>
</view>
<view wx:if="{{textReadingOpen}}" class="dialog-backdrop text-reading-backdrop">
<view
class="text-reading-dialog paper-panel"
aria-role="dialog"
aria-label="本页大字文字"
>
<button
class="dialog-close"
bindtap="closeTextReading"
aria-label="关闭文字阅读"
>×</button>
<text class="text-reading-kicker">
第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页
</text>
<scroll-view
class="text-reading-scroll"
scroll-y
enhanced
show-scrollbar="{{true}}"
>
<view class="text-reading-sheet">
<text wx:if="{{currentPage.headline}}" class="text-reading-title">{{currentPage.headline}}</text>
<text wx:if="{{currentPage.displayCaption}}" class="text-reading-body">{{currentPage.displayCaption}}</text>
<text wx:elif="{{currentPage.caption}}" class="text-reading-body">{{currentPage.caption}}</text>
<text wx:if="{{currentPage.secondaryCaption}}" class="text-reading-secondary">{{currentPage.secondaryCaption}}</text>
<view wx:if="{{currentPage.prompt}}" class="text-reading-prompt">
<text class="text-reading-label">这一页想和您聊</text>
<text>{{currentPage.prompt}}</text>
</view>
<view wx:if="{{currentPage.question}}" class="text-reading-prompt">
<text class="text-reading-label">看看画里发生了什么</text>
<text>{{currentPage.question}}</text>
</view>
</view>
</scroll-view>
<button
class="text-reading-return"
bindtap="closeTextReading"
aria-label="回到连环画"
>回到画里</button>
</view>
</view>
<view wx:if="{{decisionOpen && storyFirstComicMode}}" class="c1-decision-layer">
<view wx:if="{{resultMode === ''}}" class="c1-interaction-paper">
<view class="c1-interaction-evidence">
<image
wx:if="{{comicImageSrc}}"
class="c1-interaction-image"
src="{{comicImageSrc}}"
mode="aspectFit"
></image>
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
<text class="c1-back-stamp">画里的线索</text>
</view>
<view class="c1-interaction-panel">
<button class="c1-tray-close" bindtap="closeDecision" aria-label="收起选择,先回画面">先看画</button>
<view class="c1-tray-heading">
<text class="c1-tray-kicker">碰上这事,您怎么做?</text>
<text class="c1-tray-question">{{activeEvent.question}}</text>
</view>
<view class="c1-choice-stack">
<button
wx:for="{{activeEvent.choiceOptions}}"
wx:key="value"
class="c1-natural-choice"
data-value="{{item.value}}"
bindtap="answer"
>{{item.label}}</button>
</view>
</view>
</view>
<view wx:elif="{{resultMode === 'retry'}}" class="c1-interaction-paper">
<view class="c1-interaction-evidence">
<image
wx:if="{{comicImageSrc}}"
class="c1-interaction-image"
src="{{comicImageSrc}}"
mode="aspectFit"
></image>
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
<text class="c1-back-stamp">再看看画里</text>
</view>
<view class="c1-interaction-panel c1-retry-panel">
<button class="c1-tray-close" bindtap="closeDecision" aria-label="回到画里">先看画</button>
<text class="c1-retry-title">别着急,再瞧瞧画里。</text>
<text class="c1-retry-copy">{{activeEvent.retryHint}}</text>
<button class="c1-retry-button" bindtap="retryAnswer">再看一眼</button>
</view>
</view>
<view wx:else class="c1-back-paper c1-event-result-back">
<view class="c1-back-evidence">
<image
wx:if="{{comicImageSrc}}"
class="c1-back-image"
src="{{comicImageSrc}}"
mode="aspectFit"
></image>
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
<text class="c1-back-stamp">这下有眉目了</text>
</view>
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
<view class="c1-back-copy-inner">
<text class="c1-back-kicker">{{activeEvent.actorName}} · 这一页背面</text>
<text class="c1-back-title">{{currentPage.headline}}</text>
<text class="c1-back-body">{{activeEvent.actionDescription}}</text>
<view wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-action">
<text class="c1-back-label">先把眼前这一步做好</text>
<text>先停酒、移开危险物,并留下人陪伴。</text>
</view>
<text wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-reason">这些表现不能仅凭外观确定原因,先保证安全。</text>
<block wx:else>
<view class="c1-back-callout">
<text class="c1-back-label">您刚才瞧见的</text>
<text>{{activeEvent.evidence}}</text>
</view>
<text class="c1-back-reason">{{activeEvent.reason}}</text>
<view class="c1-back-action">
<text class="c1-back-label">下回遇到这事</text>
<text>{{activeEvent.actionAdvice}}</text>
</view>
</block>
</view>
</scroll-view>
<text class="c1-scroll-hint">上划接着看 ↑</text>
<view class="c1-back-footer">
<button
wx:if="{{!(activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen)}}"
class="c1-back-button secondary"
aria-label="回头再看一遍画面"
bindtap="returnFromChapterOneDecision"
>回去看画</button>
<button wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-button primary" bindtap="openResultDetail">再看不同情况怎么办</button>
<button wx:else class="c1-back-button primary" bindtap="nextFromChapterOneDecision">翻下一张</button>
</view>
</view>
</view>
<view wx:elif="{{decisionOpen}}" class="dialog-backdrop">
<view
class="decision-dialog paper-panel comic-decision"
aria-role="dialog"
aria-label="{{activeEvent.actorName}}这一幕的互动选择"
>
<button
wx:if="{{!(resultMode === 'correct' && activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen)}}"
class="dialog-close"
bindtap="closeDecision"
aria-label="回到画里"
>×</button>
<scroll-view class="character-story" scroll-y enhanced show-scrollbar="{{true}}">
<view class="character-story-inner">
<view class="comic-focus-visual">
<image
wx:if="{{comicImageSrc}}"
class="comic-focus-scene"
src="{{comicImageSrc}}"
mode="aspectFit"
style="{{comicFocusImageStyle}}"
></image>
<view class="comic-focus-shade"></view>
<view class="comic-focus-name">
<text>{{activePerson.eraLabel}} · 画中人物</text>
<text class="focus-name">{{activeEvent.actorName}}</text>
</view>
</view>
<text class="story-label">这一幕发生了什么</text>
<text class="action-description">{{activeEvent.actionDescription}}</text>
<view class="evidence-box">
<text class="story-label">手边证据</text>
<text>{{activeEvent.evidence}}</text>
</view>
<view class="speech-box">
<text class="story-label">桌边说话</text>
<text>{{activeEvent.speech}}</text>
</view>
</view>
</scroll-view>
<view class="judgement-side {{resultMode === '' ? 'is-choice' : 'has-result'}}">
<scroll-view
class="judgement-scroll"
scroll-y
enhanced
show-scrollbar="{{decisionResultNeedsScroll}}"
>
<view class="judgement-scroll-inner {{resultMode === '' ? 'is-choice' : 'is-result'}}">
<text class="judgement-lead">故事走到这里,您想怎么做?</text>
<text wx:if="{{resultMode === ''}}" class="judgement-question">{{activeEvent.question}}</text>
<view wx:if="{{resultMode === ''}}" class="judgement-options">
<button class="judgement-button observe" data-value="caution" bindtap="answer">
<text class="judgement-icon">A</text>
<view class="judgement-button-copy">
<text class="judgement-title">先看看现场细节</text>
</view>
</button>
<button class="judgement-button continue" data-value="safer" bindtap="answer">
<text class="judgement-icon">B</text>
<view class="judgement-button-copy">
<text class="judgement-title">继续按画里的做法</text>
</view>
</button>
</view>
<view wx:if="{{resultMode === 'retry'}}" class="result-box retry">
<text class="result-title">没关系,再看看画里的细节。</text>
<text>{{activeEvent.retryHint}}</text>
</view>
<view wx:if="{{resultMode === 'correct'}}" class="result-box correct" aria-live="polite">
<text class="result-title">这处线索对上了。</text>
<view wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="action-advice">
<text class="advice-label">先把眼前这一步做好</text>
<text>先停酒、移开危险物,并留下人陪伴。</text>
</view>
<text wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="reason">这些表现不能仅凭外观确定原因,先保证安全。</text>
<view wx:else class="action-advice">
<text class="advice-label">现在可以怎么做</text>
<text>{{activeEvent.actionAdvice}}</text>
</view>
<text wx:if="{{activeEvent.hotspotId !== 'S01-H36' || resultDetailOpen}}" class="reason">{{activeEvent.reason}}</text>
</view>
</view>
</scroll-view>
<view wx:if="{{resultMode === 'retry'}}" class="decision-footer">
<button class="decision-footer-button retry" bindtap="retryAnswer">再看一眼现场</button>
</view>
<view wx:if="{{resultMode === 'correct' && activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="decision-footer">
<button class="decision-footer-button primary" bindtap="openResultDetail">
再看不同情况怎么办
</button>
</view>
<view wx:elif="{{resultMode === 'correct'}}" class="decision-footer">
<button class="decision-footer-button primary" bindtap="continueAfterDecision">
回到画里
</button>
</view>
</view>
</view>
</view>
</view>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,135 @@
const COMIC_READER_LAYOUT_CONTRACT = Object.freeze({
contractId: 'story-first-three-frame-v1',
columns: Object.freeze({
art: 3,
content: 2,
}),
frames: Object.freeze({
front: Object.freeze({
imageMode: 'aspectFit',
initialControls: 'none',
revealGesture: 'single-tap',
revealedControls: 'topbar-only',
}),
back: Object.freeze({
art: 2,
content: 3,
imageMode: 'aspectFit',
}),
interaction: Object.freeze({
art: 2,
content: 3,
imageMode: 'aspectFit',
choiceCount: 2,
choiceDirection: 'vertical',
}),
}),
minimumControlPx: 48,
compactHeightMaxPx: 620,
fontModes: Object.freeze({
standard: 'large',
xlarge: 'xlarge',
}),
audioSlot: Object.freeze({
placement: 'unified-topbar',
minimumHeightPx: 48,
textModeLabel: '直接看文字',
readyLabel: '听一听',
playingLabel: '暂停',
replayLabel: '重听',
usesPageAudioCueIds: true,
}),
artOverlayPolicy: Object.freeze({
event: 'transparent-actor-hotspot-only',
emotion: 'transparent-actor-hotspot-only',
}),
})
const COMIC_PAGE_TEMPLATE_BY_TYPE = Object.freeze({
cover: 'chapter-ensemble',
ensemble: 'chapter-ensemble',
event: 'actor-event',
emotion: 'emotion-interaction',
memory: 'memory-cliffhanger',
})
const COMIC_PRESENTATION_BY_CHAPTER = Object.freeze({
'S01-C01': 'front-back-comic-v1',
'S01-C02': 'front-back-comic-v1',
'S01-C03': 'front-back-comic-v1',
'S01-C04': 'front-back-comic-v1',
'S01-C05': 'front-back-comic-v1',
'S01-C06': 'front-back-comic-v1',
'S01-C07': 'front-back-comic-v1',
'S01-C08': 'front-back-comic-v1',
'S01-C09': 'front-back-comic-v1',
'S01-C10': 'front-back-comic-v1',
'S01-C11': 'front-back-comic-v1',
'S01-C12': 'front-back-comic-v1',
'S01-C13': 'front-back-comic-v1',
'S01-C14': 'front-back-comic-v1',
'S01-C15': 'front-back-comic-v1',
})
function getComicPresentationMode(chapterId) {
return COMIC_PRESENTATION_BY_CHAPTER[String(chapterId || '')]
|| 'legacy-60-40-v1'
}
const COMIC_PAGE_LAYOUT_EXCEPTIONS = Object.freeze({
'S01-C09-P03': Object.freeze({
layoutVariant: 'standard-landscape-60-40',
behavior: 'key-safe-actor-hotspot',
actorHotspot: Object.freeze({ x: 16, y: 3, w: 56, h: 82 }),
reason: '人物仍可整片点击,但透明热区须止于画面下方钥匙带之前。',
}),
})
// These pages keep the full reviewed frame and add one read-only crop from
// the same asset as a second comic panel. This is an art-reading aid, not a
// new interaction or a page-layout exception.
const COMIC_FIXED_DETAIL_INSET_PAGE_IDS = Object.freeze([
'S01-C04-P04',
'S01-C09-P01',
'S01-C09-P05',
'S01-C09-P06',
'S01-C10-P07',
'S01-C13-P04',
'S01-C15-P05',
'S01-C15-P08',
])
function allowsFixedDetailInset(pageId) {
return COMIC_FIXED_DETAIL_INSET_PAGE_IDS.indexOf(
String(pageId || ''),
) >= 0
}
function getComicPageLayoutException(pageId) {
return COMIC_PAGE_LAYOUT_EXCEPTIONS[String(pageId || '')] || null
}
function resolveComicPageLayout(pageId, type) {
const templateKey = COMIC_PAGE_TEMPLATE_BY_TYPE[type] || 'chapter-ensemble'
const exception = getComicPageLayoutException(pageId)
return {
templateKey,
layoutVariant: exception
? exception.layoutVariant
: 'standard-landscape-60-40',
layoutExceptionId: exception ? String(pageId) : '',
layoutExceptionBehavior: exception ? exception.behavior : '',
}
}
module.exports = {
COMIC_READER_LAYOUT_CONTRACT,
COMIC_PRESENTATION_BY_CHAPTER,
COMIC_PAGE_TEMPLATE_BY_TYPE,
COMIC_PAGE_LAYOUT_EXCEPTIONS,
COMIC_FIXED_DETAIL_INSET_PAGE_IDS,
allowsFixedDetailInset,
getComicPresentationMode,
getComicPageLayoutException,
resolveComicPageLayout,
}
@@ -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,
}
@@ -0,0 +1,14 @@
/**
* 发布环境可覆盖这些值。
*
* cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接
* 使用包内种子图或返回文字回退,不会发起网络请求。
*/
module.exports = {
cdnBaseUrl: '',
imageCacheBudgetBytes: 28 * 1024 * 1024,
audioCacheBudgetBytes: 32 * 1024 * 1024,
audioCacheMaxEntries: 32,
prefetchAhead: 2,
downloadConcurrency: 2,
}
@@ -0,0 +1,298 @@
const { releaseAssets } = require('./releaseAssetManifest')
const PLAYABLE_VISUAL_REVIEW_STATUS = 'approved-readable'
const PROVISIONAL_VISUAL_REVIEW_STATUS = 'experience-provisional'
const RUNTIME_VISUAL_TIERS = Object.freeze({
FORMAL: 'formal',
PROVISIONAL: 'experience-provisional',
FALLBACK: 'fallback',
})
// This set deliberately mirrors `usable` in
// docs/production/art-release-gate-s01.json. Registered page art outside this
// set may be shown only as experience-provisional and never counts as formal.
const FORMAL_PAGE_ART_IDS = new Set([
'S01-C01-P01',
'S01-C01-P02',
'S01-C01-P03',
'S01-C01-P04',
'S01-C01-P05',
'S01-C01-P07',
'S01-C01-P08',
'S01-C02-P01',
'S01-C02-P02',
'S01-C02-P03',
'S01-C02-P04',
'S01-C02-P05',
'S01-C02-P06',
'S01-C02-P07',
'S01-C02-P08',
'S01-C03-P01',
'S01-C03-P02',
'S01-C03-P03',
'S01-C03-P04',
'S01-C03-P05',
'S01-C03-P07',
'S01-C03-P08',
'S01-C04-P01',
'S01-C04-P02',
'S01-C04-P03',
'S01-C04-P05',
'S01-C04-P06',
'S01-C04-P07',
'S01-C04-P08',
'S01-C05-P01',
'S01-C05-P02',
'S01-C05-P03',
'S01-C05-P04',
'S01-C05-P05',
'S01-C05-P06',
'S01-C05-P07',
'S01-C05-P08',
'S01-C06-P01',
'S01-C06-P02',
'S01-C06-P03',
'S01-C06-P04',
'S01-C06-P05',
'S01-C06-P06',
'S01-C06-P07',
'S01-C06-P08',
'S01-C07-P01',
'S01-C07-P02',
'S01-C07-P03',
'S01-C07-P04',
'S01-C07-P05',
'S01-C07-P06',
'S01-C07-P07',
'S01-C07-P08',
'S01-C08-P02',
'S01-C08-P03',
'S01-C08-P04',
'S01-C08-P05',
'S01-C08-P06',
'S01-C08-P07',
'S01-C08-P08',
'S01-C09-P02',
'S01-C09-P03',
'S01-C09-P04',
'S01-C09-P07',
'S01-C09-P08',
'S01-C10-P01',
'S01-C10-P02',
'S01-C10-P03',
'S01-C10-P04',
'S01-C10-P05',
'S01-C10-P06',
'S01-C10-P07',
'S01-C10-P08',
'S01-C11-P01',
'S01-C11-P02',
'S01-C11-P03',
'S01-C11-P04',
'S01-C11-P05',
'S01-C11-P06',
'S01-C11-P07',
'S01-C11-P08',
'S01-C12-P01',
'S01-C12-P02',
'S01-C12-P03',
'S01-C12-P04',
'S01-C12-P05',
'S01-C12-P06',
'S01-C12-P07',
'S01-C12-P08',
'S01-C13-P01',
'S01-C13-P02',
'S01-C13-P03',
'S01-C13-P05',
'S01-C13-P06',
'S01-C13-P07',
'S01-C13-P08',
'S01-C14-P01',
'S01-C14-P02',
'S01-C14-P03',
'S01-C14-P04',
'S01-C14-P05',
'S01-C14-P06',
'S01-C14-P07',
'S01-C14-P08',
'S01-C15-P01',
'S01-C15-P02',
'S01-C15-P03',
'S01-C15-P04',
'S01-C15-P05',
'S01-C15-P06',
'S01-C15-P07',
'S01-C15-P08',
])
function clean(value) {
return typeof value === 'string' ? value.trim() : ''
}
function comicAssetIdForPageId(pageId) {
const match = clean(pageId).match(/^S(\d+)-C(\d+)-P(\d+)$/)
if (!match) return ''
return `comic.s${match[1]}.c${match[2]}.p${match[3]}`
}
function registeredPageArtFor(page) {
const assetId = comicAssetIdForPageId(page && page.pageId)
const releaseAsset = assetId ? releaseAssets[assetId] : null
return Boolean(
releaseAsset
&& releaseAsset.kind === 'image'
&& clean(releaseAsset.localSeed)
&& clean(releaseAsset.localSeed) === clean(page.illustrationAsset),
)
}
function createPageArtVisual(page, chapter, runtimeTier) {
const formal = runtimeTier === RUNTIME_VISUAL_TIERS.FORMAL
return {
kind: 'page-art',
asset: clean(page.illustrationAsset),
subject: clean(page.actorName || page.headline || chapter.title),
runtimeTier,
formalReleaseEligible: formal,
reviewStatus: formal
? PLAYABLE_VISUAL_REVIEW_STATUS
: PROVISIONAL_VISUAL_REVIEW_STATUS,
reviewBasis: formal
? 'art-release-gate-s01-usable'
: 'registered-immutable-asset-experience-only',
}
}
function personByInstanceId(chapter, instanceId) {
return (chapter.people || []).find(
(person) => (
person.instanceId === instanceId
&& clean(person.portrait)
),
) || null
}
function personByName(chapter, name) {
const cleanName = clean(name)
if (!cleanName) return null
return (chapter.people || []).find(
(person) => person.name === cleanName && clean(person.portrait),
) || null
}
function featuredPersonForPage(page, chapter) {
const direct = personByInstanceId(chapter, page.actorInstanceId)
if (direct) return direct
if (page.type === 'memory') {
const memoryPerson = personByName(
chapter,
page.memoryCard && page.memoryCard.characterName,
)
if (memoryPerson) return memoryPerson
}
if (page.type === 'emotion') {
const emotionPerson = personByName(
chapter,
chapter.emotionMoment
&& chapter.emotionMoment.character
&& chapter.emotionMoment.character.name,
)
if (emotionPerson) return emotionPerson
}
return (chapter.people || []).find(
(person) => clean(person.portrait),
) || null
}
function createPlayableVisual(page, chapter) {
if (FORMAL_PAGE_ART_IDS.has(page.pageId)) {
return createPageArtVisual(page, chapter, RUNTIME_VISUAL_TIERS.FORMAL)
}
if (registeredPageArtFor(page)) {
return createPageArtVisual(
page,
chapter,
RUNTIME_VISUAL_TIERS.PROVISIONAL,
)
}
if (page.type === 'event') {
const portrait = clean(page.actorPortrait)
if (portrait) {
return {
kind: 'actor-portrait',
asset: portrait,
subject: clean(page.actorName),
actorInstanceId: clean(page.actorInstanceId),
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
formalReleaseEligible: false,
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
reviewBasis: 'season-character-continuity-sheet',
}
}
return {
kind: 'evidence-composite',
asset: clean(page.fallbackAsset),
subject: clean(page.actorName),
evidenceLabel: clean(page.headline || '看看手边证据'),
evidenceDetail: clean(
page.secondaryCaption || page.caption || page.question,
),
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
formalReleaseEligible: false,
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
reviewBasis: 'chapter-era-scene-with-reviewed-evidence-copy',
}
}
const featuredPerson = featuredPersonForPage(page, chapter)
if (featuredPerson) {
return {
kind: 'chapter-character',
asset: clean(featuredPerson.portrait),
subject: clean(featuredPerson.name),
actorInstanceId: clean(featuredPerson.instanceId),
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
formalReleaseEligible: false,
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
reviewBasis: 'season-character-continuity-sheet',
}
}
// Defensive last resort. The season currently never reaches this branch,
// but if a future chapter lacks a portrait it still gets explicit evidence
// instead of an unexplained empty room.
return {
kind: 'evidence-composite',
asset: clean(page.fallbackAsset),
subject: clean(page.headline || chapter.title),
evidenceLabel: clean(page.headline || chapter.title),
evidenceDetail: clean(page.caption || chapter.narration),
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
formalReleaseEligible: false,
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
reviewBasis: 'chapter-era-scene-with-reviewed-evidence-copy',
}
}
function attachPlayableVisuals(pageSequence, chapter) {
return (Array.isArray(pageSequence) ? pageSequence : []).map((page) => ({
...page,
playableVisual: createPlayableVisual(page, chapter),
}))
}
module.exports = {
FORMAL_PAGE_ART_IDS,
PLAYABLE_VISUAL_REVIEW_STATUS,
PROVISIONAL_VISUAL_REVIEW_STATUS,
RUNTIME_VISUAL_TIERS,
attachPlayableVisuals,
createPlayableVisual,
}
@@ -0,0 +1,2 @@
// Generated package-local production comic data. Do not hand-edit.
module.exports = {}
@@ -0,0 +1,911 @@
/**
* 发布固定资产清单 v1
*
* 约束:
* 1. 页面只保存稳定的 assetId,不直接拼 CDN 地址。
* 2. remotePath 必须包含对应文件内容哈希的前 12 位,发布后不可覆盖。
* 3. 医学结论、判断答案和行动建议必须随代码包发布,不得放进远端资产。
* 4. localSeed 是当前小程序包内的安全回退;以后迁移 CDN 时可按资产逐项移除。
*/
const RELEASE_ASSET_MANIFEST_VERSION = 1
const releaseAssets = {
'comic.s01.c01.p01': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P01-title-v1.12163b7f4667.jpg',
sha256: '12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a',
},
'comic.s01.c01.p02': {
kind: 'image',
localSeed: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.f34fe1faf167.jpg',
sha256: 'f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0',
},
'comic.s01.c01.p03': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P03-H01-scan-only-v1.6e0d20c4055f.jpg',
sha256: '6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80',
},
'comic.s01.c01.p04': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.bcef17dda041.jpg',
sha256: 'bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce',
},
'comic.s01.c01.p05': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.d2e1becd9c12.jpg',
sha256: 'd2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f',
},
'comic.s01.c01.p06': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.7bc229ef1f1c.jpg',
sha256: '7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750',
},
'comic.s01.c01.p07': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.4a19c49a53ea.jpg',
sha256: '4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31',
},
'comic.s01.c01.p08': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P08-cliffhanger-v1.7db63c3725ee.jpg',
sha256: '7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69',
},
'comic.s01.c02.p01': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P01-title-v1.c32093483c58.jpg',
sha256: 'c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552',
},
'comic.s01.c02.p02': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.892de611f097.jpg',
sha256: '892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea',
},
'comic.s01.c02.p03': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.e725c333849e.jpg',
sha256: 'e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c',
},
'comic.s01.c02.p04': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.ac89e35c3692.jpg',
sha256: 'ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593',
},
'comic.s01.c02.p05': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.cda630c13f31.jpg',
sha256: 'cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d',
},
'comic.s01.c02.p06': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.61d413562200.jpg',
sha256: '61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541',
},
'comic.s01.c02.p07': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.c7a28411b1d7.jpg',
sha256: 'c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539',
},
'comic.s01.c02.p08': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P08-clock-to-1978-v1.6c0eed6b964e.jpg',
sha256: '6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703',
},
'comic.s01.c03.p01': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P01-title-v1.7aaefa1ff157.jpg',
sha256: '7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4',
},
'comic.s01.c03.p02': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.feb9f3243262.jpg',
sha256: 'feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5',
},
'comic.s01.c03.p03': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.841146ea446b.jpg',
sha256: '841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e',
},
'comic.s01.c03.p04': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.8a838a7d0bac.jpg',
sha256: '8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727',
},
'comic.s01.c03.p05': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.cbd7d426ee42.jpg',
sha256: 'cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79',
},
'comic.s01.c03.p06': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P06-H12-two-clips-v1.00e5034f636a.jpg',
sha256: '00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1',
},
'comic.s01.c03.p07': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.787bf794fd53.jpg',
sha256: '787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602',
},
'comic.s01.c03.p08': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.056ef8b3017c.jpg',
sha256: '056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5',
},
'comic.s01.c04.p01': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P01-title-v1.e20d8bd83bfb.jpg',
sha256: 'e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30',
},
'comic.s01.c04.p02': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P02-ensemble-v1.d83c35c61a7c.jpg',
sha256: 'd83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505',
},
'comic.s01.c04.p03': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.c90b76c43463.jpg',
sha256: 'c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4',
},
'comic.s01.c04.p04': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P04-H14-belt-table-v1.70da80c5dd1d.jpg',
sha256: '70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7',
},
'comic.s01.c04.p05': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.4f4212b949b9.jpg',
sha256: '4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a',
},
'comic.s01.c04.p06': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.25834d3e9082.jpg',
sha256: '25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2',
},
'comic.s01.c04.p07': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.3f741e4ac31c.jpg',
sha256: '3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d',
},
'comic.s01.c04.p08': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P08-cliffhanger-v1.9558ee49f2e2.jpg',
sha256: '9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61',
},
'comic.s01.c05.p01': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P01-title-v1.52a79ac178e6.jpg',
sha256: '52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef',
},
'comic.s01.c05.p02': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.897aa558fe7d.jpg',
sha256: '897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb',
},
'comic.s01.c05.p03': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P03-H17-empty-window-v1.76ac18af26da.jpg',
sha256: '76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22',
},
'comic.s01.c05.p04': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P04-H18-half-bun-v1.c9506ed1fe98.jpg',
sha256: 'c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c',
},
'comic.s01.c05.p05': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.a88bbf597302.jpg',
sha256: 'a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8',
},
'comic.s01.c05.p06': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.63adf03d0d30.jpg',
sha256: '63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca',
},
'comic.s01.c05.p07': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.172fc27a2252.jpg',
sha256: '172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567',
},
'comic.s01.c05.p08': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P08-first-photo-v1.b85b2852a7dc.jpg',
sha256: 'b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f',
},
'comic.s01.c06.p01': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P01-title-v1.f7039560e49d.jpg',
sha256: 'f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40',
},
'comic.s01.c06.p02': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.0b4519763ba5.jpg',
sha256: '0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae',
},
'comic.s01.c06.p03': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.caf57f377be3.jpg',
sha256: 'caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75',
},
'comic.s01.c06.p04': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.f05b92fd22c2.jpg',
sha256: 'f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469',
},
'comic.s01.c06.p05': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.408f43b54b2b.jpg',
sha256: '408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d',
},
'comic.s01.c06.p06': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P06-H24-protect-table-v1.84156df566a6.jpg',
sha256: '84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53',
},
'comic.s01.c06.p07': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.c61d898fd982.jpg',
sha256: 'c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b',
},
'comic.s01.c06.p08': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.bcb8ec7e4720.jpg',
sha256: 'bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5',
},
'comic.s01.c07.p01': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P01-title-v1.42031fff6b37.jpg',
sha256: '42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720',
},
'comic.s01.c07.p02': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P02-opening-ensemble-v1.81e28334e743.jpg',
sha256: '81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2',
},
'comic.s01.c07.p03': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.fb9d1cd9910d.jpg',
sha256: 'fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5',
},
'comic.s01.c07.p04': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.8e12ab04091a.jpg',
sha256: '8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3',
},
'comic.s01.c07.p05': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.5d65745996d6.jpg',
sha256: '5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4',
},
'comic.s01.c07.p06': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P06-H28-shift-table-v1.7365643f439b.jpg',
sha256: '7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a',
},
'comic.s01.c07.p07': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.19ea61163655.jpg',
sha256: '19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5',
},
'comic.s01.c07.p08': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.f7f84ebecba2.jpg',
sha256: 'f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e',
},
'comic.s01.c08.p01': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P01-title-v1.b292709d6f22.jpg',
sha256: 'b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc',
},
'comic.s01.c08.p02': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P02-opening-ensemble-v1.4b755190ba3d.jpg',
sha256: '4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562',
},
'comic.s01.c08.p03': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.2a5bc840cc2d.jpg',
sha256: '2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c',
},
'comic.s01.c08.p04': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.737e9996a8f3.jpg',
sha256: '737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935',
},
'comic.s01.c08.p05': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.41da420b4e7b.jpg',
sha256: '41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618',
},
'comic.s01.c08.p06': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.fb66a9f2234c.jpg',
sha256: 'fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111',
},
'comic.s01.c08.p07': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.6c748cf7c1a6.jpg',
sha256: '6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea',
},
'comic.s01.c08.p08': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.2deaee916f92.jpg',
sha256: '2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743',
},
'comic.s01.c09.p01': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P01-opening-memory-v1.59db94b9c5dd.jpg',
sha256: '59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f',
},
'comic.s01.c09.p02': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P02-opening-ensemble-v1.0584053b2e87.jpg',
sha256: '0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d',
},
'comic.s01.c09.p03': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.9081d9c71e8f.jpg',
sha256: '9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af',
},
'comic.s01.c09.p04': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.cf00e6434a90.jpg',
sha256: 'cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff',
},
'comic.s01.c09.p05': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg',
remotePath: 'comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.5de6f5b46666.jpg',
sha256: '5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a',
},
'comic.s01.c09.p06': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.5bb10be0addb.jpg',
sha256: '5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443',
},
'comic.s01.c09.p07': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.9f4b4b8b1aa9.jpg',
sha256: '9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f',
},
'comic.s01.c09.p08': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg',
remotePath: 'comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.b3628d1471f2.jpg',
sha256: 'b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724',
},
'comic.s01.c10.p01': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P01-one-palm-space-v1.42d2b657a20f.jpg',
sha256: '42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8',
},
'comic.s01.c10.p02': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P02-opening-ensemble-v1.30c7fa3f8e9a.jpg',
sha256: '30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55',
},
'comic.s01.c10.p03': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.23e14923a5fd.jpg',
sha256: '23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9',
},
'comic.s01.c10.p04': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg',
remotePath: 'comic/s01-c10/S01-C10-P04-H38-open-palm-v3.be147c9d7c0c.jpg',
sha256: 'be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde',
},
'comic.s01.c10.p05': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.a21bec0d89b6.jpg',
sha256: 'a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd',
},
'comic.s01.c10.p06': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.6fa3594b0e5a.jpg',
sha256: '6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446',
},
'comic.s01.c10.p07': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.139439432818.jpg',
sha256: '13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99',
},
'comic.s01.c10.p08': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.2499342b7c3c.jpg',
sha256: '2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0',
},
'comic.s01.c11.p01': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P01-pen-above-paper-v1.840b82256bca.jpg',
sha256: '840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a',
},
'comic.s01.c11.p02': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P02-opening-ensemble-v1.112f4fe7b4f9.jpg',
sha256: '112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1',
},
'comic.s01.c11.p03': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.23ef59f3f23d.jpg',
sha256: '23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51',
},
'comic.s01.c11.p04': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.3e93fafafe75.jpg',
sha256: '3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50',
},
'comic.s01.c11.p05': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.c0c8c01f4e37.jpg',
sha256: 'c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5',
},
'comic.s01.c11.p06': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.4a805d19bb22.jpg',
sha256: '4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c',
},
'comic.s01.c11.p07': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.9fb66ad32712.jpg',
sha256: '9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a',
},
'comic.s01.c11.p08': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P08-can-we-open-now-v1.b80149fb0407.jpg',
sha256: 'b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de',
},
'comic.s01.c12.p01': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.e7f7138b21bb.jpg',
sha256: 'e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a',
},
'comic.s01.c12.p02': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.6d2ba8cf6cfe.jpg',
sha256: '6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602',
},
'comic.s01.c12.p03': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.c07a27557685.jpg',
sha256: 'c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9',
},
'comic.s01.c12.p04': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.1a5c07f21eae.jpg',
sha256: '1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e',
},
'comic.s01.c12.p05': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.695c7ebf0573.jpg',
sha256: '695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0',
},
'comic.s01.c12.p06': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.9b332e2a0d6d.jpg',
sha256: '9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76',
},
'comic.s01.c12.p07': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.338240b0bc54.jpg',
sha256: '338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5',
},
'comic.s01.c12.p08': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.a6aeee055eed.jpg',
sha256: 'a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f',
},
'comic.s01.c13.p01': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.2184f354e70f.jpg',
sha256: '2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e',
},
'comic.s01.c13.p02': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P02-opening-ensemble-v1.a9abf81e15a6.jpg',
sha256: 'a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198',
},
'comic.s01.c13.p03': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.13813c3961f7.jpg',
sha256: '13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b',
},
'comic.s01.c13.p04': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.d4002bf11c74.jpg',
sha256: 'd4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d',
},
'comic.s01.c13.p05': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.2c5923ca40e2.jpg',
sha256: '2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff',
},
'comic.s01.c13.p06': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.8ccbb50e6342.jpg',
sha256: '8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d',
},
'comic.s01.c13.p07': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.edac7d0ccb9f.jpg',
sha256: 'edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b',
},
'comic.s01.c13.p08': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.f40996a2f7d0.jpg',
sha256: 'f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc',
},
'comic.s01.c14.p01': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.6609c450b024.jpg',
sha256: '6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2',
},
'comic.s01.c14.p02': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.15678b028566.jpg',
sha256: '15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48',
},
'comic.s01.c14.p03': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.9d1a6e4c130a.jpg',
sha256: '9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365',
},
'comic.s01.c14.p04': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.899c26b37f6a.jpg',
sha256: '899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e',
},
'comic.s01.c14.p05': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.b87841f55cc9.jpg',
sha256: 'b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801',
},
'comic.s01.c14.p06': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.c17546b0c7e9.jpg',
sha256: 'c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd',
},
'comic.s01.c14.p07': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.d0bcbbb428bc.jpg',
sha256: 'd0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d',
},
'comic.s01.c14.p08': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.09f5a22c8a7f.jpg',
sha256: '09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c',
},
'comic.s01.c15.p01': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.194fda1fa427.jpg',
sha256: '194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab',
},
'comic.s01.c15.p02': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.b0e82af85954.jpg',
sha256: 'b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b',
},
'comic.s01.c15.p03': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.b3838eb91bea.jpg',
sha256: 'b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173',
},
'comic.s01.c15.p04': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.2471d7dd78aa.jpg',
sha256: '2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea',
},
'comic.s01.c15.p05': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.215691878476.jpg',
sha256: '21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a',
},
'comic.s01.c15.p06': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.3f1cd9abb08e.jpg',
sha256: '3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50',
},
'comic.s01.c15.p07': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.98a32b939746.jpg',
sha256: '98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98',
},
'comic.s01.c15.p08': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg',
remotePath: 'comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.520d68ce0a72.jpg',
sha256: '520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113',
},
'character.female-cook': {
kind: 'image',
localSeed: '/assets/characters/female-cook.jpg',
remotePath: 'characters/female-cook.c56420b2c632.jpg',
sha256: 'c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f',
},
'character.lele': {
kind: 'image',
localSeed: '/assets/characters/lele.jpg',
remotePath: 'characters/lele.807f5232b1e8.jpg',
sha256: '807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168',
},
'character.lin-xiulan': {
kind: 'image',
localSeed: '/assets/characters/lin-xiulan.jpg',
remotePath: 'characters/lin-xiulan.56e84abec4e5.jpg',
sha256: '56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361',
},
'character.qin-xiaoman': {
kind: 'image',
localSeed: '/assets/characters/qin-xiaoman.jpg',
remotePath: 'characters/qin-xiaoman.22fa229d5b09.jpg',
sha256: '22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5',
},
'character.qin-zhicheng': {
kind: 'image',
localSeed: '/assets/characters/qin-zhicheng.jpg',
remotePath: 'characters/qin-zhicheng.7f4be48ece92.jpg',
sha256: '7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2',
},
'character.tang-mingyuan': {
kind: 'image',
localSeed: '/assets/characters/tang-mingyuan.jpg',
remotePath: 'characters/tang-mingyuan.5ca2fa88f885.jpg',
sha256: '5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb',
},
'character.tang-shouan': {
kind: 'image',
localSeed: '/assets/characters/tang-shouan.jpg',
remotePath: 'characters/tang-shouan.4ed8d25ca911.jpg',
sha256: '4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89',
},
'character.xiaozhen': {
kind: 'image',
localSeed: '/assets/characters/xiaozhen.jpg',
remotePath: 'characters/xiaozhen.d5e9614f6006.jpg',
sha256: 'd5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb',
},
'character.zhao-jianguo': {
kind: 'image',
localSeed: '/assets/characters/zhao-jianguo.jpg',
remotePath: 'characters/zhao-jianguo.1c393fb68b02.jpg',
sha256: '1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0',
},
'scene.gui-xiang.1978': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-1978.jpg',
remotePath: 'scenes/gui-xiang-1978.bb21d9342e37.jpg',
sha256: 'bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5',
},
'scene.gui-xiang.1995': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-1995.jpg',
remotePath: 'scenes/gui-xiang-1995.a9701e2d55ad.jpg',
sha256: 'a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f',
},
'scene.gui-xiang.1998': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-1998.jpg',
remotePath: 'scenes/gui-xiang-1998.d4d7bebfd04a.jpg',
sha256: 'd4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14',
},
'scene.gui-xiang.2001': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-2001.jpg',
remotePath: 'scenes/gui-xiang-2001.9143455b349d.jpg',
sha256: '9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8',
},
'scene.gui-xiang.2003': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-2003.jpg',
remotePath: 'scenes/gui-xiang-2003.68349c74c916.jpg',
sha256: '68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431',
},
'scene.gui-xiang.2008': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-2008.jpg',
remotePath: 'scenes/gui-xiang-2008.c0fd9418aec2.jpg',
sha256: 'c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5',
},
'scene.gui-xiang.2026': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-2026.jpg',
remotePath: 'scenes/gui-xiang-2026.55e63e85388e.jpg',
sha256: '55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7',
},
'audio.cast-audition.v1': {
kind: 'audio',
reviewStatus: 'audition',
localSeed: '',
remotePath: 'audio/cast-audition-v1.7129d884f445.mp3',
sha256: '7129d884f44542434e131efb86f2f5619bedf188a1d8b21e7d0864131b58dd18',
},
}
// Legacy C01 cue-level seeds are retained as immutable backup assets only.
// The reader no longer maps them as page narration; C01 uses full-page tracks
// through the two package-local audio player pages instead.
for (const cueId of [
'S01-C01-MS007','S01-C01-MS010',
]) {
const file = `${cueId}.mp3`
const sha256 = {
'S01-C01-MS007': '1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8', 'S01-C01-MS010': '628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597',
}[cueId]
releaseAssets[`audio.s01.c01.${cueId.toLowerCase()}`] = { kind: 'audio', reviewStatus: 'approved', localSeed: `/package-game/assets/audio/${file}`, remotePath: `audio/${file}.${sha256.slice(0, 12)}.mp3`, sha256 }
}
const audioC02Sha256 = {
'S01-C02-MS001': '580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233',
'S01-C02-MS002-ATTR': 'aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8',
'S01-C02-MS002': 'ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe',
'S01-C02-MS003': '3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f',
'S01-C02-MS004': 'e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c',
'S01-C02-MS005': '6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e',
'S01-C02-MS006': '067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88',
'S01-C02-MS007': '7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c',
'S01-C02-MS008': '288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f',
'S01-C02-MS009': '5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2',
'S01-C02-MS010': 'd371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d',
'S01-C02-MS011': '2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb',
'S01-C02-MS012': '33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a',
'S01-C02-MT000': '5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e',
'S01-C02-TE900': 'd57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1',
}
const audioC03Sha256 = {
'S01-C03-MS001': 'b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43',
'S01-C03-MS002-ATTR': 'ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd',
'S01-C03-MS002': 'c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c',
'S01-C03-MS003': 'af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184',
'S01-C03-MS004': 'c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da',
'S01-C03-MS005-ATTR': '4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e',
'S01-C03-MS005': '46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442',
'S01-C03-MS006': '2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5',
'S01-C03-MS007-ATTR': 'f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0',
'S01-C03-MS007': '68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038',
'S01-C03-MS008': 'd9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7',
'S01-C03-MS009': '97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed',
'S01-C03-MS010': '49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0',
'S01-C03-MS011': '6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25',
'S01-C03-MS013': '8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a',
'S01-C03-MS014': '8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0',
'S01-C03-MS015': '661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb',
'S01-C03-MT000': '374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65',
'S01-C03-TE900': 'f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993',
}
// C02/C03 cue files are retained only as rollback/source material. A single
// cue is not a complete page track, so these assets must not pass the runtime
// audio gate or appear as “试听本页”.
for (const [chapterNumber, audioHashes] of Object.entries({ '02': audioC02Sha256, '03': audioC03Sha256 })) {
for (const [cueId, sha256] of Object.entries(audioHashes)) {
const file = `${cueId}.mp3`
releaseAssets[`audio.s01.c${chapterNumber}.${cueId.toLowerCase()}`] = {
kind: 'audio',
reviewStatus: 'reserved',
localSeed: `/package-chapter-${chapterNumber}/assets/audio/${file}`,
remotePath: `audio/${cueId}.${sha256.slice(0, 12)}.mp3`,
sha256,
}
}
}
module.exports = {
RELEASE_ASSET_MANIFEST_VERSION,
releaseAssets,
}
@@ -0,0 +1,97 @@
/**
* C02-C15 remote full-page audio manifest v1.
*
* Import contract for a future reviewed batch:
*
* 'S01-C09-P05': Object.freeze({
* pageId: 'S01-C09-P05',
* chapterNumber: 9,
* pageNumber: 5,
* assetId: 'audio.page.s01.c09.p05',
* durationSeconds: 42.1,
* reviewStatus: 'approved',
* })
*
* The matching releaseAssetManifest entry must independently be an approved,
* remote-only, immutable hash-named audio asset. Automated QA, pending human
* listening, and missing CDN records must never be relabelled as approved.
*/
const REMOTE_PAGE_AUDIO_MANIFEST_VERSION = 1
const REMOTE_PAGE_AUDIO_ID_PATTERN = /^S01-C(0[2-9]|1[0-5])-P(0[1-8])$/
const SHA256_PATTERN = /^[a-f0-9]{64}$/
// Deliberately empty until full-page tracks pass the required human reviews
// and their immutable CDN objects are available. This is a release gate, not
// a placeholder that the UI may treat as playable.
const remotePageAudioPages = Object.freeze({})
function clean(value) {
return typeof value === 'string' ? value.trim() : ''
}
function remotePageAudioAssetId(pageId) {
const stablePageId = clean(pageId)
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
if (!match) return ''
return `audio.page.s01.c${match[1]}.p${match[2]}`
}
function getApprovedRemotePageAudio(
pageId,
releaseAssets = {},
pageManifest = remotePageAudioPages,
) {
const stablePageId = clean(pageId)
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
if (!match) return null
const entry = pageManifest && pageManifest[stablePageId]
if (!entry || typeof entry !== 'object') return null
const chapterNumber = Number(match[1])
const pageNumber = Number(match[2])
const assetId = remotePageAudioAssetId(stablePageId)
const durationSeconds = Number(entry.durationSeconds)
if (
clean(entry.reviewStatus).toLowerCase() !== 'approved'
|| entry.pageId !== stablePageId
|| Number(entry.chapterNumber) !== chapterNumber
|| Number(entry.pageNumber) !== pageNumber
|| entry.assetId !== assetId
|| !Number.isFinite(durationSeconds)
|| durationSeconds <= 0
) {
return null
}
const asset = releaseAssets && releaseAssets[assetId]
const sha256 = asset && typeof asset.sha256 === 'string'
? asset.sha256
: ''
const remotePath = asset && asset.remotePath
const expectedRemotePath = (
`audio/${stablePageId.slice(0, 7).toLowerCase()}`
+ `/${stablePageId}.${sha256.slice(0, 12)}.mp3`
)
if (
!asset
|| asset.kind !== 'audio'
|| clean(asset.reviewStatus).toLowerCase() !== 'approved'
|| asset.localSeed !== ''
|| !SHA256_PATTERN.test(sha256)
|| typeof remotePath !== 'string'
|| remotePath !== expectedRemotePath
) {
return null
}
return { ...entry, assetId }
}
module.exports = {
REMOTE_PAGE_AUDIO_ID_PATTERN,
REMOTE_PAGE_AUDIO_MANIFEST_VERSION,
getApprovedRemotePageAudio,
remotePageAudioAssetId,
remotePageAudioPages,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
{
"navigationBarTitleText": "唐侦探",
"pageOrientation": "landscape",
"disableScroll": true
}
@@ -0,0 +1,932 @@
<view
wx:if="{{chapter}}"
class="chapter-page font-{{fontScale}} {{compactHeight ? 'compact-height' : ''}}"
style="{{pageStyle}}"
>
<view class="comic-topbar {{storyFirstComicMode ? ((chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct')) ? 'c1-book-topbar is-back' : ('c1-book-topbar is-front ' + (frontControlsRevealed ? 'is-controls-revealed' : 'is-controls-hidden'))) : ''}}" style="{{topbarStyle}}">
<block wx:if="{{!storyFirstComicMode || chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct') || frontControlsRevealed}}">
<button class="comic-catalog-button" bindtap="goCatalog" aria-label="返回目录"> 目录</button>
<button
wx:if="{{storyFirstComicMode && !comicPreviousDisabled}}"
class="comic-top-previous-button"
bindtap="previousComicPage"
aria-label="上一张画"
></button>
<view class="comic-bookmark">
<text class="comic-bookmark-label">
第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}}
</text>
<text class="comic-page-count">{{currentPageNumber}}{{comicPageCount}}</text>
</view>
<button
wx:if="{{!storyFirstComicMode && !currentPage.audioAvailable && !currentPage.audioCompanionAvailable}}"
class="comic-page-audio-slot is-text-mode {{storyFirstComicMode ? 'c1-audio-slot' : ''}}"
bindtap="openTextReading"
aria-label="本页可以直接看文字"
>
<text class="comic-page-audio-icon" aria-hidden="true">阅</text>
<view class="comic-page-audio-copy">
<text class="comic-page-audio-label">直接看文字</text>
<text class="comic-page-audio-meta">按页慢慢看</text>
</view>
</button>
<button
wx:elif="{{currentPage.audioAvailable || currentPage.audioCompanionAvailable}}"
class="comic-page-audio-slot {{storyFirstComicMode ? 'c1-audio-slot' : ''}} {{audioLoading ? 'is-loading' : audioPlaying ? 'is-playing' : audioPercent > 0 ? 'is-replay' : 'is-ready'}}"
disabled="{{audioLoading}}"
bindtap="toggleComicPageAudio"
data-audio-cue-count="{{currentPage.audioCueCount}}"
aria-label="本页语音播放控制"
>
<text class="comic-page-audio-icon" aria-hidden="true">
{{audioPlaying ? 'Ⅱ' : '▶'}}
</text>
<view class="comic-page-audio-copy">
<text wx:if="{{audioLoading}}" class="comic-page-audio-label">准备中</text>
<text wx:elif="{{audioPlaying}}" class="comic-page-audio-label">暂停</text>
<text wx:elif="{{audioPercent > 0}}" class="comic-page-audio-label">重听</text>
<text wx:else class="comic-page-audio-label">听一听</text>
<text class="comic-page-audio-meta">
{{audioCurrent}} / {{audioDuration === '0:00' ? currentPage.audioDurationLabel : audioDuration}}
</text>
</view>
</button>
<button
wx:if="{{!storyFirstComicMode || chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct')}}"
class="comic-font-button"
bindtap="toggleFont"
aria-label="{{fontScale === 'large' ? '把整页文字再放大' : '恢复标准大字'}}"
>
{{fontScale === 'large' ? '大字' : '标准字'}}
</button>
<button
wx:if="{{storyFirstComicMode && !chapterOneBackOpen && !decisionOpen && !chapterOneEmotionOpen}}"
class="c1-front-action-button"
bindtap="openCurrentFrontAction"
aria-label="{{currentPage.type === 'event'
? (currentEventCompleted ? '点一下,再看背面' : '点点人物,翻到背面')
: currentPage.type === 'emotion'
? (chapterFinished ? '点一下,看看章末' : '点一下,听听心里话')
: '点一下,翻到背面'}}"
>
{{currentPage.type === 'event'
? (currentEventCompleted ? '再看背面' : '点点人物')
: currentPage.type === 'emotion'
? (chapterFinished ? '看看章末' : '听听心里话')
: '翻到背面'}}
</button>
</block>
</view>
<view class="comic-reader">
<block wx:if="{{storyFirstComicMode}}">
<view class="c1-comic-page {{chapterOneBackOpen ? 'is-back' : 'is-front'}}">
<block wx:if="{{chapterOneBackOpen}}">
<view class="c1-back-paper">
<view class="c1-back-evidence">
<image
wx:if="{{comicImageSrc}}"
class="c1-back-image"
src="{{comicImageSrc}}"
mode="aspectFit"
></image>
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
<text class="c1-back-stamp">
{{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}}
</text>
</view>
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
<view wx:if="{{currentPage.type === 'memory'}}" class="c1-back-copy-inner">
<text class="c1-back-kicker">第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆</text>
<text class="c1-back-title">{{currentPage.headline}}</text>
<text class="c1-back-body">{{currentPage.caption}}</text>
<view class="c1-back-callout">
<text class="c1-back-label">桌边回声</text>
<text>{{currentPage.memoryCard.tableEcho}}</text>
</view>
<view class="c1-back-action">
<text class="c1-back-label">带回生活里</text>
<text>{{currentPage.memoryCard.lifeAction}}</text>
</view>
<text class="c1-back-family">“{{currentPage.memoryCard.familyLine}}”</text>
<view class="c1-memory-secondary-actions">
<button
class="c1-memory-secondary-action"
bindtap="openLifeReport"
aria-label="查看本回桂香生活观察"
>看看生活观察</button>
<button
class="c1-memory-secondary-action"
open-type="share"
aria-label="发给家人,一起聊聊这一页"
>发给家人聊聊</button>
</view>
</view>
<view wx:elif="{{currentPage.type === 'event' && currentEventCompleted && chapterOneReviewEvent}}" class="c1-back-copy-inner">
<text class="c1-back-kicker">{{currentPage.actorName}} · 刚才看见的</text>
<text class="c1-back-title">{{currentPage.headline}}</text>
<text class="c1-back-body">{{chapterOneReviewEvent.actionDescription}}</text>
<view class="c1-back-callout">
<text class="c1-back-label">您刚才瞧见的</text>
<text>{{chapterOneReviewEvent.evidence}}</text>
</view>
<view class="c1-back-action">
<text class="c1-back-label">下回遇到这事</text>
<text>{{chapterOneReviewEvent.actionAdvice}}</text>
</view>
</view>
<view wx:elif="{{currentPage.type === 'event' && chapterOneReviewEvent}}" class="c1-back-copy-inner">
<text class="c1-back-kicker">{{currentPage.actorName}} · 这回事</text>
<text class="c1-back-title">{{currentPage.headline}}</text>
<text class="c1-back-body">{{currentPage.caption}}</text>
<view wx:if="{{currentPage.shortDialogue}}" class="c1-back-callout">
<text class="c1-back-label">桌边一句话</text>
<text>“{{currentPage.shortDialogue}}”</text>
</view>
</view>
<view wx:else class="c1-back-copy-inner">
<text class="c1-back-kicker">{{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}}</text>
<text class="c1-back-title">{{currentPage.headline}}</text>
<text class="c1-back-body">{{currentPage.displayCaption}}</text>
<text wx:if="{{currentPage.secondaryCaption}}" class="c1-back-secondary">{{currentPage.secondaryCaption}}</text>
</view>
</scroll-view>
<text
wx:if="{{currentPage.type === 'memory' || (currentPage.type === 'event' && currentEventCompleted)}}"
class="c1-scroll-hint"
>上划接着看 ↑</text>
<view wx:if="{{currentPage.type === 'memory'}}" class="c1-back-footer c1-memory-footer">
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneBack">回去看画</button>
<button wx:if="{{chapterNumber >= chapterCount}}" class="c1-back-button primary" bindtap="goCatalog">回到目录</button>
<button wx:else class="c1-back-button primary" bindtap="nextChapter">翻下一回</button>
</view>
<view wx:else class="c1-back-footer">
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneBack">回去看画</button>
<button
wx:if="{{currentPage.type === 'event' && !currentEventCompleted}}"
class="c1-back-button primary"
bindtap="startChapterOneDecisionFromBack"
>聊聊这事</button>
<button
wx:else
class="c1-back-button primary"
bindtap="nextChapterOnePageFromBack"
>翻下一张</button>
</view>
</view>
</block>
<block wx:elif="{{currentPage.type === 'emotion' && chapterOneEmotionOpen && emotionResult}}">
<view class="c1-back-paper">
<view class="c1-back-evidence">
<image
wx:if="{{comicImageSrc}}"
class="c1-back-image"
src="{{comicImageSrc}}"
mode="aspectFit"
></image>
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
<text class="c1-back-stamp">桌边回声</text>
</view>
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
<view class="c1-back-copy-inner">
<text class="c1-back-kicker">没有标准答案</text>
<text class="c1-back-title">{{currentPage.headline}}</text>
<text class="c1-back-body">{{emotionResult.feedback}}</text>
<view class="c1-back-callout">
<text class="c1-back-label">这一桌记住了</text>
<text>{{emotionResult.echo}}</text>
</view>
</view>
</scroll-view>
<view class="c1-back-footer">
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneEmotion">回去看画</button>
<button class="c1-back-button primary" bindtap="finishChapterOneEmotionAndTurn">收下这句话</button>
</view>
</view>
</block>
<block wx:elif="{{currentPage.type === 'emotion' && chapterOneEmotionOpen && !emotionResult}}">
<view class="c1-interaction-paper c1-emotion-interaction-paper">
<view class="c1-interaction-evidence">
<image
wx:if="{{comicImageSrc}}"
class="c1-interaction-image"
src="{{comicImageSrc}}"
mode="aspectFit"
></image>
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
<text class="c1-back-stamp">桌边一句话</text>
</view>
<view class="c1-interaction-panel">
<button class="c1-tray-close" bindtap="closeChapterOneEmotion" aria-label="收起选择,先回画面">先看画</button>
<view class="c1-tray-heading">
<text class="c1-tray-kicker">这一刻,您想怎么说?</text>
<text class="c1-tray-question">{{currentPage.prompt}}</text>
</view>
<view class="c1-choice-stack">
<button
wx:for="{{chapter.emotionMoment.playerChoices}}"
wx:key="choiceId"
class="c1-natural-choice"
data-choice="{{item.choiceId}}"
bindtap="chooseEmotion"
>{{item.text}}</button>
</view>
</view>
</view>
</block>
<block wx:else>
<view class="c1-front-stage">
<image
wx:if="{{comicImageSrc}}"
class="c1-front-image"
src="{{comicImageSrc}}"
mode="aspectFit"
bindload="onComicImageLoad"
binderror="onComicImageError"
></image>
<view wx:else class="c1-front-fallback">
<text>{{comicImageError}}</text>
<text>{{currentPage.sceneAlt}}</text>
</view>
<view class="c1-front-grain"></view>
<button
wx:if="{{!frontControlsRevealed}}"
class="c1-front-reveal-gate"
bindtap="revealCurrentFrontControls"
aria-label="轻点画面,显示阅读按钮"
></button>
<block wx:else>
<button
wx:if="{{frontControlsRevealed && currentInteractionEnabled && currentPage.type === 'event' && !comicImageUsingFallback}}"
class="c1-person-hotspot"
style="{{currentPage.actorHotspot.style}}"
bindtap="openComicActor"
aria-label="点点{{currentPage.actorName}},翻到这一幕背面"
><view class="c1-hotspot-ring"></view></button>
<button
wx:elif="{{frontControlsRevealed && currentPage.type === 'event' && currentEventCompleted}}"
class="c1-full-page-tap"
bindtap="openChapterOneBack"
aria-label="翻到这一幕的背面"
></button>
<button
wx:elif="{{frontControlsRevealed && currentPage.type === 'emotion'}}"
class="c1-full-page-tap"
bindtap="openChapterOneEmotion"
aria-label="{{chapterFinished ? '轻触画面,翻到章末' : '轻触画面,听听这一桌心里话'}}"
></button>
<button
wx:else
class="c1-full-page-tap"
bindtap="openChapterOneBack"
aria-label="轻触画面,翻到这一页背面"
></button>
</block>
<text wx:if="{{frontHintVisible}}" class="c1-front-first-hint">轻点画面,按钮就出来了</text>
</view>
</block>
</view>
</block>
<block wx:elif="{{currentPage.type === 'emotion'}}">
<view class="comic-special-page emotion-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}">
<view class="special-art">
<image
wx:if="{{comicImageSrc}}"
class="special-art-image"
src="{{comicImageSrc}}"
mode="aspectFit"
bindload="onComicImageLoad"
binderror="onComicImageError"
></image>
<view wx:else class="special-art-fallback">
<text>{{comicImageError}}</text>
<text>{{currentPage.sceneAlt}}</text>
</view>
<view class="comic-paper-grain"></view>
</view>
<view class="emotion-panel">
<scroll-view class="emotion-paper" scroll-y enhanced show-scrollbar="{{true}}">
<view class="emotion-context">
<text class="emotion-context-kicker">听听这一桌心里话</text>
<text class="emotion-context-title">{{currentPage.headline}}</text>
<text class="emotion-context-caption">{{currentPage.caption}}</text>
</view>
<text class="emotion-prompt">{{currentPage.prompt}}</text>
<text
wx:if="{{!emotionResult && !chapterFinished}}"
class="emotion-note"
>没有标准答案,选您更愿意说的话。</text>
<view wx:if="{{emotionResult || chapterFinished}}" class="comic-emotion-result">
<text wx:if="{{emotionResult}}" class="feedback">{{emotionResult.feedback}}</text>
<view class="table-echo">
<text class="echo-label">桌边回声</text>
<text>{{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}}</text>
</view>
</view>
</scroll-view>
<view
wx:if="{{!emotionResult && !chapterFinished}}"
class="emotion-choice-dock"
>
<button
wx:for="{{chapter.emotionMoment.playerChoices}}"
wx:key="choiceId"
class="emotion-choice"
data-choice="{{item.choiceId}}"
bindtap="chooseEmotion"
>
<text class="choice-number">{{index + 1}}</text>
<text>{{item.text}}</text>
</button>
</view>
<view class="special-page-footer emotion-page-footer {{emotionResult || chapterFinished ? 'has-primary' : 'single-action'}}">
<button
class="special-page-button secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
bindtap="previousComicPage"
aria-label="返回前一页"
aria-disabled="{{comicPreviousDisabled}}"
>
<text class="comic-control-visible">上页</text>
</button>
<button
wx:if="{{emotionResult || chapterFinished}}"
class="special-page-button primary"
bindtap="finishComicEmotion"
aria-label="{{chapterFinished ? '查看记忆卡' : '收下这一桌的回声'}}"
>
<text class="comic-control-visible">{{chapterFinished ? '查看记忆卡' : '收下这一桌的回声'}}</text>
</button>
</view>
</view>
</view>
</block>
<block wx:elif="{{currentPage.isSeasonClose}}">
<view class="season-close-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}">
<view class="season-close-body">
<view class="season-close-art">
<image
wx:if="{{comicImageSrc}}"
class="season-close-main-image"
src="{{comicImageSrc}}"
mode="aspectFit"
bindload="onComicImageLoad"
binderror="onComicImageError"
></image>
<view wx:else class="season-close-art-fallback">
<text>{{comicImageError}}</text>
<text>{{currentPage.sceneAlt}}</text>
</view>
<view
wx:if="{{comicImageSrc && !comicImageUsingFallback && currentPage.detailInset}}"
class="season-close-detail-inset {{currentPage.detailInset.anchorClass}}"
aria-label="{{currentPage.detailInset.label}}近景"
>
<image
class="season-close-detail-image"
src="{{comicImageSrc}}"
mode="{{currentPage.detailInset.imageMode}}"
style="{{currentPage.detailInset.imageStyle}}"
></image>
<text class="season-close-detail-label">{{currentPage.detailInset.label}}</text>
</view>
</view>
<scroll-view
class="season-close-copy"
scroll-y
enhanced
show-scrollbar="{{true}}"
>
<view class="season-close-copy-sheet">
<text class="season-close-kicker">第一季 · 季终</text>
<text class="season-close-caption">{{currentPage.seasonCloseCaption}}</text>
<view class="season-close-memory-card">
<text class="season-close-card-label">桂香记忆卡</text>
<text class="season-close-era">{{currentPage.memoryCard.eraLine}}</text>
<text class="season-close-quote">{{currentPage.memoryCard.tableEcho}}</text>
<view class="season-close-life-line">
<text class="season-close-line-label">今天可以这样做</text>
<text>{{currentPage.memoryCard.lifeAction}}</text>
</view>
<view class="season-close-family-line">
<text class="season-close-line-label">带回家聊一聊</text>
<text>“{{currentPage.memoryCard.familyLine}}”</text>
</view>
</view>
<text class="season-close-tail">{{currentPage.seasonCloseTail}}</text>
</view>
</scroll-view>
</view>
<view class="memory-actions season-close-actions">
<button
class="memory-action-cell secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
bindtap="previousComicPage"
aria-label="返回前一页"
aria-disabled="{{comicPreviousDisabled}}"
>
<text class="memory-action-visible">上页</text>
</button>
<button
class="memory-action-cell report"
bindtap="openLifeReport"
aria-label="查看本回桂香生活观察"
>
<text class="memory-action-visible">生活观察</text>
</button>
<button
class="memory-action-cell share"
open-type="share"
aria-label="发给家人,一起聊聊这一页"
>
<text class="memory-action-visible">发给家人</text>
</button>
<button
class="memory-action-cell primary"
bindtap="goCatalog"
aria-label="第一季读完,回到目录"
>
<text class="memory-action-visible">回到目录</text>
</button>
</view>
</view>
</block>
<block wx:elif="{{currentPage.type === 'memory'}}">
<view class="comic-special-page memory-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}} {{currentPage.pageId === 'S01-C09-P08' ? 'c09-p08' : ''}}">
<view class="memory-art">
<image
wx:if="{{comicImageSrc}}"
class="memory-art-image"
src="{{comicImageSrc}}"
mode="aspectFit"
bindload="onComicImageLoad"
binderror="onComicImageError"
></image>
<view wx:else class="special-art-fallback">
<text>{{comicImageError}}</text>
<text>{{currentPage.sceneAlt}}</text>
</view>
<view class="comic-paper-grain"></view>
</view>
<view class="memory-panel">
<scroll-view class="memory-paper {{currentPage.pageId === 'S01-C09-P08' ? 'is-complete' : ''}}" scroll-y="{{currentPage.pageId !== 'S01-C09-P08'}}" enhanced show-scrollbar="{{currentPage.pageId !== 'S01-C09-P08'}}">
<view class="memory-paper-content">
<view class="memory-cliffhanger-copy"><text class="memory-ending-label">下一回的门缝</text><text>{{currentPage.caption}}</text></view>
<view class="memory-card-sheet"><text class="memory-stamp">桂香记忆卡</text>
<text class="memory-era">{{currentPage.memoryCard.eraLine}}</text><text class="memory-person">{{currentPage.memoryCard.characterName}}</text>
<view wx:if="{{currentPage.pageId === 'S01-C09-P08'}}" class="memory-short-lines">
<text wx:for="{{currentPage.memoryCard.displayLines}}" wx:key="*this" class="memory-short-line">{{item}}</text>
</view>
<block wx:else>
<text class="memory-quote">{{currentPage.memoryCard.tableEcho}}</text>
<view class="memory-action"><text class="memory-label">今天可以这样做</text><text>{{currentPage.memoryCard.lifeAction}}</text></view>
<button class="memory-family-toggle" bindtap="toggleMemoryFamily" aria-label="打开带回家聊一聊">
{{memoryFamilyOpen ? '收起这一句' : '带回家聊一聊'}}
</button>
<view wx:if="{{memoryFamilyOpen}}" class="memory-family"><text class="memory-label">带回家聊一聊</text><text>“{{currentPage.memoryCard.familyLine}}”</text></view>
</block>
</view>
</view>
</scroll-view>
<view class="memory-actions">
<button
class="memory-action-cell secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
bindtap="previousComicPage"
aria-label="返回前一页"
aria-disabled="{{comicPreviousDisabled}}"
>
<text class="memory-action-visible">上页</text>
</button>
<button
class="memory-action-cell report"
bindtap="openLifeReport"
aria-label="查看本回桂香生活观察"
>
<text class="memory-action-visible">生活观察</text>
</button>
<button
class="memory-action-cell share"
open-type="share"
aria-label="发给家人,一起聊聊这一页"
>
<text class="memory-action-visible">发给家人</text>
</button>
<button
wx:if="{{chapterNumber >= chapterCount}}"
class="memory-action-cell primary"
bindtap="goCatalog"
aria-label="全书读完,回到目录"
>
<text class="memory-action-visible">回到目录</text>
</button>
<button
wx:else
class="memory-action-cell primary"
bindtap="nextChapter"
aria-label="翻到下一回"
>
<text class="memory-action-visible">翻到下一回</text>
</button>
</view>
</view>
</view>
</block>
<block wx:else>
<view
class="comic-normal-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}"
bindtouchstart="onComicTouchStart"
bindtouchend="onComicTouchEnd"
>
<view class="comic-art-column">
<view class="comic-art-stage" style="{{comicArtStageStyle}}">
<image
wx:if="{{comicImageSrc}}"
class="comic-art-image"
src="{{comicImageSrc}}"
mode="{{comicPageImageMode}}"
style="{{comicPageImageStyle}}"
bindload="onComicImageLoad"
binderror="onComicImageError"
></image>
<view wx:else class="comic-art-fallback">
<text>{{comicImageError}}</text>
<text>{{currentPage.sceneAlt}}</text>
</view>
<view class="comic-paper-grain"></view>
<view
wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}"
class="comic-evidence-composite"
aria-label="{{currentPage.playableVisual.subject}}的手边证据"
>
<text class="comic-evidence-kicker">手边证据</text>
<text class="comic-evidence-label">{{currentPage.playableVisual.evidenceLabel}}</text>
<text class="comic-evidence-detail">{{currentPage.playableVisual.evidenceDetail}}</text>
</view>
<button
wx:if="{{currentInteractionEnabled && !comicImageUsingFallback}}"
class="comic-actor-hotspot is-quiet"
style="{{currentPage.actorHotspot.style}}"
bindtap="openComicActor"
aria-label="看看{{currentPage.actorName}}的手边,了解这一页发生了什么"
></button>
</view>
</view>
<view class="comic-caption-strip {{currentInteractionEnabled && compactHeight ? 'has-outside-actor-guide' : ''}}">
<scroll-view class="comic-caption-copy" scroll-y enhanced show-scrollbar="{{false}}">
<view wx:if="{{currentPage.type === 'cover'}}" class="comic-cover-caption-layout">
<view class="comic-cover-caption-heading">
<text class="comic-caption-kicker">{{chapter.year}}</text>
<text class="comic-caption-heading">{{currentPage.headline}}</text>
</view>
<text class="comic-caption comic-cover-caption-text">{{currentPage.displayCaption}}</text>
</view>
<view wx:elif="{{currentPage.type === 'ensemble'}}" class="comic-ensemble-caption-layout">
<text class="comic-caption-heading">{{currentPage.headline}}</text>
<view class="comic-ensemble-caption-copy">
<text class="comic-caption">{{currentPage.displayCaption}}</text>
<text wx:if="{{currentPage.secondaryCaption}}" class="comic-secondary-caption">{{currentPage.secondaryCaption}}</text>
</view>
</view>
<view wx:elif="{{currentPage.type === 'event'}}" class="comic-event-caption-layout {{currentEventCompleted ? 'is-completed' : ''}}">
<view class="comic-event-caption-copy">
<text class="comic-caption">{{currentPage.displayCaption}}</text>
<text wx:if="{{currentPage.secondaryCaption}}" class="comic-secondary-caption">{{currentPage.secondaryCaption}}</text>
</view>
<view class="comic-event-cue">
<text
wx:if="{{currentInteractionEnabled && !compactHeight && currentPage.artTapHint}}"
class="comic-art-tap-note"
>再看看{{currentPage.actorName}}的手边</text>
<button
wx:if="{{currentInteractionEnabled && !compactHeight}}"
class="comic-outside-actor-button"
bindtap="openComicActor"
aria-label="{{currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上的东西' : (currentPage.playableVisual.kind === 'actor-portrait' ? '看看' + currentPage.actorName + '的手边' : '看看发生了什么')}}"
>
<text wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}">看看桌上的东西</text>
<text wx:elif="{{currentPage.artTapHint}}">再看看{{currentPage.actorName}}的手边</text>
<text wx:elif="{{currentPage.playableVisual.kind === 'actor-portrait'}}">看看{{currentPage.actorName}}的手边</text>
<text wx:else>看看发生了什么</text>
</button>
<view wx:elif="{{currentEventCompleted}}" class="comic-seen-row">
<text class="seen-chip">看见了</text>
<text class="comic-tap-prompt is-seen">可以翻到下一页。</text>
</view>
</view>
</view>
</scroll-view>
<view
wx:if="{{currentInteractionEnabled && compactHeight}}"
class="comic-outside-action-dock {{currentPage.artTapHint ? 'has-art-tap-note' : ''}}"
>
<text
wx:if="{{currentPage.artTapHint}}"
class="comic-art-tap-note"
>再看看{{currentPage.actorName}}的手边</text>
<button
class="comic-outside-actor-button comic-outside-actor-dock"
bindtap="openComicActor"
aria-label="{{currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上的东西' : (currentPage.playableVisual.kind === 'actor-portrait' ? '看看' + currentPage.actorName + '的手边' : '看看发生了什么')}}"
>
<text wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}">看看桌上的东西</text>
<text wx:elif="{{currentPage.artTapHint}}">再看看{{currentPage.actorName}}的手边</text>
<text wx:elif="{{currentPage.playableVisual.kind === 'actor-portrait'}}">看看{{currentPage.actorName}}的手边</text>
<text wx:else>看看发生了什么</text>
</button>
</view>
<view class="comic-page-nav">
<button
class="page-turn-button {{comicPreviousDisabled ? 'is-disabled' : ''}}"
bindtap="previousComicPage"
aria-label="上一页"
aria-disabled="{{comicPreviousDisabled}}"
>
<text class="comic-control-visible">上页</text>
</button>
<text wx:if="{{!compactHeight}}" class="folio">{{currentPageNumber}}{{comicPageCount}}</text>
<button
class="page-turn-button next {{comicNextLocked ? 'is-locked' : ''}} {{comicNextDisabled ? 'is-disabled' : ''}}"
bindtap="nextComicPage"
aria-label="{{comicNextLocked ? '先看看这一页的画面' : '下一页'}}"
aria-disabled="{{comicNextLocked || comicNextDisabled}}"
>
<text class="comic-control-visible">
{{comicNextLocked ? (currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上' : '看看画面') : '下页'}}
</text>
</button>
</view>
</view>
</view>
</block>
</view>
<view wx:if="{{textReadingOpen}}" class="dialog-backdrop text-reading-backdrop">
<view
class="text-reading-dialog paper-panel"
aria-role="dialog"
aria-label="本页大字文字"
>
<button
class="dialog-close"
bindtap="closeTextReading"
aria-label="关闭文字阅读"
>×</button>
<text class="text-reading-kicker">
第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页
</text>
<scroll-view
class="text-reading-scroll"
scroll-y
enhanced
show-scrollbar="{{true}}"
>
<view class="text-reading-sheet">
<text wx:if="{{currentPage.headline}}" class="text-reading-title">{{currentPage.headline}}</text>
<text wx:if="{{currentPage.displayCaption}}" class="text-reading-body">{{currentPage.displayCaption}}</text>
<text wx:elif="{{currentPage.caption}}" class="text-reading-body">{{currentPage.caption}}</text>
<text wx:if="{{currentPage.secondaryCaption}}" class="text-reading-secondary">{{currentPage.secondaryCaption}}</text>
<view wx:if="{{currentPage.prompt}}" class="text-reading-prompt">
<text class="text-reading-label">这一页想和您聊</text>
<text>{{currentPage.prompt}}</text>
</view>
<view wx:if="{{currentPage.question}}" class="text-reading-prompt">
<text class="text-reading-label">看看画里发生了什么</text>
<text>{{currentPage.question}}</text>
</view>
</view>
</scroll-view>
<button
class="text-reading-return"
bindtap="closeTextReading"
aria-label="回到连环画"
>回到画里</button>
</view>
</view>
<view wx:if="{{decisionOpen && storyFirstComicMode}}" class="c1-decision-layer">
<view wx:if="{{resultMode === ''}}" class="c1-interaction-paper">
<view class="c1-interaction-evidence">
<image
wx:if="{{comicImageSrc}}"
class="c1-interaction-image"
src="{{comicImageSrc}}"
mode="aspectFit"
></image>
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
<text class="c1-back-stamp">画里的线索</text>
</view>
<view class="c1-interaction-panel">
<button class="c1-tray-close" bindtap="closeDecision" aria-label="收起选择,先回画面">先看画</button>
<view class="c1-tray-heading">
<text class="c1-tray-kicker">碰上这事,您怎么做?</text>
<text class="c1-tray-question">{{activeEvent.question}}</text>
</view>
<view class="c1-choice-stack">
<button
wx:for="{{activeEvent.choiceOptions}}"
wx:key="value"
class="c1-natural-choice"
data-value="{{item.value}}"
bindtap="answer"
>{{item.label}}</button>
</view>
</view>
</view>
<view wx:elif="{{resultMode === 'retry'}}" class="c1-interaction-paper">
<view class="c1-interaction-evidence">
<image
wx:if="{{comicImageSrc}}"
class="c1-interaction-image"
src="{{comicImageSrc}}"
mode="aspectFit"
></image>
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
<text class="c1-back-stamp">再看看画里</text>
</view>
<view class="c1-interaction-panel c1-retry-panel">
<button class="c1-tray-close" bindtap="closeDecision" aria-label="回到画里">先看画</button>
<text class="c1-retry-title">别着急,再瞧瞧画里。</text>
<text class="c1-retry-copy">{{activeEvent.retryHint}}</text>
<button class="c1-retry-button" bindtap="retryAnswer">再看一眼</button>
</view>
</view>
<view wx:else class="c1-back-paper c1-event-result-back">
<view class="c1-back-evidence">
<image
wx:if="{{comicImageSrc}}"
class="c1-back-image"
src="{{comicImageSrc}}"
mode="aspectFit"
></image>
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
<text class="c1-back-stamp">这下有眉目了</text>
</view>
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
<view class="c1-back-copy-inner">
<text class="c1-back-kicker">{{activeEvent.actorName}} · 这一页背面</text>
<text class="c1-back-title">{{currentPage.headline}}</text>
<text class="c1-back-body">{{activeEvent.actionDescription}}</text>
<view wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-action">
<text class="c1-back-label">先把眼前这一步做好</text>
<text>先停酒、移开危险物,并留下人陪伴。</text>
</view>
<text wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-reason">这些表现不能仅凭外观确定原因,先保证安全。</text>
<block wx:else>
<view class="c1-back-callout">
<text class="c1-back-label">您刚才瞧见的</text>
<text>{{activeEvent.evidence}}</text>
</view>
<text class="c1-back-reason">{{activeEvent.reason}}</text>
<view class="c1-back-action">
<text class="c1-back-label">下回遇到这事</text>
<text>{{activeEvent.actionAdvice}}</text>
</view>
</block>
</view>
</scroll-view>
<text class="c1-scroll-hint">上划接着看 ↑</text>
<view class="c1-back-footer">
<button
wx:if="{{!(activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen)}}"
class="c1-back-button secondary"
aria-label="回头再看一遍画面"
bindtap="returnFromChapterOneDecision"
>回去看画</button>
<button wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-button primary" bindtap="openResultDetail">再看不同情况怎么办</button>
<button wx:else class="c1-back-button primary" bindtap="nextFromChapterOneDecision">翻下一张</button>
</view>
</view>
</view>
<view wx:elif="{{decisionOpen}}" class="dialog-backdrop">
<view
class="decision-dialog paper-panel comic-decision"
aria-role="dialog"
aria-label="{{activeEvent.actorName}}这一幕的互动选择"
>
<button
wx:if="{{!(resultMode === 'correct' && activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen)}}"
class="dialog-close"
bindtap="closeDecision"
aria-label="回到画里"
>×</button>
<scroll-view class="character-story" scroll-y enhanced show-scrollbar="{{true}}">
<view class="character-story-inner">
<view class="comic-focus-visual">
<image
wx:if="{{comicImageSrc}}"
class="comic-focus-scene"
src="{{comicImageSrc}}"
mode="aspectFit"
style="{{comicFocusImageStyle}}"
></image>
<view class="comic-focus-shade"></view>
<view class="comic-focus-name">
<text>{{activePerson.eraLabel}} · 画中人物</text>
<text class="focus-name">{{activeEvent.actorName}}</text>
</view>
</view>
<text class="story-label">这一幕发生了什么</text>
<text class="action-description">{{activeEvent.actionDescription}}</text>
<view class="evidence-box">
<text class="story-label">手边证据</text>
<text>{{activeEvent.evidence}}</text>
</view>
<view class="speech-box">
<text class="story-label">桌边说话</text>
<text>{{activeEvent.speech}}</text>
</view>
</view>
</scroll-view>
<view class="judgement-side {{resultMode === '' ? 'is-choice' : 'has-result'}}">
<scroll-view
class="judgement-scroll"
scroll-y
enhanced
show-scrollbar="{{decisionResultNeedsScroll}}"
>
<view class="judgement-scroll-inner {{resultMode === '' ? 'is-choice' : 'is-result'}}">
<text class="judgement-lead">故事走到这里,您想怎么做?</text>
<text wx:if="{{resultMode === ''}}" class="judgement-question">{{activeEvent.question}}</text>
<view wx:if="{{resultMode === ''}}" class="judgement-options">
<button class="judgement-button observe" data-value="caution" bindtap="answer">
<text class="judgement-icon">A</text>
<view class="judgement-button-copy">
<text class="judgement-title">先看看现场细节</text>
</view>
</button>
<button class="judgement-button continue" data-value="safer" bindtap="answer">
<text class="judgement-icon">B</text>
<view class="judgement-button-copy">
<text class="judgement-title">继续按画里的做法</text>
</view>
</button>
</view>
<view wx:if="{{resultMode === 'retry'}}" class="result-box retry">
<text class="result-title">没关系,再看看画里的细节。</text>
<text>{{activeEvent.retryHint}}</text>
</view>
<view wx:if="{{resultMode === 'correct'}}" class="result-box correct" aria-live="polite">
<text class="result-title">这处线索对上了。</text>
<view wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="action-advice">
<text class="advice-label">先把眼前这一步做好</text>
<text>先停酒、移开危险物,并留下人陪伴。</text>
</view>
<text wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="reason">这些表现不能仅凭外观确定原因,先保证安全。</text>
<view wx:else class="action-advice">
<text class="advice-label">现在可以怎么做</text>
<text>{{activeEvent.actionAdvice}}</text>
</view>
<text wx:if="{{activeEvent.hotspotId !== 'S01-H36' || resultDetailOpen}}" class="reason">{{activeEvent.reason}}</text>
</view>
</view>
</scroll-view>
<view wx:if="{{resultMode === 'retry'}}" class="decision-footer">
<button class="decision-footer-button retry" bindtap="retryAnswer">再看一眼现场</button>
</view>
<view wx:if="{{resultMode === 'correct' && activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="decision-footer">
<button class="decision-footer-button primary" bindtap="openResultDetail">
再看不同情况怎么办
</button>
</view>
<view wx:elif="{{resultMode === 'correct'}}" class="decision-footer">
<button class="decision-footer-button primary" bindtap="continueAfterDecision">
回到画里
</button>
</view>
</view>
</view>
</view>
</view>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,135 @@
const COMIC_READER_LAYOUT_CONTRACT = Object.freeze({
contractId: 'story-first-three-frame-v1',
columns: Object.freeze({
art: 3,
content: 2,
}),
frames: Object.freeze({
front: Object.freeze({
imageMode: 'aspectFit',
initialControls: 'none',
revealGesture: 'single-tap',
revealedControls: 'topbar-only',
}),
back: Object.freeze({
art: 2,
content: 3,
imageMode: 'aspectFit',
}),
interaction: Object.freeze({
art: 2,
content: 3,
imageMode: 'aspectFit',
choiceCount: 2,
choiceDirection: 'vertical',
}),
}),
minimumControlPx: 48,
compactHeightMaxPx: 620,
fontModes: Object.freeze({
standard: 'large',
xlarge: 'xlarge',
}),
audioSlot: Object.freeze({
placement: 'unified-topbar',
minimumHeightPx: 48,
textModeLabel: '直接看文字',
readyLabel: '听一听',
playingLabel: '暂停',
replayLabel: '重听',
usesPageAudioCueIds: true,
}),
artOverlayPolicy: Object.freeze({
event: 'transparent-actor-hotspot-only',
emotion: 'transparent-actor-hotspot-only',
}),
})
const COMIC_PAGE_TEMPLATE_BY_TYPE = Object.freeze({
cover: 'chapter-ensemble',
ensemble: 'chapter-ensemble',
event: 'actor-event',
emotion: 'emotion-interaction',
memory: 'memory-cliffhanger',
})
const COMIC_PRESENTATION_BY_CHAPTER = Object.freeze({
'S01-C01': 'front-back-comic-v1',
'S01-C02': 'front-back-comic-v1',
'S01-C03': 'front-back-comic-v1',
'S01-C04': 'front-back-comic-v1',
'S01-C05': 'front-back-comic-v1',
'S01-C06': 'front-back-comic-v1',
'S01-C07': 'front-back-comic-v1',
'S01-C08': 'front-back-comic-v1',
'S01-C09': 'front-back-comic-v1',
'S01-C10': 'front-back-comic-v1',
'S01-C11': 'front-back-comic-v1',
'S01-C12': 'front-back-comic-v1',
'S01-C13': 'front-back-comic-v1',
'S01-C14': 'front-back-comic-v1',
'S01-C15': 'front-back-comic-v1',
})
function getComicPresentationMode(chapterId) {
return COMIC_PRESENTATION_BY_CHAPTER[String(chapterId || '')]
|| 'legacy-60-40-v1'
}
const COMIC_PAGE_LAYOUT_EXCEPTIONS = Object.freeze({
'S01-C09-P03': Object.freeze({
layoutVariant: 'standard-landscape-60-40',
behavior: 'key-safe-actor-hotspot',
actorHotspot: Object.freeze({ x: 16, y: 3, w: 56, h: 82 }),
reason: '人物仍可整片点击,但透明热区须止于画面下方钥匙带之前。',
}),
})
// These pages keep the full reviewed frame and add one read-only crop from
// the same asset as a second comic panel. This is an art-reading aid, not a
// new interaction or a page-layout exception.
const COMIC_FIXED_DETAIL_INSET_PAGE_IDS = Object.freeze([
'S01-C04-P04',
'S01-C09-P01',
'S01-C09-P05',
'S01-C09-P06',
'S01-C10-P07',
'S01-C13-P04',
'S01-C15-P05',
'S01-C15-P08',
])
function allowsFixedDetailInset(pageId) {
return COMIC_FIXED_DETAIL_INSET_PAGE_IDS.indexOf(
String(pageId || ''),
) >= 0
}
function getComicPageLayoutException(pageId) {
return COMIC_PAGE_LAYOUT_EXCEPTIONS[String(pageId || '')] || null
}
function resolveComicPageLayout(pageId, type) {
const templateKey = COMIC_PAGE_TEMPLATE_BY_TYPE[type] || 'chapter-ensemble'
const exception = getComicPageLayoutException(pageId)
return {
templateKey,
layoutVariant: exception
? exception.layoutVariant
: 'standard-landscape-60-40',
layoutExceptionId: exception ? String(pageId) : '',
layoutExceptionBehavior: exception ? exception.behavior : '',
}
}
module.exports = {
COMIC_READER_LAYOUT_CONTRACT,
COMIC_PRESENTATION_BY_CHAPTER,
COMIC_PAGE_TEMPLATE_BY_TYPE,
COMIC_PAGE_LAYOUT_EXCEPTIONS,
COMIC_FIXED_DETAIL_INSET_PAGE_IDS,
allowsFixedDetailInset,
getComicPresentationMode,
getComicPageLayoutException,
resolveComicPageLayout,
}
@@ -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,
}
@@ -0,0 +1,14 @@
/**
* 发布环境可覆盖这些值。
*
* cdnBaseUrl 默认留空:未配置合法 HTTPS 下载域名时,AssetManager 会直接
* 使用包内种子图或返回文字回退,不会发起网络请求。
*/
module.exports = {
cdnBaseUrl: '',
imageCacheBudgetBytes: 28 * 1024 * 1024,
audioCacheBudgetBytes: 32 * 1024 * 1024,
audioCacheMaxEntries: 32,
prefetchAhead: 2,
downloadConcurrency: 2,
}
@@ -0,0 +1,298 @@
const { releaseAssets } = require('./releaseAssetManifest')
const PLAYABLE_VISUAL_REVIEW_STATUS = 'approved-readable'
const PROVISIONAL_VISUAL_REVIEW_STATUS = 'experience-provisional'
const RUNTIME_VISUAL_TIERS = Object.freeze({
FORMAL: 'formal',
PROVISIONAL: 'experience-provisional',
FALLBACK: 'fallback',
})
// This set deliberately mirrors `usable` in
// docs/production/art-release-gate-s01.json. Registered page art outside this
// set may be shown only as experience-provisional and never counts as formal.
const FORMAL_PAGE_ART_IDS = new Set([
'S01-C01-P01',
'S01-C01-P02',
'S01-C01-P03',
'S01-C01-P04',
'S01-C01-P05',
'S01-C01-P07',
'S01-C01-P08',
'S01-C02-P01',
'S01-C02-P02',
'S01-C02-P03',
'S01-C02-P04',
'S01-C02-P05',
'S01-C02-P06',
'S01-C02-P07',
'S01-C02-P08',
'S01-C03-P01',
'S01-C03-P02',
'S01-C03-P03',
'S01-C03-P04',
'S01-C03-P05',
'S01-C03-P07',
'S01-C03-P08',
'S01-C04-P01',
'S01-C04-P02',
'S01-C04-P03',
'S01-C04-P05',
'S01-C04-P06',
'S01-C04-P07',
'S01-C04-P08',
'S01-C05-P01',
'S01-C05-P02',
'S01-C05-P03',
'S01-C05-P04',
'S01-C05-P05',
'S01-C05-P06',
'S01-C05-P07',
'S01-C05-P08',
'S01-C06-P01',
'S01-C06-P02',
'S01-C06-P03',
'S01-C06-P04',
'S01-C06-P05',
'S01-C06-P06',
'S01-C06-P07',
'S01-C06-P08',
'S01-C07-P01',
'S01-C07-P02',
'S01-C07-P03',
'S01-C07-P04',
'S01-C07-P05',
'S01-C07-P06',
'S01-C07-P07',
'S01-C07-P08',
'S01-C08-P02',
'S01-C08-P03',
'S01-C08-P04',
'S01-C08-P05',
'S01-C08-P06',
'S01-C08-P07',
'S01-C08-P08',
'S01-C09-P02',
'S01-C09-P03',
'S01-C09-P04',
'S01-C09-P07',
'S01-C09-P08',
'S01-C10-P01',
'S01-C10-P02',
'S01-C10-P03',
'S01-C10-P04',
'S01-C10-P05',
'S01-C10-P06',
'S01-C10-P07',
'S01-C10-P08',
'S01-C11-P01',
'S01-C11-P02',
'S01-C11-P03',
'S01-C11-P04',
'S01-C11-P05',
'S01-C11-P06',
'S01-C11-P07',
'S01-C11-P08',
'S01-C12-P01',
'S01-C12-P02',
'S01-C12-P03',
'S01-C12-P04',
'S01-C12-P05',
'S01-C12-P06',
'S01-C12-P07',
'S01-C12-P08',
'S01-C13-P01',
'S01-C13-P02',
'S01-C13-P03',
'S01-C13-P05',
'S01-C13-P06',
'S01-C13-P07',
'S01-C13-P08',
'S01-C14-P01',
'S01-C14-P02',
'S01-C14-P03',
'S01-C14-P04',
'S01-C14-P05',
'S01-C14-P06',
'S01-C14-P07',
'S01-C14-P08',
'S01-C15-P01',
'S01-C15-P02',
'S01-C15-P03',
'S01-C15-P04',
'S01-C15-P05',
'S01-C15-P06',
'S01-C15-P07',
'S01-C15-P08',
])
function clean(value) {
return typeof value === 'string' ? value.trim() : ''
}
function comicAssetIdForPageId(pageId) {
const match = clean(pageId).match(/^S(\d+)-C(\d+)-P(\d+)$/)
if (!match) return ''
return `comic.s${match[1]}.c${match[2]}.p${match[3]}`
}
function registeredPageArtFor(page) {
const assetId = comicAssetIdForPageId(page && page.pageId)
const releaseAsset = assetId ? releaseAssets[assetId] : null
return Boolean(
releaseAsset
&& releaseAsset.kind === 'image'
&& clean(releaseAsset.localSeed)
&& clean(releaseAsset.localSeed) === clean(page.illustrationAsset),
)
}
function createPageArtVisual(page, chapter, runtimeTier) {
const formal = runtimeTier === RUNTIME_VISUAL_TIERS.FORMAL
return {
kind: 'page-art',
asset: clean(page.illustrationAsset),
subject: clean(page.actorName || page.headline || chapter.title),
runtimeTier,
formalReleaseEligible: formal,
reviewStatus: formal
? PLAYABLE_VISUAL_REVIEW_STATUS
: PROVISIONAL_VISUAL_REVIEW_STATUS,
reviewBasis: formal
? 'art-release-gate-s01-usable'
: 'registered-immutable-asset-experience-only',
}
}
function personByInstanceId(chapter, instanceId) {
return (chapter.people || []).find(
(person) => (
person.instanceId === instanceId
&& clean(person.portrait)
),
) || null
}
function personByName(chapter, name) {
const cleanName = clean(name)
if (!cleanName) return null
return (chapter.people || []).find(
(person) => person.name === cleanName && clean(person.portrait),
) || null
}
function featuredPersonForPage(page, chapter) {
const direct = personByInstanceId(chapter, page.actorInstanceId)
if (direct) return direct
if (page.type === 'memory') {
const memoryPerson = personByName(
chapter,
page.memoryCard && page.memoryCard.characterName,
)
if (memoryPerson) return memoryPerson
}
if (page.type === 'emotion') {
const emotionPerson = personByName(
chapter,
chapter.emotionMoment
&& chapter.emotionMoment.character
&& chapter.emotionMoment.character.name,
)
if (emotionPerson) return emotionPerson
}
return (chapter.people || []).find(
(person) => clean(person.portrait),
) || null
}
function createPlayableVisual(page, chapter) {
if (FORMAL_PAGE_ART_IDS.has(page.pageId)) {
return createPageArtVisual(page, chapter, RUNTIME_VISUAL_TIERS.FORMAL)
}
if (registeredPageArtFor(page)) {
return createPageArtVisual(
page,
chapter,
RUNTIME_VISUAL_TIERS.PROVISIONAL,
)
}
if (page.type === 'event') {
const portrait = clean(page.actorPortrait)
if (portrait) {
return {
kind: 'actor-portrait',
asset: portrait,
subject: clean(page.actorName),
actorInstanceId: clean(page.actorInstanceId),
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
formalReleaseEligible: false,
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
reviewBasis: 'season-character-continuity-sheet',
}
}
return {
kind: 'evidence-composite',
asset: clean(page.fallbackAsset),
subject: clean(page.actorName),
evidenceLabel: clean(page.headline || '看看手边证据'),
evidenceDetail: clean(
page.secondaryCaption || page.caption || page.question,
),
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
formalReleaseEligible: false,
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
reviewBasis: 'chapter-era-scene-with-reviewed-evidence-copy',
}
}
const featuredPerson = featuredPersonForPage(page, chapter)
if (featuredPerson) {
return {
kind: 'chapter-character',
asset: clean(featuredPerson.portrait),
subject: clean(featuredPerson.name),
actorInstanceId: clean(featuredPerson.instanceId),
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
formalReleaseEligible: false,
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
reviewBasis: 'season-character-continuity-sheet',
}
}
// Defensive last resort. The season currently never reaches this branch,
// but if a future chapter lacks a portrait it still gets explicit evidence
// instead of an unexplained empty room.
return {
kind: 'evidence-composite',
asset: clean(page.fallbackAsset),
subject: clean(page.headline || chapter.title),
evidenceLabel: clean(page.headline || chapter.title),
evidenceDetail: clean(page.caption || chapter.narration),
runtimeTier: RUNTIME_VISUAL_TIERS.FALLBACK,
formalReleaseEligible: false,
reviewStatus: PLAYABLE_VISUAL_REVIEW_STATUS,
reviewBasis: 'chapter-era-scene-with-reviewed-evidence-copy',
}
}
function attachPlayableVisuals(pageSequence, chapter) {
return (Array.isArray(pageSequence) ? pageSequence : []).map((page) => ({
...page,
playableVisual: createPlayableVisual(page, chapter),
}))
}
module.exports = {
FORMAL_PAGE_ART_IDS,
PLAYABLE_VISUAL_REVIEW_STATUS,
PROVISIONAL_VISUAL_REVIEW_STATUS,
RUNTIME_VISUAL_TIERS,
attachPlayableVisuals,
createPlayableVisual,
}
@@ -0,0 +1,162 @@
// Generated package-local production comic data. Do not hand-edit.
module.exports = {
"S01-C04": {
"pages": [
{
"pageId": "S01-C04-P01",
"type": "chapter-title",
"eventId": "",
"emotionMomentId": "",
"assetName": "S01-C04-P01-title-v1.jpg",
"caption": "一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。",
"secondaryCaption": "",
"interactionPrompt": "翻开这一回",
"shortDialogue": "",
"hotspot": null,
"audioCueIds": [
"S01-C04-MT000"
],
"tableEcho": "",
"cliffhanger": ""
},
{
"pageId": "S01-C04-P02",
"type": "ensemble",
"eventId": "",
"emotionMomentId": "",
"assetName": "S01-C04-P02-ensemble-v1.jpg",
"caption": "“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。",
"secondaryCaption": "",
"interactionPrompt": "看一看,谁没有跟着笑?",
"shortDialogue": "",
"hotspot": null,
"audioCueIds": [
"S01-C04-MS001",
"S01-C04-MS002-ATTR",
"S01-C04-MS002"
],
"tableEcho": "",
"cliffhanger": ""
},
{
"pageId": "S01-C04-P03",
"type": "event",
"eventId": "S01-H13",
"emotionMomentId": "",
"assetName": "S01-C04-P03-H13-ladle-contest-v1.jpg",
"caption": "赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。",
"secondaryCaption": "",
"interactionPrompt": "为证明能干,组织“谁吃得多”的比赛,这样妥当吗?",
"shortDialogue": "",
"hotspot": null,
"audioCueIds": [
"S01-C04-MS003",
"S01-C04-MS004"
],
"tableEcho": "",
"cliffhanger": ""
},
{
"pageId": "S01-C04-P04",
"type": "event",
"eventId": "S01-H14",
"emotionMomentId": "",
"assetName": "S01-C04-P04-H14-belt-table-v1.jpg",
"caption": "笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。",
"secondaryCaption": "",
"interactionPrompt": "已经很撑、仍隐瞒不适并准备马上弯腰抬重物,这样妥当吗?",
"shortDialogue": "",
"hotspot": null,
"programDetailInset": {
"label": "扶桌近景",
"x": 13,
"y": 50,
"w": 42,
"h": 47,
"anchor": "top-left"
},
"audioCueIds": [
"S01-C04-MS005"
],
"tableEcho": "",
"cliffhanger": ""
},
{
"pageId": "S01-C04-P05",
"type": "event",
"eventId": "S01-H15",
"emotionMomentId": "",
"assetName": "S01-C04-P05-H15-water-reminder-v1.jpg",
"caption": "小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”",
"secondaryCaption": "",
"interactionPrompt": "把“别又急又撑”理解成“主食一口都不能吃”,这样妥当吗?",
"shortDialogue": "",
"hotspot": null,
"audioCueIds": [
"S01-C04-MS006",
"S01-C04-MS007",
"S01-C04-MS008",
"S01-C04-MS009",
"S01-C04-MS010",
"S01-C04-MS011"
],
"tableEcho": "",
"cliffhanger": ""
},
{
"pageId": "S01-C04-P06",
"type": "event",
"eventId": "S01-H16",
"emotionMomentId": "",
"assetName": "S01-C04-P06-H16-stool-bowl-v1.jpg",
"caption": "林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”",
"secondaryCaption": "",
"interactionPrompt": "看见同伴扶桌后,先问是否需要坐一会儿,而不是继续起哄,这样更稳妥吗?",
"shortDialogue": "",
"hotspot": null,
"audioCueIds": [
"S01-C04-MS012-ATTR",
"S01-C04-MS012",
"S01-C04-MS013",
"S01-C04-MS014"
],
"tableEcho": "",
"cliffhanger": ""
},
{
"pageId": "S01-C04-P07",
"type": "emotion",
"eventId": "",
"emotionMomentId": "S01-EM04",
"assetName": "S01-C04-P07-EM04-face-saving-pause-v1.jpg",
"caption": "赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。",
"secondaryCaption": "",
"interactionPrompt": "不拆穿他的逞强,你想怎样接住这一刻?",
"shortDialogue": "",
"hotspot": null,
"audioCueIds": [
"S01-C04-TE900"
],
"tableEcho": "饭能添,面子也要留;劝人停一停,先给他一张凳。",
"cliffhanger": ""
},
{
"pageId": "S01-C04-P08",
"type": "memory-card-and-cliffhanger",
"eventId": "",
"emotionMomentId": "",
"assetName": "S01-C04-P08-cliffhanger-v1.jpg",
"caption": "饭能添,面子也要留;劝人停一停,先给他一张凳。",
"secondaryCaption": "",
"interactionPrompt": "收进我的桂香岁月",
"shortDialogue": "",
"hotspot": null,
"audioCueIds": [
"S01-C04-MS015"
],
"tableEcho": "",
"cliffhanger": ""
}
]
}
}
@@ -0,0 +1,911 @@
/**
* 发布固定资产清单 v1
*
* 约束:
* 1. 页面只保存稳定的 assetId,不直接拼 CDN 地址。
* 2. remotePath 必须包含对应文件内容哈希的前 12 位,发布后不可覆盖。
* 3. 医学结论、判断答案和行动建议必须随代码包发布,不得放进远端资产。
* 4. localSeed 是当前小程序包内的安全回退;以后迁移 CDN 时可按资产逐项移除。
*/
const RELEASE_ASSET_MANIFEST_VERSION = 1
const releaseAssets = {
'comic.s01.c01.p01': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P01-title-v1.12163b7f4667.jpg',
sha256: '12163b7f466766af13a75024189aa28441627f67a6623b1c4b99b29110ecb35a',
},
'comic.s01.c01.p02': {
kind: 'image',
localSeed: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.f34fe1faf167.jpg',
sha256: 'f34fe1faf167da590dd36595c80803f1ba8740576e600ee3c1d004bbbd4a26f0',
},
'comic.s01.c01.p03': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P03-H01-scan-only-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P03-H01-scan-only-v1.6e0d20c4055f.jpg',
sha256: '6e0d20c4055ff20b8b2d47a12edb5902c89d7440334a1783acd11133d854ad80',
},
'comic.s01.c01.p04': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P04-H02-extra-dish-v1.bcef17dda041.jpg',
sha256: 'bcef17dda041c5e21e43281160ee09ded3fbb89b052e92da27fd50c7b78640ce',
},
'comic.s01.c01.p05': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P05-H03-four-imprints-v1.d2e1becd9c12.jpg',
sha256: 'd2e1becd9c12c80f7c90e63755454d618fa3741ca1c2685e04fe11f408130f5f',
},
'comic.s01.c01.p06': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P06-H04-unsaid-speech-v1.7bc229ef1f1c.jpg',
sha256: '7bc229ef1f1c5dc127f7e2be75dfac62a3a485984584e0caf6cf096792e03750',
},
'comic.s01.c01.p07': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.4a19c49a53ea.jpg',
sha256: '4a19c49a53eaee3d775d0b0a8965a6478d4976d820584b0abb364790294c6f31',
},
'comic.s01.c01.p08': {
kind: 'image',
localSeed: '/package-game/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c01/S01-C01-P08-cliffhanger-v1.7db63c3725ee.jpg',
sha256: '7db63c3725ee5a1ca8ad71c67746e7cd0234ed21fee07030fc1158ccd0161d69',
},
'comic.s01.c02.p01': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P01-title-v1.c32093483c58.jpg',
sha256: 'c32093483c588ab38465e16f1c4a6bf230a0c6433925df91cf588e57fca05552',
},
'comic.s01.c02.p02': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.892de611f097.jpg',
sha256: '892de611f097d6562eb7d632eb29e91da28b5fc1c141ab4911da52c0f8f869ea',
},
'comic.s01.c02.p03': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P03-H05-meal-ticket-label-v1.e725c333849e.jpg',
sha256: 'e725c333849e13dc342c01448eb34bf02b269946d4a8a40843044c7ab5f4803c',
},
'comic.s01.c02.p04': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P04-H06-bowl-direction-v1.ac89e35c3692.jpg',
sha256: 'ac89e35c369254b81cfa0c9b350c6d485d637eb8642c13952ef6b26c889b9593',
},
'comic.s01.c02.p05': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P05-H07-photo-edge-clues-v1.cda630c13f31.jpg',
sha256: 'cda630c13f31745e3ad6308cca179337c6c9c11dd33c6473f3f769d614662a4d',
},
'comic.s01.c02.p06': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P06-H08-plaque-reverse-v1.61d413562200.jpg',
sha256: '61d413562200e3a40ded87c77c1b6c887331810ae03ef2a5b6b533cc4a7ff541',
},
'comic.s01.c02.p07': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.c7a28411b1d7.jpg',
sha256: 'c7a28411b1d72d708d0c22a38b1ac3e2ccf3faac85056d603790d3c4ac3ee539',
},
'comic.s01.c02.p08': {
kind: 'image',
localSeed: '/package-chapter-02/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg',
remotePath: 'comic/s01-c02/S01-C02-P08-clock-to-1978-v1.6c0eed6b964e.jpg',
sha256: '6c0eed6b964edb72f13810a32bcfa3509f28f002dedd4bb25c9fc89f4a594703',
},
'comic.s01.c03.p01': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P01-title-v1.7aaefa1ff157.jpg',
sha256: '7aaefa1ff15756b6808abd6476b3e8d666fc011cc83760acaa89b8aeb60e26a4',
},
'comic.s01.c03.p02': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.feb9f3243262.jpg',
sha256: 'feb9f324326284127c8579153e7fba5ac32cf0e72687ea35b92986dcb7f94dd5',
},
'comic.s01.c03.p03': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P03-H09-oily-hand-bun-v1.841146ea446b.jpg',
sha256: '841146ea446b6d42e703f02c1f88446f1e8fde1f7f64fb8d3f3dc4ba01d1b95e',
},
'comic.s01.c03.p04': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P04-H10-bench-balance-v1.8a838a7d0bac.jpg',
sha256: '8a838a7d0bac89fe6d728cc2002f5cf4578659d7b5f4619efdaaa386e5a7d727',
},
'comic.s01.c03.p05': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P05-H11-staple-era-context-v1.cbd7d426ee42.jpg',
sha256: 'cbd7d426ee427c16429066feff5afd0300e65ac73dc25beb134ed4947ddc6b79',
},
'comic.s01.c03.p06': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P06-H12-two-clips-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P06-H12-two-clips-v1.00e5034f636a.jpg',
sha256: '00e5034f636aed3b8be3bfecf8be84a12b2dd80763876a5df31eaa81540d77f1',
},
'comic.s01.c03.p07': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.787bf794fd53.jpg',
sha256: '787bf794fd536bbab6bbb0e2ba9fe68193b74dbb1810ddf3929f8f332c4df602',
},
'comic.s01.c03.p08': {
kind: 'image',
localSeed: '/package-chapter-03/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.056ef8b3017c.jpg',
sha256: '056ef8b3017c857574093fd86478fab7d349261202ca65919129219a2f2dd4e5',
},
'comic.s01.c04.p01': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P01-title-v1.e20d8bd83bfb.jpg',
sha256: 'e20d8bd83bfb6084bf4aac30078fda6a5024bbf1be46a7b40e001fc510e05a30',
},
'comic.s01.c04.p02': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P02-ensemble-v1.d83c35c61a7c.jpg',
sha256: 'd83c35c61a7cff187fbc8e0a8bb5355f856413c6ebddfb32ef69d92f0dea9505',
},
'comic.s01.c04.p03': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P03-H13-ladle-contest-v1.c90b76c43463.jpg',
sha256: 'c90b76c43463f456c5323a06734f8c5e48f81938990d1715a1f3807bea1ce3d4',
},
'comic.s01.c04.p04': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P04-H14-belt-table-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P04-H14-belt-table-v1.70da80c5dd1d.jpg',
sha256: '70da80c5dd1d40279540cd1fcc657aaf6a99ece7a36a81c979f734a78fecf5d7',
},
'comic.s01.c04.p05': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P05-H15-water-reminder-v1.4f4212b949b9.jpg',
sha256: '4f4212b949b9e25bdc5041d0dc0c1c4a8e2d1247efc387b424573e58b01eff3a',
},
'comic.s01.c04.p06': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P06-H16-stool-bowl-v1.25834d3e9082.jpg',
sha256: '25834d3e908299dff9f74442eebf615d5eaf1dce22d06b2e5739a3e2a1ba8cc2',
},
'comic.s01.c04.p07': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.3f741e4ac31c.jpg',
sha256: '3f741e4ac31cb55ad4a2956d07b183ecb665e297f4612f7ba8c6c3f6becc971d',
},
'comic.s01.c04.p08': {
kind: 'image',
localSeed: '/package-chapter-04/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c04/S01-C04-P08-cliffhanger-v1.9558ee49f2e2.jpg',
sha256: '9558ee49f2e2b6b53eff173425a2adc31969f8532ff584b94980bd51b757fe61',
},
'comic.s01.c05.p01': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P01-title-v1.52a79ac178e6.jpg',
sha256: '52a79ac178e6b0a154c9918010f16be797cb62d63624f820a69c6a0cd3fcc2ef',
},
'comic.s01.c05.p02': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.897aa558fe7d.jpg',
sha256: '897aa558fe7d0f4b607686d82c48c435522664c125627a023819f7ca2ad4ccfb',
},
'comic.s01.c05.p03': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P03-H17-empty-window-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P03-H17-empty-window-v1.76ac18af26da.jpg',
sha256: '76ac18af26da9f5b6a9a2007fd6cf4cc81f584659c293c774222a7359bec0e22',
},
'comic.s01.c05.p04': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P04-H18-half-bun-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P04-H18-half-bun-v1.c9506ed1fe98.jpg',
sha256: 'c9506ed1fe983ec7d467ade84a6b0807401292d18e8ba85a5507173e8c11d23c',
},
'comic.s01.c05.p05': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P05-H19-relit-stove-v1.a88bbf597302.jpg',
sha256: 'a88bbf597302146baf361469aa904cd9c126d411ce76feb7d1d1dd520ee783e8',
},
'comic.s01.c05.p06': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P06-H20-seat-at-table-v1.63adf03d0d30.jpg',
sha256: '63adf03d0d305435bf17865cb4f17e28c8c3fad9274c730010a4f4580f75f8ca',
},
'comic.s01.c05.p07': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.172fc27a2252.jpg',
sha256: '172fc27a22529f5d6a3c12b60ec5b36a34620b50af2ab7b475287c3b70dd1567',
},
'comic.s01.c05.p08': {
kind: 'image',
localSeed: '/package-chapter-05/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg',
remotePath: 'comic/s01-c05/S01-C05-P08-first-photo-v1.b85b2852a7dc.jpg',
sha256: 'b85b2852a7dc274f866923f429451e488c1cd6436c62892db3e62a8952c06f3f',
},
'comic.s01.c06.p01': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P01-title-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P01-title-v1.f7039560e49d.jpg',
sha256: 'f7039560e49d68615812a9d154c095e69ec04aa6a60f06f4f14822ea36d91d40',
},
'comic.s01.c06.p02': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P02-sign-flip-ensemble-v1.0b4519763ba5.jpg',
sha256: '0b4519763ba53e001fa8bd6903fea84c25c97cf9e6049a05bba684da1e3ec6ae',
},
'comic.s01.c06.p03': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P03-H21-contract-boundary-v1.caf57f377be3.jpg',
sha256: 'caf57f377be31f6c018859d7e0b2149f1ce39b8ad95473a86f4e4c1f52b11f75',
},
'comic.s01.c06.p04': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P04-H22-ticket-cash-hands-v1.f05b92fd22c2.jpg',
sha256: 'f05b92fd22c222f61ea75dab51e2a0b27682362ea6f1e2ccc1e990cf11c2b469',
},
'comic.s01.c06.p05': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P05-H23-covered-staff-meal-v1.408f43b54b2b.jpg',
sha256: '408f43b54b2b0f7aa5c8fade4ff917a7461333dbfec8f44f6ee1f7b66671b88d',
},
'comic.s01.c06.p06': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P06-H24-protect-table-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P06-H24-protect-table-v1.84156df566a6.jpg',
sha256: '84156df566a673834861d991505b43495894fd3a7bbd87478ce4bdcf7d616e53',
},
'comic.s01.c06.p07': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P07-EM06-keep-seat-v1.c61d898fd982.jpg',
sha256: 'c61d898fd982b5c7953ca8dc448a8d96e5c5a3894a5647707b0290a8a3b0cc9b',
},
'comic.s01.c06.p08': {
kind: 'image',
localSeed: '/package-chapter-06/assets/comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c06/S01-C06-P08-table-held-cliffhanger-v1.bcb8ec7e4720.jpg',
sha256: 'bcb8ec7e472079206983c8e6b897ad9d611a70a280251b0cdd85462938de59a5',
},
'comic.s01.c07.p01': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P01-title-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P01-title-v1.42031fff6b37.jpg',
sha256: '42031fff6b3722bac2c389babd6f1eb283cb0b013d1c9a055292e585888dc720',
},
'comic.s01.c07.p02': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P02-opening-ensemble-v1.81e28334e743.jpg',
sha256: '81e28334e74358e7549d2ee82bd48d7bbcf16bf424274cdf4dc568f9f040aed2',
},
'comic.s01.c07.p03': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P03-H25-full-bowl-v1.fb9d1cd9910d.jpg',
sha256: 'fb9d1cd9910dfd50c039404134443e760e6afffc9ab19eeafb528de46d6a69a5',
},
'comic.s01.c07.p04': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P04-H26-ladle-across-table-v1.8e12ab04091a.jpg',
sha256: '8e12ab04091a938f2155573ae0d30896f668e3c2b77a3185481dc75156950ba3',
},
'comic.s01.c07.p05': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P05-H27-door-and-watch-v1.5d65745996d6.jpg',
sha256: '5d65745996d614acd9d38d960467658cbeea6c638e15a27861a73e2114ae01f4',
},
'comic.s01.c07.p06': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P06-H28-shift-table-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P06-H28-shift-table-v1.7365643f439b.jpg',
sha256: '7365643f439bbb1d2a87f9995e3e34f7383eae1754738e2ab69a54fe54a45c6a',
},
'comic.s01.c07.p07': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P07-EM07-listen-until-finished-v1.19ea61163655.jpg',
sha256: '19ea6116365526daa302aa43f9354cfd127ade2308808df565ac60ae6cb1aff5',
},
'comic.s01.c07.p08': {
kind: 'image',
localSeed: '/package-chapter-07/assets/comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c07/S01-C07-P08-order-slip-cliffhanger-v1.f7f84ebecba2.jpg',
sha256: 'f7f84ebecba290ae9aa9f5b1960896b2151c3a1964011835d64c80cee0fa073e',
},
'comic.s01.c08.p01': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P01-title-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P01-title-v1.b292709d6f22.jpg',
sha256: 'b292709d6f22e6865125f75c8b9b753a61467b09c90012b97eb206829fc64ddc',
},
'comic.s01.c08.p02': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P02-opening-ensemble-v1.4b755190ba3d.jpg',
sha256: '4b755190ba3d8d9e879b89bc9579909792b7ae82059ad73bf4a4d62bfb27b562',
},
'comic.s01.c08.p03': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P03-H29-extra-dishes-v1.2a5bc840cc2d.jpg',
sha256: '2a5bc840cc2dee6d4952ee632dc537ad7368034edbe416ff18cfef65b7a2689c',
},
'comic.s01.c08.p04': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P04-H30-rejected-box-v1.737e9996a8f3.jpg',
sha256: '737e9996a8f3d67a253d84455bfd2e96341a05e144d4b649e844875eb8eaf935',
},
'comic.s01.c08.p05': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P05-H31-menu-gaps-v1.41da420b4e7b.jpg',
sha256: '41da420b4e7b8c585950a998d32a5cb44e6b1fc855dbd7f403c4d786f49bf618',
},
'comic.s01.c08.p06': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P06-H32-removed-plaque-v1.fb66a9f2234c.jpg',
sha256: 'fb66a9f2234c6159678334201138e8236b4dd3f5cbc239cce0ec1e41dcddf111',
},
'comic.s01.c08.p07': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P07-EM08-back-kitchen-light-v1.6c748cf7c1a6.jpg',
sha256: '6c748cf7c1a6ea69a4be5eff221cda903e6a60f30fef95b4d7623b8d74aa54ea',
},
'comic.s01.c08.p08': {
kind: 'image',
localSeed: '/package-chapter-08/assets/comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c08/S01-C08-P08-cashbox-phone-cliffhanger-v1.2deaee916f92.jpg',
sha256: '2deaee916f92a02b7e326770ad0a6ec59410347cd1ea7133cbf61193fd7f4743',
},
'comic.s01.c09.p01': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P01-opening-memory-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P01-opening-memory-v1.59db94b9c5dd.jpg',
sha256: '59db94b9c5dd52822089ebdb749959c9f2b780e16d5e6acb21cd4deb082efc2f',
},
'comic.s01.c09.p02': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P02-opening-ensemble-v1.0584053b2e87.jpg',
sha256: '0584053b2e873ed91d448b2b643666e93f1733ef22dd641dfe9cdb4665d49e8d',
},
'comic.s01.c09.p03': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P03-H33-driver-no-alcohol-v1.9081d9c71e8f.jpg',
sha256: '9081d9c71e8f07c687428192cd51bb59b7a7dad9dc4ab630d924a9cd7cbc26af',
},
'comic.s01.c09.p04': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P04-H34-tea-toast-v1.cf00e6434a90.jpg',
sha256: 'cf00e6434a902127999662a040917b6807e36e4d20384426e0fb014f8eae33ff',
},
'comic.s01.c09.p05': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.jpg',
remotePath: 'comic/s01-c09/S01-C09-P05-H35-personal-plan-text-supported-v1-ffq2.5de6f5b46666.jpg',
sha256: '5de6f5b466664980c159f1c442be226297ea5d2832a4791ed6eda3acb18a085a',
},
'comic.s01.c09.p06': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P06-H36-stop-and-stay-v1.5bb10be0addb.jpg',
sha256: '5bb10be0addb68102b9b83569596523113c2b6e2053cc14cbe2b082807e55443',
},
'comic.s01.c09.p07': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.jpg',
remotePath: 'comic/s01-c09/S01-C09-P07-EM09-keep-my-seat-v1.9f4b4b8b1aa9.jpg',
sha256: '9f4b4b8b1aa98844603c0c5539a89196032663d341a444dc9512b9b81c8a6e4f',
},
'comic.s01.c09.p08': {
kind: 'image',
localSeed: '/package-chapter-09/assets/comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.jpg',
remotePath: 'comic/s01-c09/S01-C09-P08-push-cup-cliffhanger-v1-ffq2.b3628d1471f2.jpg',
sha256: 'b3628d1471f2c227db9a1cd2b1e00c5c92503aa7ef39fc602c054d8edc67e724',
},
'comic.s01.c10.p01': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P01-one-palm-space-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P01-one-palm-space-v1.42d2b657a20f.jpg',
sha256: '42d2b657a20f5db8e18e80155301c902132c911f57fda32e32752435675ec1d8',
},
'comic.s01.c10.p02': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P02-opening-ensemble-v1.30c7fa3f8e9a.jpg',
sha256: '30c7fa3f8e9af47a3289049c4e029e9c9888de93cb1a6af2dc5d19c906a3ae55',
},
'comic.s01.c10.p03': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P03-H37-standing-meal-v1.23e14923a5fd.jpg',
sha256: '23e14923a5fd3db39424fd764b1d795b2cb88e73293e1534ff2fa493e3ad05b9',
},
'comic.s01.c10.p04': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P04-H38-open-palm-v3.jpg',
remotePath: 'comic/s01-c10/S01-C10-P04-H38-open-palm-v3.be147c9d7c0c.jpg',
sha256: 'be147c9d7c0c5bccc7b5ddac206314c2ff96d8d424c27c0d01d87e9497f99cde',
},
'comic.s01.c10.p05': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P05-H39-phone-and-cold-meal-v1.a21bec0d89b6.jpg',
sha256: 'a21bec0d89b6d5322a7e8ee11679ab89c89f8e4ffc8afbea0387bed5b359e5dd',
},
'comic.s01.c10.p06': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P06-H40-table-not-usable-v1.6fa3594b0e5a.jpg',
sha256: '6fa3594b0e5ac61c432c9ee3d833c0f13bf757437c1cac6700d30826becca446',
},
'comic.s01.c10.p07': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P07-EM10-put-meal-down-v1.139439432818.jpg',
sha256: '13943943281892dced9132e2454c8e3b914dfda4d905668e5ed1b4d54dc6cb99',
},
'comic.s01.c10.p08': {
kind: 'image',
localSeed: '/package-chapter-10/assets/comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.jpg',
remotePath: 'comic/s01-c10/S01-C10-P08-plan-line-last-stool-v1.2499342b7c3c.jpg',
sha256: '2499342b7c3c92c101c264e59c700217989aa1157e2a95a55520ab968b4ee1a0',
},
'comic.s01.c11.p01': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P01-pen-above-paper-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P01-pen-above-paper-v1.840b82256bca.jpg',
sha256: '840b82256bca2a83bc655aa86faf0025dbd5fea3a75bd5eec4c32b974f74bb2a',
},
'comic.s01.c11.p02': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P02-opening-ensemble-v1.112f4fe7b4f9.jpg',
sha256: '112f4fe7b4f90a65e0769cdf78b00e2ede90b2206ca031c1fd0dc8e71bc19ff1',
},
'comic.s01.c11.p03': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P03-H41-display-and-seat-v1.23ef59f3f23d.jpg',
sha256: '23ef59f3f23dbc3de7dcde1fd1994ff4d2321bab5218aa0197f6fae797cf2e51',
},
'comic.s01.c11.p04': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P04-H42-inspect-old-table-v1.3e93fafafe75.jpg',
sha256: '3e93fafafe758c040aec7a2cd0f21bc1cf71656db593cc0babe65e99c69d0a50',
},
'comic.s01.c11.p05': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P05-H43-clear-passage-v1.c0c8c01f4e37.jpg',
sha256: 'c0c8c01f4e376f29ccfbfe8b84094f79d85839afbab1d09d5301740a10311ad5',
},
'comic.s01.c11.p06': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P06-H44-pen-before-signature-v1.4a805d19bb22.jpg',
sha256: '4a805d19bb229c6f6df349f06fffe9acfebc3176bcf0f3b63de12e328468ca6c',
},
'comic.s01.c11.p07': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P07-EM11-wrap-and-record-v1.9fb66ad32712.jpg',
sha256: '9fb66ad32712d5ad9e8ea85e4631e2ecf5bbc7d900a49066095c45579497a79a',
},
'comic.s01.c11.p08': {
kind: 'image',
localSeed: '/package-chapter-11/assets/comic/s01-c11/S01-C11-P08-can-we-open-now-v1.jpg',
remotePath: 'comic/s01-c11/S01-C11-P08-can-we-open-now-v1.b80149fb0407.jpg',
sha256: 'b80149fb0407529bf25dcd4188e79bc9d17a25e794b2d8e735b189792090e1de',
},
'comic.s01.c12.p01': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P01-key-pauses-half-turn-v1.e7f7138b21bb.jpg',
sha256: 'e7f7138b21bb415ff7f3fee4427973d475c5d24bf749bc0ee6bb05d8b06fee0a',
},
'comic.s01.c12.p02': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P02-modern-table-ensemble-v1.6d2ba8cf6cfe.jpg',
sha256: '6d2ba8cf6cfe0212976b65986eda881c893673933d319a9a0579adebbf373602',
},
'comic.s01.c12.p03': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P03-H45-provenance-chain-v1.c07a27557685.jpg',
sha256: 'c07a27557685087e0d7c772f2288d99e6847db42099ae32c72ded6f4003b18e9',
},
'comic.s01.c12.p04': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P04-H46-real-choice-counter-v1.1a5c07f21eae.jpg',
sha256: '1a5c07f21eaed0cdf76cec99129176cb9f8c0f608e985e5e2bfbe6e38cf1190e',
},
'comic.s01.c12.p05': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P05-H47-plus-minus-at-family-table-v1.695c7ebf0573.jpg',
sha256: '695c7ebf057367e728775292c2c483f66b4b7df757e568dfa247bf7a90cdb5f0',
},
'comic.s01.c12.p06': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P06-H48-card-is-not-a-promise-v1.9b332e2a0d6d.jpg',
sha256: '9b332e2a0d6dcd499963a3ae7184aa02ce752705ba3526da3bee6a6080abed76',
},
'comic.s01.c12.p07': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P07-EM12-hand-choice-back-v1.338240b0bc54.jpg',
sha256: '338240b0bc5417c467024398bc891d099a10c76384861a5ab794bdd0fcae8db5',
},
'comic.s01.c12.p08': {
kind: 'image',
localSeed: '/package-chapter-12/assets/comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.jpg',
remotePath: 'comic/s01-c12/S01-C12-P08-rolled-speech-cliffhanger-v1.a6aeee055eed.jpg',
sha256: 'a6aeee055eed00449f1945a5e6c1f01c9a1b7e63e96224b115bedf23f051cb6f',
},
'comic.s01.c13.p01': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P01-three-chopsticks-collide-v1.2184f354e70f.jpg',
sha256: '2184f354e70f1255420a8f1da36fdbe9f7b1f7150818a21bdb434ffabf582e7e',
},
'comic.s01.c13.p02': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P02-opening-ensemble-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P02-opening-ensemble-v1.a9abf81e15a6.jpg',
sha256: 'a9abf81e15a66c0ae9e757e1f690947728a4c17a7a67059c50c4ed72a9f26198',
},
'comic.s01.c13.p03': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P03-H49-ask-before-serving-v1.13813c3961f7.jpg',
sha256: '13813c3961f74c0b29810f9caab66f0a9b3b24ef15324f82ec30340f2cae4f9b',
},
'comic.s01.c13.p04': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P04-H50-name-card-leaves-first-v1.d4002bf11c74.jpg',
sha256: 'd4002bf11c746f495a13903447afe6b28c2cb13f4c69b32f512f7a4400baa44d',
},
'comic.s01.c13.p05': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P05-H51-family-double-standard-v1.2c5923ca40e2.jpg',
sha256: '2c5923ca40e232135128842308948099bad5e2e8f4ed481c4030b72965329aff',
},
'comic.s01.c13.p06': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P06-H52-tape-stays-rolled-v1.8ccbb50e6342.jpg',
sha256: '8ccbb50e6342c3063c060291583c9960b9b9bbc8fa747308558e88368516173d',
},
'comic.s01.c13.p07': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P07-EM13-listen-to-child-v1.edac7d0ccb9f.jpg',
sha256: 'edac7d0ccb9f0c1306265a53e6188e900a918558ef379ac9537ca07e0b68d45b',
},
'comic.s01.c13.p08': {
kind: 'image',
localSeed: '/package-chapter-13/assets/comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.jpg',
remotePath: 'comic/s01-c13/S01-C13-P08-cook-crosses-threshold-v1.f40996a2f7d0.jpg',
sha256: 'f40996a2f7d01d33c6413fa018e049bbbada0a5cc774bbc68a364f2cefa79dcc',
},
'comic.s01.c14.p01': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P01-lid-opens-old-scent-v1.6609c450b024.jpg',
sha256: '6609c450b0246d0f00cb1358d2deaba7dbe25ef4287e81fcf46eb761a70248f2',
},
'comic.s01.c14.p02': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P02-responsibility-ensemble-v1.15678b028566.jpg',
sha256: '15678b0285667a5ce84e0f486d8c8ee9ffe60ef59a6850dfcadfc0b400febd48',
},
'comic.s01.c14.p03': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P03-H53-ordinary-soup-no-myth-v1.9d1a6e4c130a.jpg',
sha256: '9d1a6e4c130ad9e3873c1c73dfcadd58c216b2cc87f85c3228a2a8d5d448d365',
},
'comic.s01.c14.p04': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P04-H54-evidence-chain-v1.899c26b37f6a.jpg',
sha256: '899c26b37f6a0ff45d13bdad0ef14c2015467f669da5ffbeb712feaf60db256e',
},
'comic.s01.c14.p05': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P05-H55-doctor-takes-ordinary-seat-v1.b87841f55cc9.jpg',
sha256: 'b87841f55cc96f46cd199a65098ff2d4e0085da7cecf1b880d7fa073dff03801',
},
'comic.s01.c14.p06': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P06-H56-tea-stays-in-the-circle-v1.c17546b0c7e9.jpg',
sha256: 'c17546b0c7e960ea8cae5fcc2cfd7e5daef907a3ecbc49d4e7823f6cf99ab3bd',
},
'comic.s01.c14.p07': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P07-EM14-watch-face-down-v1.d0bcbbb428bc.jpg',
sha256: 'd0bcbbb428bc9aeb5c53b0bbff2b3b6bb8b0eb3391a35ee2229e9f825b302b8d',
},
'comic.s01.c14.p08': {
kind: 'image',
localSeed: '/package-chapter-14/assets/comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.jpg',
remotePath: 'comic/s01-c14/S01-C14-P08-measure-the-empty-place-v1.09f5a22c8a7f.jpg',
sha256: '09f5a22c8a7f63deb287191b1758eed82e838b3a0a3bd6f753122a6ed946cf2c',
},
'comic.s01.c15.p01': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P01-chair-opens-for-late-workers-v1.194fda1fa427.jpg',
sha256: '194fda1fa427f25c020af7395caa55fffbc546843c724561402a20f3f2d5b8ab',
},
'comic.s01.c15.p02': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P02-everyone-makes-room-ensemble-v1.b0e82af85954.jpg',
sha256: 'b0e82af85954a2bd1da0676810fdbd40f3357a756442768332d67768d217fd8b',
},
'comic.s01.c15.p03': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P03-H57-three-real-portions-v1.b3838eb91bea.jpg',
sha256: 'b3838eb91bea2b1f246540996f796265912a5b6dd38abc215646db65c4560173',
},
'comic.s01.c15.p04': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P04-H58-water-within-reach-v1.2471d7dd78aa.jpg',
sha256: '2471d7dd78aaea522d00454592863e36df6402211f25fdc7687f55d7c42383ea',
},
'comic.s01.c15.p05': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P05-H59-ask-before-serving-v1.215691878476.jpg',
sha256: '21569187847697f005939f8ff56a5144a3c15e06041749b6fbfa44374787540a',
},
'comic.s01.c15.p06': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P06-H60-check-before-packing-v1.3f1cd9abb08e.jpg',
sha256: '3f1cd9abb08e28239b612b77f17cdd533efd976e39e4b4d4010fbd7598dbdf50',
},
'comic.s01.c15.p07': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.jpg',
remotePath: 'comic/s01-c15/S01-C15-P07-EM15-old-bowl-before-choice-v1.98a32b939746.jpg',
sha256: '98a32b939746dc1fa8c039ecba168f9290007f93527c9e355ee25e6aa7796e98',
},
'comic.s01.c15.p08': {
kind: 'image',
localSeed: '/package-chapter-15/assets/comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.jpg',
remotePath: 'comic/s01-c15/S01-C15-P08-people-seated-season-close-v2.520d68ce0a72.jpg',
sha256: '520d68ce0a7297e431b335d7171d3d4a8acf598c4759ef1502ef945d43e0f113',
},
'character.female-cook': {
kind: 'image',
localSeed: '/assets/characters/female-cook.jpg',
remotePath: 'characters/female-cook.c56420b2c632.jpg',
sha256: 'c56420b2c632315aab3a44317b38358f5823424c97f80a1a8e046b0ad7ffec4f',
},
'character.lele': {
kind: 'image',
localSeed: '/assets/characters/lele.jpg',
remotePath: 'characters/lele.807f5232b1e8.jpg',
sha256: '807f5232b1e8a855dac0147bbdb203237d586ee00e2234e50ca1f30a107cc168',
},
'character.lin-xiulan': {
kind: 'image',
localSeed: '/assets/characters/lin-xiulan.jpg',
remotePath: 'characters/lin-xiulan.56e84abec4e5.jpg',
sha256: '56e84abec4e5bfa7bf1167197d1cf73e7a5cd170bf39dbe1d6123b64b0648361',
},
'character.qin-xiaoman': {
kind: 'image',
localSeed: '/assets/characters/qin-xiaoman.jpg',
remotePath: 'characters/qin-xiaoman.22fa229d5b09.jpg',
sha256: '22fa229d5b091943e2ff14a200205bb69081c6bfb467ffdb26685af65c6a2cb5',
},
'character.qin-zhicheng': {
kind: 'image',
localSeed: '/assets/characters/qin-zhicheng.jpg',
remotePath: 'characters/qin-zhicheng.7f4be48ece92.jpg',
sha256: '7f4be48ece925311081874279c63c0f5758ace85629e2b28a5f19efac0ac34d2',
},
'character.tang-mingyuan': {
kind: 'image',
localSeed: '/assets/characters/tang-mingyuan.jpg',
remotePath: 'characters/tang-mingyuan.5ca2fa88f885.jpg',
sha256: '5ca2fa88f88596f3618147e6679da0c1f7d116c476fec7c424e4ebc70ff5aceb',
},
'character.tang-shouan': {
kind: 'image',
localSeed: '/assets/characters/tang-shouan.jpg',
remotePath: 'characters/tang-shouan.4ed8d25ca911.jpg',
sha256: '4ed8d25ca9111f8a6a8597c355ad45c0e9f010c778b4e108d9b9126498f51b89',
},
'character.xiaozhen': {
kind: 'image',
localSeed: '/assets/characters/xiaozhen.jpg',
remotePath: 'characters/xiaozhen.d5e9614f6006.jpg',
sha256: 'd5e9614f60061b11914d8bfc70fa5ab18ae9c3a00bcd5e4de85dcb058e5174cb',
},
'character.zhao-jianguo': {
kind: 'image',
localSeed: '/assets/characters/zhao-jianguo.jpg',
remotePath: 'characters/zhao-jianguo.1c393fb68b02.jpg',
sha256: '1c393fb68b020b7f27bde617fb663cd943f8e184b1e78d63506cf6821a88c8e0',
},
'scene.gui-xiang.1978': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-1978.jpg',
remotePath: 'scenes/gui-xiang-1978.bb21d9342e37.jpg',
sha256: 'bb21d9342e370ba3aed9b47555a3b77e1be48674f786aaf0ca753245c978a5d5',
},
'scene.gui-xiang.1995': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-1995.jpg',
remotePath: 'scenes/gui-xiang-1995.a9701e2d55ad.jpg',
sha256: 'a9701e2d55ad94819becf074072f63e21d6cfbbcfbe58a483ac2e35ee10d206f',
},
'scene.gui-xiang.1998': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-1998.jpg',
remotePath: 'scenes/gui-xiang-1998.d4d7bebfd04a.jpg',
sha256: 'd4d7bebfd04a8b755ba393699708f8971b525fc29f57b457334f73c525d1be14',
},
'scene.gui-xiang.2001': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-2001.jpg',
remotePath: 'scenes/gui-xiang-2001.9143455b349d.jpg',
sha256: '9143455b349d990d141c6d971a90c31295ec56665502f3d0c55feee6517cfba8',
},
'scene.gui-xiang.2003': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-2003.jpg',
remotePath: 'scenes/gui-xiang-2003.68349c74c916.jpg',
sha256: '68349c74c9168d16edb91ad308e3071e70b9a9b9abebb21bca8291df1e1f3431',
},
'scene.gui-xiang.2008': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-2008.jpg',
remotePath: 'scenes/gui-xiang-2008.c0fd9418aec2.jpg',
sha256: 'c0fd9418aec24f2599d52167f15dbba1b1696295df309a931e125da530bc62f5',
},
'scene.gui-xiang.2026': {
kind: 'image',
localSeed: '/assets/scenes/gui-xiang-2026.jpg',
remotePath: 'scenes/gui-xiang-2026.55e63e85388e.jpg',
sha256: '55e63e85388e3c64d63167aadbfce3d0b62762d55aeb144a02508bd67cb2c6a7',
},
'audio.cast-audition.v1': {
kind: 'audio',
reviewStatus: 'audition',
localSeed: '',
remotePath: 'audio/cast-audition-v1.7129d884f445.mp3',
sha256: '7129d884f44542434e131efb86f2f5619bedf188a1d8b21e7d0864131b58dd18',
},
}
// Legacy C01 cue-level seeds are retained as immutable backup assets only.
// The reader no longer maps them as page narration; C01 uses full-page tracks
// through the two package-local audio player pages instead.
for (const cueId of [
'S01-C01-MS007','S01-C01-MS010',
]) {
const file = `${cueId}.mp3`
const sha256 = {
'S01-C01-MS007': '1ebae89180bcfef9d1efd174bc6be466593e59fd43366af50603cc5d2c71dde8', 'S01-C01-MS010': '628e19cea16a617c2600d9321339aaa78da683fd33b9c1fd6482a870702ee597',
}[cueId]
releaseAssets[`audio.s01.c01.${cueId.toLowerCase()}`] = { kind: 'audio', reviewStatus: 'approved', localSeed: `/package-game/assets/audio/${file}`, remotePath: `audio/${file}.${sha256.slice(0, 12)}.mp3`, sha256 }
}
const audioC02Sha256 = {
'S01-C02-MS001': '580bbaa7effcdbd1af308842b79943069d22753d74ca9d27f6bf24e216f07233',
'S01-C02-MS002-ATTR': 'aa54f392e8d035d9952cfc8ff127d1bd7069f12e8c6c3a08b9c10da349fb46a8',
'S01-C02-MS002': 'ccb9fb8872e49880a5e11105b2c721cddef264fa248120989a61806c99995ffe',
'S01-C02-MS003': '3f3fc816f6e79d4ee83ab36cd1c2986c9898254bb097582124c50c529bed169f',
'S01-C02-MS004': 'e9099bad6986b0a6f48094fb033305045d1d97813d42d46b131186d43a639f7c',
'S01-C02-MS005': '6ac0d1f9bea8f4a3280ccad178cdd1976c8735b7bd63c261385a493fee44d34e',
'S01-C02-MS006': '067715e6a3715433d499bfd55cd637a6d1f149e6b78b37588d5675c3429f3e88',
'S01-C02-MS007': '7d2c022a7e2a4ccb0bb9690fe7cea6cb677f390255e71a7727e9dc22b0511a8c',
'S01-C02-MS008': '288131da5b75ec8257345d0aa1c99c3b5ec534435ca02f7040ebbd5542cf940f',
'S01-C02-MS009': '5ad03edbc491459429fcd8098a3c56f86f5d4519eca75a8427bdd5a4a50fcda2',
'S01-C02-MS010': 'd371d1db33d9adbc2b0f8f8fbb9b0d15cd81a43cb3585d261d73a122608ce71d',
'S01-C02-MS011': '2606be3875e8461b102d0417326c2d8cf44207c5cd6662cda6e75ee50fe0baeb',
'S01-C02-MS012': '33dc5837d97a745024e080ab76c11af33d492c707e129338a11b9662c64d5b9a',
'S01-C02-MT000': '5085e1471366cd7296a1e5bc170b80a26e24d7dff33d768b11fe3f3b7f56586e',
'S01-C02-TE900': 'd57638264944a9bb87a3433006f70eba01b08ba7d3220a50d358bbfc77b6a9c1',
}
const audioC03Sha256 = {
'S01-C03-MS001': 'b27feb03e2f5db957fa0e81aa5b9ca3e498620cfd2521fdb70b1252343fb9c43',
'S01-C03-MS002-ATTR': 'ea3de72562174a4e1b48480b368401df890b2becf98e3fdadb46e58eb3a771dd',
'S01-C03-MS002': 'c108896a42d9ec316c7c1dea41d3855edaf577c85e16efede0b28e10613cf83c',
'S01-C03-MS003': 'af5b0743979b0ae87c7e0c7a17b20940eebe75ab9cd1bb6b29ce63afc6039184',
'S01-C03-MS004': 'c131d2d80ad17a7e6bad708efc919c6349269d320b20eee2dae3393f906f27da',
'S01-C03-MS005-ATTR': '4ad0fd3ea4ef7996a95fce3201ecbb05754ea9af08e87f4a03ab664ccf9c6a6e',
'S01-C03-MS005': '46de0ae7e37f855412bda3e441fe3d537bb7979f5db0260508dccfd1b543a442',
'S01-C03-MS006': '2ae6d3ae24430162941f5ecdb4d426a4e865e6fab0ee37b1445eca1c71a374f5',
'S01-C03-MS007-ATTR': 'f172cf850e6f90e8209b7691d5ecd6af637c6bc4f1cbe25206cbe9e78ebcb0c0',
'S01-C03-MS007': '68757f28e84dd17fb85faa19eebc4e1e8d6d39e1d49687621606cb61cb738038',
'S01-C03-MS008': 'd9352fc81b3bae1bf49f53a742745f7c7ab418e406b1b5c27d54f5da0e2d62f7',
'S01-C03-MS009': '97d9395b869e7faa051a461f0ac927aaf3cf73d9ca9cd27b3d0af200dc1131ed',
'S01-C03-MS010': '49467f7ac6584f5a9d1e355d4ca2bce5732dd037b6ff22dbf6e899513ed6e1f0',
'S01-C03-MS011': '6d76cac756daae850530e4fc295fffec5593af46009be35064fb7820dd54ea25',
'S01-C03-MS013': '8c50a597cc7fd01d93d2fc6c2ba27b85690fcde5ce2eea8323b7587889e2771a',
'S01-C03-MS014': '8eb817be47b0d933acc8a587b3226bb42160cb64f02fea9f6bbdcf773fc7dfa0',
'S01-C03-MS015': '661905745a5bd542da92d2680ab589d80f3e7d8236401acdb61b81b461f2e6bb',
'S01-C03-MT000': '374390a364c06922b44dca48e7ffca51f8775ae0d02bff8548aab3e1ff122a65',
'S01-C03-TE900': 'f9d770f2104da0e37d1b479f3e25479e49af4ec84ef51339940aef8d90f63993',
}
// C02/C03 cue files are retained only as rollback/source material. A single
// cue is not a complete page track, so these assets must not pass the runtime
// audio gate or appear as “试听本页”.
for (const [chapterNumber, audioHashes] of Object.entries({ '02': audioC02Sha256, '03': audioC03Sha256 })) {
for (const [cueId, sha256] of Object.entries(audioHashes)) {
const file = `${cueId}.mp3`
releaseAssets[`audio.s01.c${chapterNumber}.${cueId.toLowerCase()}`] = {
kind: 'audio',
reviewStatus: 'reserved',
localSeed: `/package-chapter-${chapterNumber}/assets/audio/${file}`,
remotePath: `audio/${cueId}.${sha256.slice(0, 12)}.mp3`,
sha256,
}
}
}
module.exports = {
RELEASE_ASSET_MANIFEST_VERSION,
releaseAssets,
}
@@ -0,0 +1,97 @@
/**
* C02-C15 remote full-page audio manifest v1.
*
* Import contract for a future reviewed batch:
*
* 'S01-C09-P05': Object.freeze({
* pageId: 'S01-C09-P05',
* chapterNumber: 9,
* pageNumber: 5,
* assetId: 'audio.page.s01.c09.p05',
* durationSeconds: 42.1,
* reviewStatus: 'approved',
* })
*
* The matching releaseAssetManifest entry must independently be an approved,
* remote-only, immutable hash-named audio asset. Automated QA, pending human
* listening, and missing CDN records must never be relabelled as approved.
*/
const REMOTE_PAGE_AUDIO_MANIFEST_VERSION = 1
const REMOTE_PAGE_AUDIO_ID_PATTERN = /^S01-C(0[2-9]|1[0-5])-P(0[1-8])$/
const SHA256_PATTERN = /^[a-f0-9]{64}$/
// Deliberately empty until full-page tracks pass the required human reviews
// and their immutable CDN objects are available. This is a release gate, not
// a placeholder that the UI may treat as playable.
const remotePageAudioPages = Object.freeze({})
function clean(value) {
return typeof value === 'string' ? value.trim() : ''
}
function remotePageAudioAssetId(pageId) {
const stablePageId = clean(pageId)
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
if (!match) return ''
return `audio.page.s01.c${match[1]}.p${match[2]}`
}
function getApprovedRemotePageAudio(
pageId,
releaseAssets = {},
pageManifest = remotePageAudioPages,
) {
const stablePageId = clean(pageId)
const match = REMOTE_PAGE_AUDIO_ID_PATTERN.exec(stablePageId)
if (!match) return null
const entry = pageManifest && pageManifest[stablePageId]
if (!entry || typeof entry !== 'object') return null
const chapterNumber = Number(match[1])
const pageNumber = Number(match[2])
const assetId = remotePageAudioAssetId(stablePageId)
const durationSeconds = Number(entry.durationSeconds)
if (
clean(entry.reviewStatus).toLowerCase() !== 'approved'
|| entry.pageId !== stablePageId
|| Number(entry.chapterNumber) !== chapterNumber
|| Number(entry.pageNumber) !== pageNumber
|| entry.assetId !== assetId
|| !Number.isFinite(durationSeconds)
|| durationSeconds <= 0
) {
return null
}
const asset = releaseAssets && releaseAssets[assetId]
const sha256 = asset && typeof asset.sha256 === 'string'
? asset.sha256
: ''
const remotePath = asset && asset.remotePath
const expectedRemotePath = (
`audio/${stablePageId.slice(0, 7).toLowerCase()}`
+ `/${stablePageId}.${sha256.slice(0, 12)}.mp3`
)
if (
!asset
|| asset.kind !== 'audio'
|| clean(asset.reviewStatus).toLowerCase() !== 'approved'
|| asset.localSeed !== ''
|| !SHA256_PATTERN.test(sha256)
|| typeof remotePath !== 'string'
|| remotePath !== expectedRemotePath
) {
return null
}
return { ...entry, assetId }
}
module.exports = {
REMOTE_PAGE_AUDIO_ID_PATTERN,
REMOTE_PAGE_AUDIO_MANIFEST_VERSION,
getApprovedRemotePageAudio,
remotePageAudioAssetId,
remotePageAudioPages,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
{
"navigationBarTitleText": "唐侦探",
"pageOrientation": "landscape",
"disableScroll": true
}
@@ -0,0 +1,932 @@
<view
wx:if="{{chapter}}"
class="chapter-page font-{{fontScale}} {{compactHeight ? 'compact-height' : ''}}"
style="{{pageStyle}}"
>
<view class="comic-topbar {{storyFirstComicMode ? ((chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct')) ? 'c1-book-topbar is-back' : ('c1-book-topbar is-front ' + (frontControlsRevealed ? 'is-controls-revealed' : 'is-controls-hidden'))) : ''}}" style="{{topbarStyle}}">
<block wx:if="{{!storyFirstComicMode || chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct') || frontControlsRevealed}}">
<button class="comic-catalog-button" bindtap="goCatalog" aria-label="返回目录"> 目录</button>
<button
wx:if="{{storyFirstComicMode && !comicPreviousDisabled}}"
class="comic-top-previous-button"
bindtap="previousComicPage"
aria-label="上一张画"
></button>
<view class="comic-bookmark">
<text class="comic-bookmark-label">
第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回{{compactHeight ? '' : ' · ' + chapter.year}}
</text>
<text class="comic-page-count">{{currentPageNumber}}{{comicPageCount}}</text>
</view>
<button
wx:if="{{!storyFirstComicMode && !currentPage.audioAvailable && !currentPage.audioCompanionAvailable}}"
class="comic-page-audio-slot is-text-mode {{storyFirstComicMode ? 'c1-audio-slot' : ''}}"
bindtap="openTextReading"
aria-label="本页可以直接看文字"
>
<text class="comic-page-audio-icon" aria-hidden="true">阅</text>
<view class="comic-page-audio-copy">
<text class="comic-page-audio-label">直接看文字</text>
<text class="comic-page-audio-meta">按页慢慢看</text>
</view>
</button>
<button
wx:elif="{{currentPage.audioAvailable || currentPage.audioCompanionAvailable}}"
class="comic-page-audio-slot {{storyFirstComicMode ? 'c1-audio-slot' : ''}} {{audioLoading ? 'is-loading' : audioPlaying ? 'is-playing' : audioPercent > 0 ? 'is-replay' : 'is-ready'}}"
disabled="{{audioLoading}}"
bindtap="toggleComicPageAudio"
data-audio-cue-count="{{currentPage.audioCueCount}}"
aria-label="本页语音播放控制"
>
<text class="comic-page-audio-icon" aria-hidden="true">
{{audioPlaying ? 'Ⅱ' : '▶'}}
</text>
<view class="comic-page-audio-copy">
<text wx:if="{{audioLoading}}" class="comic-page-audio-label">准备中</text>
<text wx:elif="{{audioPlaying}}" class="comic-page-audio-label">暂停</text>
<text wx:elif="{{audioPercent > 0}}" class="comic-page-audio-label">重听</text>
<text wx:else class="comic-page-audio-label">听一听</text>
<text class="comic-page-audio-meta">
{{audioCurrent}} / {{audioDuration === '0:00' ? currentPage.audioDurationLabel : audioDuration}}
</text>
</view>
</button>
<button
wx:if="{{!storyFirstComicMode || chapterOneBackOpen || (chapterOneEmotionOpen && emotionResult) || (decisionOpen && resultMode === 'correct')}}"
class="comic-font-button"
bindtap="toggleFont"
aria-label="{{fontScale === 'large' ? '把整页文字再放大' : '恢复标准大字'}}"
>
{{fontScale === 'large' ? '大字' : '标准字'}}
</button>
<button
wx:if="{{storyFirstComicMode && !chapterOneBackOpen && !decisionOpen && !chapterOneEmotionOpen}}"
class="c1-front-action-button"
bindtap="openCurrentFrontAction"
aria-label="{{currentPage.type === 'event'
? (currentEventCompleted ? '点一下,再看背面' : '点点人物,翻到背面')
: currentPage.type === 'emotion'
? (chapterFinished ? '点一下,看看章末' : '点一下,听听心里话')
: '点一下,翻到背面'}}"
>
{{currentPage.type === 'event'
? (currentEventCompleted ? '再看背面' : '点点人物')
: currentPage.type === 'emotion'
? (chapterFinished ? '看看章末' : '听听心里话')
: '翻到背面'}}
</button>
</block>
</view>
<view class="comic-reader">
<block wx:if="{{storyFirstComicMode}}">
<view class="c1-comic-page {{chapterOneBackOpen ? 'is-back' : 'is-front'}}">
<block wx:if="{{chapterOneBackOpen}}">
<view class="c1-back-paper">
<view class="c1-back-evidence">
<image
wx:if="{{comicImageSrc}}"
class="c1-back-image"
src="{{comicImageSrc}}"
mode="aspectFit"
></image>
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
<text class="c1-back-stamp">
{{currentPage.type === 'memory' ? '桂香记忆' : currentPage.type === 'event' ? '这回事' : '画背后的事'}}
</text>
</view>
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
<view wx:if="{{currentPage.type === 'memory'}}" class="c1-back-copy-inner">
<text class="c1-back-kicker">第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 章末记忆</text>
<text class="c1-back-title">{{currentPage.headline}}</text>
<text class="c1-back-body">{{currentPage.caption}}</text>
<view class="c1-back-callout">
<text class="c1-back-label">桌边回声</text>
<text>{{currentPage.memoryCard.tableEcho}}</text>
</view>
<view class="c1-back-action">
<text class="c1-back-label">带回生活里</text>
<text>{{currentPage.memoryCard.lifeAction}}</text>
</view>
<text class="c1-back-family">“{{currentPage.memoryCard.familyLine}}”</text>
<view class="c1-memory-secondary-actions">
<button
class="c1-memory-secondary-action"
bindtap="openLifeReport"
aria-label="查看本回桂香生活观察"
>看看生活观察</button>
<button
class="c1-memory-secondary-action"
open-type="share"
aria-label="发给家人,一起聊聊这一页"
>发给家人聊聊</button>
</view>
</view>
<view wx:elif="{{currentPage.type === 'event' && currentEventCompleted && chapterOneReviewEvent}}" class="c1-back-copy-inner">
<text class="c1-back-kicker">{{currentPage.actorName}} · 刚才看见的</text>
<text class="c1-back-title">{{currentPage.headline}}</text>
<text class="c1-back-body">{{chapterOneReviewEvent.actionDescription}}</text>
<view class="c1-back-callout">
<text class="c1-back-label">您刚才瞧见的</text>
<text>{{chapterOneReviewEvent.evidence}}</text>
</view>
<view class="c1-back-action">
<text class="c1-back-label">下回遇到这事</text>
<text>{{chapterOneReviewEvent.actionAdvice}}</text>
</view>
</view>
<view wx:elif="{{currentPage.type === 'event' && chapterOneReviewEvent}}" class="c1-back-copy-inner">
<text class="c1-back-kicker">{{currentPage.actorName}} · 这回事</text>
<text class="c1-back-title">{{currentPage.headline}}</text>
<text class="c1-back-body">{{currentPage.caption}}</text>
<view wx:if="{{currentPage.shortDialogue}}" class="c1-back-callout">
<text class="c1-back-label">桌边一句话</text>
<text>“{{currentPage.shortDialogue}}”</text>
</view>
</view>
<view wx:else class="c1-back-copy-inner">
<text class="c1-back-kicker">{{currentPage.eyebrow || chapter.year + ' · ' + chapter.location}}</text>
<text class="c1-back-title">{{currentPage.headline}}</text>
<text class="c1-back-body">{{currentPage.displayCaption}}</text>
<text wx:if="{{currentPage.secondaryCaption}}" class="c1-back-secondary">{{currentPage.secondaryCaption}}</text>
</view>
</scroll-view>
<text
wx:if="{{currentPage.type === 'memory' || (currentPage.type === 'event' && currentEventCompleted)}}"
class="c1-scroll-hint"
>上划接着看 ↑</text>
<view wx:if="{{currentPage.type === 'memory'}}" class="c1-back-footer c1-memory-footer">
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneBack">回去看画</button>
<button wx:if="{{chapterNumber >= chapterCount}}" class="c1-back-button primary" bindtap="goCatalog">回到目录</button>
<button wx:else class="c1-back-button primary" bindtap="nextChapter">翻下一回</button>
</view>
<view wx:else class="c1-back-footer">
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneBack">回去看画</button>
<button
wx:if="{{currentPage.type === 'event' && !currentEventCompleted}}"
class="c1-back-button primary"
bindtap="startChapterOneDecisionFromBack"
>聊聊这事</button>
<button
wx:else
class="c1-back-button primary"
bindtap="nextChapterOnePageFromBack"
>翻下一张</button>
</view>
</view>
</block>
<block wx:elif="{{currentPage.type === 'emotion' && chapterOneEmotionOpen && emotionResult}}">
<view class="c1-back-paper">
<view class="c1-back-evidence">
<image
wx:if="{{comicImageSrc}}"
class="c1-back-image"
src="{{comicImageSrc}}"
mode="aspectFit"
></image>
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
<text class="c1-back-stamp">桌边回声</text>
</view>
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
<view class="c1-back-copy-inner">
<text class="c1-back-kicker">没有标准答案</text>
<text class="c1-back-title">{{currentPage.headline}}</text>
<text class="c1-back-body">{{emotionResult.feedback}}</text>
<view class="c1-back-callout">
<text class="c1-back-label">这一桌记住了</text>
<text>{{emotionResult.echo}}</text>
</view>
</view>
</scroll-view>
<view class="c1-back-footer">
<button class="c1-back-button secondary" aria-label="回头再看一遍画面" bindtap="closeChapterOneEmotion">回去看画</button>
<button class="c1-back-button primary" bindtap="finishChapterOneEmotionAndTurn">收下这句话</button>
</view>
</view>
</block>
<block wx:elif="{{currentPage.type === 'emotion' && chapterOneEmotionOpen && !emotionResult}}">
<view class="c1-interaction-paper c1-emotion-interaction-paper">
<view class="c1-interaction-evidence">
<image
wx:if="{{comicImageSrc}}"
class="c1-interaction-image"
src="{{comicImageSrc}}"
mode="aspectFit"
></image>
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
<text class="c1-back-stamp">桌边一句话</text>
</view>
<view class="c1-interaction-panel">
<button class="c1-tray-close" bindtap="closeChapterOneEmotion" aria-label="收起选择,先回画面">先看画</button>
<view class="c1-tray-heading">
<text class="c1-tray-kicker">这一刻,您想怎么说?</text>
<text class="c1-tray-question">{{currentPage.prompt}}</text>
</view>
<view class="c1-choice-stack">
<button
wx:for="{{chapter.emotionMoment.playerChoices}}"
wx:key="choiceId"
class="c1-natural-choice"
data-choice="{{item.choiceId}}"
bindtap="chooseEmotion"
>{{item.text}}</button>
</view>
</view>
</view>
</block>
<block wx:else>
<view class="c1-front-stage">
<image
wx:if="{{comicImageSrc}}"
class="c1-front-image"
src="{{comicImageSrc}}"
mode="aspectFit"
bindload="onComicImageLoad"
binderror="onComicImageError"
></image>
<view wx:else class="c1-front-fallback">
<text>{{comicImageError}}</text>
<text>{{currentPage.sceneAlt}}</text>
</view>
<view class="c1-front-grain"></view>
<button
wx:if="{{!frontControlsRevealed}}"
class="c1-front-reveal-gate"
bindtap="revealCurrentFrontControls"
aria-label="轻点画面,显示阅读按钮"
></button>
<block wx:else>
<button
wx:if="{{frontControlsRevealed && currentInteractionEnabled && currentPage.type === 'event' && !comicImageUsingFallback}}"
class="c1-person-hotspot"
style="{{currentPage.actorHotspot.style}}"
bindtap="openComicActor"
aria-label="点点{{currentPage.actorName}},翻到这一幕背面"
><view class="c1-hotspot-ring"></view></button>
<button
wx:elif="{{frontControlsRevealed && currentPage.type === 'event' && currentEventCompleted}}"
class="c1-full-page-tap"
bindtap="openChapterOneBack"
aria-label="翻到这一幕的背面"
></button>
<button
wx:elif="{{frontControlsRevealed && currentPage.type === 'emotion'}}"
class="c1-full-page-tap"
bindtap="openChapterOneEmotion"
aria-label="{{chapterFinished ? '轻触画面,翻到章末' : '轻触画面,听听这一桌心里话'}}"
></button>
<button
wx:else
class="c1-full-page-tap"
bindtap="openChapterOneBack"
aria-label="轻触画面,翻到这一页背面"
></button>
</block>
<text wx:if="{{frontHintVisible}}" class="c1-front-first-hint">轻点画面,按钮就出来了</text>
</view>
</block>
</view>
</block>
<block wx:elif="{{currentPage.type === 'emotion'}}">
<view class="comic-special-page emotion-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}">
<view class="special-art">
<image
wx:if="{{comicImageSrc}}"
class="special-art-image"
src="{{comicImageSrc}}"
mode="aspectFit"
bindload="onComicImageLoad"
binderror="onComicImageError"
></image>
<view wx:else class="special-art-fallback">
<text>{{comicImageError}}</text>
<text>{{currentPage.sceneAlt}}</text>
</view>
<view class="comic-paper-grain"></view>
</view>
<view class="emotion-panel">
<scroll-view class="emotion-paper" scroll-y enhanced show-scrollbar="{{true}}">
<view class="emotion-context">
<text class="emotion-context-kicker">听听这一桌心里话</text>
<text class="emotion-context-title">{{currentPage.headline}}</text>
<text class="emotion-context-caption">{{currentPage.caption}}</text>
</view>
<text class="emotion-prompt">{{currentPage.prompt}}</text>
<text
wx:if="{{!emotionResult && !chapterFinished}}"
class="emotion-note"
>没有标准答案,选您更愿意说的话。</text>
<view wx:if="{{emotionResult || chapterFinished}}" class="comic-emotion-result">
<text wx:if="{{emotionResult}}" class="feedback">{{emotionResult.feedback}}</text>
<view class="table-echo">
<text class="echo-label">桌边回声</text>
<text>{{emotionResult ? emotionResult.echo : chapter.emotionMoment.tableEcho}}</text>
</view>
</view>
</scroll-view>
<view
wx:if="{{!emotionResult && !chapterFinished}}"
class="emotion-choice-dock"
>
<button
wx:for="{{chapter.emotionMoment.playerChoices}}"
wx:key="choiceId"
class="emotion-choice"
data-choice="{{item.choiceId}}"
bindtap="chooseEmotion"
>
<text class="choice-number">{{index + 1}}</text>
<text>{{item.text}}</text>
</button>
</view>
<view class="special-page-footer emotion-page-footer {{emotionResult || chapterFinished ? 'has-primary' : 'single-action'}}">
<button
class="special-page-button secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
bindtap="previousComicPage"
aria-label="返回前一页"
aria-disabled="{{comicPreviousDisabled}}"
>
<text class="comic-control-visible">上页</text>
</button>
<button
wx:if="{{emotionResult || chapterFinished}}"
class="special-page-button primary"
bindtap="finishComicEmotion"
aria-label="{{chapterFinished ? '查看记忆卡' : '收下这一桌的回声'}}"
>
<text class="comic-control-visible">{{chapterFinished ? '查看记忆卡' : '收下这一桌的回声'}}</text>
</button>
</view>
</view>
</view>
</block>
<block wx:elif="{{currentPage.isSeasonClose}}">
<view class="season-close-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}">
<view class="season-close-body">
<view class="season-close-art">
<image
wx:if="{{comicImageSrc}}"
class="season-close-main-image"
src="{{comicImageSrc}}"
mode="aspectFit"
bindload="onComicImageLoad"
binderror="onComicImageError"
></image>
<view wx:else class="season-close-art-fallback">
<text>{{comicImageError}}</text>
<text>{{currentPage.sceneAlt}}</text>
</view>
<view
wx:if="{{comicImageSrc && !comicImageUsingFallback && currentPage.detailInset}}"
class="season-close-detail-inset {{currentPage.detailInset.anchorClass}}"
aria-label="{{currentPage.detailInset.label}}近景"
>
<image
class="season-close-detail-image"
src="{{comicImageSrc}}"
mode="{{currentPage.detailInset.imageMode}}"
style="{{currentPage.detailInset.imageStyle}}"
></image>
<text class="season-close-detail-label">{{currentPage.detailInset.label}}</text>
</view>
</view>
<scroll-view
class="season-close-copy"
scroll-y
enhanced
show-scrollbar="{{true}}"
>
<view class="season-close-copy-sheet">
<text class="season-close-kicker">第一季 · 季终</text>
<text class="season-close-caption">{{currentPage.seasonCloseCaption}}</text>
<view class="season-close-memory-card">
<text class="season-close-card-label">桂香记忆卡</text>
<text class="season-close-era">{{currentPage.memoryCard.eraLine}}</text>
<text class="season-close-quote">{{currentPage.memoryCard.tableEcho}}</text>
<view class="season-close-life-line">
<text class="season-close-line-label">今天可以这样做</text>
<text>{{currentPage.memoryCard.lifeAction}}</text>
</view>
<view class="season-close-family-line">
<text class="season-close-line-label">带回家聊一聊</text>
<text>“{{currentPage.memoryCard.familyLine}}”</text>
</view>
</view>
<text class="season-close-tail">{{currentPage.seasonCloseTail}}</text>
</view>
</scroll-view>
</view>
<view class="memory-actions season-close-actions">
<button
class="memory-action-cell secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
bindtap="previousComicPage"
aria-label="返回前一页"
aria-disabled="{{comicPreviousDisabled}}"
>
<text class="memory-action-visible">上页</text>
</button>
<button
class="memory-action-cell report"
bindtap="openLifeReport"
aria-label="查看本回桂香生活观察"
>
<text class="memory-action-visible">生活观察</text>
</button>
<button
class="memory-action-cell share"
open-type="share"
aria-label="发给家人,一起聊聊这一页"
>
<text class="memory-action-visible">发给家人</text>
</button>
<button
class="memory-action-cell primary"
bindtap="goCatalog"
aria-label="第一季读完,回到目录"
>
<text class="memory-action-visible">回到目录</text>
</button>
</view>
</view>
</block>
<block wx:elif="{{currentPage.type === 'memory'}}">
<view class="comic-special-page memory-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}} {{currentPage.pageId === 'S01-C09-P08' ? 'c09-p08' : ''}}">
<view class="memory-art">
<image
wx:if="{{comicImageSrc}}"
class="memory-art-image"
src="{{comicImageSrc}}"
mode="aspectFit"
bindload="onComicImageLoad"
binderror="onComicImageError"
></image>
<view wx:else class="special-art-fallback">
<text>{{comicImageError}}</text>
<text>{{currentPage.sceneAlt}}</text>
</view>
<view class="comic-paper-grain"></view>
</view>
<view class="memory-panel">
<scroll-view class="memory-paper {{currentPage.pageId === 'S01-C09-P08' ? 'is-complete' : ''}}" scroll-y="{{currentPage.pageId !== 'S01-C09-P08'}}" enhanced show-scrollbar="{{currentPage.pageId !== 'S01-C09-P08'}}">
<view class="memory-paper-content">
<view class="memory-cliffhanger-copy"><text class="memory-ending-label">下一回的门缝</text><text>{{currentPage.caption}}</text></view>
<view class="memory-card-sheet"><text class="memory-stamp">桂香记忆卡</text>
<text class="memory-era">{{currentPage.memoryCard.eraLine}}</text><text class="memory-person">{{currentPage.memoryCard.characterName}}</text>
<view wx:if="{{currentPage.pageId === 'S01-C09-P08'}}" class="memory-short-lines">
<text wx:for="{{currentPage.memoryCard.displayLines}}" wx:key="*this" class="memory-short-line">{{item}}</text>
</view>
<block wx:else>
<text class="memory-quote">{{currentPage.memoryCard.tableEcho}}</text>
<view class="memory-action"><text class="memory-label">今天可以这样做</text><text>{{currentPage.memoryCard.lifeAction}}</text></view>
<button class="memory-family-toggle" bindtap="toggleMemoryFamily" aria-label="打开带回家聊一聊">
{{memoryFamilyOpen ? '收起这一句' : '带回家聊一聊'}}
</button>
<view wx:if="{{memoryFamilyOpen}}" class="memory-family"><text class="memory-label">带回家聊一聊</text><text>“{{currentPage.memoryCard.familyLine}}”</text></view>
</block>
</view>
</view>
</scroll-view>
<view class="memory-actions">
<button
class="memory-action-cell secondary {{comicPreviousDisabled ? 'is-disabled' : ''}}"
bindtap="previousComicPage"
aria-label="返回前一页"
aria-disabled="{{comicPreviousDisabled}}"
>
<text class="memory-action-visible">上页</text>
</button>
<button
class="memory-action-cell report"
bindtap="openLifeReport"
aria-label="查看本回桂香生活观察"
>
<text class="memory-action-visible">生活观察</text>
</button>
<button
class="memory-action-cell share"
open-type="share"
aria-label="发给家人,一起聊聊这一页"
>
<text class="memory-action-visible">发给家人</text>
</button>
<button
wx:if="{{chapterNumber >= chapterCount}}"
class="memory-action-cell primary"
bindtap="goCatalog"
aria-label="全书读完,回到目录"
>
<text class="memory-action-visible">回到目录</text>
</button>
<button
wx:else
class="memory-action-cell primary"
bindtap="nextChapter"
aria-label="翻到下一回"
>
<text class="memory-action-visible">翻到下一回</text>
</button>
</view>
</view>
</view>
</block>
<block wx:else>
<view
class="comic-normal-page template-{{currentPage.templateKey}} layout-{{currentPage.layoutVariant}}"
bindtouchstart="onComicTouchStart"
bindtouchend="onComicTouchEnd"
>
<view class="comic-art-column">
<view class="comic-art-stage" style="{{comicArtStageStyle}}">
<image
wx:if="{{comicImageSrc}}"
class="comic-art-image"
src="{{comicImageSrc}}"
mode="{{comicPageImageMode}}"
style="{{comicPageImageStyle}}"
bindload="onComicImageLoad"
binderror="onComicImageError"
></image>
<view wx:else class="comic-art-fallback">
<text>{{comicImageError}}</text>
<text>{{currentPage.sceneAlt}}</text>
</view>
<view class="comic-paper-grain"></view>
<view
wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}"
class="comic-evidence-composite"
aria-label="{{currentPage.playableVisual.subject}}的手边证据"
>
<text class="comic-evidence-kicker">手边证据</text>
<text class="comic-evidence-label">{{currentPage.playableVisual.evidenceLabel}}</text>
<text class="comic-evidence-detail">{{currentPage.playableVisual.evidenceDetail}}</text>
</view>
<button
wx:if="{{currentInteractionEnabled && !comicImageUsingFallback}}"
class="comic-actor-hotspot is-quiet"
style="{{currentPage.actorHotspot.style}}"
bindtap="openComicActor"
aria-label="看看{{currentPage.actorName}}的手边,了解这一页发生了什么"
></button>
</view>
</view>
<view class="comic-caption-strip {{currentInteractionEnabled && compactHeight ? 'has-outside-actor-guide' : ''}}">
<scroll-view class="comic-caption-copy" scroll-y enhanced show-scrollbar="{{false}}">
<view wx:if="{{currentPage.type === 'cover'}}" class="comic-cover-caption-layout">
<view class="comic-cover-caption-heading">
<text class="comic-caption-kicker">{{chapter.year}}</text>
<text class="comic-caption-heading">{{currentPage.headline}}</text>
</view>
<text class="comic-caption comic-cover-caption-text">{{currentPage.displayCaption}}</text>
</view>
<view wx:elif="{{currentPage.type === 'ensemble'}}" class="comic-ensemble-caption-layout">
<text class="comic-caption-heading">{{currentPage.headline}}</text>
<view class="comic-ensemble-caption-copy">
<text class="comic-caption">{{currentPage.displayCaption}}</text>
<text wx:if="{{currentPage.secondaryCaption}}" class="comic-secondary-caption">{{currentPage.secondaryCaption}}</text>
</view>
</view>
<view wx:elif="{{currentPage.type === 'event'}}" class="comic-event-caption-layout {{currentEventCompleted ? 'is-completed' : ''}}">
<view class="comic-event-caption-copy">
<text class="comic-caption">{{currentPage.displayCaption}}</text>
<text wx:if="{{currentPage.secondaryCaption}}" class="comic-secondary-caption">{{currentPage.secondaryCaption}}</text>
</view>
<view class="comic-event-cue">
<text
wx:if="{{currentInteractionEnabled && !compactHeight && currentPage.artTapHint}}"
class="comic-art-tap-note"
>再看看{{currentPage.actorName}}的手边</text>
<button
wx:if="{{currentInteractionEnabled && !compactHeight}}"
class="comic-outside-actor-button"
bindtap="openComicActor"
aria-label="{{currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上的东西' : (currentPage.playableVisual.kind === 'actor-portrait' ? '看看' + currentPage.actorName + '的手边' : '看看发生了什么')}}"
>
<text wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}">看看桌上的东西</text>
<text wx:elif="{{currentPage.artTapHint}}">再看看{{currentPage.actorName}}的手边</text>
<text wx:elif="{{currentPage.playableVisual.kind === 'actor-portrait'}}">看看{{currentPage.actorName}}的手边</text>
<text wx:else>看看发生了什么</text>
</button>
<view wx:elif="{{currentEventCompleted}}" class="comic-seen-row">
<text class="seen-chip">看见了</text>
<text class="comic-tap-prompt is-seen">可以翻到下一页。</text>
</view>
</view>
</view>
</scroll-view>
<view
wx:if="{{currentInteractionEnabled && compactHeight}}"
class="comic-outside-action-dock {{currentPage.artTapHint ? 'has-art-tap-note' : ''}}"
>
<text
wx:if="{{currentPage.artTapHint}}"
class="comic-art-tap-note"
>再看看{{currentPage.actorName}}的手边</text>
<button
class="comic-outside-actor-button comic-outside-actor-dock"
bindtap="openComicActor"
aria-label="{{currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上的东西' : (currentPage.playableVisual.kind === 'actor-portrait' ? '看看' + currentPage.actorName + '的手边' : '看看发生了什么')}}"
>
<text wx:if="{{currentPage.playableVisual.kind === 'evidence-composite'}}">看看桌上的东西</text>
<text wx:elif="{{currentPage.artTapHint}}">再看看{{currentPage.actorName}}的手边</text>
<text wx:elif="{{currentPage.playableVisual.kind === 'actor-portrait'}}">看看{{currentPage.actorName}}的手边</text>
<text wx:else>看看发生了什么</text>
</button>
</view>
<view class="comic-page-nav">
<button
class="page-turn-button {{comicPreviousDisabled ? 'is-disabled' : ''}}"
bindtap="previousComicPage"
aria-label="上一页"
aria-disabled="{{comicPreviousDisabled}}"
>
<text class="comic-control-visible">上页</text>
</button>
<text wx:if="{{!compactHeight}}" class="folio">{{currentPageNumber}}{{comicPageCount}}</text>
<button
class="page-turn-button next {{comicNextLocked ? 'is-locked' : ''}} {{comicNextDisabled ? 'is-disabled' : ''}}"
bindtap="nextComicPage"
aria-label="{{comicNextLocked ? '先看看这一页的画面' : '下一页'}}"
aria-disabled="{{comicNextLocked || comicNextDisabled}}"
>
<text class="comic-control-visible">
{{comicNextLocked ? (currentPage.playableVisual.kind === 'evidence-composite' ? '看看桌上' : '看看画面') : '下页'}}
</text>
</button>
</view>
</view>
</view>
</block>
</view>
<view wx:if="{{textReadingOpen}}" class="dialog-backdrop text-reading-backdrop">
<view
class="text-reading-dialog paper-panel"
aria-role="dialog"
aria-label="本页大字文字"
>
<button
class="dialog-close"
bindtap="closeTextReading"
aria-label="关闭文字阅读"
>×</button>
<text class="text-reading-kicker">
第{{chapterNumber < 10 ? '0' + chapterNumber : chapterNumber}}回 · 第{{currentPageNumber}}页
</text>
<scroll-view
class="text-reading-scroll"
scroll-y
enhanced
show-scrollbar="{{true}}"
>
<view class="text-reading-sheet">
<text wx:if="{{currentPage.headline}}" class="text-reading-title">{{currentPage.headline}}</text>
<text wx:if="{{currentPage.displayCaption}}" class="text-reading-body">{{currentPage.displayCaption}}</text>
<text wx:elif="{{currentPage.caption}}" class="text-reading-body">{{currentPage.caption}}</text>
<text wx:if="{{currentPage.secondaryCaption}}" class="text-reading-secondary">{{currentPage.secondaryCaption}}</text>
<view wx:if="{{currentPage.prompt}}" class="text-reading-prompt">
<text class="text-reading-label">这一页想和您聊</text>
<text>{{currentPage.prompt}}</text>
</view>
<view wx:if="{{currentPage.question}}" class="text-reading-prompt">
<text class="text-reading-label">看看画里发生了什么</text>
<text>{{currentPage.question}}</text>
</view>
</view>
</scroll-view>
<button
class="text-reading-return"
bindtap="closeTextReading"
aria-label="回到连环画"
>回到画里</button>
</view>
</view>
<view wx:if="{{decisionOpen && storyFirstComicMode}}" class="c1-decision-layer">
<view wx:if="{{resultMode === ''}}" class="c1-interaction-paper">
<view class="c1-interaction-evidence">
<image
wx:if="{{comicImageSrc}}"
class="c1-interaction-image"
src="{{comicImageSrc}}"
mode="aspectFit"
></image>
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
<text class="c1-back-stamp">画里的线索</text>
</view>
<view class="c1-interaction-panel">
<button class="c1-tray-close" bindtap="closeDecision" aria-label="收起选择,先回画面">先看画</button>
<view class="c1-tray-heading">
<text class="c1-tray-kicker">碰上这事,您怎么做?</text>
<text class="c1-tray-question">{{activeEvent.question}}</text>
</view>
<view class="c1-choice-stack">
<button
wx:for="{{activeEvent.choiceOptions}}"
wx:key="value"
class="c1-natural-choice"
data-value="{{item.value}}"
bindtap="answer"
>{{item.label}}</button>
</view>
</view>
</view>
<view wx:elif="{{resultMode === 'retry'}}" class="c1-interaction-paper">
<view class="c1-interaction-evidence">
<image
wx:if="{{comicImageSrc}}"
class="c1-interaction-image"
src="{{comicImageSrc}}"
mode="aspectFit"
></image>
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
<text class="c1-back-stamp">再看看画里</text>
</view>
<view class="c1-interaction-panel c1-retry-panel">
<button class="c1-tray-close" bindtap="closeDecision" aria-label="回到画里">先看画</button>
<text class="c1-retry-title">别着急,再瞧瞧画里。</text>
<text class="c1-retry-copy">{{activeEvent.retryHint}}</text>
<button class="c1-retry-button" bindtap="retryAnswer">再看一眼</button>
</view>
</view>
<view wx:else class="c1-back-paper c1-event-result-back">
<view class="c1-back-evidence">
<image
wx:if="{{comicImageSrc}}"
class="c1-back-image"
src="{{comicImageSrc}}"
mode="aspectFit"
></image>
<view wx:else class="c1-back-image-fallback">{{currentPage.sceneAlt}}</view>
<text class="c1-back-stamp">这下有眉目了</text>
</view>
<scroll-view class="c1-back-copy" scroll-y enhanced show-scrollbar="{{true}}">
<view class="c1-back-copy-inner">
<text class="c1-back-kicker">{{activeEvent.actorName}} · 这一页背面</text>
<text class="c1-back-title">{{currentPage.headline}}</text>
<text class="c1-back-body">{{activeEvent.actionDescription}}</text>
<view wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-action">
<text class="c1-back-label">先把眼前这一步做好</text>
<text>先停酒、移开危险物,并留下人陪伴。</text>
</view>
<text wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-reason">这些表现不能仅凭外观确定原因,先保证安全。</text>
<block wx:else>
<view class="c1-back-callout">
<text class="c1-back-label">您刚才瞧见的</text>
<text>{{activeEvent.evidence}}</text>
</view>
<text class="c1-back-reason">{{activeEvent.reason}}</text>
<view class="c1-back-action">
<text class="c1-back-label">下回遇到这事</text>
<text>{{activeEvent.actionAdvice}}</text>
</view>
</block>
</view>
</scroll-view>
<text class="c1-scroll-hint">上划接着看 ↑</text>
<view class="c1-back-footer">
<button
wx:if="{{!(activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen)}}"
class="c1-back-button secondary"
aria-label="回头再看一遍画面"
bindtap="returnFromChapterOneDecision"
>回去看画</button>
<button wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="c1-back-button primary" bindtap="openResultDetail">再看不同情况怎么办</button>
<button wx:else class="c1-back-button primary" bindtap="nextFromChapterOneDecision">翻下一张</button>
</view>
</view>
</view>
<view wx:elif="{{decisionOpen}}" class="dialog-backdrop">
<view
class="decision-dialog paper-panel comic-decision"
aria-role="dialog"
aria-label="{{activeEvent.actorName}}这一幕的互动选择"
>
<button
wx:if="{{!(resultMode === 'correct' && activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen)}}"
class="dialog-close"
bindtap="closeDecision"
aria-label="回到画里"
>×</button>
<scroll-view class="character-story" scroll-y enhanced show-scrollbar="{{true}}">
<view class="character-story-inner">
<view class="comic-focus-visual">
<image
wx:if="{{comicImageSrc}}"
class="comic-focus-scene"
src="{{comicImageSrc}}"
mode="aspectFit"
style="{{comicFocusImageStyle}}"
></image>
<view class="comic-focus-shade"></view>
<view class="comic-focus-name">
<text>{{activePerson.eraLabel}} · 画中人物</text>
<text class="focus-name">{{activeEvent.actorName}}</text>
</view>
</view>
<text class="story-label">这一幕发生了什么</text>
<text class="action-description">{{activeEvent.actionDescription}}</text>
<view class="evidence-box">
<text class="story-label">手边证据</text>
<text>{{activeEvent.evidence}}</text>
</view>
<view class="speech-box">
<text class="story-label">桌边说话</text>
<text>{{activeEvent.speech}}</text>
</view>
</view>
</scroll-view>
<view class="judgement-side {{resultMode === '' ? 'is-choice' : 'has-result'}}">
<scroll-view
class="judgement-scroll"
scroll-y
enhanced
show-scrollbar="{{decisionResultNeedsScroll}}"
>
<view class="judgement-scroll-inner {{resultMode === '' ? 'is-choice' : 'is-result'}}">
<text class="judgement-lead">故事走到这里,您想怎么做?</text>
<text wx:if="{{resultMode === ''}}" class="judgement-question">{{activeEvent.question}}</text>
<view wx:if="{{resultMode === ''}}" class="judgement-options">
<button class="judgement-button observe" data-value="caution" bindtap="answer">
<text class="judgement-icon">A</text>
<view class="judgement-button-copy">
<text class="judgement-title">先看看现场细节</text>
</view>
</button>
<button class="judgement-button continue" data-value="safer" bindtap="answer">
<text class="judgement-icon">B</text>
<view class="judgement-button-copy">
<text class="judgement-title">继续按画里的做法</text>
</view>
</button>
</view>
<view wx:if="{{resultMode === 'retry'}}" class="result-box retry">
<text class="result-title">没关系,再看看画里的细节。</text>
<text>{{activeEvent.retryHint}}</text>
</view>
<view wx:if="{{resultMode === 'correct'}}" class="result-box correct" aria-live="polite">
<text class="result-title">这处线索对上了。</text>
<view wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="action-advice">
<text class="advice-label">先把眼前这一步做好</text>
<text>先停酒、移开危险物,并留下人陪伴。</text>
</view>
<text wx:if="{{activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="reason">这些表现不能仅凭外观确定原因,先保证安全。</text>
<view wx:else class="action-advice">
<text class="advice-label">现在可以怎么做</text>
<text>{{activeEvent.actionAdvice}}</text>
</view>
<text wx:if="{{activeEvent.hotspotId !== 'S01-H36' || resultDetailOpen}}" class="reason">{{activeEvent.reason}}</text>
</view>
</view>
</scroll-view>
<view wx:if="{{resultMode === 'retry'}}" class="decision-footer">
<button class="decision-footer-button retry" bindtap="retryAnswer">再看一眼现场</button>
</view>
<view wx:if="{{resultMode === 'correct' && activeEvent.hotspotId === 'S01-H36' && !resultDetailOpen}}" class="decision-footer">
<button class="decision-footer-button primary" bindtap="openResultDetail">
再看不同情况怎么办
</button>
</view>
<view wx:elif="{{resultMode === 'correct'}}" class="decision-footer">
<button class="decision-footer-button primary" bindtap="continueAfterDecision">
回到画里
</button>
</view>
</view>
</view>
</view>
</view>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,135 @@
const COMIC_READER_LAYOUT_CONTRACT = Object.freeze({
contractId: 'story-first-three-frame-v1',
columns: Object.freeze({
art: 3,
content: 2,
}),
frames: Object.freeze({
front: Object.freeze({
imageMode: 'aspectFit',
initialControls: 'none',
revealGesture: 'single-tap',
revealedControls: 'topbar-only',
}),
back: Object.freeze({
art: 2,
content: 3,
imageMode: 'aspectFit',
}),
interaction: Object.freeze({
art: 2,
content: 3,
imageMode: 'aspectFit',
choiceCount: 2,
choiceDirection: 'vertical',
}),
}),
minimumControlPx: 48,
compactHeightMaxPx: 620,
fontModes: Object.freeze({
standard: 'large',
xlarge: 'xlarge',
}),
audioSlot: Object.freeze({
placement: 'unified-topbar',
minimumHeightPx: 48,
textModeLabel: '直接看文字',
readyLabel: '听一听',
playingLabel: '暂停',
replayLabel: '重听',
usesPageAudioCueIds: true,
}),
artOverlayPolicy: Object.freeze({
event: 'transparent-actor-hotspot-only',
emotion: 'transparent-actor-hotspot-only',
}),
})
const COMIC_PAGE_TEMPLATE_BY_TYPE = Object.freeze({
cover: 'chapter-ensemble',
ensemble: 'chapter-ensemble',
event: 'actor-event',
emotion: 'emotion-interaction',
memory: 'memory-cliffhanger',
})
const COMIC_PRESENTATION_BY_CHAPTER = Object.freeze({
'S01-C01': 'front-back-comic-v1',
'S01-C02': 'front-back-comic-v1',
'S01-C03': 'front-back-comic-v1',
'S01-C04': 'front-back-comic-v1',
'S01-C05': 'front-back-comic-v1',
'S01-C06': 'front-back-comic-v1',
'S01-C07': 'front-back-comic-v1',
'S01-C08': 'front-back-comic-v1',
'S01-C09': 'front-back-comic-v1',
'S01-C10': 'front-back-comic-v1',
'S01-C11': 'front-back-comic-v1',
'S01-C12': 'front-back-comic-v1',
'S01-C13': 'front-back-comic-v1',
'S01-C14': 'front-back-comic-v1',
'S01-C15': 'front-back-comic-v1',
})
function getComicPresentationMode(chapterId) {
return COMIC_PRESENTATION_BY_CHAPTER[String(chapterId || '')]
|| 'legacy-60-40-v1'
}
const COMIC_PAGE_LAYOUT_EXCEPTIONS = Object.freeze({
'S01-C09-P03': Object.freeze({
layoutVariant: 'standard-landscape-60-40',
behavior: 'key-safe-actor-hotspot',
actorHotspot: Object.freeze({ x: 16, y: 3, w: 56, h: 82 }),
reason: '人物仍可整片点击,但透明热区须止于画面下方钥匙带之前。',
}),
})
// These pages keep the full reviewed frame and add one read-only crop from
// the same asset as a second comic panel. This is an art-reading aid, not a
// new interaction or a page-layout exception.
const COMIC_FIXED_DETAIL_INSET_PAGE_IDS = Object.freeze([
'S01-C04-P04',
'S01-C09-P01',
'S01-C09-P05',
'S01-C09-P06',
'S01-C10-P07',
'S01-C13-P04',
'S01-C15-P05',
'S01-C15-P08',
])
function allowsFixedDetailInset(pageId) {
return COMIC_FIXED_DETAIL_INSET_PAGE_IDS.indexOf(
String(pageId || ''),
) >= 0
}
function getComicPageLayoutException(pageId) {
return COMIC_PAGE_LAYOUT_EXCEPTIONS[String(pageId || '')] || null
}
function resolveComicPageLayout(pageId, type) {
const templateKey = COMIC_PAGE_TEMPLATE_BY_TYPE[type] || 'chapter-ensemble'
const exception = getComicPageLayoutException(pageId)
return {
templateKey,
layoutVariant: exception
? exception.layoutVariant
: 'standard-landscape-60-40',
layoutExceptionId: exception ? String(pageId) : '',
layoutExceptionBehavior: exception ? exception.behavior : '',
}
}
module.exports = {
COMIC_READER_LAYOUT_CONTRACT,
COMIC_PRESENTATION_BY_CHAPTER,
COMIC_PAGE_TEMPLATE_BY_TYPE,
COMIC_PAGE_LAYOUT_EXCEPTIONS,
COMIC_FIXED_DETAIL_INSET_PAGE_IDS,
allowsFixedDetailInset,
getComicPresentationMode,
getComicPageLayoutException,
resolveComicPageLayout,
}
@@ -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,
}

Some files were not shown because too many files have changed in this diff Show More