feat: add stable story atmosphere and motion controls
This commit is contained in:
190
scripts/verify-story-environment-profiles.mjs
Normal file
190
scripts/verify-story-environment-profiles.mjs
Normal file
@@ -0,0 +1,190 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
const expectedStoryPageCount = 466;
|
||||
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'
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user