Files
Genarrative/src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts
2026-04-28 19:36:39 +08:00

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: '服务端故事',
}),
);
});
});