286 lines
9.2 KiB
TypeScript
286 lines
9.2 KiB
TypeScript
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import { expect, test } from 'vitest';
|
|
|
|
import { AnimationState, type Character, type StoryMoment, type StoryOption, WorldType } from '../../types';
|
|
import { RpgAdventurePanel } from './RpgAdventurePanel';
|
|
|
|
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: 8,
|
|
spirit: 9,
|
|
},
|
|
personality: 'calm',
|
|
skills: [],
|
|
adventureOpenings: {},
|
|
} as Character;
|
|
}
|
|
|
|
function createOption(functionId: string, actionText: string): StoryOption {
|
|
return {
|
|
functionId,
|
|
actionText,
|
|
text: actionText,
|
|
visuals: {
|
|
playerAnimation: AnimationState.IDLE,
|
|
playerMoveMeters: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
scrollWorld: false,
|
|
monsterChanges: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
function renderPanel(
|
|
currentStory: StoryMoment,
|
|
displayedOptions: StoryOption[],
|
|
overrides: {
|
|
canRefreshOptions?: boolean;
|
|
hideOptions?: boolean;
|
|
isLoading?: boolean;
|
|
onSubmitNpcChatInput?: (input: string) => boolean;
|
|
onExitNpcChat?: () => boolean;
|
|
} = {},
|
|
) {
|
|
return renderToStaticMarkup(
|
|
<RpgAdventurePanel
|
|
aiError={null}
|
|
currentStory={currentStory}
|
|
isLoading={overrides.isLoading ?? false}
|
|
displayedOptions={displayedOptions}
|
|
hideOptions={overrides.hideOptions ?? false}
|
|
canRefreshOptions={overrides.canRefreshOptions ?? false}
|
|
onRefreshOptions={() => undefined}
|
|
onChoice={() => undefined}
|
|
onSubmitNpcChatInput={overrides.onSubmitNpcChatInput}
|
|
onExitNpcChat={overrides.onExitNpcChat}
|
|
onOpenCharacter={() => undefined}
|
|
onOpenInventory={() => undefined}
|
|
playerCharacter={createCharacter()}
|
|
worldType={WorldType.WUXIA}
|
|
quests={[]}
|
|
questUi={{
|
|
acknowledgeQuestCompletion: () => undefined,
|
|
claimQuestReward: () => null,
|
|
}}
|
|
npcChatQuestOfferUi={{
|
|
replacePendingOffer: () => false,
|
|
abandonPendingOffer: () => false,
|
|
acceptPendingOffer: () => null,
|
|
}}
|
|
goalStack={{
|
|
northStarGoal: null,
|
|
activeGoal: null,
|
|
immediateStepGoal: null,
|
|
supportGoals: [],
|
|
}}
|
|
goalPulse={null}
|
|
onDismissGoalPulse={() => undefined}
|
|
battleRewardUi={{
|
|
reward: null,
|
|
dismiss: () => undefined,
|
|
}}
|
|
playerHp={100}
|
|
playerMaxHp={100}
|
|
playerMana={20}
|
|
playerMaxMana={20}
|
|
playerSkillCooldowns={{}}
|
|
inBattle={false}
|
|
currentNpcBattleMode={null}
|
|
statistics={{
|
|
playTimeMs: 0,
|
|
hostileNpcsDefeated: 0,
|
|
questsAccepted: 0,
|
|
questsCompleted: 0,
|
|
questsTurnedIn: 0,
|
|
itemsUsed: 0,
|
|
scenesTraveled: 0,
|
|
currentSceneName: '竹林古道',
|
|
playerCurrency: 0,
|
|
inventoryItemCount: 0,
|
|
inventoryStackCount: 0,
|
|
activeCompanionCount: 0,
|
|
rosterCompanionCount: 0,
|
|
}}
|
|
musicVolume={0.6}
|
|
onMusicVolumeChange={() => undefined}
|
|
onSaveAndExit={() => undefined}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
test('adventure panel recognizes story_continue_adventure by function id instead of action text', () => {
|
|
const continueOption = createOption('story_continue_adventure', '查看后续');
|
|
const currentStory: StoryMoment = {
|
|
text: '你们交换完这一轮判断。',
|
|
options: [continueOption],
|
|
deferredOptions: [createOption('idle_explore_forward', '继续向前探索')],
|
|
};
|
|
|
|
const html = renderPanel(currentStory, [continueOption]);
|
|
|
|
expect(html).toContain('剧情推理完成,继续后显示新的冒险选项');
|
|
});
|
|
|
|
test('adventure panel does not show deferred hint for non-continue options with the same text', () => {
|
|
const misleadingOption = createOption('npc_chat', '查看后续');
|
|
const currentStory: StoryMoment = {
|
|
text: '你们交换完这一轮判断。',
|
|
options: [misleadingOption],
|
|
deferredOptions: [createOption('idle_explore_forward', '继续向前探索')],
|
|
};
|
|
|
|
const html = renderPanel(currentStory, [misleadingOption]);
|
|
|
|
expect(html).not.toContain('剧情推理完成,继续后显示新的冒险选项');
|
|
});
|
|
|
|
test('adventure panel renders compact function tags before option text', () => {
|
|
const chatOption = createOption('npc_chat', '继续追问桥上的旧账');
|
|
const questOption = createOption('npc_quest_accept', '接下断桥客的委托');
|
|
const giftOption = createOption('npc_gift', '把玉牌递给柳无声');
|
|
const currentStory: StoryMoment = {
|
|
text: '你看向眼前的人。',
|
|
options: [chatOption, questOption, giftOption],
|
|
};
|
|
|
|
const html = renderPanel(currentStory, [chatOption, questOption, giftOption]);
|
|
|
|
expect(html).toContain('聊天');
|
|
expect(html).toContain('继续追问桥上的旧账');
|
|
expect(html).toContain('任务');
|
|
expect(html).toContain('接下断桥客的委托');
|
|
expect(html).toContain('送礼');
|
|
expect(html).toContain('把玉牌递给柳无声');
|
|
});
|
|
|
|
test('adventure panel shows npc chat custom input and exit button in chat mode', () => {
|
|
const optionA = createOption('npc_chat', '先听对方把话说完');
|
|
const optionB = createOption('npc_chat', '顺着这个问题继续追问');
|
|
const optionC = createOption('npc_chat', '换个更轻松的语气回应');
|
|
const currentStory: StoryMoment = {
|
|
text: '你们的对话正在继续。',
|
|
displayMode: 'dialogue',
|
|
dialogue: [
|
|
{ speaker: 'player', text: '你刚才那句话是什么意思?' },
|
|
{ speaker: 'npc', speakerName: '柳无声', text: '意思是这件事还没结束。' },
|
|
],
|
|
options: [optionA, optionB, optionC],
|
|
npcAffinityEffect: {
|
|
eventId: 'effect-liu-1',
|
|
npcId: 'npc-liu',
|
|
delta: 3,
|
|
},
|
|
npcChatState: {
|
|
npcId: 'npc-liu',
|
|
npcName: '柳无声',
|
|
turnCount: 2,
|
|
customInputPlaceholder: '输入你想对 TA 说的话',
|
|
},
|
|
};
|
|
|
|
const html = renderPanel(currentStory, [optionA, optionB, optionC], {
|
|
canRefreshOptions: true,
|
|
onSubmitNpcChatInput: () => true,
|
|
onExitNpcChat: () => true,
|
|
});
|
|
|
|
expect(html).toContain('退出聊天');
|
|
expect(html).toContain('输入你想对 TA 说的话');
|
|
expect(html).toContain('发送');
|
|
expect(html).toContain('换一换');
|
|
expect(html).not.toContain('关系升温');
|
|
});
|
|
|
|
test('adventure panel hides custom input and shows quest offer actions during npc quest offer mode', () => {
|
|
const viewOption = createOption('npc_chat_quest_offer_view', '查看任务');
|
|
viewOption.runtimePayload = {
|
|
npcChatQuestOfferAction: 'view',
|
|
};
|
|
const replaceOption = createOption('npc_chat_quest_offer_replace', '更换任务');
|
|
replaceOption.runtimePayload = {
|
|
npcChatQuestOfferAction: 'replace',
|
|
};
|
|
const abandonOption = createOption('npc_chat_quest_offer_abandon', '放弃任务');
|
|
abandonOption.runtimePayload = {
|
|
npcChatQuestOfferAction: 'abandon',
|
|
};
|
|
const currentStory: StoryMoment = {
|
|
text: '柳无声把真正的委托说了出来。',
|
|
displayMode: 'dialogue',
|
|
dialogue: [
|
|
{ speaker: 'player', text: '你像是还有别的话想说。' },
|
|
{ speaker: 'npc', speakerName: '柳无声', text: '确实有一件事想正式托付给你。' },
|
|
],
|
|
options: [viewOption, replaceOption, abandonOption],
|
|
npcChatState: {
|
|
npcId: 'npc-liu',
|
|
npcName: '柳无声',
|
|
turnCount: 2,
|
|
customInputPlaceholder: '输入你想对 TA 说的话',
|
|
pendingQuestOffer: {
|
|
quest: {
|
|
id: 'quest-liu-1',
|
|
issuerNpcId: 'npc-liu',
|
|
issuerNpcName: '柳无声',
|
|
sceneId: 'scene-bamboo',
|
|
title: '竹林密信',
|
|
description: '替柳无声查清竹林中的密信来源。',
|
|
summary: '去竹林查清密信来源。',
|
|
objective: {
|
|
kind: 'inspect_treasure',
|
|
requiredCount: 1,
|
|
},
|
|
progress: 0,
|
|
status: 'active',
|
|
reward: {
|
|
affinityBonus: 5,
|
|
currency: 10,
|
|
items: [],
|
|
},
|
|
rewardText: '完成后可获得报酬。',
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const html = renderPanel(currentStory, [viewOption, replaceOption, abandonOption], {
|
|
onSubmitNpcChatInput: () => true,
|
|
onExitNpcChat: () => true,
|
|
});
|
|
|
|
expect(html).toContain('查看任务');
|
|
expect(html).toContain('更换任务');
|
|
expect(html).toContain('放弃任务');
|
|
expect(html).not.toContain('发送');
|
|
expect(html).not.toContain('输入你想对 TA 说的话');
|
|
});
|
|
|
|
test('adventure panel renders narrative story text without italics and hides option detail text', () => {
|
|
const option = createOption('idle_observe_signs', '观察风里残下的痕迹');
|
|
option.detailText = '这段说明不应该继续出现在 UI 里。';
|
|
const currentStory: StoryMoment = {
|
|
text: '风从桥洞里灌过来,你把注意力重新放回脚下与前路。',
|
|
options: [option],
|
|
};
|
|
|
|
const html = renderPanel(currentStory, [option]);
|
|
|
|
expect(html).toContain('font-serif');
|
|
expect(html).not.toContain('italic');
|
|
expect(html).toContain('text-[15px]');
|
|
expect(html).not.toContain('这段说明不应该继续出现在 UI 里。');
|
|
});
|