Files
2026-09-09 14:47:29 +08:00

299 lines
7.4 KiB
JavaScript

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