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