import { readFileSync } from 'node:fs'; import { createServer } from 'vite'; const server = await createServer({ logLevel: 'error', server: { middlewareMode: true, hmr: false }, appType: 'custom' }); function assert(condition, message) { if (!condition) { throw new Error(message); } } function createStorage() { const values = new Map(); return { values, storage: { getItem: (key) => values.get(key) ?? null, setItem: (key, value) => values.set(key, value) } }; } const fullMotionPreference = () => ({ matches: false }); const reducedMotionPreference = () => ({ matches: true }); try { const motion = await server.ssrLoadModule('/src/game/settings/visualMotion.ts'); assert( motion.preferredVisualMotionMode(fullMotionPreference) === 'full', 'The OS default must use full motion when reduced motion is not requested.' ); assert( motion.preferredVisualMotionMode(reducedMotionPreference) === 'reduced', 'The OS reduced-motion preference must become the default mode.' ); assert( motion.preferredVisualMotionMode(() => { throw new Error('unavailable'); }) === 'full', 'An unavailable media query must safely fall back to full motion.' ); const { values, storage } = createStorage(); assert( motion.loadVisualMotionMode(storage, fullMotionPreference) === 'full', 'A fresh full-motion environment must load full mode.' ); assert( motion.loadVisualMotionMode(storage, reducedMotionPreference) === 'reduced', 'A fresh reduced-motion environment must load reduced mode.' ); for (const mode of motion.visualMotionModes) { motion.saveVisualMotionMode(mode, storage); assert( motion.loadVisualMotionMode(storage, reducedMotionPreference) === mode, `Stored mode ${mode} must override the OS default.` ); } values.set(motion.visualMotionStorageKey, 'invalid'); assert( motion.loadVisualMotionMode(storage, fullMotionPreference) === 'full' && motion.loadVisualMotionMode(storage, reducedMotionPreference) === 'reduced', 'Invalid stored values must normalize to the current OS default.' ); assert( motion.normalizeVisualMotionMode('full', 'reduced') === 'full' && motion.normalizeVisualMotionMode('reduced', 'full') === 'reduced' && motion.normalizeVisualMotionMode('invalid', 'reduced') === 'reduced', 'Visual motion normalization must preserve valid values and replace invalid values.' ); assert( motion.nextVisualMotionMode('full') === 'reduced' && motion.nextVisualMotionMode('reduced') === 'full', 'The two visual motion modes must cycle in display order.' ); assert( motion.visualMotionModeLabel('full') === '보통' && motion.visualMotionModeLabel('reduced') === '줄임', 'Visual motion labels must match the title settings copy.' ); motion.saveVisualMotionMode('reduced', storage); assert( motion.isVisualMotionReduced(storage, fullMotionPreference), 'The shared reduced-motion predicate must honor the stored reduced mode.' ); motion.saveVisualMotionMode('full', storage); assert( !motion.isVisualMotionReduced(storage, reducedMotionPreference), 'The shared reduced-motion predicate must let a stored full mode override the OS preference.' ); const throwingStorage = { getItem: () => { throw new Error('blocked'); }, setItem: () => { throw new Error('blocked'); } }; assert( motion.loadVisualMotionMode(throwingStorage, reducedMotionPreference) === 'reduced', 'Storage read failures must fall back to the OS preference.' ); motion.saveVisualMotionMode('full', throwingStorage); const titleSource = readFileSync('src/game/scenes/TitleScene.ts', 'utf8'); assert( titleSource.includes("from '../settings/visualMotion'") && titleSource.includes('화면 움직임:') && titleSource.includes('nextVisualMotionMode(loadVisualMotionMode())') && titleSource.includes('ui(468)'), 'The title settings panel must expose the persisted motion toggle in its expanded layout.' ); assert( titleSource.includes('this.visualMotionReduced = isVisualMotionReduced();') && titleSource.includes('this.applyTitleMotionPreference(this.visualMotionReduced);'), 'TitleScene must honor the stored or operating-system motion preference on creation.' ); assert( titleSource.includes('this.stopTitleMotionTweens();') && titleSource.includes("this.applyTitleMotionPreference(nextMode === 'reduced');") && titleSource.includes('activeInfiniteTweenCount: this.titleMotionTweens.length'), 'The title motion toggle must immediately stop or rebuild its tracked infinite atmosphere tweens.' ); const battleSource = readFileSync('src/game/scenes/BattleScene.ts', 'utf8'); assert( battleSource.includes("import { isVisualMotionReduced } from '../settings/visualMotion';"), 'BattleScene must use the shared visual motion preference.' ); assert( !battleSource.includes("matchMedia('(prefers-reduced-motion: reduce)')"), 'BattleScene must not keep direct reduced-motion media queries.' ); assert( battleSource.includes('if (!reducedMotion && profile.haze)') && battleSource.includes('if (!reducedMotion && profile.particles)'), 'Reduced mode must disable moving battle haze and particles without suppressing tint creation.' ); assert( (battleSource.match(/this\.shakeCamera\(/g) ?? []).length === 3 && (battleSource.match(/this\.cameras\.main\.shake\(/g) ?? []).length === 1 && battleSource.includes('if (isVisualMotionReduced())'), 'Every battle camera shake must pass through the shared reduced-motion guard.' ); const storySource = readFileSync('src/game/scenes/StoryScene.ts', 'utf8'); assert( storySource.includes("import { isVisualMotionReduced } from '../settings/visualMotion';") && storySource.includes('this.visualMotionReduced = isVisualMotionReduced();'), 'StoryScene must load the shared visual motion preference when the scene starts.' ); assert( storySource.includes('const particleCount = this.visualMotionReduced ? 0') && storySource.includes('? Math.min(1, profile.haze.count)') && storySource.includes('if (this.visualMotionReduced) {\n return;\n }'), 'Reduced story motion must stop background panning and particles while retaining one static haze layer.' ); const explorationScenePaths = [ 'src/game/scenes/PrologueVillageScene.ts', 'src/game/scenes/PrologueMilitiaCampScene.ts', 'src/game/scenes/CityStayScene.ts', 'src/game/scenes/CampVisitExplorationScene.ts' ]; explorationScenePaths.forEach((scenePath) => { const sceneSource = readFileSync(scenePath, 'utf8'); assert( sceneSource.includes("import { isVisualMotionReduced } from '../settings/visualMotion';") && sceneSource.includes('this.visualMotionReduced = isVisualMotionReduced();'), `${scenePath} must load the shared reduced-motion preference on creation.` ); assert( sceneSource.includes('applyExplorationCharacterMotion(') && sceneSource.includes('this.visualMotionReduced'), `${scenePath} must use the shared motion helper so idle loops freeze without teleporting walks.` ); assert( sceneSource.includes('if (!this.visualMotionReduced)') && sceneSource.includes('if (this.visualMotionReduced)'), `${scenePath} must suppress ambient loops and replace transition motion with static feedback.` ); assert( sceneSource.includes('if (this.completionToast === toast)') && sceneSource.includes('targets: toast'), `${scenePath} must bind toast cleanup to the toast that created the timer or tween.` ); assert( /this\.autoDialogueInputReadyAt\s*=\s*this\.time\.now \+ inputCarryoverGuardMs;/.test( sceneSource ) && sceneSource.includes( 'time >= this.autoDialogueInputReadyAt' ) && sceneSource.includes( 'this.time.now < this.autoDialogueInputReadyAt' ), `${scenePath} must guard automatic-arrival dialogue from carry-over pointer and keyboard input without blocking modal controls.` ); }); assert( readFileSync( 'src/game/scenes/PrologueVillageScene.ts', 'utf8' ).includes('this.time.delayedCall(1510, clearToast)') && readFileSync( 'src/game/scenes/PrologueMilitiaCampScene.ts', 'utf8' ).includes('this.time.delayedCall(1510, clearToast)') && readFileSync( 'src/game/scenes/CityStayScene.ts', 'utf8' ).includes('this.time.delayedCall(1880, clearToast)') && readFileSync( 'src/game/scenes/CampVisitExplorationScene.ts', 'utf8' ).includes( 'this.time.delayedCall(duration + 380, clearToast)' ), 'Reduced-motion toasts must preserve the same total reading time as their animated variants.' ); console.info( 'Verified visual motion defaults, persistence, title controls, and story/battle/exploration motion guards.' ); } finally { await server.close(); }