const COMIC_CHAPTER_ID = 'S01-C04' const CHAPTER_ONE_ID = 'S01-C01' const CHAPTER_TWO_ID = 'S01-C02' const CHAPTER_THREE_ID = 'S01-C03' const CHAPTER_FIVE_ID = 'S01-C05' const CHAPTER_FOUR_AUDIO_CUE_IDS = Object.freeze({ 'S01-C04-P01': Object.freeze(['S01-C04-MT000']), 'S01-C04-P02': Object.freeze([ 'S01-C04-MS001', 'S01-C04-MS002-ATTR', 'S01-C04-MS002', ]), 'S01-C04-P03': Object.freeze(['S01-C04-MS003', 'S01-C04-MS004']), 'S01-C04-P04': Object.freeze(['S01-C04-MS005']), 'S01-C04-P05': Object.freeze([ 'S01-C04-MS006', 'S01-C04-MS007', 'S01-C04-MS008', 'S01-C04-MS009', 'S01-C04-MS010', 'S01-C04-MS011', ]), 'S01-C04-P06': Object.freeze([ 'S01-C04-MS012-ATTR', 'S01-C04-MS012', 'S01-C04-MS013', 'S01-C04-MS014', ]), 'S01-C04-P07': Object.freeze(['S01-C04-TE900']), 'S01-C04-P08': Object.freeze(['S01-C04-MS015']), }) // This generated data is copied into every chapter subpackage. Keeping the // dependency package-local is important: a file referenced only from a // subpackage can otherwise be omitted from the main package by WeChat's // unused-file optimisation, leaving the chapter page blank at runtime. const productionComicChapters = require('../../data/productionComicPages') const memoryCards = require('../../../data/memoryCards') const { attachPlayableVisuals, } = require('../../data/playableVisualPolicy') const { allowsFixedDetailInset, getComicPresentationMode, getComicPageLayoutException, resolveComicPageLayout, } = require('./chapterLayout') function chapterPackageRoot(value) { const chapter = Math.min( 15, Math.max(1, Math.floor(Number(value) || 1)), ) return chapter === 1 ? 'package-game' : `package-chapter-${String(chapter).padStart(2, '0')}` } function pageId(chapterId, pageNumber) { return `${chapterId}-P${String(pageNumber).padStart(2, '0')}` } function findPerson(chapter, instanceId) { return (chapter.people || []).find( (person) => person.instanceId === instanceId, ) || null } function actorHotspot(personOrPosition) { const position = personOrPosition && personOrPosition.position ? personOrPosition.position : (personOrPosition || {}) const x = Math.max(0, Number(position.xPercent) || 0) const y = Math.max(0, Number(position.yPercent) || 0) const width = Math.max(12, Number(position.widthPercent) || 18) const height = Math.max(22, Number(position.heightPercent) || 48) const expandedX = Math.max(0, x - 2.5) const expandedY = Math.max(0, y - 3) const expandedWidth = Math.min(100 - expandedX, width + 5) const expandedHeight = Math.min(100 - expandedY, height + 6) return { xPercent: expandedX, yPercent: expandedY, widthPercent: expandedWidth, heightPercent: expandedHeight, style: [ `left:${expandedX}%`, `top:${expandedY}%`, `width:${expandedWidth}%`, `height:${expandedHeight}%`, ].join(';'), } } function fixedHotspot(position) { const x = Math.max(0, Number(position && position.x) || 0) const y = Math.max(0, Number(position && position.y) || 0) const width = Math.min(100 - x, Math.max(12, Number(position && position.w) || 18)) const height = Math.min(100 - y, Math.max(22, Number(position && position.h) || 48)) return { xPercent: x, yPercent: y, widthPercent: width, heightPercent: height, style: [ `left:${x}%`, `top:${y}%`, `width:${width}%`, `height:${height}%`, ].join(';'), } } function fixedDetailInset(position) { if (!position || typeof position !== 'object') return null const x = Math.max(0, Math.min(99, Number(position.x) || 0)) const y = Math.max(0, Math.min(99, Number(position.y) || 0)) const width = Math.min(100 - x, Math.max(1, Number(position.w) || 1)) const height = Math.min(100 - y, Math.max(1, Number(position.h) || 1)) const requestedAnchor = String(position.anchor || '') const anchor = requestedAnchor === 'top-right' || requestedAnchor === 'top-center' ? requestedAnchor : 'top-left' const labelAnchorClass = position.labelAnchor === 'bottom-left' ? 'label-bottom-left' : '' const imageWidth = 10000 / width const imageHeight = 10000 / height const imageLeft = -(x / width * 100) const imageTop = -(y / height * 100) return { label: String(position.label || ''), xPercent: x, yPercent: y, widthPercent: width, heightPercent: height, anchor, anchorClass: `anchor-${anchor}`, ...(labelAnchorClass ? { labelAnchorClass } : {}), imageMode: 'scaleToFill', imageStyle: [ `left:${imageLeft.toFixed(3)}%`, `top:${imageTop.toFixed(3)}%`, `width:${imageWidth.toFixed(3)}%`, `height:${imageHeight.toFixed(3)}%`, ].join(';'), } } function productionPageFor(stablePageId) { const match = /^(S\d+-C\d+)-P\d+$/.exec(String(stablePageId || '')) const productionChapter = match ? productionComicChapters[match[1]] : null const pages = productionChapter && productionChapter.pages return Array.isArray(pages) ? pages.find((page) => page.pageId === stablePageId) || null : null } function attachFixedDetailInset(page) { if (!page || page.detailInset || !allowsFixedDetailInset(page.pageId)) { return page } const productionPage = productionPageFor(page.pageId) const detailInset = fixedDetailInset( productionPage && productionPage.programDetailInset, ) return detailInset ? { ...page, detailInset } : page } function fixedProgramEvidenceInset(source) { if (!source || typeof source !== 'object') return null const countValue = Number(source.countValue) if ( source.kind !== 'order-count' || !Number.isInteger(countValue) || countValue < 0 ) { return null } const anchor = source.anchor === 'top-right' ? 'top-right' : 'top-left' return { kind: 'order-count', kicker: String(source.kicker || '画中近景'), screenLabel: String(source.screenLabel || '手机点单'), countLabel: String(source.countLabel || '已点'), countValue, countUnit: String(source.countUnit || '道'), plusGlyph: String(source.plusGlyph || '+'), note: String(source.note || ''), ariaLabel: String(source.ariaLabel || ''), anchor, anchorClass: `anchor-${anchor}`, } } // Keep internal provenance / implementation language out of the reader-facing story. function playerFacingCopy(value) { return String(value || '') .replace(/AI只读客观信息,不作医疗判断/g, '手机只帮忙把菜名和份量读清楚,怎么选还是自己决定') .replace(/AI/g, '手机') .replace(/证据链/g, '前后几样线索') .replace(/证据来源/g, '物件上的记号') .replace(/记录来源/g, '记下它从哪里来') .replace(/来源标签/g, '物件上的记号') .replace(/来源/g, '来处') } function basePage(chapter, number, type, extra = {}) { const packageRoot = chapterPackageRoot(chapter.chapterNumber) const stablePageId = pageId(chapter.chapterId, number) return { pageId: stablePageId, pageNumber: number, type, ...resolveComicPageLayout(stablePageId, type), hotspotGuideOutside: type === 'event', year: chapter.year, location: chapter.location, sceneAlt: chapter.sceneAlt, // The art-specific path is intentionally only a declaration for the next // illustration pass. Until that file exists, the reviewed era scene keeps // the text game usable and the page engine testable. illustrationAsset: `/${packageRoot}/assets/comic/${chapter.chapterId.toLowerCase()}/page-${String(number).padStart(2, '0')}.jpg`, fallbackAsset: chapter.sceneAsset, ...extra, } } function pageAudioDeclaration(source = {}) { const declaration = { audioCueIds: Array.isArray(source.audioCueIds) ? [...source.audioCueIds] : [], } // Cue IDs are production provenance, not complete page narration. C01 uses // its two dedicated full-page player packages; C02-C15 may expose audio only // through the approved remote full-page manifest and shared player. for (const field of ['audioStatus', 'audioAssetId', 'audioSrc']) { if (Object.prototype.hasOwnProperty.call(source, field)) { declaration[field] = String(source[field] || '') } } if (Object.prototype.hasOwnProperty.call(source, 'audioDurationSeconds')) { const durationSeconds = Number(source.audioDurationSeconds) if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { throw new Error('page audio duration must be a non-negative number') } declaration.audioDurationSeconds = durationSeconds } return declaration } function chapterFourAudioDeclaration(pageNumber) { return pageAudioDeclaration({ audioCueIds: CHAPTER_FOUR_AUDIO_CUE_IDS[ pageId(COMIC_CHAPTER_ID, pageNumber) ], }) } function buildChapterFourPages(chapter) { const eventCaptions = [ { caption: '赵建国把饭勺举得像奖杯。工友一喊“第三碗才算数”,他笑着把碗又往前送。', tapPrompt: '点画中的赵建国,听听这一桌的起哄。', question: '拿饭量证明能干,这样妥当吗?', position: { xPercent: 28, yPercent: 10, widthPercent: 31, heightPercent: 82 }, assetName: 'S01-C04-P03-H13-ladle-contest-v1.jpg', }, { caption: '笑声还没落,赵建国已松了半格皮带。一手扶住桌沿,另一手仍伸向饭勺。', tapPrompt: '点画中的赵建国,看看他没有说出口的不舒服。', question: '已经吃撑还不说、马上抬重物,妥当吗?', position: { xPercent: 21, yPercent: 3, widthPercent: 52, heightPercent: 94 }, assetName: 'S01-C04-P04-H14-belt-table-v1.jpg', }, { caption: '小唐没同他争,只把水杯推过去:“我不管你几碗,只管你别又急又撑。”', tapPrompt: '点画中的小唐,听清他究竟在提醒什么。', question: '提醒“别又急又撑”,等于主食一口不能吃吗?', position: { xPercent: 51, yPercent: 19, widthPercent: 30, heightPercent: 70 }, assetName: 'S01-C04-P05-H15-water-reminder-v1.jpg', }, { caption: '林秀兰拉近空凳,放下小碗:“笑归笑,先坐下。真不舒服就说。”', tapPrompt: '点画中的林秀兰,看看她怎样给人留住面子。', question: '先拉凳、递水,再问清不适,这样更稳妥吗?', position: { xPercent: 55, yPercent: 8, widthPercent: 31, heightPercent: 82 }, assetName: 'S01-C04-P06-H16-stool-bowl-v1.jpg', }, ] const pages = [ basePage(chapter, 1, 'cover', { eyebrow: `第04回 · ${chapter.year}`, headline: chapter.title, caption: '一阵笑声越过饭桌,第三碗饭正悬在勺与碗之间。', primaryAction: '翻开这一回', illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P01-title-v1.jpg`, ...chapterFourAudioDeclaration(1), }), basePage(chapter, 2, 'ensemble', { eyebrow: `${chapter.year} · ${chapter.location}`, headline: '食堂里又比起了饭量', caption: '“人是铁,饭是钢,一顿不吃饿得慌!”那年月干的是力气活,能吃常被当成能干。', secondaryCaption: '赵建国坐在长桌中央,工友围着起哄;小唐、林秀兰和秦师傅都看见了不同的细节。', primaryAction: '看看第三碗饭', illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P02-ensemble-v1.jpg`, ...chapterFourAudioDeclaration(2), }), ] ;(chapter.events || []).forEach((event, index) => { const person = findPerson(chapter, event.actorInstanceId) const copy = eventCaptions[index] || {} pages.push(basePage(chapter, index + 3, 'event', { eventId: event.hotspotId, actorInstanceId: event.actorInstanceId, actorName: event.actorName, actorPortrait: event.actorPortrait || (person ? person.portrait : ''), actorPosition: copy.position || (person ? person.position : null), actorHotspot: actorHotspot(copy.position || person), question: copy.question || event.question, headline: event.label, caption: copy.caption || event.actionDescription, resolvedCaption: `这一页记下:${event.actionAdvice}`, tapPrompt: copy.tapPrompt || `点画中的${event.actorName},看看他正在做什么。`, shortDialogue: event.speech, illustrationAsset: copy.assetName ? `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/${copy.assetName}` : '', ...chapterFourAudioDeclaration(index + 3), })) }) pages.push( basePage(chapter, 7, 'emotion', { emotionMomentId: chapter.emotionMoment.emotionMomentId, headline: chapter.emotionMoment.title, caption: '赵建国嘴上还说“骨干扛得住”,扶桌的手却一直没有松开。', prompt: chapter.emotionMoment.prompt, illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P07-EM04-face-saving-pause-v1.jpg`, ...chapterFourAudioDeclaration(7), }), basePage(chapter, 8, 'memory', { headline: '这一页,收进桂香岁月', memoryCard: { cardId: 'S01-C04-MC01', characterName: chapter.emotionMoment.character.name, eraLine: `${chapter.year} · ${chapter.location}`, tableEcho: chapter.emotionMoment.tableEcho, lifeAction: chapter.events[1].actionAdvice, familyLine: '下回我嘴硬时,先给我拉把凳子。', }, caption: chapter.cliffhanger, illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c04/S01-C04-P08-cliffhanger-v1.jpg`, ...chapterFourAudioDeclaration(8), }), ) return pages } function buildChapterOnePages(chapter) { const eventOverlays = [ { eventId: 'S01-H01', caption: '小满把平板往老人面前一递:“扫一扫就能找座。”纸名单还压在桌角,没人铺开。', question: '几位老人举着手机,纸名单还压在桌角。您先怎么帮?', shortDialogue: '不扫,就没有我的座了?', tapPrompt: '点点小满,看看赵伯为什么停住了。', hotspot: { x: 46, y: 6, w: 36, h: 88 }, assetName: 'S01-C01-P03-H01-scan-only-v1.jpg', audioCueIds: [ 'S01-C01-MS002-ATTR', 'S01-C01-MS002', 'S01-C01-MS003', 'S01-C01-MS004', 'S01-C01-MS005', 'S01-C01-MS006', ], }, { eventId: 'S01-H02', caption: '十个人的菜已经摆得满满当当,赵伯的拇指还停在“再来一道”上:“桌上可不能显得空。”', question: '菜已经不少了,赵伯还想再添一道撑场面。您会怎么接这句话?', shortDialogue: '桌上可不能显得空。', tapPrompt: '点点赵伯,听听他为什么还想加菜。', hotspot: { x: 16, y: 6, w: 40, h: 90 }, assetName: 'S01-C01-P04-H02-extra-dish-v1.jpg', audioCueIds: [ 'S01-C01-MS007', 'S01-C01-MS008', 'S01-C01-MS009-ATTR', 'S01-C01-MS009', ], }, { eventId: 'S01-H03', caption: '乐乐蹲下来一量:四个桌脚印还是新的,拖痕一路朝侧门去了。', question: '地上有新压痕,请柬又写着十五桌。您先怎么查?', shortDialogue: '它不是没来过,是刚走。', tapPrompt: '点点乐乐,看他在地上发现了什么。', hotspot: { x: 31, y: 14, w: 49, h: 80 }, assetName: 'S01-C01-P05-H03-four-imprints-v1.jpg', audioCueIds: ['S01-C01-MS010', 'S01-C01-MS011'], }, { eventId: 'S01-H04', caption: '秦师傅隔着门问:“十五桌的人齐了吗?”稿纸越攥越紧,脚却往后退。', question: '铜牌卷在没念完的稿纸里。您觉得先怎么办?', shortDialogue: '十五桌的人……齐了吗?', tapPrompt: '点点秦师傅,看看他为什么不肯出门。', hotspot: { x: 51, y: 2, w: 42, h: 96 }, assetName: 'S01-C01-P06-H04-unsaid-speech-v1.jpg', audioCueIds: ['S01-C01-MS012-ATTR', 'S01-C01-MS012'], }, ] const pages = [ basePage(chapter, 1, 'cover', { eyebrow: `第01回 · ${chapter.year}`, headline: chapter.title, caption: '请柬明明写着十五桌。可赵伯走到十四桌和十六桌中间,偏偏没找着自己的座。', tapPrompt: '点一下,翻过来听个开头。', primaryAction: '翻开这一回', illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P01-title-v1.jpg`, audioCueIds: ['S01-C01-MT000', 'S01-C01-MS001'], }), basePage(chapter, 2, 'ensemble', { eyebrow: `${chapter.year} · ${chapter.location}`, headline: '十五桌,到底去哪儿了?', caption: '请柬写着十五桌,座位图却从十四跳到十六。赵伯站在空地边,手里的请柬捏了又捏。', secondaryCaption: '后厨门里,秦师傅看了一眼,又把身子缩了回去。', tapPrompt: '先看看,谁在等,谁又躲开了。', primaryAction: '先看看,大家都在看哪里', illustrationAsset: '/assets/comic/s01-c01/S01-C01-P02-missing-table-ensemble-v1.jpg', audioCueIds: [ 'S01-C01-MS002-ATTR', 'S01-C01-MS002', 'S01-C01-MS003', 'S01-C01-MS004', ], }), ] ;(chapter.events || []).forEach((event, index) => { const person = findPerson(chapter, event.actorInstanceId) const overlay = eventOverlays[index] if (!overlay || overlay.eventId !== event.hotspotId) { throw new Error(`S01-C01 overlay does not match ${event.hotspotId}`) } pages.push(basePage(chapter, index + 3, 'event', { eventId: event.hotspotId, actorInstanceId: event.actorInstanceId, actorName: event.actorName, actorPortrait: event.actorPortrait || (person ? person.portrait : ''), actorPosition: person ? person.position : null, actorHotspot: fixedHotspot(overlay.hotspot), question: overlay.question, headline: event.label, caption: overlay.caption, resolvedCaption: `这一页记下:${event.actionAdvice}`, tapPrompt: overlay.tapPrompt, shortDialogue: overlay.shortDialogue, illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/${overlay.assetName}`, audioCueIds: overlay.audioCueIds, })) }) pages.push( basePage(chapter, 7, 'emotion', { emotionMomentId: chapter.emotionMoment.emotionMomentId, headline: chapter.emotionMoment.title, caption: '赵伯把请柬捏平,嘴上说“别管我”,两只脚却一直守着那块空地。', prompt: '赵伯嘴上说没事。您会怎么请他一块儿查?', illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P07-EM01-call-zhao-in-v1.jpg`, actorHotspot: fixedHotspot({ x: 28, y: 3, w: 45, h: 94 }), audioCueIds: ['S01-C01-TE900'], }), basePage(chapter, 8, 'memory', { headline: '这一页,收进桂香岁月', memoryCard: { cardId: 'S01-C01-MC01', characterName: '赵建国', eraLine: `${chapter.year} · ${chapter.location}`, tableEcho: '空着的位置,不是少摆一张桌,是在等一个人被叫到名字。', lifeAction: '替家里人张罗之前,先问一句:“您想怎么来?”', familyLine: '下回别急着替我安排,先叫我一起商量。', eraObject: '手写请柬与十五号旧铜牌', }, caption: '乐乐从立牌箱底下摸出一个纸筒。铜牌刚展开,后厨“哐当”一声,锅盖掉了。秦师傅没有出来。', tapPrompt: '锅盖这一响,接着往下看。', illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c01/S01-C01-P08-cliffhanger-v1.jpg`, audioCueIds: ['S01-C01-MS013'], }), ) return pages } function buildChapterTwoPages(chapter) { const eventOverlays = [ { eventId: 'S01-H05', caption: '林秀兰用票夹压住展柜说明牌:“这是厂内饭菜票,不是全国粮票。”', question: '把桂香厂内饭菜票直接说成全国粮票,这样妥当吗?', shortDialogue: '名字差一个,来路就差远了。', tapPrompt: '点画中的林秀兰,看她把票名说清。', hotspot: { x: 33, y: 1, w: 52, h: 96 }, assetName: 'S01-C02-P03-H05-meal-ticket-label-v1.jpg', audioCueIds: ['S01-C02-MS007'], }, { eventId: 'S01-H06', caption: '赵伯认定蓝边缺口碗是自己的。林秀兰把照片转来:他的碗,缺口却在另一边。', question: '只凭赵伯一句“这是我的”就确认物主,这样妥当吗?', shortDialogue: '我这不是先把自己认回来嘛。', tapPrompt: '点画中的赵伯,陪他再认一遍。', hotspot: { x: 6, y: 1, w: 51, h: 97 }, assetName: 'S01-C02-P04-H06-bowl-direction-v1.jpg', audioCueIds: [ 'S01-C02-MS002-ATTR', 'S01-C02-MS002', 'S01-C02-MS003', 'S01-C02-MS004', 'S01-C02-MS005', 'S01-C02-MS006', ], }, { eventId: 'S01-H07', caption: '大人还在说碗,乐乐已在合影边缘找出棕布包,也看见长桌下沿那道暗红旧漆。', question: '用重复物件辅助辨认年轻小唐与旧桌,但不急着下最终结论,这样更稳妥吗?', shortDialogue: '我先记“待核对”,不写“就是”。', tapPrompt: '点画中的乐乐,跟着他看照片最边上的人。', hotspot: { x: 25, y: 8, w: 55, h: 89 }, assetName: 'S01-C02-P05-H07-photo-edge-clues-v1.jpg', audioCueIds: [ 'S01-C02-MS008', 'S01-C02-MS009', 'S01-C02-MS010', ], }, { eventId: 'S01-H08', caption: '林秀兰翻过铜牌,拍下旧漆和钉孔;两张磨裂的饭菜票仍留在细铁丝上。', question: '拍下红漆与孔位,点击后自动放大铁丝上的两枚破损票,这样更稳妥吗?', shortDialogue: '证据会说话,可别逼它一次把所有话都说完。', tapPrompt: '点画中的林秀兰,看她怎样留证。', hotspot: { x: 31, y: 0, w: 58, h: 98 }, assetName: 'S01-C02-P06-H08-plaque-reverse-v1.jpg', audioCueIds: ['S01-C02-MS011'], }, ] const pages = [ basePage(chapter, 1, 'cover', { eyebrow: `第02回 · ${chapter.year}`, headline: chapter.title, caption: '锅盖声落下,秦师傅仍没出来。铜牌背后的暗红旧漆,把众人引向展柜。', primaryAction: '翻开这一回', illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P01-title-v1.jpg`, audioCueIds: ['S01-C02-MT000'], }), basePage(chapter, 2, 'ensemble', { eyebrow: `${chapter.year} · ${chapter.location}`, headline: '三个人,三种线索', caption: '赵伯认碗,林秀兰翻照片,乐乐发现旧票。三双眼睛看得不同,正好相互核对。', secondaryCaption: '', primaryAction: '先看看,大家各自在看什么', illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P02-exhibit-ensemble-v1.jpg`, audioCueIds: ['S01-C02-MS001'], }), ] ;(chapter.events || []).forEach((event, index) => { const person = findPerson(chapter, event.actorInstanceId) const overlay = eventOverlays[index] if (!overlay || overlay.eventId !== event.hotspotId) { throw new Error(`S01-C02 overlay does not match ${event.hotspotId}`) } pages.push(basePage(chapter, index + 3, 'event', { eventId: event.hotspotId, actorInstanceId: event.actorInstanceId, actorName: event.actorName, actorPortrait: event.actorPortrait || (person ? person.portrait : ''), actorPosition: person ? person.position : null, actorHotspot: fixedHotspot(overlay.hotspot), question: overlay.question, headline: event.label, caption: overlay.caption, resolvedCaption: `这一页记下:${event.actionAdvice}`, tapPrompt: overlay.tapPrompt, shortDialogue: overlay.shortDialogue, illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/${overlay.assetName}`, audioCueIds: overlay.audioCueIds, })) }) pages.push( basePage(chapter, 7, 'emotion', { emotionMomentId: chapter.emotionMoment.emotionMomentId, headline: chapter.emotionMoment.title, caption: '赵伯认错了眼前的碗,林秀兰没有替他宣布哪段记忆作废。', prompt: '面对一段记得不太清楚的往事,你想怎样陪他们继续认?', illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P07-EM02-slow-recognition-v1.jpg`, actorHotspot: fixedHotspot({ x: 8, y: 3, w: 84, h: 94 }), audioCueIds: ['S01-C02-TE900'], }), basePage(chapter, 8, 'memory', { headline: '这一页,收进桂香岁月', memoryCard: { cardId: 'S01-C02-MC01', characterName: '林秀兰', eraLine: `${chapter.year} · ${chapter.location}`, tableEcho: '旧东西会认错,慢一点核对,人就不会被轻易抹掉。', lifeAction: '保留近照和尺寸,等待旧桌板出现。', familyLine: '我说起旧事时,先让我慢慢说完;咱们再一起核对。', eraObject: '十五号铜牌与两枚破损饭菜票', }, caption: chapter.cliffhanger, illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c02/S01-C02-P08-clock-to-1978-v1.jpg`, audioCueIds: ['S01-C02-MS012'], }), ) return pages } function buildChapterThreePages(chapter) { const eventOverlays = [ { eventId: 'S01-H09', caption: '老吕刚从车间赶来,指缝还留着机油,伸手就要抓馒头。小唐替他先把碗放稳。', question: '没有清洁双手就直接抓食物,这样妥当吗?', shortDialogue: '差点把车间也吃进去了,我先去洗。', tapPrompt: '点画中的老吕,看看他从车间带回来的手。', hotspot: { x: 18, y: 1, w: 56, h: 97 }, artAspectRatio: 16 / 9, assetName: 'S01-C03-P03-H09-oily-hand-bun-v1.jpg', audioCueIds: [ 'S01-C03-MS006', 'S01-C03-MS007-ATTR', 'S01-C03-MS007', 'S01-C03-MS008', ], }, { eventId: 'S01-H10', caption: '同坐一条长凳的工友忽然起身,另一端连人带汤碗向后翘;一句“坐稳了”慢了半拍。', question: '一个人不提醒同伴就突然起身,这样妥当吗?', shortDialogue: '这声早半拍,我的汤就保住了。', tapPrompt: '点画中的两位工友,看看长凳两端发生了什么。', hotspot: { x: 14, y: 1, w: 78, h: 97 }, artAspectRatio: 16 / 9, assetName: 'S01-C03-P04-H10-bench-balance-v1.jpg', audioCueIds: ['S01-C03-MS010'], }, { eventId: 'S01-H11', caption: '乐乐看旧画问“那时怎么总吃这些?”赵伯答:菜少、活重,下午机器不等人。', question: '用今天的生活条件嘲笑当年的工人“只会吃主食”,这样妥当吗?', shortDialogue: '赵伯:“不是不知道换着吃,是那时候没得换。”', tapPrompt: '点画中的赵建国,把这一盆饭放回当年的日子里看。', hotspot: { x: 29, y: 1, w: 49, h: 97 }, assetName: 'S01-C03-P05-H11-staple-era-context-v1.jpg', audioCueIds: [ 'S01-C03-MS003', 'S01-C03-MS004', 'S01-C03-MS011', ], }, { eventId: 'S01-H12', caption: '林秀兰左手收饭菜票,右手另夹晚班人数单:“票是票,人是人,回来吃饭的人不能漏。”', question: '把结算票券和抢修/晚班人数登记分开核对,这样更稳妥吗?', shortDialogue: '账能重算,回来吃饭的人不能漏。', tapPrompt: '点画中的林秀兰,看看她为什么把两种凭据分开。', hotspot: { x: 29, y: 0, w: 56, h: 98 }, artAspectRatio: 1280 / 540, assetName: 'S01-C03-P06-H12-two-clips-v1.jpg', audioCueIds: ['S01-C03-MS005-ATTR', 'S01-C03-MS005'], }, ] const pages = [ basePage(chapter, 1, 'cover', { eyebrow: `第03回 · ${chapter.year}`, headline: chapter.title, caption: '厂铃一响,车间门开;饭盆、饭票和脚步声一齐涌进食堂。', primaryAction: '翻开这一回', illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P01-title-v1.jpg`, audioCueIds: ['S01-C03-MT000', 'S01-C03-MS001'], }), basePage(chapter, 2, 'ensemble', { eyebrow: `${chapter.year} · ${chapter.location}`, headline: '一顿午饭,同时忙着六件事', caption: '窗口喊着“下一位”,林秀兰分开两只票夹;老吕赶着抓馒头,长凳那头险些一翘。', secondaryCaption: '', primaryAction: '先看看,谁在忙什么', artAspectRatio: 16 / 9, illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P02-canteen-ensemble-v1.jpg`, audioCueIds: [ 'S01-C03-MS002-ATTR', 'S01-C03-MS002', 'S01-C03-MS003', ], }), ] ;(chapter.events || []).forEach((event, index) => { const person = findPerson(chapter, event.actorInstanceId) const overlay = eventOverlays[index] if (!overlay || overlay.eventId !== event.hotspotId) { throw new Error(`S01-C03 overlay does not match ${event.hotspotId}`) } pages.push(basePage(chapter, index + 3, 'event', { eventId: event.hotspotId, actorInstanceId: event.actorInstanceId, actorName: event.actorName, actorPortrait: event.actorPortrait || (person ? person.portrait : ''), actorPosition: person ? person.position : null, actorHotspot: fixedHotspot(overlay.hotspot), question: overlay.question, headline: event.label, caption: overlay.caption, resolvedCaption: `这一页记下:${event.actionAdvice}`, tapPrompt: overlay.tapPrompt, shortDialogue: overlay.shortDialogue, ...(overlay.artAspectRatio ? { artAspectRatio: overlay.artAspectRatio } : {}), illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/${overlay.assetName}`, audioCueIds: overlay.audioCueIds, })) }) pages.push( basePage(chapter, 7, 'emotion', { emotionMomentId: chapter.emotionMoment.emotionMomentId, headline: chapter.emotionMoment.title, caption: '小唐替人放稳碗、挪好凳,又退回桌角。满屋都问谁还能多干,没人问他累不累。', prompt: '你想怎样给这个不起眼的小唐留一点位置?', illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P07-EM03-ask-tired-v1.jpg`, actorHotspot: fixedHotspot({ x: 14, y: 1, w: 80, h: 97 }), audioCueIds: ['S01-C03-MS009', 'S01-C03-TE900'], }), basePage(chapter, 8, 'memory', { headline: '这一页,收进桂香岁月', memoryCard: { cardId: 'S01-C03-MC01', characterName: '唐守安', eraLine: `${chapter.year} · ${chapter.location}`, tableEcho: '看见一个人,不只看他能干多少,也问他累不累。', lifeAction: '进食前按条件把手清洁并擦干', familyLine: '回家聊一聊:你想怎样给这个不起眼的小唐留一点位置?', eraObject: '饭票与搪瓷饭盒', }, caption: chapter.cliffhanger, illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c03/S01-C03-P08-third-bowl-cliffhanger-v1.jpg`, audioCueIds: [ 'S01-C03-MS013', 'S01-C03-MS014', 'S01-C03-MS015', ], }), ) return pages } function buildChapterFivePages(chapter) { const eventOverlays = [ { eventId: 'S01-H17', caption: '老吕把缺口碗往怀里一收,脚尖已经朝向车间:“算了,空一顿也能顶。”', shortDialogue: '算了,空一顿也能顶。', tapPrompt: '点画中的老吕,看看他为什么又想转身。', hotspot: { x: 20, y: 1, w: 53, h: 97 }, assetName: 'S01-C05-P03-H17-empty-window-v1.jpg', audioCueIds: [ 'S01-C05-MS002-ATTR', 'S01-C05-MS002', 'S01-C05-MS003-ATTR', 'S01-C05-MS003', ], }, { eventId: 'S01-H18', caption: '白天吃第三碗的赵建国,把仅剩半个馒头掰开:“先数人,别数谁功劳大。”', shortDialogue: '先数人,别数功劳。', tapPrompt: '点画中的赵建国,看看白天第三碗的人怎样分这半个馒头。', hotspot: { x: 21, y: 1, w: 56, h: 97 }, assetName: 'S01-C05-P04-H18-half-bun-v1.jpg', audioCueIds: ['S01-C05-MS004', 'S01-C05-MS005'], }, { eventId: 'S01-H19', caption: '秦师傅没翻午间久置的熟菜。他重新点火,把另存的面和白菜现下进锅。', shortDialogue: '旧菜不赌,重新做。', tapPrompt: '点画中的秦师傅,看看他用的是什么、没有用什么。', hotspot: { x: 29, y: 0, w: 55, h: 98 }, assetName: 'S01-C05-P05-H19-relit-stove-v1.jpg', audioCueIds: [ 'S01-C05-MS008', 'S01-C05-MS009', 'S01-C05-MS010', 'S01-C05-MS012', ], }, { eventId: 'S01-H20', caption: '秦师傅把最后一张长桌拖到灯下,小唐摆稳长凳;热饭终于有地方放,人也有地方坐。', shortDialogue: '来晚的人,也得有地方坐。', tapPrompt: '点画中的秦师傅,看看这张空位准备让谁坐下。', hotspot: { x: 47, y: 1, w: 43, h: 97 }, assetName: 'S01-C05-P06-H20-seat-at-table-v1.jpg', audioCueIds: [ 'S01-C05-MS011', 'S01-C05-MS013', 'S01-C05-MS014', 'S01-C05-MS015', ], }, ] const pages = [ basePage(chapter, 1, 'cover', { eyebrow: `第05回 · ${chapter.year}`, headline: chapter.title, caption: '灯熄了一半,晚班脚步回来时,铁勺正刮到空锅底。', primaryAction: '翻开这一回', illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P01-title-v1.jpg`, audioCueIds: ['S01-C05-MT000', 'S01-C05-MS001'], }), basePage(chapter, 2, 'ensemble', { eyebrow: `${chapter.year} · ${chapter.location}`, headline: '人报过了,饭却没有留下', caption: '老吕想转身,小唐拖凳,赵建国掰开半个馒头;林秀兰把人数单和饭票压在窗台。', secondaryCaption: '', primaryAction: '先看看,谁正准备走,谁正把人留下', illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P02-late-shift-ensemble-v1.jpg`, audioCueIds: [ 'S01-C05-MS002-ATTR', 'S01-C05-MS002', 'S01-C05-MS003-ATTR', 'S01-C05-MS003', ], }), ] ;(chapter.events || []).forEach((event, index) => { const person = findPerson(chapter, event.actorInstanceId) const overlay = eventOverlays[index] if (!overlay || overlay.eventId !== event.hotspotId) { throw new Error(`S01-C05 overlay does not match ${event.hotspotId}`) } pages.push(basePage(chapter, index + 3, 'event', { eventId: event.hotspotId, actorInstanceId: event.actorInstanceId, actorName: event.actorName, actorPortrait: event.actorPortrait || (person ? person.portrait : ''), actorPosition: person ? person.position : null, actorHotspot: fixedHotspot(overlay.hotspot), question: event.question, evidence: playerFacingCopy(event.evidence), headline: event.label, caption: overlay.caption, resolvedCaption: `这一页记下:${playerFacingCopy(event.actionAdvice)}`, tapPrompt: overlay.tapPrompt, shortDialogue: overlay.shortDialogue, illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/${overlay.assetName}`, audioCueIds: overlay.audioCueIds, })) }) pages.push( basePage(chapter, 7, 'emotion', { emotionMomentId: chapter.emotionMoment.emotionMomentId, headline: chapter.emotionMoment.title, caption: '灶火重新亮起,热汤的白汽把门口站着的人慢慢连成了一桌。', prompt: chapter.emotionMoment.prompt, actorHotspot: fixedHotspot({ x: 18, y: 0, w: 65, h: 98 }), illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P07-EM05-light-for-latecomers-v1.jpg`, audioCueIds: ['S01-C05-TE900'], }), basePage(chapter, 8, 'memory', { headline: '这一页,收进桂香岁月', memoryCard: { cardId: 'S01-C05-MC01', characterName: '秦志成', eraLine: `${chapter.year} · ${chapter.location}`, tableEcho: '有人为晚归的人留了一盏灯。', lifeAction: '尽快说明情况,按单位安排和个人需要解决进食', familyLine: '回家聊一聊:你想和秦师傅一起,为这张桌留下些什么?', eraObject: '第十五桌木牌与留饭灯', }, caption: chapter.cliffhanger, illustrationAsset: `/${chapterPackageRoot(chapter.chapterNumber)}/assets/comic/s01-c05/S01-C05-P08-first-photo-v1.jpg`, audioCueIds: ['S01-C05-MS016'], }), ) return pages } function reviewedProductionAsset(chapter, productionPage) { return [ '', chapterPackageRoot(chapter.chapterNumber), 'assets', 'comic', chapter.chapterId.toLowerCase(), productionPage.assetName, ].join('/') } function buildReviewedProductionChapterPages(chapter) { const productionChapter = productionComicChapters[chapter.chapterId] const productionPages = productionChapter && productionChapter.pages if (!Array.isArray(productionPages) || productionPages.length !== 8) { throw new Error(`${chapter.chapterId} needs eight reviewed production pages`) } const [cover, ensemble, ...remainingPages] = productionPages const eventPages = remainingPages.slice(0, 4) const emotionPage = remainingPages[4] const memoryPage = remainingPages[5] const memoryLayoutException = getComicPageLayoutException(memoryPage.pageId) const isSeasonClose = Boolean( memoryLayoutException && memoryLayoutException.behavior === 'season-close-detail-inset' ) const reviewedMemoryCard = memoryCards.find( (card) => card.chapterId === chapter.chapterId, ) if (!reviewedMemoryCard) { throw new Error(`${chapter.chapterId} is missing its reviewed memory card`) } const pages = [ basePage(chapter, 1, 'cover', { eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, headline: chapter.title, caption: cover.caption, primaryAction: cover.interactionPrompt || '翻开这一回', illustrationAsset: reviewedProductionAsset(chapter, cover), ...pageAudioDeclaration(cover), }), basePage(chapter, 2, 'ensemble', { eyebrow: `${chapter.year} · ${chapter.location}`, headline: chapter.title, caption: ensemble.caption, secondaryCaption: '', primaryAction: ensemble.interactionPrompt || '先看看画中人物', illustrationAsset: reviewedProductionAsset(chapter, ensemble), ...pageAudioDeclaration(ensemble), }), ] ;(chapter.events || []).forEach((event, index) => { const productionPage = eventPages[index] const person = findPerson(chapter, event.actorInstanceId) const layoutException = productionPage ? getComicPageLayoutException(productionPage.pageId) : null if ( !productionPage || productionPage.pageId !== pageId(chapter.chapterId, index + 3) || productionPage.eventId !== event.hotspotId || !productionPage.hotspot ) { throw new Error( `${chapter.chapterId} production page does not match ${event.hotspotId}`, ) } pages.push(basePage(chapter, index + 3, 'event', { eventId: event.hotspotId, actorInstanceId: event.actorInstanceId, actorName: event.actorName, actorPortrait: event.actorPortrait || (person ? person.portrait : ''), actorPosition: person ? person.position : null, // Page-specific hit-area changes come only from the explicit exception // registry. Visible guidance for every event is part of the shared // actor-event template and always stays outside the artwork. actorHotspot: fixedHotspot( layoutException && layoutException.actorHotspot ? layoutException.actorHotspot : productionPage.hotspot, ), question: event.question, headline: event.label, caption: productionPage.caption, secondaryCaption: productionPage.secondaryCaption || '', outsideActionLabel: productionPage.outsideActionLabel || '', artTapHint: productionPage.artTapHint || '', resolvedCaption: `这一页记下:${event.actionAdvice}`, tapPrompt: `点画中的${event.actorName},看看这一刻。`, shortDialogue: productionPage.shortDialogue || event.speech, illustrationAsset: reviewedProductionAsset(chapter, productionPage), ...(productionPage.programEvidenceInset ? { programEvidenceInset: fixedProgramEvidenceInset( productionPage.programEvidenceInset, ), } : {}), ...pageAudioDeclaration(productionPage), })) }) if ( emotionPage.emotionMomentId !== chapter.emotionMoment.emotionMomentId ) { throw new Error(`${chapter.chapterId} emotion page is not locked to source`) } pages.push( basePage(chapter, 7, 'emotion', { emotionMomentId: chapter.emotionMoment.emotionMomentId, headline: chapter.emotionMoment.title, caption: emotionPage.caption, prompt: chapter.emotionMoment.prompt, actorHotspot: emotionPage.hotspot ? fixedHotspot(emotionPage.hotspot) : null, illustrationAsset: reviewedProductionAsset(chapter, emotionPage), ...pageAudioDeclaration(emotionPage), }), basePage(chapter, 8, 'memory', { headline: '这一页,收进桂香岁月', memoryCard: { cardId: reviewedMemoryCard.cardId, characterName: reviewedMemoryCard.characterName, eraLine: reviewedMemoryCard.eraLine, tableEcho: reviewedMemoryCard.tableEcho, lifeAction: reviewedMemoryCard.lifeAction, familyLine: reviewedMemoryCard.familyLine, eraObject: reviewedMemoryCard.eraObject, ...(Array.isArray(memoryPage.memoryDisplayLines) && memoryPage.memoryDisplayLines.length ? { displayLines: memoryPage.memoryDisplayLines.map( (line) => String(line), ), } : {}), }, caption: memoryPage.cliffhanger || chapter.cliffhanger, illustrationAsset: reviewedProductionAsset(chapter, memoryPage), ...pageAudioDeclaration(memoryPage), ...(isSeasonClose ? { isSeasonClose: true, seasonCloseCaption: memoryPage.caption, seasonCloseTail: memoryPage.cliffhanger || chapter.cliffhanger, detailInset: fixedDetailInset(memoryPage.programDetailInset), } : {}), }), ) return pages } function chapterPeopleLine(chapter) { const names = (chapter.people || []) .map((person) => person.name) .filter(Boolean) .filter((name, index, allNames) => allNames.indexOf(name) === index) .slice(0, 5) if (!names.length) return '这一桌的人已经到齐,画里的动作正等你来看。' return `${names.join('、')}都在画里;先看他们正在做什么,再慢慢判断。` } function appendGenericInteractivePages(pages, chapter) { ;(chapter.events || []).forEach((event, index) => { const person = findPerson(chapter, event.actorInstanceId) pages.push(basePage(chapter, index + 3, 'event', { eventId: event.hotspotId, actorInstanceId: event.actorInstanceId, actorName: event.actorName, actorPortrait: event.actorPortrait || (person ? person.portrait : ''), actorPosition: person ? person.position : null, actorHotspot: actorHotspot(person), question: event.question, headline: event.label, caption: event.actionDescription, resolvedCaption: `这一页记下:${event.actionAdvice}`, tapPrompt: `点画中的${event.actorName},看看${event.actionDescription}`, shortDialogue: event.speech, })) }) const emotionMoment = chapter.emotionMoment pages.push( basePage(chapter, 7, 'emotion', { emotionMomentId: emotionMoment.emotionMomentId, headline: emotionMoment.title, caption: emotionMoment.sceneText, prompt: emotionMoment.prompt, }), basePage(chapter, 8, 'memory', { headline: '这一页,收进桂香岁月', memoryCard: { cardId: `${chapter.chapterId}-MC01`, characterName: emotionMoment.character.name, eraLine: `${chapter.year} · ${chapter.location}`, tableEcho: emotionMoment.tableEcho, lifeAction: chapter.events[0].actionAdvice, familyLine: `回家聊一聊:${emotionMoment.prompt}`, }, caption: chapter.cliffhanger, }), ) } function buildGenericChapterPages(chapter) { const pages = [ basePage(chapter, 1, 'cover', { eyebrow: `第${String(chapter.chapterNumber).padStart(2, '0')}回 · ${chapter.year}`, headline: chapter.title, caption: chapter.narration, primaryAction: '翻开这一回', }), basePage(chapter, 2, 'ensemble', { eyebrow: `${chapter.year} · ${chapter.location}`, headline: chapter.title, caption: chapter.narration, secondaryCaption: chapterPeopleLine(chapter), primaryAction: '看看画中人物', }), ] appendGenericInteractivePages(pages, chapter) return pages } function buildComicPageModel(chapter, chapterNumber) { if (!chapter || !chapter.chapterId) { return { mode: 'legacy', chapterId: '', chapterNumber: Number(chapterNumber) || 1, pageSequence: [], firstPageId: '', lastPageId: '', } } let pageSequence = [] let prototypeScope = '' if (chapter.chapterId === COMIC_CHAPTER_ID) { pageSequence = buildChapterFourPages(chapter) prototypeScope = 'full-eight-pages' } else if (chapter.chapterId === CHAPTER_ONE_ID) { pageSequence = buildChapterOnePages(chapter) prototypeScope = 'full-eight-pages' } else if (chapter.chapterId === CHAPTER_TWO_ID) { pageSequence = buildChapterTwoPages(chapter) prototypeScope = 'full-eight-pages' } else if (chapter.chapterId === CHAPTER_THREE_ID) { pageSequence = buildChapterThreePages(chapter) prototypeScope = 'full-eight-pages' } else if (chapter.chapterId === CHAPTER_FIVE_ID) { pageSequence = buildChapterFivePages(chapter) prototypeScope = 'full-eight-pages' } else if (productionComicChapters[chapter.chapterId]) { pageSequence = buildReviewedProductionChapterPages(chapter) prototypeScope = 'full-eight-pages' } else { pageSequence = buildGenericChapterPages(chapter) prototypeScope = 'full-eight-pages' } pageSequence = pageSequence.map(attachFixedDetailInset) pageSequence = attachPlayableVisuals(pageSequence, chapter) return { mode: 'comic', chapterId: chapter.chapterId, chapterNumber: Number(chapterNumber) || chapter.chapterNumber, presentationMode: getComicPresentationMode(chapter.chapterId), pageSequence, firstPageId: pageSequence[0].pageId, lastPageId: pageSequence[pageSequence.length - 1].pageId, prototypeScope, } } function findPageIndex(model, requestedPageId) { if (!model || !Array.isArray(model.pageSequence)) return -1 return model.pageSequence.findIndex( (page) => page.pageId === requestedPageId, ) } function getResumePageId( model, savedPageId, completedIds = [], chapterFinished = false, ) { if (!model || model.mode !== 'comic' || !model.pageSequence.length) { return '' } if (chapterFinished) return model.lastPageId const eventPages = model.pageSequence.filter((page) => page.type === 'event') const savedPageIndex = findPageIndex(model, savedPageId) if (!eventPages.length) { return savedPageIndex >= 0 ? savedPageId : model.firstPageId } const validEventIds = new Set(eventPages.map((page) => page.eventId)) const completed = new Set( (Array.isArray(completedIds) ? completedIds : []).filter( (eventId) => validEventIds.has(eventId), ), ) const firstIncomplete = eventPages.find( (page) => !completed.has(page.eventId), ) if (firstIncomplete) { const firstIncompleteIndex = findPageIndex(model, firstIncomplete.pageId) if (savedPageIndex >= 0 && savedPageIndex <= firstIncompleteIndex) { return savedPageId } if (savedPageIndex > firstIncompleteIndex) { return firstIncomplete.pageId } return completed.size ? firstIncomplete.pageId : model.firstPageId } const emotionPage = model.pageSequence.find( (page) => page.type === 'emotion', ) if (!emotionPage) return model.lastPageId const emotionPageIndex = findPageIndex(model, emotionPage.pageId) if (savedPageIndex >= 0 && savedPageIndex <= emotionPageIndex) { return savedPageId } return emotionPage.pageId } module.exports = { buildComicPageModel, findPageIndex, getResumePageId, pageAudioDeclaration, }