import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { createServer } from 'vite'; const expectedStoryPageCount = 469; const server = await createServer({ logLevel: 'error', server: { middlewareMode: true, hmr: false }, appType: 'custom' }); try { const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts'); const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); const { storyEnvironmentDepths, storyEnvironmentProfiles, storyEnvironmentProfileFor, storyEnvironmentProfileVerification } = await server.ssrLoadModule('/src/game/data/storyEnvironmentProfiles.ts'); const { ambienceTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts'); assert.equal(storyEnvironmentProfileVerification.profileCount, 7, 'seven story environment profiles are required'); assert(storyEnvironmentDepths.tint > 0, 'story tint must render above the background'); assert(storyEnvironmentDepths.tint < storyEnvironmentDepths.haze, 'story haze must render above tint'); assert(storyEnvironmentDepths.haze < storyEnvironmentDepths.particles, 'story particles must render above haze'); assert(storyEnvironmentDepths.particles < 1, 'story environment must remain below the scene shade'); assert(storyEnvironmentProfileVerification.maxParticleCount <= 24, 'story particle pool exceeds the 24-object budget'); assert(storyEnvironmentProfileVerification.maxHazeCount <= 2, 'story haze pool exceeds the two-object budget'); assert(storyEnvironmentProfileVerification.maxObjectCount <= 27, 'story environment pool exceeds 27 objects'); Object.values(storyEnvironmentProfiles).forEach((profile) => { assert(ambienceTracks[profile.ambienceKey], `${profile.id} references missing ambience ${profile.ambienceKey}`); assert(profile.ambienceVolume > 0 && profile.ambienceVolume <= 0.09, `${profile.id} ambience is outside the quiet story mix`); assert(profile.tint.alpha >= 0 && profile.tint.alpha <= 0.08, `${profile.id} tint is too opaque`); assert((profile.haze?.count ?? 0) <= 2, `${profile.id} creates too many haze layers`); assert((profile.particles?.count ?? 0) <= 24, `${profile.id} creates too many particles`); if (profile.effect === 'mist') { assert.equal(profile.particles, undefined, `${profile.id} mist should not create particles`); } else { const expectedKind = profile.effect === 'embers' ? 'ember' : profile.effect; assert.equal(profile.particles?.kind, expectedKind, `${profile.id} particle kind does not match its effect`); } }); const representativeCases = [ ['story-yiling-fire-attack', 'story-fire-embers'], ['story-fan-castle-flood', 'story-rain-mist'], ['story-hanzhong-rain', 'story-rain-mist'], ['story-maicheng-isolation', 'story-snow-wind'], ['story-nanzhong-captures', 'story-jungle-mist'], ['story-weishui-northbank', 'story-river-mist'], ['story-weishui-camps', 'story-camp-night'], ['story-northern', 'story-mountain-wind'], ['story-liu-bei-resolve', null] ]; representativeCases.forEach(([key, expectedProfileId]) => { assert.equal( storyEnvironmentProfileFor(key)?.id ?? null, expectedProfileId, `${key} should resolve to ${expectedProfileId ?? 'no environment'}` ); }); assert.equal( storyEnvironmentProfileFor('story-red-cliffs', 'twenty-second-fire-attack-sortie')?.id, 'story-fire-embers', 'the explicit Red Cliffs fire set piece should retain its fire environment' ); assert.equal( storyEnvironmentProfileFor('story-red-cliffs', 'twenty-first-red-cliffs-sortie'), undefined, 'the broader Red Cliffs story family must remain neutral before the fire attack' ); const unmappedVariantCases = [ 'story-yiling-fire-attack-riverbank', 'story-hanzhong-rain-night-watch', 'story-maicheng-isolation-snow-road', 'story-wandering-night-camp', 'story-training', 'story-red-cliffs', 'story-anti-dong', 'story-baidi-entrustment', 'story-yiling-baidi', 'story-yizhou' ]; unmappedVariantCases.forEach((key) => { assert.equal( storyEnvironmentProfileFor(key), undefined, `${key} must not bypass semantic story background-family mapping` ); }); const storySources = collectStorySources(scenarioModule, battleScenarios); const selectedCounts = new Map(); let pageCount = 0; let coveredPageCount = 0; let environmentTransitionCount = 0; let multiTransitionSequenceCount = 0; let fullyChangingSequenceCount = 0; storySources.forEach((pages) => { const profileIds = pages.map((page) => storyEnvironmentProfileFor(page.background, page.id)?.id ?? null); let sequenceTransitionCount = 0; profileIds.forEach((profileId, index) => { pageCount += 1; if (index > 0 && profileIds[index - 1] !== profileId) { sequenceTransitionCount += 1; } if (!profileId) { return; } coveredPageCount += 1; selectedCounts.set(profileId, (selectedCounts.get(profileId) ?? 0) + 1); }); environmentTransitionCount += sequenceTransitionCount; if (sequenceTransitionCount >= 2) { multiTransitionSequenceCount += 1; } if (pages.length > 1 && sequenceTransitionCount === pages.length - 1) { fullyChangingSequenceCount += 1; } }); assert.equal(pageCount, expectedStoryPageCount, 'story environment coverage must inspect every story page'); assert(coveredPageCount >= 220, `story environments cover too few pages (${coveredPageCount}/${pageCount})`); assert( environmentTransitionCount <= 8, `story environments change too often inside sequences (${environmentTransitionCount} transitions)` ); assert( multiTransitionSequenceCount <= 4, `too many story sequences switch environments repeatedly (${multiTransitionSequenceCount})` ); assert( fullyChangingSequenceCount <= 3, `too many story sequences change environment on every page (${fullyChangingSequenceCount})` ); Object.values(storyEnvironmentProfiles).forEach((profile) => { assert((selectedCounts.get(profile.id) ?? 0) > 0, `${profile.id} is never selected by campaign pages`); }); const storySceneSource = readFileSync('src/game/scenes/StoryScene.ts', 'utf8'); [ 'soundDirector.playSoundscape({', 'storyEnvironmentProfileFor(page.background, page.id)', 'this.applyStoryEnvironment(environmentProfile, backgroundKey, page.id)', 'soundDirector.playStoryAdvanceCue()', 'this.updateStoryEnvironment(delta)', 'this.clearStoryEnvironment()', 'environment: this.storyEnvironmentDebugState()' ].forEach((snippet) => { assert(storySceneSource.includes(snippet), `StoryScene is missing environment integration: ${snippet}`); }); assert( !storySceneSource.includes('soundDirector.stopAmbience()'), 'StoryScene shutdown must leave ambience handoff to the destination soundscape so crossfades stay continuous.' ); console.info( `Verified ${storyEnvironmentProfileVerification.profileCount} story environment profiles across ` + `${coveredPageCount}/${pageCount} pages with ${environmentTransitionCount} in-sequence transitions and ` + `a fixed ${storyEnvironmentProfileVerification.maxObjectCount}-object pool.` ); } finally { await server.close(); } function collectStorySources(scenarioModule, battleScenarios) { const sources = []; const seenArrays = new WeakSet(); Object.values(scenarioModule) .filter(isStoryPageList) .forEach((pages) => { sources.push(pages); seenArrays.add(pages); }); Object.values(battleScenarios).forEach((scenario) => { if (isStoryPageList(scenario.victoryPages) && !seenArrays.has(scenario.victoryPages)) { sources.push(scenario.victoryPages); seenArrays.add(scenario.victoryPages); } }); return sources; } function isStoryPageList(value) { return Array.isArray(value) && value.length > 0 && value.every((page) => page && typeof page === 'object' && typeof page.id === 'string' && typeof page.background === 'string' ); }