357 lines
10 KiB
TypeScript
357 lines
10 KiB
TypeScript
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,
|
|
interaction?: StoryOption['interaction'],
|
|
): StoryOption {
|
|
return {
|
|
functionId,
|
|
actionText,
|
|
text: actionText,
|
|
interaction,
|
|
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',
|
|
characterId: 'sword-princess',
|
|
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('眼下的风向不对,我们不能直接把底牌亮出来。');
|
|
expect(text).toContain('我真正要找的东西,还不能让更多人知道。');
|
|
expect(text).not.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 the opening camp options focused on继续交谈', () => {
|
|
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']);
|
|
});
|
|
|
|
it('uses AI follow-up options when the camp follow-up request succeeds and falls back on errors', async () => {
|
|
const baseOptions = [
|
|
createOption('npc_chat', '继续交谈', {
|
|
kind: 'npc',
|
|
npcId: 'camp-companion',
|
|
action: 'chat',
|
|
}),
|
|
createOption('camp_travel_home_scene', '前往旧地点'),
|
|
];
|
|
const generateNextStep = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
storyText: '继续营地交谈',
|
|
options: [
|
|
createOption('npc_chat', '顺着刚才的话继续问下去'),
|
|
createOption('camp_travel_home_scene', '先回云河渡'),
|
|
],
|
|
})
|
|
.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).toEqual([
|
|
expect.objectContaining({
|
|
functionId: 'npc_chat',
|
|
actionText: '顺着刚才的话继续问下去',
|
|
interaction: {
|
|
kind: 'npc',
|
|
npcId: 'camp-companion',
|
|
action: 'chat',
|
|
},
|
|
}),
|
|
expect.objectContaining({
|
|
functionId: 'camp_travel_home_scene',
|
|
actionText: '先回云河渡',
|
|
}),
|
|
]);
|
|
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',
|
|
]);
|
|
});
|
|
});
|