This commit is contained in:
2026-04-28 19:36:39 +08:00
parent a9febe7678
commit f0471a4f8d
206 changed files with 18456 additions and 10133 deletions

View File

@@ -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 () => {