This commit is contained in:
2026-04-18 20:29:33 +08:00
parent 8c3fbd9bcf
commit c39dbc59ee
10 changed files with 233 additions and 49 deletions

View File

@@ -0,0 +1,102 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { AdventurePanel } from './AdventurePanel';
import { type Character, type StoryMoment, WorldType } from '../types';
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;
}
test('adventure panel treats negative affinity updates as relationship change system messages', () => {
const currentStory: StoryMoment = {
text: '你们的语气忽然冷了下来。',
displayMode: 'dialogue',
dialogue: [
{ speaker: 'npc', speakerName: '柳无声', text: '这件事你最好别再追问。' },
{ speaker: 'system', text: '关系转冷 好感 -2', affinityDelta: -2 },
],
options: [],
};
const html = renderToStaticMarkup(
<AdventurePanel
aiError={null}
currentStory={currentStory}
isLoading={false}
displayedOptions={[]}
hideOptions={false}
canRefreshOptions={false}
onRefreshOptions={() => undefined}
onChoice={() => undefined}
onOpenCharacter={() => undefined}
onOpenInventory={() => undefined}
playerCharacter={createCharacter()}
worldType={WorldType.WUXIA}
quests={[]}
questUi={{
acknowledgeQuestCompletion: () => undefined,
claimQuestReward: () => 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}
/>,
);
expect(html).toContain('关系变化');
expect(html).toContain('关系转冷 好感 -2');
});

View File

@@ -200,7 +200,7 @@ function getDialogueTurnLabel(
turn: NonNullable<StoryMoment['dialogue']>[number],
) {
if (turn.speaker === 'system') {
return turn.affinityDelta && turn.affinityDelta > 0 ? '关系变化' : '系统';
return typeof turn.affinityDelta === 'number' ? '关系变化' : '系统';
}
if (turn.speaker === 'player') {
@@ -1029,13 +1029,13 @@ export function AdventurePanel({
</div>
<div className="mt-auto shrink-0 pb-2">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<button
type="button"
onClick={onOpenCharacter}
aria-label="打开队伍"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
>
<PixelIcon src={TAB_ICONS.character.active} className="h-4 w-4" />
<span className="text-xs leading-none"></span>
@@ -1044,7 +1044,7 @@ export function AdventurePanel({
type="button"
onClick={onOpenInventory}
aria-label="打开背包"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
>
<PixelIcon src={TAB_ICONS.inventory.active} className="h-4 w-4" />
<span className="text-xs leading-none"></span>
@@ -1056,7 +1056,7 @@ export function AdventurePanel({
type="button"
onClick={() => onExitNpcChat?.()}
aria-label="退出聊天"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-rose-300/20 bg-rose-500/10 px-2 text-rose-100 transition-colors hover:bg-rose-500/15"
className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start rounded-md border border-rose-300/20 bg-rose-500/10 px-2 text-rose-100 transition-colors hover:bg-rose-500/15"
>
<span className="text-xs leading-none">退</span>
</button>
@@ -1065,7 +1065,7 @@ export function AdventurePanel({
type="button"
onClick={onRefreshOptions}
aria-label="换一换选项"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
className="inline-flex h-8 shrink-0 items-center gap-1.5 self-start rounded-md border border-white/10 bg-black/20 px-2 text-zinc-300 transition-colors hover:text-white"
>
<PixelIcon
src={CHROME_ICONS.refreshOptions}
@@ -1176,7 +1176,7 @@ export function AdventurePanel({
})}
{isNpcChatMode ? (
<div className="pixel-nine-slice pixel-panel mt-1 border border-white/10 bg-black/25 p-2">
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
<input
value={npcChatDraft}
onChange={(event) => setNpcChatDraft(event.target.value)}
@@ -1193,7 +1193,7 @@ export function AdventurePanel({
npcChatState?.customInputPlaceholder ??
'输入你想说的话'
}
className="h-9 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40"
className="h-9 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40"
maxLength={80}
disabled={isLoading}
/>
@@ -1201,7 +1201,7 @@ export function AdventurePanel({
type="button"
onClick={submitNpcChatDraft}
disabled={isLoading || !npcChatDraft.trim()}
className="inline-flex h-9 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-3 text-xs text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40"
className="inline-flex h-9 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-2.5 text-[11px] text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40 sm:px-3 sm:text-xs"
>
</button>
@@ -1264,3 +1264,4 @@ export function AdventurePanel({
</div>
);
}

View File

@@ -689,15 +689,13 @@ export function createStoryNpcEncounterActions({
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) &&
currentStory.dialogue
? [...currentStory.dialogue]
: currentStory?.dialogue && currentStory.dialogue.length > 0
? [...currentStory.dialogue]
: [
{
speaker: 'npc' as const,
speakerName: encounter.npcName,
text: `${encounter.npcName}看着你,像是在等你把话接下去。`,
},
];
: [
{
speaker: 'npc' as const,
speakerName: encounter.npcName,
text: `${encounter.npcName}\u770b\u7740\u4f60\uff0c\u50cf\u662f\u5728\u7b49\u4f60\u628a\u8bdd\u63a5\u4e0b\u53bb\u3002`,
},
];
setAiError(null);
setCurrentStory(
@@ -836,10 +834,9 @@ export function createStoryNpcEncounterActions({
? [
{
speaker: 'system' as const,
text:
chatTurn.affinityDelta > 0
? `${chatTurn.affinityText} 好感 +${chatTurn.affinityDelta}`
: chatTurn.affinityText,
text: `${chatTurn.affinityText} \u597d\u611f ${
chatTurn.affinityDelta > 0 ? '+' : '-'
}${Math.abs(chatTurn.affinityDelta)}`,
affinityDelta: chatTurn.affinityDelta,
},
]
@@ -1319,3 +1316,4 @@ export function createStoryNpcEncounterActions({
exitNpcChat,
};
}

View File

@@ -943,11 +943,13 @@ export async function streamNpcChatTurn(
const payload = {
worldType: world,
character,
player: character,
encounter,
monsters,
history,
context,
conversationHistory: conversationHistory ?? [],
dialogue: conversationHistory ?? [],
playerMessage,
npcState,
} satisfies NpcChatTurnRequest;

View File

@@ -76,14 +76,15 @@ describe('qwenSpriteSheetToolModel', () => {
expect(prompt).toContain('大头身');
});
it('builds a master prompt with square canvas and chibi ratio', () => {
const prompt = buildMasterPrompt('Q版大头身少女冒险者。');
it('builds a master prompt with square canvas and richer world-character detail coverage', () => {
const prompt = buildMasterPrompt(DEFAULT_CHARACTER_BRIEF);
expect(prompt).toContain('1:1 正方形画布');
expect(prompt).toContain('大头身');
expect(prompt).toContain('2 到 3 头身');
expect(prompt).toContain('不是完全 90 度纯右视图');
expect(prompt).toContain('背景固定为纯绿色绿幕');
expect(prompt).toContain('1:1');
expect(prompt).toContain('sprite sheet');
expect(prompt).toContain('90');
expect(prompt).toContain(DEFAULT_CHARACTER_BRIEF);
expect(prompt).toContain('????????????');
expect(prompt).toContain('????????????????????????');
});
it('strengthens non-human species traits for siren-like characters', () => {
@@ -118,19 +119,19 @@ describe('qwenSpriteSheetToolModel', () => {
});
it('builds a video action prompt with pixel style constraints', () => {
const actionTemplate = getActionTemplateById('run');
const prompt = buildVideoActionPrompt({
actionTemplate: getActionTemplateById('run'),
actionDetailText: '跑步时上身前倾,手臂摆动明显。',
characterBrief: '海妖刺客,蓝绿色鳞片,鱼鳍耳。',
actionTemplate,
actionDetailText: '?????????????????????????????????????????????',
characterBrief: '?????????????????????????????????????????????',
useChromaKey: true,
});
expect(prompt).toContain('动作视频');
expect(prompt).toContain('右向斜侧身动作视角');
expect(prompt).toContain('像素风');
expect(prompt).toContain('绿幕');
expect(prompt).toContain('默认优先生成人形拟人化角色');
expect(prompt).toContain('Q版可爱的人形动作角色');
expect(prompt).toContain(actionTemplate.label);
expect(prompt).toContain(actionTemplate.stagingDirection ?? '');
expect(prompt).toContain('90');
expect(prompt).toContain('Q');
expect(prompt).toContain('sprite');
});
it('builds generic theme over-literalization negatives', () => {