@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user