Files
Genarrative/src/services/prompt.test.ts
高物 bd9fdcbe31
Some checks failed
CI / verify (push) Has been cancelled
Implement scene-based chapter quest progression
2026-04-08 11:58:47 +08:00

303 lines
12 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { AnimationState, type Character, WorldType } from '../types';
import { buildExpandedCustomWorldProfile } from './customWorldBuilder';
import { buildUserPrompt } from './prompt';
import { buildSceneNarrativeDirective } from './storyEngine/sceneNarrativeDirector';
import { buildEncounterVisibilitySlice } from './storyEngine/visibilityEngine';
function createCharacter(): Character {
return {
id: 'hero',
name: '林澈',
title: '行旅客',
description: '一名谨慎前行的旅人。',
backstory: '从北境一路追着旧案残线而来。',
avatar: '/hero.png',
portrait: '/hero-portrait.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 9,
intelligence: 8,
spirit: 9,
},
personality: '谨慎、克制、先看局势。',
skills: [],
adventureOpenings: {},
};
}
describe('buildUserPrompt', () => {
it('does not leak full custom-world backstory on first contact', () => {
const profile = buildExpandedCustomWorldProfile(
{
id: 'prompt-world',
name: '裂潮边城',
subtitle: '旧案回响',
summary: '一座在裂潮与旧案回响之间摇摇欲坠的边城。',
tone: '紧张、克制、暗流涌动',
playerGoal: '查清边城裂潮背后的封桥旧令',
templateWorldType: 'WUXIA',
majorFactions: ['巡边司', '潮商会'],
coreConflicts: ['裂潮再度逼近边路', '封桥旧案再被人提起'],
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '灰炬向导',
role: '向导',
description: '熟悉裂潮边路的灰炬向导。',
backstory: '曾在旧撤离线里失去一整支同行队。',
personality: '谨慎寡言,先看风向再开口。',
motivation: '想查清旧撤离线为何再次失控。',
combatStyle: '短弓牵制后贴近补刀。',
initialAffinity: 18,
relationshipHooks: ['旧撤离线', '名单'],
tags: ['裂潮', '向导'],
backstoryReveal: {
publicSummary: '他只说自己熟悉边路。',
chapters: [
{ id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他总盯着风向和路标。', content: '他先把注意力放在边路是否还能走。', contextSnippet: '他总先谈路和风。' },
{ id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '旧撤离线像在他身上留下了什么。', content: '那次撤离失控后,他一直没再离开这片边路。', contextSnippet: '撤离旧事还没过去。' },
{ id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '名单上的名字让他一直不肯放手。', content: '他一直在比对那份回响名单与旧撤离线。', contextSnippet: '他一直在查名单。' },
{ id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '他知道裂潮里有人故意改过路标。', content: '他怀疑有人借裂潮重启旧案。', contextSnippet: '有人在利用裂潮。' },
],
},
skills: [],
initialItems: [],
},
],
storyNpcs: [
{
id: 'story-1',
name: '梁砺',
title: '断桥巡守',
role: '巡守',
description: '守着断桥与旧哨火的巡守。',
backstory: '旧案爆发时,他是最后一个封桥的人。',
personality: '警觉直接,不喜欢绕弯。',
motivation: '不想让旧案再次借裂潮翻上来。',
combatStyle: '长兵先压,再卡住路口。',
initialAffinity: 6,
relationshipHooks: ['封桥', '旧哨火'],
tags: ['巡守', '断桥'],
backstoryReveal: {
publicSummary: '他只承认自己还在守桥。',
chapters: [
{ id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他只说桥还不能放开。', content: '他总先谈桥和路。', contextSnippet: '桥还不能放开。' },
{ id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '封桥那夜明显留下了后劲。', content: '他始终忘不了那夜桥上的名单。', contextSnippet: '封桥旧事还压着他。' },
{ id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '他像还在替谁守着一个错误。', content: '他一直替旧命令继续守线。', contextSnippet: '他还在守旧命令。' },
{ id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '有人逼他在封桥和救人之间选过。', content: '那夜真正下封桥令的人还没有露面。', contextSnippet: '封桥命令另有来头。' },
],
},
skills: [],
initialItems: [
{
id: 'item-1',
name: '旧哨铜钥',
category: '稀有品',
quantity: 1,
rarity: 'rare',
description: '钥身磨得发亮。',
tags: ['旧哨火'],
},
],
},
],
items: [],
landmarks: [
{
id: 'landmark-1',
name: '断桥旧哨',
description: '旧哨火和断桥一起守着边城北口。',
dangerLevel: 'high',
sceneNpcIds: ['story-1'],
connections: [],
},
],
},
'玩家想要一个裂潮边城与旧案回响交织的世界。',
);
const npc = profile.storyNpcs[0]!;
const visibilitySlice = buildEncounterVisibilitySlice({
narrativeProfile: npc.narrativeProfile,
backstoryReveal: npc.backstoryReveal,
disclosureStage: 'guarded',
isFirstMeaningfulContact: true,
seenBackstoryChapterIds: [],
storyEngineMemory: {
discoveredFactIds: [],
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
resolvedScarIds: [],
recentCarrierIds: [],
},
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
});
const prompt = buildUserPrompt(
WorldType.CUSTOM,
createCharacter(),
[],
[],
{
playerHp: 30,
playerMaxHp: 40,
playerMana: 10,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
sceneId: 'custom-scene-landmark-1',
sceneName: '断桥旧哨',
sceneDescription: '风里尽是旧哨火和潮声。',
encounterKind: 'npc',
encounterId: npc.id,
encounterName: npc.name,
encounterDescription: npc.description,
encounterContext: npc.role,
encounterAffinity: npc.initialAffinity,
encounterAffinityText: '对你仍有戒备,也在观察你会怎么试探。',
encounterDisclosureStage: 'guarded',
encounterWarmthStage: 'distant',
encounterAnswerMode: 'situational_only',
encounterAllowedTopics: ['眼前危险', '现场判断', '模糊钩子'],
encounterBlockedTopics: ['完整来历', '真正目标', '旧事全貌'],
isFirstMeaningfulContact: true,
firstContactRelationStance: 'guarded',
recentSharedEvent: '你们还只是刚刚真正把话对上。',
talkPriority: '优先谈桥口、来意和眼前压力,不要直接摊开旧案全貌。',
encounterCustomProfile: npc,
encounterNarrativeProfile: npc.narrativeProfile,
visibilitySlice,
sceneNarrativeDirective: buildSceneNarrativeDirective({
sceneId: 'custom-scene-landmark-1',
sceneName: '断桥旧哨',
encounterId: npc.id,
encounterName: npc.name,
recentActions: [],
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
visibilitySlice,
encounterNarrativeProfile: npc.narrativeProfile,
disclosureStage: 'guarded',
isFirstMeaningfulContact: true,
affinity: npc.initialAffinity,
}),
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
customWorldProfile: profile,
},
);
expect(prompt).toContain(npc.narrativeProfile?.publicMask ?? '');
expect(prompt).toContain(npc.narrativeProfile?.immediatePressure ?? '');
expect(prompt).not.toContain(npc.backstory);
expect(prompt).not.toContain(npc.backstoryReveal.chapters[3]!.content);
expect(prompt).not.toContain(npc.initialItems[0]!.name);
});
it('requires an empty encounter payload during non-pending follow-up reasoning such as post-battle continuation', () => {
const prompt = buildUserPrompt(
WorldType.WUXIA,
createCharacter(),
[],
[
{
text: '挥刀抢攻',
options: [],
historyRole: 'action',
},
{
text: '山道客已经败下阵来。',
options: [],
historyRole: 'result',
},
],
{
playerHp: 26,
playerMaxHp: 40,
playerMana: 8,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
sceneId: 'forest_road',
sceneName: '山道',
sceneDescription: '风从林梢压下来,地上还留着刚才交手的痕迹。',
pendingSceneEncounter: false,
},
'挥刀抢攻',
);
expect(prompt).toContain('encounter 必须为 null');
expect(prompt).toContain('战斗结束后的续写');
});
it('does not feed mixed-language history and directive snippets back into story prompts', () => {
const prompt = buildUserPrompt(
WorldType.WUXIA,
createCharacter(),
[],
[
{
text: 'Move forward carefully.',
options: [],
historyRole: 'action',
},
{
text: 'The wind is cold. 你听见山道尽头有脚步声。',
options: [],
historyRole: 'result',
},
],
{
playerHp: 26,
playerMaxHp: 40,
playerMana: 8,
playerMaxMana: 20,
inBattle: false,
playerX: 0,
playerFacing: 'right',
playerAnimation: AnimationState.ATTACK,
skillCooldowns: {},
sceneId: 'forest_road',
sceneName: '山道',
sceneDescription: '风从林梢压下来。',
pendingSceneEncounter: false,
conversationSituation: 'post_battle_breath',
conversationPressure: 'medium',
recentSharedEvent:
'A fight just ended. Both sides are still catching their breath.',
talkPriority:
'Focus on the most useful judgment, danger, and next step.',
partyRelationshipNotes:
'Lan is becoming more open in private conversation.',
recentChronicleSummary: 'Baseline summary from previous run.',
sceneNarrativeDirective: {
primaryPressure: 'Danger is still active near the camp.',
activeThreadIds: ['thread-old-case'],
foregroundActorIds: [],
foregroundCarrierIds: [],
revealBudget: 'low',
emotionalCadence: 'tense',
},
},
'Move forward carefully.',
);
expect(prompt).not.toContain('A fight just ended');
expect(prompt).not.toContain('Focus on the most useful judgment');
expect(prompt).not.toContain('Baseline summary');
expect(prompt).not.toContain('Move forward carefully');
expect(prompt).not.toContain('thread-old-case');
expect(prompt).not.toContain('Danger is still active');
expect(prompt).toContain('战后缓气');
expect(prompt).toContain('紧绷');
expect(prompt).toContain('这一轮的局势已经出现了新的变化。');
});
});