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, }