1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-20 15:45:14 +08:00
parent 8a7bd90458
commit 1c72066bab
73 changed files with 7814 additions and 1018 deletions

View File

@@ -164,6 +164,44 @@ function createState(overrides: Partial<GameState> = {}): GameState {
} as GameState;
}
function createSceneActProfile(
primaryNpcId = 'npc-rival',
): NonNullable<GameState['customWorldProfile']> {
return {
id: 'custom-world-scene-act-test',
name: '断桥旧案',
summary: '用于测试场景幕主角色聊天规则。',
playableNpcs: [],
storyNpcs: [],
sceneChapterBlueprints: [
{
id: 'scene-bridge-chapter',
sceneId: 'scene-bridge',
title: '断桥口',
summary: '桥口旧账还没了结。',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: 'scene-bridge-act-1',
sceneId: 'scene-bridge',
title: '对峙幕',
summary: '玩家与断桥客正面碰头。',
stageCoverage: ['opening'],
backgroundImageSrc: '/bridge-act-1.png',
encounterNpcIds: [primaryNpcId, 'npc-bystander'],
primaryNpcId,
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '逼近断桥旧案的核心线索。',
transitionHook: '桥下藏着还没灭的灯。',
},
],
},
],
} as unknown as NonNullable<GameState['customWorldProfile']>;
}
function createCurrentChatStory(): StoryMoment {
return {
text: '断桥客:你居然还敢来。\n你我只是想把话说清楚。',
@@ -195,6 +233,42 @@ function createCurrentChatStory(): StoryMoment {
};
}
function createLimitedPrimaryNpcChatStory(turnCount: number): StoryMoment {
return {
text: '断桥客还在压着不肯说完的话。',
options: [
createOption('npc_chat', '那你至少告诉我接下来该去哪', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
],
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: '断桥客',
text: '该听见的人还没到。',
},
{
speaker: 'player',
text: '你总得让我知道下一步该往哪边走。',
},
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount,
customInputPlaceholder: '输入你想对 TA 说的话',
sceneActId: 'scene-bridge-act-1',
turnLimit: 5,
remainingTurns: Math.max(0, 5 - turnCount),
limitReason: 'negative_affinity',
forceExitAfterTurn: false,
},
};
}
function createQuest(id: string, title: string): QuestLogEntry {
return {
id,
@@ -541,6 +615,170 @@ describe('npcEncounterActions', () => {
},
);
it('opens npc chat without injecting a local preset opening line', () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
npcReply: '先站住。你想从哪一句开始问,我先听听。',
suggestions: ['我先问桥上出了什么事'],
});
const actions = createNpcEncounterActions({
gameState: createState({
currentEncounter: encounter,
npcInteractionActive: false,
}),
currentStory: {
text: '断桥客站在风口,等你先挑明来意。',
options: [],
},
});
expect(actions.enterNpcInteraction(encounter, '与断桥客搭话')).toBe(true);
const nextStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(nextStory.displayMode).toBe('dialogue');
expect(nextStory.dialogue ?? []).toEqual([]);
expect(nextStory.text).toBe('');
expect(nextStory.npcChatState).toMatchObject({
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 0,
});
});
it('streams a model-driven npc-initiated opening on first meaningful contact', async () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
npcReply: '先别急着拔话头。桥上的风向刚变,我得先确认你是来问旧账,还是来救人。',
suggestions: ['我先听你说桥上出了什么事', '你先说你在防谁', '我不是来翻旧账的'],
});
const actions = createNpcEncounterActions({
gameState: createState({
currentEncounter: encounter,
npcInteractionActive: false,
npcStates: {
'npc-rival': {
affinity: 8,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: false,
},
},
}),
currentStory: {
text: '断桥客站在风口,等你先挑明来意。',
options: [],
},
});
expect(actions.enterNpcInteraction(encounter, '与断桥客搭话')).toBe(true);
await flushAsyncWork();
expect(streamNpcChatTurnMock).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
id: 'npc-rival',
}),
expect.anything(),
expect.anything(),
expect.anything(),
[],
'【NPC 主动开场】',
expect.anything(),
expect.objectContaining({
npcInitiatesConversation: true,
}),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.streaming).toBe(false);
expect(lastStory.dialogue).toEqual([
{
speaker: 'npc',
speakerName: '断桥客',
text: '先别急着拔话头。桥上的风向刚变,我得先确认你是来问旧账,还是来救人。',
},
]);
expect(lastStory.npcChatState).toMatchObject({
npcId: 'npc-rival',
openingSource: 'npc_initiated',
turnCount: 0,
});
expect(lastStory.options.map((option) => option.actionText)).toEqual([
'我先听你说桥上出了什么事',
'你先说你在防谁',
'我不是来翻旧账的',
]);
});
it('removes any prefilled local opening line before the first model-driven npc reply', async () => {
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 1,
affinityText: '断桥客的语气稍微松了一点。',
npcReply: '先打个招呼。你盯着我看了这么久,总得先告诉我你想问哪一层。',
suggestions: ['我先问你刚才在防谁'],
});
const actions = createNpcEncounterActions({
currentStory: {
text: '先和你打个招呼。前面的风不太对。',
options: [
createOption('npc_chat', '先问问你刚才在留意什么', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
],
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: '断桥客',
text: '先和你打个招呼。前面的风不太对。',
},
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 0,
customInputPlaceholder: '输入你想对 TA 说的话',
openingSource: 'player_reply',
},
},
});
await expect(
actions.handleNpcChatTurn(createEncounter(), '你刚才到底在看什么?'),
).resolves.toBe(true);
expect(streamNpcChatTurnMock).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
[],
'你刚才到底在看什么?',
expect.anything(),
expect.anything(),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(
lastStory.dialogue?.some((turn) =>
turn.text.includes('先和你打个招呼。前面的风不太对。'),
),
).toBe(false);
});
it('passes the quest id through to the server runtime resolver for quest turn-in', async () => {
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
hydratedSnapshot: {
@@ -729,6 +967,134 @@ describe('npcEncounterActions', () => {
);
});
it('lets the current act primary npc enter limited chat even with negative affinity', () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
npcReply: '先把来意说清楚,我再决定要不要把后半句给你。',
suggestions: ['你先说你到底在防谁'],
});
const actions = createNpcEncounterActions({
gameState: createState({
currentEncounter: encounter,
customWorldProfile: createSceneActProfile(),
npcInteractionActive: false,
npcStates: {
'npc-rival': {
affinity: -8,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
}),
currentStory: {
text: '断桥客停在桥口,像是在等你自己把话说出来。',
options: [],
},
});
expect(actions.enterNpcInteraction(encounter, '与断桥客搭话')).toBe(true);
const nextStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(nextStory.npcChatState).toMatchObject({
npcId: 'npc-rival',
sceneActId: 'scene-bridge-act-1',
turnLimit: 5,
remainingTurns: 5,
limitReason: 'negative_affinity',
});
expect(
nextStory.options.some((option) => option.functionId === 'npc_fight'),
).toBe(false);
expect(
nextStory.options.some(
(option) => option.functionId === 'battle_escape_breakout',
),
).toBe(false);
});
it('force exits limited hostile chat on the fifth turn and offers a continue option', async () => {
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
npcReply: '去城西废桥下找那盏没灭的灯。等你看见它,再来问我剩下那半句。',
suggestions: [],
chatDirective: {
turnLimit: 5,
remainingTurns: 0,
forceExit: true,
closingMode: 'foreshadow_close',
},
});
const actions = createNpcEncounterActions({
gameState: createState({
customWorldProfile: createSceneActProfile(),
npcStates: {
'npc-rival': {
affinity: -12,
helpUsed: false,
chattedCount: 4,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
}),
currentStory: createLimitedPrimaryNpcChatStory(4),
});
await expect(
actions.handleNpcChatTurn(
createEncounter(),
'那你至少告诉我,接下来该去哪里找答案。',
),
).resolves.toBe(true);
expect(streamNpcChatTurnMock).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
id: 'npc-rival',
}),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
'那你至少告诉我,接下来该去哪里找答案。',
expect.anything(),
expect.objectContaining({
questOfferContext: null,
chatDirective: expect.objectContaining({
sceneActId: 'scene-bridge-act-1',
turnLimit: 5,
remainingTurns: 0,
limitReason: 'negative_affinity',
forceExitAfterTurn: true,
}),
}),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState).toBeUndefined();
expect(lastStory.options).toEqual([
expect.objectContaining({
functionId: 'story_continue_adventure',
actionText: '继续',
}),
]);
expect(lastStory.dialogue?.at(-1)).toEqual(
expect.objectContaining({
speaker: 'system',
text: '这轮交谈先在这里收束,对方留下的线索把你推向了下一步。',
}),
);
});
it('offers a pending quest after enough warmup chat turns with a positive-affinity npc', async () => {
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
streamNpcChatTurnMock.mockResolvedValueOnce({

View File

@@ -21,6 +21,9 @@ import { resolveFunctionOption } from '../../data/stateFunctions';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { streamNpcChatTurn } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import {
resolveLimitedPrimaryNpcChatState,
} from '../../services/customWorldSceneActRuntime';
import { generateQuestForNpcEncounter } from '../../services/questDirector';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { createHistoryMoment } from '../../services/storyHistory';
@@ -74,6 +77,14 @@ type BuildStoryContextExtras = {
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
};
type NpcChatDirective = {
sceneActId?: string | null;
turnLimit?: number | null;
remainingTurns?: number | null;
limitReason?: 'negative_affinity' | null;
forceExitAfterTurn?: boolean;
} | null;
function isNpcEncounter(
encounter: GameState['currentEncounter'],
): encounter is Encounter {
@@ -103,6 +114,7 @@ export function createStoryNpcEncounterActions({
generateStoryForState,
getStoryGenerationHostileNpcs,
getAvailableOptionsForState,
buildContinueAdventureOption,
getNpcEncounterKey,
getResolvedNpcState,
updateNpcState,
@@ -587,9 +599,11 @@ export function createStoryNpcEncounterActions({
options: StoryOption[];
streaming: boolean;
turnCount: number;
chatDirective?: NpcChatDirective;
pendingQuestOffer?: {
quest: QuestLogEntry;
} | null;
openingSource?: 'npc_initiated' | 'player_reply';
}): StoryMoment => ({
text: params.dialogue.map((turn) => turn.text).join('\n'),
options: params.options,
@@ -601,6 +615,12 @@ export function createStoryNpcEncounterActions({
npcName: params.encounter.npcName,
turnCount: params.turnCount,
customInputPlaceholder: '输入你想对 TA 说的话',
openingSource: params.openingSource ?? 'player_reply',
sceneActId: params.chatDirective?.sceneActId ?? null,
turnLimit: params.chatDirective?.turnLimit ?? null,
remainingTurns: params.chatDirective?.remainingTurns ?? null,
limitReason: params.chatDirective?.limitReason ?? null,
forceExitAfterTurn: params.chatDirective?.forceExitAfterTurn ?? false,
pendingQuestOffer: params.pendingQuestOffer ?? null,
},
});
@@ -622,17 +642,51 @@ export function createStoryNpcEncounterActions({
});
};
const buildNpcChatOpeningDialogue = (encounter: Encounter) =>
const buildLegacyNpcChatOpeningPlaceholder = (encounter: Encounter) =>
`${encounter.npcName}看着你,像是在等你把话接下去。`;
const sanitizeNpcChatDialogueHistory = (
encounter: Encounter,
dialogue: NonNullable<StoryMoment['dialogue']>,
turnCount: number,
openingSource?: StoryMoment['npcChatState'] extends infer T
? T extends { openingSource?: infer U }
? U
: never
: never,
) => {
const legacyOpeningText = buildLegacyNpcChatOpeningPlaceholder(encounter);
return dialogue.filter((turn, index) => {
if (index !== 0 || turn.speaker !== 'npc') {
return true;
}
if (turn.text.trim() === legacyOpeningText) {
return false;
}
if (turnCount === 0 && dialogue.length === 1) {
return openingSource === 'npc_initiated';
}
return true;
});
};
const buildNpcChatDialogueHistory = (
encounter: Encounter,
turnCount: number,
) =>
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) &&
currentStory.dialogue
? [...currentStory.dialogue]
: [
{
speaker: 'npc' as const,
speakerName: encounter.npcName,
text: `${encounter.npcName}看着你,像是在等你把话接下去。`,
},
];
? sanitizeNpcChatDialogueHistory(
encounter,
currentStory.dialogue,
turnCount,
currentStory.npcChatState?.openingSource,
)
: [];
const buildHostileNpcDeclarationText = (
encounter: Encounter,
@@ -744,8 +798,10 @@ export function createStoryNpcEncounterActions({
encounter: Encounter,
selectedOption: StoryOption,
extraOptions: StoryOption[] = [],
chatDirective?: NpcChatDirective,
openingSource: 'npc_initiated' | 'player_reply' = 'player_reply',
) => {
const openingDialogue = buildNpcChatOpeningDialogue(encounter);
const openingDialogue = buildNpcChatDialogueHistory(encounter, 0);
setAiError(null);
setCurrentStory(
@@ -759,11 +815,144 @@ export function createStoryNpcEncounterActions({
),
streaming: false,
turnCount: 0,
chatDirective,
openingSource,
}),
);
return true;
};
const startNpcInitiatedOpening = async (
encounter: Encounter,
selectedOption: StoryOption,
extraOptions: StoryOption[] = [],
chatDirective?: NpcChatDirective,
) => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !gameState.worldType) {
return enterNpcChat(
encounter,
selectedOption,
extraOptions,
chatDirective,
'npc_initiated',
);
}
const npcState = getResolvedNpcState(gameState, encounter);
const openingCampContext = buildOpeningCampChatContext(
gameState,
playerCharacter,
encounter,
);
const existingDialogue = buildNpcChatDialogueHistory(encounter, 0);
const openingOptions = buildNpcChatEntryOptions(
encounter,
selectedOption,
extraOptions,
);
setAiError(null);
setIsLoading(true);
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: existingDialogue,
options: [],
streaming: true,
turnCount: 0,
chatDirective,
openingSource: 'npc_initiated',
}),
);
try {
const chatTurn = await streamNpcChatTurn(
gameState.worldType,
playerCharacter,
encounter,
getStoryGenerationHostileNpcs(gameState),
gameState.storyHistory,
buildStoryContextFromState(gameState, {
lastFunctionId: 'npc_chat',
...openingCampContext,
encounterNpcStateOverride: npcState,
}),
existingDialogue,
'【NPC 主动开场】',
{
affinity: npcState.affinity,
chattedCount: npcState.chattedCount,
recruited: npcState.recruited,
},
{
onReplyUpdate: (text) => {
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: [
...existingDialogue,
{
speaker: 'npc',
speakerName: encounter.npcName,
text,
},
],
options: [],
streaming: true,
turnCount: 0,
chatDirective,
openingSource: 'npc_initiated',
}),
);
},
chatDirective,
npcInitiatesConversation: true,
},
);
if (!chatTurn?.npcReply?.trim()) {
throw new Error('NPC 主动开场结果为空');
}
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: [
...existingDialogue,
{
speaker: 'npc',
speakerName: encounter.npcName,
text: chatTurn.npcReply,
},
],
options: buildNpcChatTurnOptions(
encounter,
chatTurn.suggestions.length > 0
? chatTurn.suggestions
: openingOptions.map((option) => option.actionText),
),
streaming: false,
turnCount: 0,
chatDirective,
openingSource: 'npc_initiated',
}),
);
return true;
} catch (error) {
console.error('Failed to start npc initiated opening:', error);
setAiError(error instanceof Error ? error.message : 'NPC 主动开场失败');
return enterNpcChat(
encounter,
selectedOption,
extraOptions,
chatDirective,
'npc_initiated',
);
} finally {
setIsLoading(false);
}
};
const handleNpcChatTurn = async (
encounter: Encounter,
playerMessage: string,
@@ -780,7 +969,12 @@ export function createStoryNpcEncounterActions({
: null;
const existingDialogue =
currentStory?.dialogue && currentNpcChatState
? [...currentStory.dialogue]
? sanitizeNpcChatDialogueHistory(
encounter,
currentStory.dialogue,
currentNpcChatState.turnCount ?? 0,
currentNpcChatState.openingSource,
)
: [];
const dialogueWithPlayer = [
...existingDialogue,
@@ -790,6 +984,12 @@ export function createStoryNpcEncounterActions({
},
];
const nextTurnCount = (currentNpcChatState?.turnCount ?? 0) + 1;
const limitedChatDirective = resolveLimitedPrimaryNpcChatState({
state: gameState,
npcId: encounter.id ?? encounter.npcName,
affinity: npcState.affinity,
nextTurnCount,
});
const openingCampContext = buildOpeningCampChatContext(
gameState,
playerCharacter,
@@ -805,6 +1005,7 @@ export function createStoryNpcEncounterActions({
options: [],
streaming: true,
turnCount: nextTurnCount,
chatDirective: limitedChatDirective,
}),
);
@@ -843,13 +1044,17 @@ export function createStoryNpcEncounterActions({
options: [],
streaming: true,
turnCount: nextTurnCount,
chatDirective: limitedChatDirective,
}),
);
},
questOfferContext: {
state: gameState,
turnCount: nextTurnCount,
},
questOfferContext: limitedChatDirective
? null
: {
state: gameState,
turnCount: nextTurnCount,
},
chatDirective: limitedChatDirective,
},
);
@@ -912,8 +1117,45 @@ export function createStoryNpcEncounterActions({
const pendingQuest =
(chatTurn.pendingQuestOffer?.quest as QuestLogEntry | undefined) ??
null;
const resolvedChatDirective = limitedChatDirective
? {
sceneActId: limitedChatDirective.sceneActId ?? null,
turnLimit:
chatTurn.chatDirective?.turnLimit ??
limitedChatDirective.turnLimit ??
null,
remainingTurns:
chatTurn.chatDirective?.remainingTurns ??
limitedChatDirective.remainingTurns ??
null,
limitReason: limitedChatDirective.limitReason ?? null,
forceExitAfterTurn:
chatTurn.chatDirective?.forceExit ??
limitedChatDirective.forceExitAfterTurn ??
false,
}
: null;
const shouldForceExitAfterTurn =
resolvedChatDirective?.forceExitAfterTurn === true;
const pendingQuestIntroText =
chatTurn.pendingQuestOffer?.introText?.trim() || '';
if (shouldForceExitAfterTurn) {
const closingDialogue = [
...nextDialogue,
{
speaker: 'system' as const,
text: '这轮交谈先在这里收束,对方留下的线索把你推向了下一步。',
},
];
setCurrentStory({
text: closingDialogue.map((turn) => turn.text).join('\n'),
options: [buildContinueAdventureOption()],
displayMode: 'dialogue',
dialogue: closingDialogue,
streaming: false,
});
return true;
}
if (pendingQuest) {
setCurrentStory(
buildNpcChatStoryMoment({
@@ -931,6 +1173,7 @@ export function createStoryNpcEncounterActions({
options: buildPendingQuestOfferOptions(encounter),
streaming: false,
turnCount: nextTurnCount,
chatDirective: resolvedChatDirective,
pendingQuestOffer: {
quest: pendingQuest,
},
@@ -951,6 +1194,7 @@ export function createStoryNpcEncounterActions({
),
streaming: false,
turnCount: nextTurnCount,
chatDirective: resolvedChatDirective,
}),
);
return true;
@@ -967,6 +1211,7 @@ export function createStoryNpcEncounterActions({
),
streaming: false,
turnCount: nextTurnCount,
chatDirective: limitedChatDirective,
}),
);
return false;
@@ -1041,7 +1286,14 @@ export function createStoryNpcEncounterActions({
setGameState(nextState);
setAiError(null);
if (npcState.affinity < 0 || encounter.hostile) {
const limitedChatDirective = resolveLimitedPrimaryNpcChatState({
state: nextState,
npcId: encounter.id ?? encounter.npcName,
affinity: npcState.affinity,
nextTurnCount: 0,
});
if ((npcState.affinity < 0 || encounter.hostile) && !limitedChatDirective) {
setCurrentStory(
buildHostileNpcStoryMoment(
encounter,
@@ -1079,7 +1331,22 @@ export function createStoryNpcEncounterActions({
},
} satisfies StoryOption);
return enterNpcChat(encounter, seedChatOption, chatOptions.slice(1));
if (!currentStory?.npcChatState && !npcState.firstMeaningfulContactResolved) {
void startNpcInitiatedOpening(
encounter,
seedChatOption,
chatOptions.slice(1),
limitedChatDirective,
);
return true;
}
return enterNpcChat(
encounter,
seedChatOption,
chatOptions.slice(1),
limitedChatDirective,
);
};
const resolveServerNpcStoryAction = async (params: {

View File

@@ -66,6 +66,7 @@ import {
import { appendChronicleEntries } from '../../services/storyEngine/storyChronicle';
import { buildThemePackFromWorldProfile } from '../../services/storyEngine/themePack';
import { buildThreadContractsFromProfile } from '../../services/storyEngine/threadContract';
import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime';
import {
collectStorySignals,
resolveSignalsToThreadUpdates,
@@ -216,6 +217,12 @@ function ensureSceneChapterQuestState(params: {
storyEngineMemory: {
...storyEngineMemory,
openedSceneChapterIds,
currentSceneActState:
buildInitialSceneActRuntimeState({
profile: params.nextState.customWorldProfile,
sceneId: scene.id,
storyEngineMemory,
}) ?? storyEngineMemory.currentSceneActState ?? null,
},
};
}
@@ -223,6 +230,12 @@ function ensureSceneChapterQuestState(params: {
const nextMemory = {
...storyEngineMemory,
openedSceneChapterIds: [...openedSceneChapterIds, scene.id],
currentSceneActState:
buildInitialSceneActRuntimeState({
profile: params.nextState.customWorldProfile,
sceneId: scene.id,
storyEngineMemory,
}) ?? storyEngineMemory.currentSceneActState ?? null,
};
const existingChapterQuest = getChapterQuestForScene(
params.nextState.quests,

View File

@@ -73,6 +73,7 @@ function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
return {
id: 'camp-companion',
kind: 'npc',
characterId: 'sword-princess',
npcName: '沈砺',
npcDescription: '正靠在营地灯火旁观察风向。',
npcAvatar: '/npc.png',
@@ -152,9 +153,11 @@ describe('storyCampCompanion', () => {
WorldType.WUXIA,
);
expect(text).toContain('先和你打个招呼。');
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', () => {
@@ -168,7 +171,7 @@ describe('storyCampCompanion', () => {
expect(text).toContain('眼下的风向不对');
});
it('keeps chat and recruit options while appending the travel action for camp openings', () => {
it('keeps the opening camp options focused on继续交谈', () => {
const buildNpcStory = vi.fn(() =>
createStory('营地开场', [
createOption('npc_chat', '继续交谈'),
@@ -190,11 +193,7 @@ describe('storyCampCompanion', () => {
createEncounter(),
);
expect(options.map((option) => option.functionId)).toEqual([
'npc_chat',
'npc_recruit',
'camp_travel_home_scene',
]);
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 () => {

View File

@@ -7,9 +7,11 @@ import {
NPC_CHAT_FUNCTION,
NPC_FIGHT_FUNCTION,
NPC_LEAVE_FUNCTION,
NPC_RECRUIT_FUNCTION,
} from '../../data/functionCatalog';
import { buildInitialNpcState } from '../../data/npcInteractions';
import {
buildInitialNpcState,
buildNpcChatOpeningText,
} from '../../data/npcInteractions';
import {
getForwardScenePreset,
getScenePresetById,
@@ -57,15 +59,20 @@ export function buildInitialCompanionDialogueText(
encounter: Encounter,
worldType: WorldType | null,
) {
const opening = getCharacterAdventureOpening(character, worldType);
const surfaceHook =
opening?.surfaceHook ?? '这个地方与我来此的目的息息相关。';
const immediateConcern =
opening?.immediateConcern ?? '前方似乎有些不对劲,我们不能贸然前进。';
const guardedMotive =
opening?.guardedMotive ?? '我并非偶然来到这里,但我还不准备全盘托出。';
return `${encounter.npcName}看着你,先压低声音开口:“${immediateConcern}${guardedMotive}`;
const resolvedEncounter =
encounter.characterId === character.id
? encounter
: {
...encounter,
characterId: encounter.characterId ?? character.id,
};
const initialNpcState = buildInitialNpcState(resolvedEncounter, worldType);
return buildNpcChatOpeningText(
resolvedEncounter,
initialNpcState,
worldType,
character,
);
}
export function buildCampCompanionOpeningResultText(