This commit is contained in:
2026-04-30 17:49:07 +08:00
parent 805d6f8cae
commit 9d684cb7b3
615 changed files with 15368 additions and 6172 deletions

View File

@@ -315,6 +315,20 @@ describe('createStoryChoiceActions', () => {
treasureHints: [],
npcs: [],
},
storyEngineMemory: {
discoveredFactIds: [],
activeThreadIds: [],
resolvedScarIds: [],
recentCarrierIds: [],
currentSceneActState: {
sceneId: 'scene-bridge',
chapterId: 'chapter-bridge',
currentActId: 'act-2',
currentActIndex: 1,
completedActIds: ['act-1'],
visitedActIds: ['act-1', 'act-2'],
},
},
},
};
const setCurrentStory = vi.fn();
@@ -369,11 +383,13 @@ describe('createStoryChoiceActions', () => {
currentScenePreset: expect.objectContaining({
id: 'scene-bridge',
}),
storyEngineMemory: expect.objectContaining({
currentSceneActState: expect.objectContaining({
currentActId: 'act-2',
}),
}),
}),
);
expect(setGameState.mock.calls[0]?.[0]).not.toHaveProperty(
'storyEngineMemory',
);
expect(setCurrentStory).toHaveBeenCalledWith({
...currentStory,
options: deferredOptions,

View File

@@ -198,6 +198,9 @@ export function createStoryChoiceActions({
currentScenePreset:
currentStory.deferredRuntimeState.currentScenePreset ??
gameState.currentScenePreset,
storyEngineMemory:
currentStory.deferredRuntimeState.storyEngineMemory ??
gameState.storyEngineMemory,
});
}
setCurrentStory({

View File

@@ -175,7 +175,7 @@ function createState(overrides: Partial<GameState> = {}): GameState {
function createSceneActProfile(
primaryNpcId = 'npc-rival',
actCount = 1,
actCount = 2,
): NonNullable<GameState['customWorldProfile']> {
const acts = Array.from({ length: actCount }, (_, index) => ({
id: `scene-bridge-act-${index + 1}`,
@@ -847,6 +847,79 @@ describe('npcEncounterActions', () => {
]);
});
it('streams a model-driven npc-initiated opening after first contact was resolved', 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: 1,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: true,
},
},
}),
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(),
[],
'',
expect.objectContaining({
affinity: 8,
chattedCount: 1,
}),
expect.objectContaining({
npcInitiatesConversation: true,
}),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.dialogue).toEqual([
{
speaker: 'npc',
speakerName: '断桥客',
text: '又见面了。桥口的风比刚才更乱,我先把你漏掉的那句话补上。',
},
]);
expect(lastStory.npcChatState).toMatchObject({
npcId: 'npc-rival',
openingSource: 'npc_initiated',
turnCount: 0,
});
expect(lastStory.options).toEqual([
expect.objectContaining({
functionId: 'npc_chat',
actionText: '你先说我漏掉了什么',
}),
]);
});
it('removes any prefilled local opening line before the first model-driven npc reply', async () => {
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 1,
@@ -960,6 +1033,7 @@ describe('npcEncounterActions', () => {
it('sends a closing chat turn after exiting npc chat and keeps the dialogue panel until continue', async () => {
const gameState = createState({
customWorldProfile: createSceneActProfile(),
storyHistory: [
{
text: '你先试探了对方的态度。',
@@ -1026,11 +1100,17 @@ describe('npcEncounterActions', () => {
forceExitAfterTurn: true,
functionOptions: expect.arrayContaining([
expect.objectContaining({ functionId: 'npc_help' }),
expect.objectContaining({ functionId: 'npc_fight' }),
]),
}),
}),
);
const exitChatDirective = streamNpcChatTurnMock.mock.calls.at(-1)?.[9]
?.chatDirective;
expect(exitChatDirective.functionOptions).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ functionId: 'npc_fight' }),
]),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState).toBeUndefined();
@@ -1051,11 +1131,35 @@ describe('npcEncounterActions', () => {
functionId: 'story_continue_adventure',
}),
]);
expect(
lastStory.deferredRuntimeState?.storyEngineMemory?.currentSceneActState,
).toEqual(
expect.objectContaining({
currentActId: 'scene-bridge-act-2',
completedActIds: expect.arrayContaining(['scene-bridge-act-1']),
}),
);
expect(lastStory.deferredOptions).toEqual([
expect.objectContaining({
functionId: 'npc_chat',
interaction: expect.objectContaining({
npcId: 'npc-rival',
action: 'chat',
}),
}),
expect.objectContaining({
functionId: 'npc_chat',
interaction: expect.objectContaining({
npcId: 'npc-rival',
action: 'chat',
}),
}),
]);
expect(actions.setIsLoading).toHaveBeenNthCalledWith(1, true);
expect(actions.setIsLoading).toHaveBeenLastCalledWith(false);
});
it('feeds current story non-chat function options into npc chat context', async () => {
it('feeds allowed positive-affinity function options into npc chat context', async () => {
const gameState = createState({
storyHistory: [
{
@@ -1149,14 +1253,17 @@ describe('npcEncounterActions', () => {
functionId: 'npc_help',
actionText: '借你的人脉把线索铺开',
}),
expect.objectContaining({
functionId: 'npc_fight',
actionText: '现在就把这笔旧账打清',
}),
]),
}),
}),
);
const chatDirective = streamNpcChatTurnMock.mock.calls.at(-1)?.[9]
?.chatDirective;
expect(chatDirective.functionOptions).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ functionId: 'npc_fight' }),
]),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.options).toEqual(
@@ -1235,6 +1342,163 @@ describe('npcEncounterActions', () => {
);
});
it('keeps negative-affinity chat function context empty so only chat choices appear mid-dialogue', async () => {
const gameState = createState({
customWorldProfile: createSceneActProfile(),
npcStates: {
'npc-rival': {
affinity: -8,
helpUsed: false,
chattedCount: 1,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: true,
},
},
});
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '关系暂未变化',
npcReply: '你只剩这一句话的机会。',
suggestions: ['我只问最后一句'],
functionSuggestions: [
{
functionId: 'npc_help',
actionText: '让你帮我一次',
},
],
});
const actions = createNpcEncounterActions({
gameState,
currentStory: {
text: '断桥客没有放下戒备。',
displayMode: 'dialogue',
dialogue: [
{ speaker: 'npc', speakerName: '断桥客', text: '别再靠近。' },
],
options: [
createOption('npc_chat', '先问清最后一句', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_help', '请求援手', {
kind: 'npc',
npcId: 'npc-rival',
action: 'help',
}),
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 1,
customInputPlaceholder: '输入你想对 TA 说的话',
},
},
getAvailableOptionsForState: vi.fn(() => [
createOption('npc_chat', '先问清最后一句', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_help', '请求援手', {
kind: 'npc',
npcId: 'npc-rival',
action: 'help',
}),
]),
});
await expect(
actions.handleNpcChatTurn(createEncounter(), '我只问最后一句。'),
).resolves.toBe(true);
const chatDirective = streamNpcChatTurnMock.mock.calls.at(-1)?.[9]
?.chatDirective;
expect(chatDirective.functionOptions).toEqual([]);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.options).toEqual([
expect.objectContaining({
functionId: 'npc_chat',
actionText: '我只问最后一句',
}),
]);
});
it('sends hostile termination mode for any negative-affinity npc chat turn', async () => {
const gameState = createState({
customWorldProfile: null,
npcStates: {
'npc-rival': {
affinity: -8,
helpUsed: false,
chattedCount: 1,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: true,
},
},
});
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '关系暂未变化',
npcReply: '话到这里就够了。',
suggestions: [],
chatDirective: {
forceExit: true,
terminationReason: 'hostile_breakoff',
},
});
const actions = createNpcEncounterActions({
gameState,
currentStory: {
text: '断桥客没有放下戒备。',
displayMode: 'dialogue',
dialogue: [
{ speaker: 'npc', speakerName: '断桥客', text: '别再靠近。' },
],
options: [
createOption('npc_chat', '先问清最后一句', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 1,
customInputPlaceholder: '输入你想对 TA 说的话',
},
},
});
await expect(
actions.handleNpcChatTurn(createEncounter(), '我只问最后一句。'),
).resolves.toBe(true);
const chatDirective = streamNpcChatTurnMock.mock.calls.at(-1)?.[9]
?.chatDirective;
expect(chatDirective).toMatchObject({
turnLimit: null,
remainingTurns: null,
limitReason: 'negative_affinity',
terminationMode: 'hostile_model',
isHostileChat: true,
functionOptions: [],
});
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState).toBeUndefined();
expect(lastStory.options).toEqual([
expect.objectContaining({ functionId: 'npc_fight' }),
expect.objectContaining({ functionId: 'battle_escape_breakout' }),
expect.objectContaining({ functionId: 'battle_escape_breakout' }),
expect.objectContaining({ functionId: 'battle_escape_breakout' }),
]);
});
it('lets the current act primary npc enter limited chat even with negative affinity', () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
@@ -1287,7 +1551,7 @@ describe('npcEncounterActions', () => {
).toBe(false);
});
it('streams npc-initiated opening when negative affinity chat starts from interaction options', async () => {
it('streams npc-initiated opening when resolved negative affinity chat starts from interaction options', async () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
@@ -1308,7 +1572,7 @@ describe('npcEncounterActions', () => {
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: false,
firstMeaningfulContactResolved: true,
},
},
}),
@@ -1375,6 +1639,86 @@ describe('npcEncounterActions', () => {
]);
});
it('lets the model terminate a hostile npc-initiated opening immediately', async () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
npcReply: '离桥口远一点。再往前,我就不听你解释了。',
suggestions: [],
functionSuggestions: [],
chatDirective: {
forceExit: true,
closingMode: 'foreshadow_close',
terminationReason: 'hostile_breakoff',
},
});
const actions = createNpcEncounterActions({
gameState: createState({
currentEncounter: encounter,
customWorldProfile: createSceneActProfile(),
npcInteractionActive: true,
npcStates: {
'npc-rival': {
affinity: -8,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: false,
},
},
}),
currentStory: {
text: '断桥客仍挡在桥口。',
options: [
createOption('npc_chat', '先问问你为什么堵在这里', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
],
},
});
expect(
actions.handleNpcInteraction(
createOption('npc_chat', '先问问你为什么堵在这里', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
),
).toBe(true);
await flushAsyncWork();
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState).toBeUndefined();
expect(lastStory.dialogue).toEqual([
{
speaker: 'npc',
speakerName: '断桥客',
text: '离桥口远一点。再往前,我就不听你解释了。',
},
]);
expect(lastStory.options).toEqual([
expect.objectContaining({
functionId: 'npc_fight',
actionText: '战斗',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
}),
expect.objectContaining({
functionId: 'battle_escape_breakout',
}),
]);
});
it('lets player exit hostile chat and offers fight plus scene escape routes instead of continuing adventure', async () => {
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,

View File

@@ -17,14 +17,18 @@ import {
applyQuestProgressFromSpar,
} from '../../data/questFlow';
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
import { getScenePresetById } from '../../data/scenePresets';
import { resolveFunctionOption } from '../../data/stateFunctions';
import { streamNpcChatTurn } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import {
advanceSceneActRuntimeState,
getSceneConnectionDirectionText,
resolveSceneActProgression,
resolveLimitedPrimaryNpcChatState,
} from '../../services/customWorldSceneActRuntime';
import { normalizeStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import type {
Character,
Encounter,
@@ -389,6 +393,9 @@ export function createStoryNpcEncounterActions({
}),
params.encounter,
playerCharacter,
{
sourceState: params.nextState,
},
);
setCurrentStory(
@@ -744,12 +751,32 @@ export function createStoryNpcEncounterActions({
const buildNpcChatFunctionOptionCatalog = (
encounter: Encounter,
playerCharacter: Character,
) =>
buildPostNpcChatOptionCatalog(encounter, playerCharacter)
sourceState: GameState = gameState,
) => {
const npcState = getResolvedNpcState(sourceState, encounter);
if (npcState.affinity < 0) {
return [];
}
const allowedFunctionIds = new Set([
'npc_help',
'npc_trade',
'npc_gift',
'npc_quest_accept',
'npc_quest_turn_in',
'npc_recruit',
'npc_chat_quest_offer_view',
'npc_chat_quest_offer_replace',
'npc_chat_quest_offer_abandon',
]);
return buildPostNpcChatOptionCatalog(encounter, playerCharacter)
.filter((option) => option.functionId !== 'battle_escape_breakout')
.filter((option) => !isNpcChatOptionForEncounter(option, encounter))
.filter((option) => option.interaction?.kind === 'npc')
.filter((option) => allowedFunctionIds.has(option.functionId))
.map(cloneNpcChatFunctionOption);
};
const toNpcChatDirectiveWithFunctionOptions = (
directive: NpcChatDirective,
@@ -757,11 +784,15 @@ export function createStoryNpcEncounterActions({
playerCharacter: Character,
options?: {
forcePlayerExit?: boolean;
sourceState?: GameState;
},
): NpcChatDirective => {
const sourceState = options?.sourceState ?? gameState;
const npcState = getResolvedNpcState(sourceState, encounter);
const functionOptions = buildNpcChatFunctionOptionCatalog(
encounter,
playerCharacter,
sourceState,
).map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
@@ -769,12 +800,22 @@ export function createStoryNpcEncounterActions({
action:
option.interaction?.kind === 'npc' ? option.interaction.action : null,
}));
const isHostileChat =
// 中文注释:只要当前 NPC 仍是负好感,本轮聊天就必须交给模型判断是否主动中止,不能只依赖场景幕 directive。
const shouldForceHostileModelChat =
npcState.affinity < 0 ||
directive?.limitReason === 'negative_affinity' ||
directive?.isHostileChat === true ||
directive?.terminationMode === 'hostile_model';
const isHostileChat =
shouldForceHostileModelChat || encounter.hostile === true;
return {
...(directive ?? {}),
turnLimit: directive?.turnLimit ?? null,
remainingTurns: directive?.remainingTurns ?? null,
limitReason:
directive?.limitReason ??
(npcState.affinity < 0 ? 'negative_affinity' : null),
terminationMode: isHostileChat ? 'hostile_model' : 'none',
isHostileChat,
terminationReason: options?.forcePlayerExit
@@ -988,14 +1029,57 @@ export function createStoryNpcEncounterActions({
encounter: Encounter,
playerCharacter: Character,
) => {
const travelOptions = buildSceneConnectionTravelOptions(gameState);
const sceneActProgression = resolveSceneActProgression({
profile: gameState.customWorldProfile,
sceneId: gameState.currentScenePreset?.id ?? null,
storyEngineMemory: gameState.storyEngineMemory,
});
const nextSceneActState = sceneActProgression
? advanceSceneActRuntimeState({
progress: sceneActProgression,
})
: null;
if (nextSceneActState) {
const nextStoryEngineMemory = {
...normalizeStoryEngineMemoryState(gameState.storyEngineMemory),
currentSceneActState: nextSceneActState,
};
const nextState: GameState = {
...ensureSceneEncounterPreview({
...gameState,
storyEngineMemory: nextStoryEngineMemory,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
inBattle: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
}),
};
const nextOptions =
getAvailableOptionsForState(nextState, playerCharacter) ??
buildSceneConnectionTravelOptions(nextState);
const nextActEntryOptions = nextOptions.filter(
(option) =>
option.functionId === 'npc_preview_talk' ||
option.functionId === 'npc_chat',
);
return {
deferredRuntimeState: {
currentScenePreset: gameState.currentScenePreset,
storyEngineMemory: nextStoryEngineMemory,
},
options:
nextActEntryOptions.length > 0 ? nextActEntryOptions : nextOptions,
};
}
return {
deferredRuntimeState: null,
options:
travelOptions.length > 0
? travelOptions
: buildPostNpcChatOptionCatalog(encounter, playerCharacter),
options: buildSceneConnectionTravelOptions(gameState),
};
};
@@ -1045,23 +1129,6 @@ export function createStoryNpcEncounterActions({
)
: [];
const buildHostileNpcDeclarationText = (
encounter: Encounter,
affinity: number,
) => {
const hostilityText =
affinity <= -20
? '旧账就留到今天一起清。'
: affinity <= -10
? '我们之间已经没什么可谈的了。'
: '你再往前一步,我就当你是在挑衅。';
const contextText = encounter.context?.trim()
? `你居然还敢带着${encounter.context}的事来见我,`
: '';
return `${contextText}${hostilityText} 要么现在转身逃开,要么就拔刀。`;
};
const buildHostileNpcEscapeOption = (
character: Character,
actionText = '逃跑',
@@ -1178,31 +1245,6 @@ export function createStoryNpcEncounterActions({
},
});
const buildHostileNpcStoryMoment = (
encounter: Encounter,
character: Character,
affinity: number,
): StoryMoment => {
const declarationText = buildHostileNpcDeclarationText(encounter, affinity);
return {
text: declarationText,
options: [
buildHostileNpcFightOption(encounter),
...buildHostileNpcEscapeOptions(character),
],
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: encounter.npcName,
text: declarationText,
},
],
streaming: false,
};
};
const shouldUseHostileNpcChatClosureOptions = (
directive: NpcChatDirective,
affinity: number,
@@ -1369,17 +1411,62 @@ export function createStoryNpcEncounterActions({
throw new Error('NPC 主动开场结果为空');
}
const resolvedOpeningDirective = {
sceneActId: resolvedChatDirective?.sceneActId ?? null,
turnLimit:
chatTurn.chatDirective?.turnLimit ??
resolvedChatDirective?.turnLimit ??
null,
remainingTurns:
chatTurn.chatDirective?.remainingTurns ??
resolvedChatDirective?.remainingTurns ??
null,
limitReason: resolvedChatDirective?.limitReason ?? null,
terminationMode: resolvedChatDirective?.terminationMode ?? null,
terminationReason:
chatTurn.chatDirective?.terminationReason ??
resolvedChatDirective?.terminationReason ??
null,
isHostileChat: resolvedChatDirective?.isHostileChat ?? false,
closingMode:
chatTurn.chatDirective?.closingMode ??
resolvedChatDirective?.closingMode ??
'free',
forceExitAfterTurn:
chatTurn.chatDirective?.forceExit ??
resolvedChatDirective?.forceExitAfterTurn ??
false,
} satisfies NonNullable<NpcChatDirective>;
const openingDialogue = [
...existingDialogue,
{
speaker: 'npc' as const,
speakerName: encounter.npcName,
text: chatTurn.npcReply,
},
];
if (resolvedOpeningDirective.forceExitAfterTurn) {
setCurrentStory({
text: openingDialogue.map((turn) => turn.text).join('\n'),
options: buildNpcChatClosureOptions(
encounter,
playerCharacter,
resolvedOpeningDirective,
npcState.affinity,
),
displayMode: 'dialogue',
dialogue: openingDialogue,
streaming: false,
deferredOptions: undefined,
deferredRuntimeState: undefined,
});
return true;
}
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: [
...existingDialogue,
{
speaker: 'npc',
speakerName: encounter.npcName,
text: chatTurn.npcReply,
},
],
dialogue: openingDialogue,
options: buildNpcChatMixedTurnOptions(
encounter,
playerCharacter,
@@ -1390,7 +1477,7 @@ export function createStoryNpcEncounterActions({
),
streaming: false,
turnCount: 0,
chatDirective: resolvedChatDirective,
chatDirective: resolvedOpeningDirective,
openingSource: 'npc_initiated',
}),
);
@@ -1731,6 +1818,15 @@ export function createStoryNpcEncounterActions({
);
const nextState = {
...gameState,
...(progressionResult.deferredRuntimeState?.storyEngineMemory
? {
storyEngineMemory:
progressionResult.deferredRuntimeState.storyEngineMemory,
}
: {}),
currentScenePreset:
progressionResult.deferredRuntimeState?.currentScenePreset ??
gameState.currentScenePreset,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
@@ -1738,8 +1834,6 @@ export function createStoryNpcEncounterActions({
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
currentScenePreset: gameState.currentScenePreset,
storyEngineMemory: gameState.storyEngineMemory,
};
setGameState(nextState);
@@ -1812,36 +1906,14 @@ export function createStoryNpcEncounterActions({
},
} satisfies StoryOption);
if (
!currentStory?.npcChatState &&
!npcState.firstMeaningfulContactResolved
) {
void startNpcInitiatedOpening(
encounter,
seedChatOption,
chatOptions.slice(1),
limitedChatDirective,
);
return true;
}
if ((npcState.affinity < 0 || encounter.hostile) && !limitedChatDirective) {
setCurrentStory(
buildHostileNpcStoryMoment(
encounter,
playerCharacter,
npcState.affinity,
),
);
return true;
}
return enterNpcChat(
// 中文注释:每次从 NPC 入口新开聊天,都必须由模型生成 NPC 首句;首遇标记只用于关系结算,不再决定谁先开口。
void startNpcInitiatedOpening(
encounter,
seedChatOption,
chatOptions.slice(1),
limitedChatDirective,
);
return true;
};
const resolveServerNpcStoryAction = async (params: {
@@ -2092,17 +2164,14 @@ export function createStoryNpcEncounterActions({
nextTurnCount: 0,
});
if (!npcState.firstMeaningfulContactResolved) {
void startNpcInitiatedOpening(
encounter,
resolvedOption,
[],
limitedChatDirective,
);
return true;
}
return enterNpcChat(encounter, resolvedOption);
// 中文注释:不在已有聊天里时,点击聊天入口也重新走 NPC 模型首句,避免回到玩家先选话题的旧分支。
void startNpcInitiatedOpening(
encounter,
resolvedOption,
[],
limitedChatDirective,
);
return true;
}
case 'quest_accept': {
void resolveServerNpcStoryAction({