528 lines
14 KiB
TypeScript
528 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const { resolveServerRuntimeChoiceMock } = vi.hoisted(() => ({
|
|
resolveServerRuntimeChoiceMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('.', () => ({
|
|
resolveRpgRuntimeChoice: resolveServerRuntimeChoiceMock,
|
|
}));
|
|
|
|
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
|
|
import { WorldType } from '../../types/core';
|
|
import {
|
|
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, options: StoryOption[] = []): 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(() => {
|
|
resolveServerRuntimeChoiceMock.mockReset();
|
|
});
|
|
|
|
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('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('uses the server-returned defeat revive snapshot without local death reconstruction', async () => {
|
|
const gameState = createState({
|
|
worldType: WorldType.WUXIA,
|
|
inBattle: true,
|
|
playerHp: 6,
|
|
playerMaxHp: 30,
|
|
playerMana: 10,
|
|
playerMaxMana: 10,
|
|
currentScenePreset: {
|
|
id: 'wuxia-bamboo-road',
|
|
name: '竹林古道',
|
|
description: '风穿竹影,路面狭长。',
|
|
imageSrc: '/scene-a.png',
|
|
connectedSceneIds: [],
|
|
connections: [],
|
|
forwardSceneId: undefined,
|
|
treasureHints: [],
|
|
npcs: [],
|
|
},
|
|
sceneHostileNpcs: [
|
|
{
|
|
id: 'wolf',
|
|
name: '山狼',
|
|
action: '逼近',
|
|
description: '山狼',
|
|
animation: 'idle',
|
|
xMeters: 3,
|
|
yOffset: 0,
|
|
facing: 'left',
|
|
attackRange: 1,
|
|
speed: 1,
|
|
hp: 4,
|
|
maxHp: 18,
|
|
},
|
|
],
|
|
});
|
|
const serverRevivedState = createState({
|
|
...gameState,
|
|
inBattle: false,
|
|
playerHp: 30,
|
|
playerMana: 10,
|
|
currentEncounter: {
|
|
kind: 'npc',
|
|
id: 'wolf',
|
|
npcName: '山狼',
|
|
npcDescription: '林间伏击的野兽',
|
|
npcAvatar: '狼',
|
|
context: '复活后的首场景威胁',
|
|
hostile: true,
|
|
},
|
|
sceneHostileNpcs: [],
|
|
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: {
|
|
presentation: {
|
|
battle: {
|
|
targetId: 'wolf',
|
|
damageDealt: 22,
|
|
damageTaken: 8,
|
|
outcome: 'defeat',
|
|
},
|
|
resultText: '你在山狼的反扑下倒地。',
|
|
},
|
|
},
|
|
hydratedSnapshot: {
|
|
gameState: serverRevivedState,
|
|
},
|
|
nextStory: serverDeathStory,
|
|
});
|
|
|
|
await runServerRuntimeChoiceAction({
|
|
gameState,
|
|
currentStory: createStory('当前故事'),
|
|
option: createOption('battle_all_in_crush'),
|
|
character: createCharacter(),
|
|
setBattleReward: vi.fn(),
|
|
setAiError: vi.fn(),
|
|
setIsLoading: vi.fn(),
|
|
setGameState,
|
|
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
|
|
buildFallbackStoryForState: () => createStory('fallback'),
|
|
turnVisualMs: 1,
|
|
});
|
|
|
|
expect(setGameState).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
playerHp: 0,
|
|
animationState: 'die',
|
|
inBattle: false,
|
|
}),
|
|
);
|
|
expect(setGameState).toHaveBeenLastCalledWith(serverRevivedState);
|
|
expect(setCurrentStory).toHaveBeenCalledWith(serverDeathStory);
|
|
});
|
|
|
|
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: '服务端故事',
|
|
}),
|
|
);
|
|
});
|
|
});
|