Files
Genarrative/src/hooks/combat/escapeFlow.test.ts
2026-04-27 22:50:18 +08:00

247 lines
6.3 KiB
TypeScript

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<void>(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);
});
});