247 lines
6.3 KiB
TypeScript
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);
|
|
});
|
|
});
|