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

@@ -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,