1
This commit is contained in:
@@ -1,34 +1,16 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
rollHostileNpcLootMock,
|
||||
resolveServerRuntimeChoiceMock,
|
||||
} = vi.hoisted(() => ({
|
||||
rollHostileNpcLootMock: vi.fn(),
|
||||
const { resolveServerRuntimeChoiceMock } = vi.hoisted(() => ({
|
||||
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 { WorldType } from '../../types/core';
|
||||
import {
|
||||
buildCombatResolutionContextText,
|
||||
buildHostileNpcBattleReward,
|
||||
buildReasonedOptionCatalog,
|
||||
runServerRuntimeChoiceAction,
|
||||
shouldOpenLocalRuntimeNpcModal,
|
||||
} from './storyChoiceRuntime';
|
||||
@@ -56,10 +38,10 @@ function createCharacter(): Character {
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
function createStory(text: string, options: StoryOption[] = []): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -140,23 +122,9 @@ function createState(overrides: Partial<GameState> = {}): 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(
|
||||
@@ -190,117 +158,6 @@ describe('storyChoiceRuntime', () => {
|
||||
).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('当前故事');
|
||||
@@ -452,9 +309,9 @@ describe('storyChoiceRuntime', () => {
|
||||
expect(setGameState).toHaveBeenLastCalledWith(finalState);
|
||||
});
|
||||
|
||||
it('routes server defeat outcomes into death and revive flow instead of victory settlement', async () => {
|
||||
it('uses the server-returned defeat revive snapshot without local death reconstruction', async () => {
|
||||
const gameState = createState({
|
||||
worldType: 'WUXIA',
|
||||
worldType: WorldType.WUXIA,
|
||||
inBattle: true,
|
||||
playerHp: 6,
|
||||
playerMaxHp: 30,
|
||||
@@ -467,7 +324,7 @@ describe('storyChoiceRuntime', () => {
|
||||
imageSrc: '/scene-a.png',
|
||||
connectedSceneIds: [],
|
||||
connections: [],
|
||||
forwardSceneId: null,
|
||||
forwardSceneId: undefined,
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
@@ -488,16 +345,45 @@ describe('storyChoiceRuntime', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
const finalState = createState({
|
||||
const serverRevivedState = createState({
|
||||
...gameState,
|
||||
inBattle: false,
|
||||
playerHp: 0,
|
||||
currentEncounter: null,
|
||||
playerHp: 30,
|
||||
playerMana: 10,
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'wolf',
|
||||
npcName: '山狼',
|
||||
npcDescription: '林间伏击的野兽',
|
||||
npcAvatar: '狼',
|
||||
context: '复活后的首场景威胁',
|
||||
hostile: true,
|
||||
},
|
||||
sceneHostileNpcs: [],
|
||||
currentNpcBattleOutcome: 'fight_defeat',
|
||||
currentNpcBattleOutcome: null,
|
||||
currentScenePreset: {
|
||||
id: 'wuxia-bamboo-road',
|
||||
name: '竹林古道',
|
||||
description: '风穿竹影,路面狭长。',
|
||||
imageSrc: '/scene-a.png',
|
||||
connectedSceneIds: ['wuxia-mountain-gate'],
|
||||
connections: [
|
||||
{
|
||||
sceneId: 'wuxia-mountain-gate',
|
||||
relativePosition: 'forward',
|
||||
summary: '沿主路继续深入前方区域',
|
||||
},
|
||||
],
|
||||
forwardSceneId: 'wuxia-mountain-gate',
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
});
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
const serverDeathStory = createStory('你在战斗中倒下,随后在竹林古道重新醒来。', [
|
||||
createOption('story_continue_adventure'),
|
||||
]);
|
||||
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
response: {
|
||||
@@ -512,9 +398,9 @@ describe('storyChoiceRuntime', () => {
|
||||
},
|
||||
},
|
||||
hydratedSnapshot: {
|
||||
gameState: finalState,
|
||||
gameState: serverRevivedState,
|
||||
},
|
||||
nextStory: createStory('不会进入胜利文本'),
|
||||
nextStory: serverDeathStory,
|
||||
});
|
||||
|
||||
await runServerRuntimeChoiceAction({
|
||||
@@ -527,10 +413,7 @@ describe('storyChoiceRuntime', () => {
|
||||
setIsLoading: vi.fn(),
|
||||
setGameState,
|
||||
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
|
||||
buildFallbackStoryForState: () =>
|
||||
createStory('fallback', [
|
||||
createOption('idle_explore_forward'),
|
||||
]),
|
||||
buildFallbackStoryForState: () => createStory('fallback'),
|
||||
turnVisualMs: 1,
|
||||
});
|
||||
|
||||
@@ -541,21 +424,8 @@ describe('storyChoiceRuntime', () => {
|
||||
inBattle: false,
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('重新醒来'),
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
functionId: 'story_continue_adventure',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '不会进入胜利文本',
|
||||
}),
|
||||
);
|
||||
expect(setGameState).toHaveBeenLastCalledWith(serverRevivedState);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(serverDeathStory);
|
||||
});
|
||||
|
||||
it('commits server-returned next-scene state after idle_travel_next_scene resolution', async () => {
|
||||
|
||||
Reference in New Issue
Block a user