552 lines
14 KiB
TypeScript
552 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const {
|
|
rollHostileNpcLootMock,
|
|
resolveServerRuntimeChoiceMock,
|
|
} = vi.hoisted(() => ({
|
|
rollHostileNpcLootMock: vi.fn(),
|
|
resolveServerRuntimeChoiceMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../data/hostileNpcPresets', async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import('../../data/hostileNpcPresets')>(
|
|
'../../data/hostileNpcPresets',
|
|
);
|
|
|
|
return {
|
|
...actual,
|
|
rollHostileNpcLoot: rollHostileNpcLootMock,
|
|
};
|
|
});
|
|
|
|
vi.mock('.', () => ({
|
|
resolveRpgRuntimeChoice: resolveServerRuntimeChoiceMock,
|
|
}));
|
|
|
|
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
|
|
import {
|
|
buildCombatResolutionContextText,
|
|
buildHostileNpcBattleReward,
|
|
buildReasonedOptionCatalog,
|
|
runServerRuntimeChoiceAction,
|
|
shouldOpenLocalRuntimeNpcModal,
|
|
} from './storyChoiceRuntime';
|
|
|
|
function createCharacter(): Character {
|
|
return {
|
|
id: 'hero',
|
|
name: '沈行',
|
|
title: '试剑客',
|
|
description: '测试角色',
|
|
backstory: '测试背景',
|
|
avatar: '/hero.png',
|
|
portrait: '/hero.png',
|
|
assetFolder: 'hero',
|
|
assetVariant: 'default',
|
|
attributes: {
|
|
strength: 10,
|
|
agility: 10,
|
|
intelligence: 10,
|
|
spirit: 10,
|
|
},
|
|
personality: 'calm',
|
|
skills: [],
|
|
adventureOpenings: {},
|
|
} as unknown as Character;
|
|
}
|
|
|
|
function createStory(text: string): StoryMoment {
|
|
return {
|
|
text,
|
|
options: [],
|
|
};
|
|
}
|
|
|
|
function createOption(
|
|
functionId: string,
|
|
interaction?: StoryOption['interaction'],
|
|
): StoryOption {
|
|
return {
|
|
functionId,
|
|
actionText: functionId,
|
|
text: functionId,
|
|
interaction,
|
|
visuals: {
|
|
playerAnimation: 'idle',
|
|
playerMoveMeters: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
scrollWorld: false,
|
|
monsterChanges: [],
|
|
},
|
|
} as StoryOption;
|
|
}
|
|
|
|
function createState(overrides: Partial<GameState> = {}): GameState {
|
|
return {
|
|
worldType: 'WUXIA',
|
|
customWorldProfile: null,
|
|
playerCharacter: createCharacter(),
|
|
runtimeStats: {
|
|
playTimeMs: 0,
|
|
lastPlayTickAt: null,
|
|
hostileNpcsDefeated: 0,
|
|
questsAccepted: 0,
|
|
itemsUsed: 0,
|
|
scenesTraveled: 0,
|
|
},
|
|
currentScene: 'Story',
|
|
storyHistory: [],
|
|
characterChats: {},
|
|
animationState: 'idle',
|
|
currentEncounter: null,
|
|
npcInteractionActive: false,
|
|
currentScenePreset: null,
|
|
sceneHostileNpcs: [],
|
|
playerX: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
playerActionMode: 'idle',
|
|
scrollWorld: false,
|
|
inBattle: false,
|
|
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: null,
|
|
currentNpcBattleMode: null,
|
|
currentNpcBattleOutcome: null,
|
|
sparReturnEncounter: null,
|
|
sparPlayerHpBefore: null,
|
|
sparPlayerMaxHpBefore: null,
|
|
sparStoryHistoryBefore: null,
|
|
...overrides,
|
|
} as GameState;
|
|
}
|
|
|
|
describe('storyChoiceRuntime', () => {
|
|
beforeEach(() => {
|
|
rollHostileNpcLootMock.mockReset();
|
|
resolveServerRuntimeChoiceMock.mockReset();
|
|
});
|
|
|
|
it('deduplicates option catalogs by function id for post-battle recovery', () => {
|
|
const options = buildReasonedOptionCatalog([
|
|
createOption('npc_chat'),
|
|
createOption('npc_chat'),
|
|
createOption('npc_help'),
|
|
]);
|
|
|
|
expect(options.map((option) => option.functionId)).toEqual([
|
|
'npc_chat',
|
|
'npc_help',
|
|
]);
|
|
});
|
|
|
|
it('keeps npc chat, trade and gift on the local runtime npc interaction path', () => {
|
|
expect(
|
|
shouldOpenLocalRuntimeNpcModal(
|
|
createOption('npc_chat', {
|
|
kind: 'npc',
|
|
npcId: 'npc-friend',
|
|
action: 'chat',
|
|
}),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
shouldOpenLocalRuntimeNpcModal(
|
|
createOption('npc_trade', {
|
|
kind: 'npc',
|
|
npcId: 'npc-merchant',
|
|
action: 'trade',
|
|
}),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
shouldOpenLocalRuntimeNpcModal(
|
|
createOption('npc_gift', {
|
|
kind: 'npc',
|
|
npcId: 'npc-friend',
|
|
action: 'gift',
|
|
}),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
shouldOpenLocalRuntimeNpcModal(createOption('idle_explore_forward')),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('builds escape and victory context text for local battle resolution', () => {
|
|
const baseState = createState({
|
|
inBattle: true,
|
|
sceneHostileNpcs: [
|
|
{ id: 'wolf', name: '山狼' },
|
|
] as GameState['sceneHostileNpcs'],
|
|
});
|
|
|
|
expect(
|
|
buildCombatResolutionContextText({
|
|
baseState,
|
|
afterSequence: {
|
|
...baseState,
|
|
inBattle: false,
|
|
sceneHostileNpcs: [],
|
|
},
|
|
optionKind: 'escape',
|
|
projectedBattleReward: null,
|
|
getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs,
|
|
}),
|
|
).toContain('你已成功逃脱');
|
|
|
|
expect(
|
|
buildCombatResolutionContextText({
|
|
baseState: {
|
|
...baseState,
|
|
currentBattleNpcId: null,
|
|
},
|
|
afterSequence: {
|
|
...baseState,
|
|
inBattle: false,
|
|
sceneHostileNpcs: [],
|
|
},
|
|
optionKind: 'battle',
|
|
projectedBattleReward: {
|
|
id: 'reward-1',
|
|
defeatedHostileNpcs: [{ id: 'wolf', name: '山狼' }],
|
|
items: [
|
|
{ id: 'loot-1', category: '材料', name: '狼牙', quantity: 1, rarity: 'common', tags: [] },
|
|
],
|
|
},
|
|
getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs,
|
|
}),
|
|
).toContain('战利品:狼牙。');
|
|
});
|
|
|
|
it('builds defeated hostile rewards from locally resolved battle states', async () => {
|
|
rollHostileNpcLootMock.mockResolvedValue([
|
|
{
|
|
id: 'loot-1',
|
|
category: '材料',
|
|
name: '狼牙',
|
|
quantity: 1,
|
|
rarity: 'common',
|
|
tags: [],
|
|
},
|
|
]);
|
|
|
|
const reward = await buildHostileNpcBattleReward(
|
|
createState({
|
|
inBattle: true,
|
|
sceneHostileNpcs: [
|
|
{ id: 'wolf', name: '山狼' },
|
|
] as GameState['sceneHostileNpcs'],
|
|
currentBattleNpcId: null,
|
|
}),
|
|
createState({
|
|
inBattle: false,
|
|
sceneHostileNpcs: [],
|
|
}),
|
|
'battle',
|
|
(state) => state.sceneHostileNpcs,
|
|
);
|
|
|
|
expect(rollHostileNpcLootMock).toHaveBeenCalledTimes(1);
|
|
expect(reward?.items[0]).toEqual(
|
|
expect.objectContaining({
|
|
name: '狼牙',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('keeps defeated hostile reward render keys unique for duplicate monster ids', async () => {
|
|
rollHostileNpcLootMock.mockResolvedValue([]);
|
|
|
|
const reward = await buildHostileNpcBattleReward(
|
|
createState({
|
|
inBattle: true,
|
|
sceneHostileNpcs: [
|
|
{ id: 'monster-16', name: '雷翼甲' },
|
|
{ id: 'monster-16', name: '雷翼乙', xMeters: 4.1, yOffset: 32 },
|
|
] as GameState['sceneHostileNpcs'],
|
|
currentBattleNpcId: null,
|
|
}),
|
|
createState({
|
|
inBattle: false,
|
|
sceneHostileNpcs: [],
|
|
}),
|
|
'battle',
|
|
(state) => state.sceneHostileNpcs,
|
|
);
|
|
|
|
expect(reward?.defeatedHostileNpcs).toHaveLength(2);
|
|
expect(reward?.defeatedHostileNpcs.map((npc) => npc.id)).toEqual([
|
|
'monster-16',
|
|
'monster-16',
|
|
]);
|
|
expect(new Set(reward?.defeatedHostileNpcs.map((npc) => npc.renderKey)).size)
|
|
.toBe(2);
|
|
});
|
|
|
|
it('applies server runtime responses and falls back locally when the request fails', async () => {
|
|
const gameState = createState();
|
|
const currentStory = createStory('当前故事');
|
|
const setBattleReward = vi.fn();
|
|
const setAiError = vi.fn();
|
|
const setIsLoading = vi.fn();
|
|
const setGameState = vi.fn();
|
|
const setCurrentStory = vi.fn();
|
|
|
|
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
|
hydratedSnapshot: {
|
|
gameState: {
|
|
...gameState,
|
|
runtimeActionVersion: 3,
|
|
},
|
|
},
|
|
nextStory: createStory('服务端故事'),
|
|
});
|
|
|
|
await runServerRuntimeChoiceAction({
|
|
gameState,
|
|
currentStory,
|
|
option: createOption('npc_chat'),
|
|
character: createCharacter(),
|
|
setBattleReward,
|
|
setAiError,
|
|
setIsLoading,
|
|
setGameState,
|
|
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
|
|
buildFallbackStoryForState: () => createStory('fallback'),
|
|
});
|
|
|
|
expect(setGameState).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
runtimeActionVersion: 3,
|
|
}),
|
|
);
|
|
expect(setCurrentStory).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
text: '服务端故事',
|
|
}),
|
|
);
|
|
|
|
resolveServerRuntimeChoiceMock.mockRejectedValueOnce(new Error('boom'));
|
|
const consoleErrorSpy = vi
|
|
.spyOn(console, 'error')
|
|
.mockImplementation(() => undefined);
|
|
|
|
try {
|
|
await runServerRuntimeChoiceAction({
|
|
gameState,
|
|
currentStory: null,
|
|
option: createOption('npc_chat'),
|
|
character: createCharacter(),
|
|
setBattleReward,
|
|
setAiError,
|
|
setIsLoading,
|
|
setGameState,
|
|
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
|
|
buildFallbackStoryForState: () => createStory('fallback'),
|
|
});
|
|
} finally {
|
|
consoleErrorSpy.mockRestore();
|
|
}
|
|
|
|
expect(setAiError).toHaveBeenCalledWith('boom');
|
|
expect(setCurrentStory).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
text: 'fallback',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('plays server battle presentation before committing the hydrated snapshot', async () => {
|
|
const gameState = createState({
|
|
inBattle: true,
|
|
playerHp: 30,
|
|
playerMana: 10,
|
|
sceneHostileNpcs: [
|
|
{
|
|
id: 'wolf',
|
|
name: '山狼',
|
|
action: '逼近',
|
|
description: '山狼',
|
|
animation: 'idle',
|
|
xMeters: 3,
|
|
yOffset: 0,
|
|
facing: 'left',
|
|
attackRange: 1,
|
|
speed: 1,
|
|
hp: 18,
|
|
maxHp: 18,
|
|
},
|
|
],
|
|
});
|
|
const finalState = createState({
|
|
...gameState,
|
|
inBattle: false,
|
|
playerHp: 26,
|
|
sceneHostileNpcs: [],
|
|
});
|
|
const setGameState = vi.fn();
|
|
|
|
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
|
response: {
|
|
presentation: {
|
|
battle: {
|
|
targetId: 'wolf',
|
|
damageDealt: 18,
|
|
damageTaken: 4,
|
|
outcome: 'victory',
|
|
},
|
|
resultText: '山狼被你压制下去。',
|
|
},
|
|
},
|
|
hydratedSnapshot: {
|
|
gameState: finalState,
|
|
},
|
|
nextStory: createStory('服务端故事'),
|
|
});
|
|
|
|
await runServerRuntimeChoiceAction({
|
|
gameState,
|
|
currentStory: createStory('当前故事'),
|
|
option: createOption('battle_attack_basic'),
|
|
character: createCharacter(),
|
|
setBattleReward: vi.fn(),
|
|
setAiError: vi.fn(),
|
|
setIsLoading: vi.fn(),
|
|
setGameState,
|
|
setCurrentStory: vi.fn() as (story: StoryMoment) => void,
|
|
buildFallbackStoryForState: () => createStory('fallback'),
|
|
turnVisualMs: 1,
|
|
});
|
|
|
|
expect(setGameState).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
animationState: 'idle',
|
|
playerHp: 26,
|
|
sceneHostileNpcs: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: 'wolf',
|
|
hp: 0,
|
|
animation: 'die',
|
|
}),
|
|
]),
|
|
}),
|
|
);
|
|
expect(setGameState).toHaveBeenLastCalledWith(finalState);
|
|
});
|
|
|
|
it('commits server-returned next-scene state after idle_travel_next_scene resolution', async () => {
|
|
const gameState = createState({
|
|
currentScenePreset: {
|
|
id: 'wuxia-bamboo-road',
|
|
name: '竹林古道',
|
|
description: '风穿竹影,路面狭长。',
|
|
imageSrc: '/scene-a.png',
|
|
connectedSceneIds: ['wuxia-rain-street'],
|
|
connections: [
|
|
{
|
|
sceneId: 'wuxia-rain-street',
|
|
relativePosition: 'forward',
|
|
summary: '沿石板路继续前行',
|
|
},
|
|
],
|
|
forwardSceneId: 'wuxia-rain-street',
|
|
treasureHints: [],
|
|
npcs: [],
|
|
},
|
|
currentEncounter: {
|
|
kind: 'npc',
|
|
id: 'npc-bridge',
|
|
npcName: '桥头行商',
|
|
npcDescription: '正准备收摊离开的行商',
|
|
npcAvatar: '桥',
|
|
context: '桥口',
|
|
hostile: false,
|
|
},
|
|
npcInteractionActive: true,
|
|
});
|
|
const setGameState = vi.fn();
|
|
const setCurrentStory = vi.fn();
|
|
|
|
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
|
hydratedSnapshot: {
|
|
gameState: {
|
|
...gameState,
|
|
runtimeActionVersion: 3,
|
|
currentScenePreset: {
|
|
id: 'wuxia-rain-street',
|
|
name: '夜雨长街',
|
|
description: '雨丝压低灯火,街面反着潮光。',
|
|
imageSrc: '/scene-b.png',
|
|
connectedSceneIds: ['wuxia-bamboo-road'],
|
|
connections: [
|
|
{
|
|
sceneId: 'wuxia-bamboo-road',
|
|
relativePosition: 'back',
|
|
summary: '可以沿原路退回竹林古道',
|
|
},
|
|
],
|
|
forwardSceneId: 'wuxia-ferry-bridge',
|
|
treasureHints: [],
|
|
npcs: [],
|
|
},
|
|
currentEncounter: null,
|
|
npcInteractionActive: false,
|
|
sceneHostileNpcs: [],
|
|
runtimeStats: {
|
|
...gameState.runtimeStats,
|
|
scenesTraveled: 1,
|
|
},
|
|
},
|
|
},
|
|
nextStory: createStory('服务端故事'),
|
|
});
|
|
|
|
await runServerRuntimeChoiceAction({
|
|
gameState,
|
|
currentStory: createStory('当前故事'),
|
|
option: createOption('idle_travel_next_scene'),
|
|
character: createCharacter(),
|
|
setBattleReward: vi.fn(),
|
|
setAiError: vi.fn(),
|
|
setIsLoading: vi.fn(),
|
|
setGameState,
|
|
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
|
|
buildFallbackStoryForState: () => createStory('fallback'),
|
|
});
|
|
|
|
expect(setGameState).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
currentScenePreset: expect.objectContaining({
|
|
id: 'wuxia-rain-street',
|
|
}),
|
|
runtimeStats: expect.objectContaining({
|
|
scenesTraveled: 1,
|
|
}),
|
|
}),
|
|
);
|
|
expect(setCurrentStory).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
text: '服务端故事',
|
|
}),
|
|
);
|
|
});
|
|
});
|