205 lines
5.6 KiB
TypeScript
205 lines
5.6 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
buildStateFunctionDefinitions,
|
|
getExecutableFunctions,
|
|
getFunctionById,
|
|
resolveFunctionOption,
|
|
sortStoryOptionsByPriority,
|
|
} from './stateFunctions';
|
|
import {
|
|
getScenePresetsByWorld,
|
|
getTravelScenePreset,
|
|
getWorldCampScenePreset,
|
|
} from './scenePresets';
|
|
import type { FunctionAvailabilityContext } from './stateFunctions';
|
|
import type { SceneHostileNpc, StoryOption } from '../types';
|
|
import { AnimationState, WorldType } from '../types';
|
|
|
|
function createMonster(
|
|
overrides: Partial<SceneHostileNpc> = {},
|
|
): SceneHostileNpc {
|
|
return {
|
|
id: 'monster-wolf',
|
|
name: '山狼',
|
|
action: '朝你低吼逼近',
|
|
description: '一只逼近的山狼。',
|
|
animation: 'idle',
|
|
xMeters: 3.2,
|
|
yOffset: 0,
|
|
facing: 'left',
|
|
attackRange: 1.4,
|
|
speed: 1,
|
|
hp: 18,
|
|
maxHp: 18,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createContext(
|
|
overrides: Partial<FunctionAvailabilityContext> = {},
|
|
): FunctionAvailabilityContext {
|
|
const defaultScene = getScenePresetsByWorld(WorldType.WUXIA)[0];
|
|
if (!defaultScene) {
|
|
throw new Error('Expected wuxia scene presets to exist');
|
|
}
|
|
|
|
return {
|
|
worldType: WorldType.WUXIA,
|
|
playerCharacter: null,
|
|
inBattle: false,
|
|
currentSceneId: defaultScene.id,
|
|
currentSceneName: defaultScene.name,
|
|
monsters: [],
|
|
playerHp: 80,
|
|
playerMaxHp: 100,
|
|
playerMana: 50,
|
|
playerMaxMana: 100,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('stateFunctions', () => {
|
|
it('builds runtime state function definitions without inactive idle_follow_clue', () => {
|
|
const definitions = buildStateFunctionDefinitions();
|
|
|
|
expect(getFunctionById('idle_follow_clue', definitions)).toBeNull();
|
|
expect(getFunctionById('idle_explore_forward', definitions)?.text).toBe(
|
|
'继续向前探索',
|
|
);
|
|
});
|
|
|
|
it('prioritizes battle_recover_breath in high-pressure combat contexts', () => {
|
|
const executableFunctions = getExecutableFunctions(
|
|
createContext({
|
|
inBattle: true,
|
|
monsters: [createMonster()],
|
|
playerHp: 20,
|
|
playerMana: 10,
|
|
}),
|
|
);
|
|
|
|
expect(executableFunctions[0]?.id).toBe('battle_recover_breath');
|
|
});
|
|
|
|
it('hides idle_explore_forward in the world camp scene', () => {
|
|
const campScene = getWorldCampScenePreset(WorldType.WUXIA);
|
|
if (!campScene) {
|
|
throw new Error('Expected wuxia camp scene to exist');
|
|
}
|
|
|
|
const executableIds = getExecutableFunctions(
|
|
createContext({
|
|
currentSceneId: campScene.id,
|
|
currentSceneName: campScene.name,
|
|
}),
|
|
).map((definition) => definition.id);
|
|
|
|
expect(executableIds).not.toContain('idle_explore_forward');
|
|
});
|
|
|
|
it('forces suggested action text for travel options and keeps custom battle copy', () => {
|
|
const idleContext = createContext();
|
|
const travelScene = getTravelScenePreset(
|
|
idleContext.worldType,
|
|
idleContext.currentSceneId,
|
|
);
|
|
const travelOption = resolveFunctionOption(
|
|
'idle_travel_next_scene',
|
|
idleContext,
|
|
'这段自定义文案会被覆盖',
|
|
);
|
|
const battleOption = resolveFunctionOption(
|
|
'battle_all_in_crush',
|
|
createContext({
|
|
inBattle: true,
|
|
monsters: [createMonster()],
|
|
}),
|
|
'顶上去狠狠干一轮',
|
|
);
|
|
|
|
expect(travelOption).not.toBeNull();
|
|
expect(travelOption?.actionText).toBe(
|
|
travelScene ? `前往${travelScene.name}` : '前往其他场景',
|
|
);
|
|
expect(battleOption?.actionText).toBe('顶上去狠狠干一轮');
|
|
});
|
|
|
|
it('sorts story options after preserving the first two model-locked entries', () => {
|
|
const options: StoryOption[] = [
|
|
{
|
|
functionId: 'npc_chat',
|
|
actionText: '继续交谈',
|
|
text: '继续交谈',
|
|
visuals: {
|
|
playerAnimation: AnimationState.IDLE,
|
|
playerMoveMeters: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
scrollWorld: false,
|
|
monsterChanges: [],
|
|
},
|
|
},
|
|
{
|
|
functionId: 'battle_probe_pressure',
|
|
actionText: '稳住节奏',
|
|
text: '稳住节奏',
|
|
visuals: {
|
|
playerAnimation: AnimationState.IDLE,
|
|
playerMoveMeters: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
scrollWorld: false,
|
|
monsterChanges: [],
|
|
},
|
|
},
|
|
{
|
|
functionId: 'npc_preview_talk',
|
|
actionText: '和对方说话',
|
|
text: '和对方说话',
|
|
visuals: {
|
|
playerAnimation: AnimationState.IDLE,
|
|
playerMoveMeters: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
scrollWorld: false,
|
|
monsterChanges: [],
|
|
},
|
|
},
|
|
{
|
|
functionId: 'camp_travel_home_scene',
|
|
actionText: '离开营地',
|
|
text: '离开营地',
|
|
visuals: {
|
|
playerAnimation: AnimationState.IDLE,
|
|
playerMoveMeters: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
scrollWorld: false,
|
|
monsterChanges: [],
|
|
},
|
|
},
|
|
];
|
|
|
|
const sortedOptions = sortStoryOptionsByPriority(options);
|
|
|
|
expect(sortedOptions.map((option) => option.functionId)).toEqual([
|
|
'npc_chat',
|
|
'battle_probe_pressure',
|
|
'camp_travel_home_scene',
|
|
'npc_preview_talk',
|
|
]);
|
|
});
|
|
|
|
it('removes battle_recover_breath when combat has no living monsters', () => {
|
|
const executableIds = getExecutableFunctions(
|
|
createContext({
|
|
inBattle: true,
|
|
monsters: [createMonster({ hp: 0 })],
|
|
}),
|
|
).map((definition) => definition.id);
|
|
|
|
expect(executableIds).toEqual([]);
|
|
});
|
|
});
|