1
This commit is contained in:
337
src/hooks/story/storyCampCompanion.test.ts
Normal file
337
src/hooks/story/storyCampCompanion.test.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getWorldCampScenePreset } from '../../data/scenePresets';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import {
|
||||
buildCampCompanionOpeningResultText,
|
||||
buildInitialCompanionDialogueText,
|
||||
createCampCompanionStoryHelpers,
|
||||
} from './storyCampCompanion';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'sword-princess',
|
||||
name: '测试同伴',
|
||||
title: '试剑公主',
|
||||
description: '在营地观察局势的试炼者。',
|
||||
backstory: '她在旅途中始终保留自己的真正目标。',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 12,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: '谨慎冷静',
|
||||
skills: [],
|
||||
adventureOpenings: {
|
||||
[WorldType.WUXIA]: {
|
||||
reason: '调查旧路异动',
|
||||
goal: '查清前方局势',
|
||||
monologue: '风声里还藏着未说破的话。',
|
||||
surfaceHook: '我来这里,是为了确认旧路尽头到底出了什么事。',
|
||||
immediateConcern: '眼下的风向不对,我们不能直接把底牌亮出来。',
|
||||
guardedMotive: '我真正要找的东西,还不能让更多人知道。',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(
|
||||
functionId: string,
|
||||
actionText = functionId,
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
||||
return {
|
||||
id: 'camp-companion',
|
||||
kind: 'npc',
|
||||
npcName: '沈砺',
|
||||
npcDescription: '正靠在营地灯火旁观察风向。',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '营地夜谈',
|
||||
specialBehavior: 'camp_companion',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createStory(text: string, options: StoryOption[] = []): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
function createGameState(overrides: Partial<GameState> = {}): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: getWorldCampScenePreset(WorldType.WUXIA),
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 30,
|
||||
playerMaxMana: 30,
|
||||
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('storyCampCompanion', () => {
|
||||
it('builds opening dialogue from the character adventure opening', () => {
|
||||
const text = buildInitialCompanionDialogueText(
|
||||
createCharacter(),
|
||||
createEncounter(),
|
||||
WorldType.WUXIA,
|
||||
);
|
||||
|
||||
expect(text).toContain('我来这里,是为了确认旧路尽头到底出了什么事。');
|
||||
expect(text).toContain('沈砺:那就不要说得太快太多。');
|
||||
expect(text).toContain('我真正要找的东西,还不能让更多人知道。');
|
||||
});
|
||||
|
||||
it('summarizes the camp opening result with the current concern', () => {
|
||||
const text = buildCampCompanionOpeningResultText(
|
||||
createCharacter(),
|
||||
createEncounter(),
|
||||
WorldType.WUXIA,
|
||||
);
|
||||
|
||||
expect(text).toContain('沈砺 在');
|
||||
expect(text).toContain('眼下的风向不对');
|
||||
});
|
||||
|
||||
it('keeps chat and recruit options while appending the travel action for camp openings', () => {
|
||||
const buildNpcStory = vi.fn(() =>
|
||||
createStory('营地开场', [
|
||||
createOption('npc_chat', '继续交谈'),
|
||||
createOption('npc_recruit', '邀请同行'),
|
||||
createOption('npc_trade', '查看货物'),
|
||||
]),
|
||||
);
|
||||
const helpers = createCampCompanionStoryHelpers({
|
||||
buildNpcStory,
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getNpcEncounterKey: vi.fn(() => 'camp-companion'),
|
||||
generateNextStep: vi.fn(),
|
||||
});
|
||||
|
||||
const options = helpers.buildCampCompanionOpeningOptions(
|
||||
createGameState(),
|
||||
createCharacter(),
|
||||
createEncounter(),
|
||||
);
|
||||
|
||||
expect(options.map((option) => option.functionId)).toEqual([
|
||||
'npc_chat',
|
||||
'npc_recruit',
|
||||
'camp_travel_home_scene',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses AI follow-up options when the camp follow-up request succeeds and falls back on errors', async () => {
|
||||
const baseOptions = [createOption('npc_chat', '继续交谈')];
|
||||
const generateNextStep = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
storyText: '继续营地交谈',
|
||||
options: [
|
||||
createOption('npc_trade', '先看对方带来的东西'),
|
||||
createOption('npc_chat', '继续交谈'),
|
||||
],
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('llm failed'));
|
||||
const buildStoryContextFromState = vi.fn(() => ({
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 30,
|
||||
playerMaxMana: 30,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
sceneId: 'camp',
|
||||
sceneName: '营地',
|
||||
sceneDescription: '营火微亮。',
|
||||
pendingSceneEncounter: false,
|
||||
}));
|
||||
const helpers = createCampCompanionStoryHelpers({
|
||||
buildNpcStory: vi.fn(),
|
||||
buildStoryContextFromState,
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getNpcEncounterKey: vi.fn(() => 'camp-companion'),
|
||||
generateNextStep,
|
||||
});
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
const resolvedOptions = await helpers.inferOpeningCampFollowupOptions(
|
||||
state,
|
||||
character,
|
||||
baseOptions,
|
||||
'营地里风声微沉。',
|
||||
'你们刚交换完第一轮判断。',
|
||||
);
|
||||
const fallbackOptions = await helpers.inferOpeningCampFollowupOptions(
|
||||
state,
|
||||
character,
|
||||
baseOptions,
|
||||
'营地里风声微沉。',
|
||||
'你们刚交换完第一轮判断。',
|
||||
);
|
||||
|
||||
expect(buildStoryContextFromState).toHaveBeenCalledWith(
|
||||
state,
|
||||
expect.objectContaining({
|
||||
openingCampBackground: '营地里风声微沉。',
|
||||
openingCampDialogue: '你们刚交换完第一轮判断。',
|
||||
}),
|
||||
);
|
||||
expect(resolvedOptions.map((option) => option.functionId)).toEqual([
|
||||
'npc_trade',
|
||||
'npc_chat',
|
||||
]);
|
||||
expect(fallbackOptions).toBe(baseOptions);
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reconstructs the opening camp chat context from story history and filters idle camp options', () => {
|
||||
const encounter = createEncounter();
|
||||
const buildNpcStory = vi.fn(() =>
|
||||
createStory('营地常态', [
|
||||
createOption('npc_chat', '继续交谈'),
|
||||
createOption('npc_leave', '结束对话'),
|
||||
createOption('npc_fight', '直接切磋'),
|
||||
createOption('npc_trade', '查看货物'),
|
||||
]),
|
||||
);
|
||||
const helpers = createCampCompanionStoryHelpers({
|
||||
buildNpcStory,
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getNpcEncounterKey: vi.fn(() => 'camp-companion'),
|
||||
generateNextStep: vi.fn(),
|
||||
});
|
||||
const state = createGameState({
|
||||
currentEncounter: encounter,
|
||||
npcStates: {
|
||||
'camp-companion': {
|
||||
affinity: 16,
|
||||
helpUsed: false,
|
||||
chattedCount: 1,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
storyHistory: [
|
||||
{
|
||||
text: `在营地与 ${encounter.npcName} 交换开场判断`,
|
||||
options: [],
|
||||
historyRole: 'action',
|
||||
},
|
||||
{
|
||||
text: '你们先对了一遍眼前局势。',
|
||||
options: [],
|
||||
historyRole: 'result',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const chatContext = helpers.buildOpeningCampChatContext(
|
||||
state,
|
||||
createCharacter(),
|
||||
encounter,
|
||||
);
|
||||
const idleStory = helpers.buildCampCompanionIdleStory(
|
||||
state,
|
||||
createCharacter(),
|
||||
encounter,
|
||||
);
|
||||
|
||||
expect(chatContext).toEqual(
|
||||
expect.objectContaining({
|
||||
openingCampBackground: expect.stringContaining('沈砺 在'),
|
||||
openingCampDialogue: '你们先对了一遍眼前局势。',
|
||||
}),
|
||||
);
|
||||
expect(idleStory.options.map((option) => option.functionId)).toEqual([
|
||||
'npc_chat',
|
||||
'npc_trade',
|
||||
'camp_travel_home_scene',
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user