1
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -198,6 +198,9 @@ export function createStoryChoiceActions({
|
||||
currentScenePreset:
|
||||
currentStory.deferredRuntimeState.currentScenePreset ??
|
||||
gameState.currentScenePreset,
|
||||
storyEngineMemory:
|
||||
currentStory.deferredRuntimeState.storyEngineMemory ??
|
||||
gameState.storyEngineMemory,
|
||||
});
|
||||
}
|
||||
setCurrentStory({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user