@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user