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

This commit is contained in:
2026-04-20 21:06:48 +08:00
parent 1c72066bab
commit 75944b1f1f
102 changed files with 9648 additions and 1540 deletions

View File

@@ -221,6 +221,7 @@ describe('createStoryChoiceActions', () => {
(inputState: GameState) => inputState.sceneHostileNpcs,
),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
@@ -296,6 +297,7 @@ describe('createStoryChoiceActions', () => {
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
@@ -385,6 +387,7 @@ describe('createStoryChoiceActions', () => {
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
@@ -410,8 +413,20 @@ describe('createStoryChoiceActions', () => {
expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
});
it('keeps the finishing action in history before npc victory follow-up generation', async () => {
const state = createBaseState();
it('reopens npc chat instead of running generic follow-up after local npc victory', async () => {
const encounter: Encounter = {
id: 'npc-opponent',
kind: 'npc',
npcName: '山道客',
npcDescription: '拦路旧敌',
npcAvatar: '/npc.png',
context: '山道旧案',
};
const state = {
...createBaseState(),
currentEncounter: encounter,
npcInteractionActive: true,
};
const option = createBattleOption();
const afterSequence = {
...state,
@@ -422,6 +437,7 @@ describe('createStoryChoiceActions', () => {
const generateStoryForState = vi.fn().mockResolvedValue(createFallbackStory('战后续写'));
const setCurrentStory = vi.fn();
const setGameState = vi.fn();
const handleNpcBattleConversationContinuation = vi.fn(() => true);
const { handleChoice } = createStoryChoiceActions({
gameState: state,
@@ -456,6 +472,7 @@ describe('createStoryChoiceActions', () => {
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation,
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
@@ -484,15 +501,23 @@ describe('createStoryChoiceActions', () => {
await handleChoice(option);
expect(generateStoryForState).toHaveBeenCalledTimes(1);
const [{ history }] = generateStoryForState.mock.calls[0] as [
{ history: StoryMoment[] },
];
expect(history.map((entry) => `${entry.historyRole}:${entry.text}`)).toEqual([
'action:挥刀抢攻',
'result:山道客已经败下阵来。胜利奖励:无战利品。',
]);
expect(setCurrentStory).toHaveBeenCalledWith(createFallbackStory('战后续写'));
expect(handleNpcBattleConversationContinuation).toHaveBeenCalledWith(
expect.objectContaining({
nextState: expect.objectContaining({
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
}),
encounter,
actionText: '挥刀抢攻',
resultText: '山道客已经败下阵来。胜利奖励:无战利品。',
battleMode: 'fight',
}),
);
expect(generateStoryForState).not.toHaveBeenCalled();
expect(setCurrentStory).not.toHaveBeenCalledWith(
createFallbackStory('战后续写'),
);
});
it('injects an escape resolution into the immediate story context before ai continuation', async () => {
@@ -568,6 +593,7 @@ describe('createStoryChoiceActions', () => {
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats,
getCampCompanionTravelScene: vi.fn(() => null),

View File

@@ -49,6 +49,15 @@ type BuildNpcStory = (
overrideText?: string,
) => StoryMoment;
type HandleNpcBattleConversationContinuation = (params: {
nextState: GameState;
encounter: Encounter;
character: Character;
actionText: string;
resultText: string;
battleMode: NonNullable<GameState['currentNpcBattleMode']>;
}) => boolean;
type BuildStoryContextFromState = (
state: GameState,
extras?: {
@@ -87,6 +96,7 @@ export function createStoryChoiceActions({
getStoryGenerationHostileNpcs,
getResolvedSceneHostileNpcs,
buildNpcStory,
handleNpcBattleConversationContinuation,
updateQuestLog,
incrementRuntimeStats,
getCampCompanionTravelScene,
@@ -127,6 +137,7 @@ export function createStoryChoiceActions({
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
buildNpcStory: BuildNpcStory;
handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation;
updateQuestLog: UpdateQuestLog;
incrementRuntimeStats: IncrementRuntimeStats;
getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
@@ -247,6 +258,7 @@ export function createStoryChoiceActions({
getStoryGenerationHostileNpcs,
getResolvedSceneHostileNpcs,
buildNpcStory,
handleNpcBattleConversationContinuation,
updateQuestLog,
incrementRuntimeStats,
finalizeNpcBattleResult,

View File

@@ -390,6 +390,13 @@ function createAcceptedPendingQuestStory(
};
}
function createFallbackStory(text = 'fallback'): StoryMoment {
return {
text,
options: [],
};
}
type GenerateStoryForStateTestDouble = (params: {
state: GameState;
character: Character;
@@ -407,6 +414,12 @@ function createNpcEncounterActions(overrides: {
state: GameState,
character: Character,
) => StoryOption[] | null;
buildNpcStory?: (
state: GameState,
character: Character,
encounter: Encounter,
overrideText?: string,
) => StoryMoment;
}) {
const gameState = overrides.gameState ?? createState();
const currentStory = overrides.currentStory ?? createCurrentChatStory();
@@ -437,6 +450,33 @@ function createNpcEncounterActions(overrides: {
historyRole: 'result' as const,
},
]),
buildNpcStory:
overrides.buildNpcStory ??
vi.fn(
(
_state: GameState,
_character: Character,
encounter: Encounter,
overrideText?: string,
) => ({
text:
overrideText ??
`${encounter.npcName}还在盯着你,像是在等你继续把话说下去。`,
options: [
createOption('npc_chat', '先把刚才那一刀说清楚', {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'chat',
}),
createOption('npc_chat', '你刚才为什么会收手', {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'chat',
}),
],
displayMode: 'dialogue',
}),
),
buildOpeningCampChatContext: vi.fn(() => ({})),
buildStoryContextFromState: vi.fn(() => ({
playerHp: gameState.playerHp,
@@ -707,16 +747,17 @@ describe('npcEncounterActions', () => {
text: '先别急着拔话头。桥上的风向刚变,我得先确认你是来问旧账,还是来救人。',
},
]);
expect(lastStory.npcChatState).toMatchObject({
npcId: 'npc-rival',
openingSource: 'npc_initiated',
turnCount: 0,
});
expect(lastStory.options.map((option) => option.actionText)).toEqual([
'我先听你说桥上出了什么事',
'你先说你在防谁',
'我不是来翻旧账的',
]);
expect(lastStory.npcChatState).toMatchObject({
npcId: 'npc-rival',
openingSource: 'npc_initiated',
turnCount: 0,
});
expect(lastStory.npcAffinityEffect).toBeNull();
expect(lastStory.options.map((option) => option.actionText)).toEqual([
'我先听你说桥上出了什么事',
'你先说你在防谁',
'我不是来翻旧账的',
]);
});
it('removes any prefilled local opening line before the first model-driven npc reply', async () => {
@@ -777,6 +818,11 @@ describe('npcEncounterActions', () => {
turn.text.includes('先和你打个招呼。前面的风不太对。'),
),
).toBe(false);
expect(lastStory.npcAffinityEffect).toEqual({
eventId: expect.stringContaining('npc-chat-affinity-npc-rival-'),
npcId: 'npc-rival',
delta: 1,
});
});
it('passes the quest id through to the server runtime resolver for quest turn-in', async () => {
@@ -920,6 +966,94 @@ describe('npcEncounterActions', () => {
expect(actions.setIsLoading).toHaveBeenLastCalledWith(false);
});
it('prefers the current story non-chat options when rebuilding options after exiting npc chat', async () => {
const gameState = createState({
storyHistory: [
{
text: '你先试探了对方的态度。',
options: [],
historyRole: 'action',
},
],
});
const generateStoryForState = vi.fn().mockResolvedValue({
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
options: [createOption('idle_observe_signs', '观察周围动静')],
});
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',
}),
createOption('npc_fight', '现在就把这笔旧账打清', {
kind: 'npc',
npcId: 'npc-rival',
action: 'fight',
}),
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 2,
customInputPlaceholder: '输入你想对 TA 说的话',
},
},
generateStoryForState,
getAvailableOptionsForState: vi.fn(() => [
createOption('npc_chat', '先问问你为什么堵在这里', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_help', '请求援手', {
kind: 'npc',
npcId: 'npc-rival',
action: 'help',
}),
createOption('npc_fight', '直接动手', {
kind: 'npc',
npcId: 'npc-rival',
action: 'fight',
}),
]),
});
expect(actions.exitNpcChat()).toBe(true);
await flushAsyncWork();
const [{ optionCatalog }] = generateStoryForState.mock.calls[0] as [
{ optionCatalog: StoryOption[] },
];
expect(optionCatalog).toEqual([
expect.objectContaining({
functionId: 'npc_chat',
actionText: '先问问你为什么堵在这里',
}),
expect.objectContaining({
functionId: 'npc_help',
actionText: '借你的人脉把线索铺开',
}),
expect.objectContaining({
functionId: 'npc_fight',
actionText: '现在就把这笔旧账打清',
}),
]);
});
it('opens hostile npc encounters as a declaration dialogue with only escape and fight options', () => {
const encounter = createEncounter();
const actions = createNpcEncounterActions({
@@ -1095,6 +1229,105 @@ describe('npcEncounterActions', () => {
);
});
it('reopens npc chat after battle victory with combat context and preserved negative affinity limit', () => {
const actions = createNpcEncounterActions({
gameState: createState({
customWorldProfile: createSceneActProfile(),
currentEncounter: createEncounter(),
npcInteractionActive: true,
npcStates: {
'npc-rival': {
affinity: -12,
helpUsed: false,
chattedCount: 2,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: true,
},
},
storyHistory: [
{
historyRole: 'action',
text: '你挥刀抢攻,逼住了断桥客的退路。',
options: [],
},
{
historyRole: 'result',
text: '断桥客被逼到桥栏边,刀势已经散了。',
options: [],
},
],
}),
currentStory: createFallbackStory(),
});
const reopened = actions.reopenNpcChatAfterBattle({
nextState: createState({
customWorldProfile: createSceneActProfile(),
currentEncounter: createEncounter(),
npcInteractionActive: true,
npcStates: {
'npc-rival': {
affinity: -12,
helpUsed: false,
chattedCount: 2,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: true,
},
},
storyHistory: [
{
historyRole: 'action',
text: '你挥刀抢攻,逼住了断桥客的退路。',
options: [],
},
{
historyRole: 'result',
text: '断桥客被逼到桥栏边,刀势已经散了。',
options: [],
},
],
}),
encounter: createEncounter(),
actionText: '挥刀抢攻',
resultText: '断桥客已经败下阵来。胜利奖励:无战利品。',
battleMode: 'fight',
});
expect(reopened).toBe(true);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState).toMatchObject({
npcId: 'npc-rival',
sceneActId: 'scene-bridge-act-1',
turnLimit: 5,
remainingTurns: 5,
limitReason: 'negative_affinity',
combatContext: {
battleOutcome: 'victory',
},
});
expect(lastStory.dialogue?.[0]).toEqual(
expect.objectContaining({
speaker: 'system',
text: '断桥客已经败下阵来。胜利奖励:无战利品。',
}),
);
expect(lastStory.npcChatState?.combatContext?.summary).toContain(
'你刚赢下这场交锋',
);
expect(lastStory.npcChatState?.combatContext?.logLines).toEqual(
expect.arrayContaining([
'你挥刀抢攻,逼住了断桥客的退路。',
'断桥客被逼到桥栏边,刀势已经散了。',
'挥刀抢攻',
'断桥客已经败下阵来。胜利奖励:无战利品。',
]),
);
});
it('offers a pending quest after enough warmup chat turns with a positive-affinity npc', async () => {
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
streamNpcChatTurnMock.mockResolvedValueOnce({
@@ -1142,6 +1375,11 @@ describe('npcEncounterActions', () => {
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState?.pendingQuestOffer?.quest).toEqual(pendingQuest);
expect(lastStory.npcAffinityEffect).toEqual({
eventId: expect.stringContaining('npc-chat-affinity-npc-rival-'),
npcId: 'npc-rival',
delta: 2,
});
expect(lastStory.options.map((option) => option.actionText)).toEqual([
'查看任务',
'更换任务',

View File

@@ -85,6 +85,10 @@ type NpcChatDirective = {
forceExitAfterTurn?: boolean;
} | null;
type NpcChatCombatContext = NonNullable<
NonNullable<StoryMoment['npcChatState']>['combatContext']
>;
function isNpcEncounter(
encounter: GameState['currentEncounter'],
): encounter is Encounter {
@@ -108,6 +112,7 @@ export function createStoryNpcEncounterActions({
setAiError,
setIsLoading,
appendHistory,
buildNpcStory,
buildOpeningCampChatContext,
buildStoryContextFromState,
buildFallbackStoryForState,
@@ -135,6 +140,12 @@ export function createStoryNpcEncounterActions({
actionText: string,
resultText: string,
) => GameState['storyHistory'];
buildNpcStory: (
state: GameState,
character: Character,
encounter: Encounter,
overrideText?: string,
) => StoryMoment;
buildOpeningCampChatContext: (
state: GameState,
character: Character,
@@ -308,6 +319,98 @@ export function createStoryNpcEncounterActions({
'先把这附近真正危险的地方说清楚',
].map((actionText) => buildNpcChatOption(encounter, actionText));
const extractRecentCombatLogLines = (history: GameState['storyHistory']) =>
history
.slice(-6)
.map((moment) => moment.text.trim())
.filter(Boolean)
.slice(-4);
const buildNpcBattleChatCombatContext = (params: {
battleMode: NpcBattleMode;
resultText: string;
actionText: string;
historyBase: GameState['storyHistory'];
}): NpcChatCombatContext => {
const logLines = [
...extractRecentCombatLogLines(params.historyBase),
params.actionText,
params.resultText,
].filter((line, index, lines) => lines.indexOf(line) === index);
return {
summary:
params.battleMode === 'spar'
? `你们刚结束一场切磋,${params.resultText}`
: `你刚赢下这场交锋,${params.resultText}`,
logLines,
battleOutcome:
params.battleMode === 'spar' ? 'spar_complete' : 'victory',
};
};
const reopenNpcChatAfterBattle = (params: {
nextState: GameState;
encounter: Encounter;
actionText: string;
resultText: string;
battleMode: NpcBattleMode;
}) => {
const playerCharacter = params.nextState.playerCharacter;
if (!playerCharacter) {
return false;
}
const reopenedNpcState = getResolvedNpcState(params.nextState, params.encounter);
const chatDirective = resolveLimitedPrimaryNpcChatState({
state: params.nextState,
npcId: params.encounter.id ?? params.encounter.npcName,
affinity: reopenedNpcState.affinity,
nextTurnCount: 0,
});
const baseStory = buildNpcStory(
params.nextState,
playerCharacter,
params.encounter,
params.resultText,
);
const baseChatOptions = (baseStory.options ?? []).filter((option) =>
isNpcChatOptionForEncounter(option, params.encounter),
);
const fallbackChatOption =
baseChatOptions[0] ??
buildNpcChatOption(params.encounter, `继续和${params.encounter.npcName}对话`);
const combatContext = buildNpcBattleChatCombatContext({
battleMode: params.battleMode,
resultText: params.resultText,
actionText: params.actionText,
historyBase: params.nextState.storyHistory,
});
setCurrentStory(
buildNpcChatStoryMoment({
encounter: params.encounter,
dialogue: [
{
speaker: 'system',
text: params.resultText,
},
],
options: buildNpcChatEntryOptions(
params.encounter,
fallbackChatOption,
baseChatOptions.slice(1),
),
streaming: false,
turnCount: 0,
chatDirective,
openingSource: 'player_reply',
combatContext,
}),
);
return true;
};
const finalizeNpcBattleResult = (
state: GameState,
character: Character,
@@ -380,6 +483,18 @@ export function createStoryNpcEncounterActions({
const defeatedHostileNpcIds = activeBattleHostiles.map(
(hostileNpc) => hostileNpc.id,
);
const restoredEncounter =
(state.currentEncounter?.kind === 'npc' ? state.currentEncounter : null) ??
activeBattleHostiles[0]?.encounter ??
({
id: battleNpcId,
kind: 'npc',
npcName: activeBattleHostiles[0]?.name ?? battleNpcId,
npcDescription: '',
npcAvatar: '',
context: '',
hostile: false,
} satisfies Encounter);
const progressedQuests = applyQuestProgressFromHostileNpcDefeat(
state.quests,
state.currentScenePreset?.id ?? null,
@@ -398,8 +513,8 @@ export function createStoryNpcEncounterActions({
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
currentEncounter: null,
npcInteractionActive: false,
currentEncounter: restoredEncounter,
npcInteractionActive: true,
sceneHostileNpcs: [],
playerInventory: addInventoryItems(state.playerInventory, lootItems),
quests: progressedQuests,
@@ -407,8 +522,8 @@ export function createStoryNpcEncounterActions({
...state.npcStates,
[battleNpcId]: {
...markNpcFirstMeaningfulContactResolved(npcState),
affinity: 0,
relationState: buildRelationState(0),
affinity: npcState.affinity,
relationState: buildRelationState(npcState.affinity),
recruited: false,
inventory: nextNpcInventory,
},
@@ -604,12 +719,15 @@ export function createStoryNpcEncounterActions({
quest: QuestLogEntry;
} | null;
openingSource?: 'npc_initiated' | 'player_reply';
combatContext?: NpcChatCombatContext | null;
latestAffinityEffect?: StoryMoment['npcAffinityEffect'];
}): StoryMoment => ({
text: params.dialogue.map((turn) => turn.text).join('\n'),
options: params.options,
displayMode: 'dialogue',
dialogue: params.dialogue,
streaming: params.streaming,
npcAffinityEffect: params.latestAffinityEffect ?? null,
npcChatState: {
npcId: params.encounter.id ?? params.encounter.npcName,
npcName: params.encounter.npcName,
@@ -622,6 +740,7 @@ export function createStoryNpcEncounterActions({
limitReason: params.chatDirective?.limitReason ?? null,
forceExitAfterTurn: params.chatDirective?.forceExitAfterTurn ?? false,
pendingQuestOffer: params.pendingQuestOffer ?? null,
combatContext: params.combatContext ?? null,
},
});
@@ -642,6 +761,50 @@ export function createStoryNpcEncounterActions({
});
};
const buildPostNpcChatOptionCatalog = (
encounter: Encounter,
playerCharacter: Character,
) => {
const resolvedStateOptions =
collapseNpcChatOptions(
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
);
const currentStoryOptions = currentStory?.options ?? [];
const currentNpcKey = encounter.id ?? encounter.npcName;
const currentChatOptions = currentStoryOptions.filter((option) =>
isNpcChatOptionForEncounter(option, encounter),
);
const nonChatCurrentOptions = currentStoryOptions.filter(
(option) => !currentChatOptions.includes(option),
);
const nonChatResolvedOptions = resolvedStateOptions.filter(
(option) => !isNpcChatOptionForEncounter(option, encounter),
);
const mergedOptions: StoryOption[] = [];
const seenOptionIdentity = new Set<string>();
const pushUniqueOption = (option: StoryOption) => {
const optionIdentity = [
option.functionId,
option.interaction?.kind ?? '',
option.interaction?.kind === 'npc' ? option.interaction.npcId : '',
option.interaction?.kind === 'npc' ? option.interaction.action : '',
].join('::');
if (seenOptionIdentity.has(optionIdentity)) {
return;
}
seenOptionIdentity.add(optionIdentity);
mergedOptions.push(option);
};
currentChatOptions.slice(0, 1).forEach(pushUniqueOption);
nonChatCurrentOptions.forEach(pushUniqueOption);
nonChatResolvedOptions.forEach(pushUniqueOption);
return mergedOptions;
};
const buildLegacyNpcChatOpeningPlaceholder = (encounter: Encounter) =>
`${encounter.npcName}看着你,像是在等你把话接下去。`;
@@ -967,6 +1130,7 @@ export function createStoryNpcEncounterActions({
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName)
? currentStory.npcChatState
: null;
const currentCombatContext = currentNpcChatState?.combatContext ?? null;
const existingDialogue =
currentStory?.dialogue && currentNpcChatState
? sanitizeNpcChatDialogueHistory(
@@ -1006,6 +1170,7 @@ export function createStoryNpcEncounterActions({
streaming: true,
turnCount: nextTurnCount,
chatDirective: limitedChatDirective,
combatContext: currentCombatContext,
}),
);
@@ -1045,6 +1210,7 @@ export function createStoryNpcEncounterActions({
streaming: true,
turnCount: nextTurnCount,
chatDirective: limitedChatDirective,
combatContext: currentCombatContext,
}),
);
},
@@ -1055,6 +1221,7 @@ export function createStoryNpcEncounterActions({
turnCount: nextTurnCount,
},
chatDirective: limitedChatDirective,
combatContext: currentCombatContext,
},
);
@@ -1092,18 +1259,15 @@ export function createStoryNpcEncounterActions({
};
setGameState(finalState);
const affinityTurn =
// 好感变化只保留为一次性表现事件,不再插入聊天消息流。
const latestAffinityEffect =
chatTurn.affinityDelta !== 0
? [
{
speaker: 'system' as const,
text: `${chatTurn.affinityText} \u597d\u611f ${
chatTurn.affinityDelta > 0 ? '+' : '-'
}${Math.abs(chatTurn.affinityDelta)}`,
affinityDelta: chatTurn.affinityDelta,
},
]
: [];
? {
eventId: `npc-chat-affinity-${encounter.id ?? encounter.npcName}-${Date.now()}`,
npcId: encounter.id ?? encounter.npcName,
delta: chatTurn.affinityDelta,
}
: null;
const nextDialogue = [
...dialogueWithPlayer,
@@ -1112,7 +1276,6 @@ export function createStoryNpcEncounterActions({
speakerName: encounter.npcName,
text: chatTurn.npcReply,
},
...affinityTurn,
];
const pendingQuest =
(chatTurn.pendingQuestOffer?.quest as QuestLogEntry | undefined) ??
@@ -1153,6 +1316,7 @@ export function createStoryNpcEncounterActions({
displayMode: 'dialogue',
dialogue: closingDialogue,
streaming: false,
npcAffinityEffect: latestAffinityEffect,
});
return true;
}
@@ -1177,6 +1341,8 @@ export function createStoryNpcEncounterActions({
pendingQuestOffer: {
quest: pendingQuest,
},
combatContext: currentCombatContext,
latestAffinityEffect,
}),
);
return true;
@@ -1195,6 +1361,8 @@ export function createStoryNpcEncounterActions({
streaming: false,
turnCount: nextTurnCount,
chatDirective: resolvedChatDirective,
combatContext: currentCombatContext,
latestAffinityEffect,
}),
);
return true;
@@ -1212,6 +1380,7 @@ export function createStoryNpcEncounterActions({
streaming: false,
turnCount: nextTurnCount,
chatDirective: limitedChatDirective,
combatContext: currentCombatContext,
}),
);
return false;
@@ -1234,8 +1403,9 @@ export function createStoryNpcEncounterActions({
const choiceText = `结束与${encounter.npcName}的这轮交谈,重新观察当前局势`;
try {
const postChatOptionCatalog = collapseNpcChatOptions(
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
const postChatOptionCatalog = buildPostNpcChatOptionCatalog(
encounter,
playerCharacter,
);
const nextStory = await generateStoryForState({
state: gameState,
@@ -1691,6 +1861,7 @@ export function createStoryNpcEncounterActions({
enterNpcInteraction,
handleNpcInteraction,
finalizeNpcBattleResult,
reopenNpcChatAfterBattle,
handleNpcChatTurn,
exitNpcChat,
replacePendingNpcQuestOffer,

View File

@@ -49,6 +49,15 @@ type BuildNpcStory = (
overrideText?: string,
) => StoryMoment;
type HandleNpcBattleConversationContinuation = (params: {
nextState: GameState;
encounter: Encounter;
character: Character;
actionText: string;
resultText: string;
battleMode: NonNullable<GameState['currentNpcBattleMode']>;
}) => boolean;
type BuildStoryContextFromState = (
state: GameState,
extras?: {
@@ -112,6 +121,7 @@ export async function runLocalStoryChoiceContinuation(params: {
state: GameState,
) => GameState['sceneHostileNpcs'];
buildNpcStory: BuildNpcStory;
handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation;
updateQuestLog: UpdateQuestLog;
incrementRuntimeStats: IncrementRuntimeStats;
finalizeNpcBattleResult: (
@@ -289,6 +299,19 @@ export async function runLocalStoryChoiceContinuation(params: {
: null;
fallbackState = nextState;
params.setGameState(nextState);
if (
nextState.currentEncounter &&
params.handleNpcBattleConversationContinuation({
nextState,
encounter: nextState.currentEncounter,
character: params.character,
actionText: params.option.actionText,
resultText: victory.resultText,
battleMode: baseChoiceState.currentNpcBattleMode!,
})
) {
return;
}
try {
const nextStory = await params.generateStoryForState({
state: nextState,

View File

@@ -65,7 +65,10 @@ export type ChoiceRuntimeController = {
export type ChoiceRuntimeSupport = Pick<
StoryRuntimeSupport,
'buildNpcStory' | 'updateQuestLog' | 'updateRuntimeStats'
| 'buildNpcStory'
| 'handleNpcBattleConversationContinuation'
| 'updateQuestLog'
| 'updateRuntimeStats'
>;
export type StoryChoiceCoordinatorParams = {
@@ -148,6 +151,8 @@ export function createStoryChoiceCoordinatorConfig(
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
buildNpcStory: params.runtimeSupport.buildNpcStory,
handleNpcBattleConversationContinuation:
params.runtimeSupport.handleNpcBattleConversationContinuation,
updateQuestLog: params.runtimeSupport.updateQuestLog,
incrementRuntimeStats: params.runtimeSupport.updateRuntimeStats,
getCampCompanionTravelScene:

View File

@@ -61,6 +61,7 @@ describe('storyInteractionCoordinator', () => {
const resolveNpcInteractionDecision = vi.fn(() => ({ kind: 'chat' }));
const runtimeSupport = {
buildNpcStory: vi.fn(),
handleNpcBattleConversationContinuation: vi.fn(() => false),
cloneInventoryItemForOwner: vi.fn(),
getNpcEncounterKey: vi.fn(),
getResolvedNpcState: vi.fn(),

View File

@@ -10,6 +10,7 @@ import type {
Encounter,
GameState,
InventoryItem,
NpcBattleMode,
} from '../../types';
import { getNpcEncounterKey } from './storyGenerationState';
@@ -119,6 +120,14 @@ export const storyRuntimeSupport = {
getNpcEncounterKey,
getResolvedNpcState,
buildNpcStory,
handleNpcBattleConversationContinuation: (_params: {
nextState: GameState;
encounter: Encounter;
character: Character;
actionText: string;
resultText: string;
battleMode: NpcBattleMode;
}) => false,
updateNpcState,
updateQuestLog,
updateRuntimeStats,

View File

@@ -16,6 +16,7 @@ import { useStoryNpcInteractionFlow } from './npcInteraction';
import type { StoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
import type {
ChoiceRuntimeSupport,
ChoiceRuntimeController,
StoryChoiceCoordinatorParams,
} from './storyChoiceCoordinator';
@@ -97,6 +98,7 @@ export function useStoryInteractionCoordinator({
enterNpcInteraction,
handleNpcInteraction,
finalizeNpcBattleResult,
reopenNpcChatAfterBattle,
handleNpcChatTurn,
exitNpcChat,
replacePendingNpcQuestOffer,
@@ -104,6 +106,7 @@ export function useStoryInteractionCoordinator({
acceptPendingNpcQuestOffer,
} = createStoryNpcEncounterActions({
...interactionConfig.npcEncounterActions,
buildNpcStory: runtimeSupport.buildNpcStory,
npcInteractionFlow,
});
@@ -173,6 +176,23 @@ export function useStoryInteractionCoordinator({
);
},
};
const choiceRuntimeSupport: ChoiceRuntimeSupport = {
...runtimeSupport,
handleNpcBattleConversationContinuation: ({
nextState,
encounter,
actionText,
resultText,
battleMode,
}) =>
reopenNpcChatAfterBattle({
nextState,
encounter,
actionText,
resultText,
battleMode,
}),
};
const { handleChoice, battleRewardUi, clearStoryChoiceUi } =
useStoryChoiceCoordinator({
gameState,
@@ -187,7 +207,7 @@ export function useStoryInteractionCoordinator({
interactionConfig.npcEncounterActions.getStoryGenerationHostileNpcs,
getResolvedSceneHostileNpcs,
runtimeController: choiceRuntimeController,
runtimeSupport,
runtimeSupport: choiceRuntimeSupport,
enterNpcInteraction,
handleNpcInteraction,
handleTreasureInteraction,