import { describe, expect, it, vi } from 'vitest'; vi.mock('../../data/stateFunctions', () => ({ getFunctionEffect: () => ({ escapeDistance: 5, escapeDurationMs: 5000, }), })); import { AnimationState, type Character, type GameState, type SceneHostileNpc, type StoryOption, WorldType, } from '../../types'; import { buildEscapeAfterSequence, playEscapeSequenceWithStorySync, } from './escapeFlow'; function createCharacter(): Character { return { id: 'hero', name: 'Hero', title: 'Wanderer', description: 'A reliable test hero.', backstory: 'Travels the land.', avatar: '/hero.png', portrait: '/hero-portrait.png', assetFolder: 'hero', assetVariant: 'default', attributes: { strength: 10, agility: 9, intelligence: 8, spirit: 7, }, personality: 'steady', skills: [], adventureOpenings: {}, }; } function createMonster(): SceneHostileNpc { return { id: 'wolf', name: 'Wolf', action: 'Growls', description: 'A test wolf.', animation: 'idle', xMeters: 3.5, yOffset: 0, facing: 'left', attackRange: 1.2, speed: 1, hp: 10, maxHp: 10, }; } function createState(): GameState { return { worldType: WorldType.WUXIA, customWorldProfile: null, playerCharacter: createCharacter(), runtimeStats: { playTimeMs: 0, lastPlayTickAt: null, hostileNpcsDefeated: 0, questsAccepted: 0, itemsUsed: 0, scenesTraveled: 0, }, currentScene: 'Story', storyHistory: [], characterChats: {}, animationState: AnimationState.IDLE, currentEncounter: { id: 'npc-1', kind: 'npc', npcName: 'Bandit', npcDescription: 'A bandit', npcAvatar: 'B', context: 'bandit', }, npcInteractionActive: false, currentScenePreset: null, sceneHostileNpcs: [createMonster()], playerX: 0.2, playerOffsetY: 0, playerFacing: 'right', playerActionMode: 'idle', scrollWorld: false, inBattle: true, playerHp: 100, playerMaxHp: 100, playerMana: 20, playerMaxMana: 20, playerSkillCooldowns: {}, activeCombatEffects: [], playerCurrency: 0, playerInventory: [], playerEquipment: { weapon: null, armor: null, relic: null, }, npcStates: {}, quests: [], roster: [], companions: [], currentBattleNpcId: 'npc-1', currentNpcBattleMode: 'fight', currentNpcBattleOutcome: null, sparReturnEncounter: null, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, }; } function createEscapeOption(): StoryOption { return { functionId: 'battle_escape_breakout', actionText: 'Run', text: 'Run', visuals: { playerAnimation: AnimationState.RUN, playerMoveMeters: -0.6, playerOffsetY: 0, playerFacing: 'left', scrollWorld: true, monsterChanges: [], }, }; } describe('escapeFlow', () => { it('builds a deterministic escape state without playback timing state', () => { const state = createState(); const resolved = buildEscapeAfterSequence(state, createEscapeOption()); expect(resolved.inBattle).toBe(false); expect(resolved.currentEncounter).toBeNull(); expect(resolved.currentBattleNpcId).toBeNull(); expect(resolved.sceneHostileNpcs).toEqual([]); expect(resolved.playerFacing).toBe('right'); expect(resolved.scrollWorld).toBe(false); expect(resolved.playerX).toBeLessThan(0.2); }); it('waits for the story response before settling the escape presentation', async () => { const state = createState(); const option = createEscapeOption(); const finalState = buildEscapeAfterSequence(state, option); const committedStates: GameState[] = []; let sleepCalls = 0; let resolveStoryResponse!: () => void; const waitForStoryResponse = new Promise(resolve => { resolveStoryResponse = resolve; }); const result = await playEscapeSequenceWithStorySync({ setGameState: (nextState: GameState) => { committedStates.push(nextState); }, state, option, finalState, sync: { waitForStoryResponse }, sleepMs: async () => { sleepCalls += 1; if (sleepCalls === 22) { resolveStoryResponse(); } await Promise.resolve(); }, }); expect(sleepCalls).toBeGreaterThan(21); expect(committedStates[0]?.animationState).toBe(AnimationState.RUN); expect(committedStates.at(-1)?.playerFacing).toBe('right'); expect(result.scrollWorld).toBe(false); expect(result.playerFacing).toBe('right'); }); it('plays left exit and right-facing entry when escape targets a scene start', async () => { const state = { ...createState(), currentScenePreset: { id: 'scene-bridge', name: 'Bridge', description: 'Bridge', imageSrc: '/bridge.png', worldType: WorldType.WUXIA, connectedSceneIds: [], connections: [], npcs: [], treasureHints: [], }, }; const targetScene = { ...state.currentScenePreset!, id: 'scene-east', name: 'East Street', }; const option = { ...createEscapeOption(), runtimePayload: { escapeTargetSceneId: targetScene.id, escapeEntry: 'from_left', }, }; const finalState = buildEscapeAfterSequence(state, option, targetScene); const committedStates: GameState[] = []; const result = await playEscapeSequenceWithStorySync({ setGameState: (nextState: GameState) => { committedStates.push(nextState); }, state, option, finalState, sleepMs: async () => { await Promise.resolve(); }, }); expect(committedStates[0]).toEqual(expect.objectContaining({ playerFacing: 'left', animationState: AnimationState.RUN, scrollWorld: true, })); expect(committedStates.some((committedState) => committedState.currentScenePreset?.id === 'scene-east' && committedState.playerX < 0 && committedState.playerFacing === 'right', )).toBe(true); expect(result.currentScenePreset?.id).toBe('scene-east'); expect(result.playerX).toBe(0); expect(result.playerFacing).toBe('right'); expect(result.scrollWorld).toBe(false); }); });