Files
Genarrative/src/hooks/useStoryGeneration.ts

1610 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
getCharacterAdventureOpening,
getCharacterById,
getCharacterHomeSceneId,
} from '../data/characterPresets';
import {
buildCampTravelHomeOption,
buildContinueAdventureOption,
buildNpcPreviewTalkOption,
isCampTravelHomeOption,
isContinueAdventureOption,
NPC_CHAT_FUNCTION,
NPC_FIGHT_FUNCTION,
NPC_LEAVE_FUNCTION,
NPC_PREVIEW_TALK_FUNCTION,
NPC_RECRUIT_FUNCTION,
STORY_OPENING_CAMP_DIALOGUE_FUNCTION,
} from '../data/functionCatalog';
import {
buildInitialNpcState,
buildNpcEncounterStoryMoment,
describeNpcAffinityInWords,
getNpcConversationDirective,
isNpcFirstMeaningfulContact,
normalizeNpcPersistentState,
} from '../data/npcInteractions';
import { applyQuestProgressFromTreasure } from '../data/questFlow';
import { incrementGameRuntimeStats } from '../data/runtimeStats';
import {
buildSceneEntityCatalogText,
getForwardScenePreset,
getScenePresetById,
getTravelScenePreset,
getWorldCampScenePreset,
} from '../data/scenePresets';
import {
getDefaultFunctionIdsForContext,
resolveFunctionOption,
sortStoryOptionsByPriority,
} from '../data/stateFunctions';
import { generateInitialStory, generateNextStep } from '../services/ai';
import {
Character,
Encounter,
GameState,
InventoryItem,
StoryDialogueTurn,
StoryMoment,
StoryOption,
WorldType,
} from '../types';
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from './combat/escapeFlow';
import type { ResolvedChoiceState } from './combat/resolvedChoice';
import {
buildFallbackStoryMoment,
normalizeSkillProbabilities,
} from './combatStoryUtils';
import {
getCharacterChatRecord,
useCharacterChatFlow,
} from './story/characterChat';
import { createStoryChoiceActions } from './story/choiceActions';
import { useStoryInventoryActions } from './story/inventoryActions';
import { createStoryNpcEncounterActions } from './story/npcEncounterActions';
import { useStoryNpcInteractionFlow } from './story/npcInteraction';
import {
buildPreparedOpeningAdventure as buildPreparedOpeningAdventureState,
playOpeningAdventureSequence,
type PreparedOpeningAdventure,
} from './story/openingAdventure';
import {
appendStoryHistory,
createStoryProgressionActions,
} from './story/progressionActions';
import { createStorySessionActions } from './story/sessionActions';
import { resolveNpcInteractionDecision } from './story/storyGenerationState';
import type {
BattleRewardSummary,
BattleRewardUi,
QuestFlowUi,
} from './story/uiTypes';
import { useStoryOptions } from './useStoryOptions';
import {
buildTreasureStory,
isTreasureEncounter,
useTreasureFlow,
} from './useTreasureFlow';
const MIN_OPTION_POOL_SIZE = 6;
const TURN_VISUAL_MS = 820;
const OPENING_CAMP_DIALOGUE_FUNCTION_ID =
STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
const NPC_PREVIEW_TALK_FUNCTION_ID = NPC_PREVIEW_TALK_FUNCTION.id;
const FALLBACK_COMPANION_NAME = '同伴';
export type {
CharacterChatModalState,
CharacterChatTarget,
CharacterChatUi,
} from './story/characterChat';
export type {
BattleRewardSummary,
BattleRewardUi,
GiftModalState,
InventoryFlowUi,
QuestFlowUi,
RecruitModalState,
StoryGenerationNpcUi,
TradeModalState,
} from './story/uiTypes';
type CampCompanionEncounter = Encounter & {
specialBehavior: 'camp_companion';
};
function dedupeStoryOptions(options: StoryOption[]) {
const seen = new Set<string>();
return options.filter((option) => {
const identity = `${option.functionId}::${option.actionText}::${option.text}`;
if (seen.has(identity)) return false;
seen.add(identity);
return true;
});
}
function _buildLocalCharacterChatSummary(
character: Character,
history: Array<{ speaker: 'player' | 'character'; text: string }>,
previousSummary: string,
) {
const latestTurns = history
.slice(-4)
.map(
(turn) =>
`${turn.speaker === 'player' ? 'Player' : character.name}: ${turn.text}`,
)
.join(' ');
const currentSummary = latestTurns
? `${character.name} is becoming more open in private conversation. Recent exchange: ${latestTurns}`
: `${character.name} is willing to continue private conversation and gradually trusts the player more.`;
if (!previousSummary) {
return currentSummary.slice(0, 118);
}
return `${previousSummary} ${currentSummary}`.slice(0, 118);
}
function _buildLocalCharacterChatSuggestions(character: Character) {
return [
'我想听你把这件事再说得更明白一点。',
`${character.name},你现在真正担心的是什么?`,
'先把外面的局势放一放,我想更了解你一些。',
];
}
function buildPartyRelationshipNotes(state: GameState) {
const lines: string[] = [];
const seenCharacterIds = new Set<string>();
const appendNote = (characterId: string, roleLabel: string) => {
if (seenCharacterIds.has(characterId)) return;
const character = getCharacterById(characterId);
const summary = getCharacterChatRecord(state, characterId).summary.trim();
if (!character || !summary) return;
seenCharacterIds.add(characterId);
lines.push(
`- ${character.name} (${character.title} / ${roleLabel}): ${summary}`,
);
};
state.companions.forEach((companion) =>
appendNote(companion.characterId, '当前同行'),
);
state.roster.forEach((companion) =>
appendNote(companion.characterId, '营地待命'),
);
return lines.length > 0 ? lines.join('\n') : null;
}
function buildRecentConversationEventText(state: GameState) {
const recentText = state.storyHistory
.slice(-6)
.map((item) => item.text)
.join('\n');
if (
/|||||/u.test(recentText)
) {
return '你们刚经历过一场交锋或切磋,空气里的紧张感还没有完全散去。';
}
if (/|||/u.test(recentText)) {
return '你们刚并肩配合过一次,彼此之间的距离感稍微淡了一些。';
}
return null;
}
function inferConversationSituation(
state: GameState,
extras: {
lastFunctionId?: string | null;
openingCampDialogue?: string | null;
},
) {
if (state.inBattle) return 'shared_danger_coordination' as const;
if (extras.lastFunctionId === OPENING_CAMP_DIALOGUE_FUNCTION_ID)
return 'camp_first_contact' as const;
if (
state.currentEncounter?.specialBehavior === 'camp_companion' &&
extras.openingCampDialogue?.trim()
) {
return 'camp_followup' as const;
}
const recentText = state.storyHistory
.slice(-6)
.map((item) => item.text)
.join('\n');
if (
/|||||/u.test(recentText)
) {
return 'post_battle_breath' as const;
}
if (extras.lastFunctionId === NPC_CHAT_FUNCTION.id)
return 'private_followup' as const;
return 'first_contact_cautious' as const;
}
function inferConversationPressure(
state: GameState,
situation: ReturnType<typeof inferConversationSituation>,
) {
const hpRatio = state.playerHp / Math.max(state.playerMaxHp, 1);
if (state.inBattle || hpRatio < 0.35) return 'high' as const;
if (
situation === 'post_battle_breath' ||
situation === 'shared_danger_coordination'
)
return 'medium' as const;
if (situation === 'camp_first_contact' || situation === 'camp_followup')
return 'low' as const;
return 'medium' as const;
}
function describeConversationSituation(
situation: ReturnType<typeof inferConversationSituation>,
) {
switch (situation) {
case 'camp_first_contact':
return 'This is the first quiet moment at camp, so the tone should stay careful, observant, and lightly probing.';
case 'camp_followup':
return 'The first camp exchange already happened, so this can pick up the previous thread and go a little deeper.';
case 'post_battle_breath':
return 'A fight just ended. The immediate danger is lower, but both sides are still tense and catching their breath.';
case 'shared_danger_coordination':
return 'Danger is still active, so the conversation should stay short, direct, and practical.';
case 'private_followup':
return 'This is not a strict first meeting anymore. It works best as a continuation of something half-said a moment ago.';
default:
return 'They have only just met, and both sides are still deciding how much they can trust the other.';
}
}
function describeConversationTalkPriority(
situation: ReturnType<typeof inferConversationSituation>,
) {
switch (situation) {
case 'camp_first_contact':
return 'Start with immediate impressions, mutual attitude, and the atmosphere at camp instead of over-explaining motives.';
case 'camp_followup':
return 'Start by picking up the unresolved thread from the last exchange, then decide whether to press further.';
case 'post_battle_breath':
return 'Talk about the clash that just happened and how each side judged the other before moving deeper.';
case 'shared_danger_coordination':
return 'Focus on the most useful judgment, danger, and next step instead of expanding into long background exposition.';
case 'private_followup':
return 'Pick up the current thread and relationship shift instead of resetting the conversation back to a first meeting.';
default:
return 'Probe stance and现场 judgment first instead of fully exposing motive and secrets.';
}
}
function buildStoryContextFromState(
state: GameState,
extras: {
pendingSceneEncounter?: boolean;
lastFunctionId?: string | null;
observeSignsRequested?: boolean;
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
} = {},
) {
const conversationSituation = inferConversationSituation(state, extras);
const conversationPressure = inferConversationPressure(
state,
conversationSituation,
);
const recentSharedEvent = buildRecentConversationEventText(state);
const encounterNpcState =
state.currentEncounter?.kind === 'npc'
? (() => {
const encounter = state.currentEncounter;
return extras.encounterNpcStateOverride
?? state.npcStates[getNpcEncounterKey(encounter)]
?? buildInitialNpcState(encounter, state.worldType, state);
})()
: null;
const encounterDirective =
state.currentEncounter?.kind === 'npc'
? (() => {
const encounter = state.currentEncounter;
return encounterNpcState
? getNpcConversationDirective(encounter, encounterNpcState)
: null;
})()
: null;
const isFirstMeaningfulContact =
state.currentEncounter?.kind === 'npc'
? (() => {
const encounter = state.currentEncounter;
return encounterNpcState
? isNpcFirstMeaningfulContact(encounter, encounterNpcState)
: false;
})()
: false;
const firstContactRelationStance = (() => {
if (
!isFirstMeaningfulContact ||
!state.currentEncounter ||
state.currentEncounter.kind !== 'npc'
) {
return null;
}
const stance = encounterNpcState?.relationState?.stance ?? null;
if (
stance === 'guarded' ||
stance === 'neutral' ||
stance === 'cooperative' ||
stance === 'bonded'
) {
return stance;
}
return null;
})();
const encounterAffinityText =
state.currentEncounter?.kind === 'npc'
? (() => {
const encounter = state.currentEncounter;
return encounterNpcState
? describeNpcAffinityInWords(encounter, encounterNpcState.affinity, {
recruited: encounterNpcState.recruited,
})
: null;
})()
: null;
const baseSceneDescription = state.currentScenePreset?.description ?? null;
const observeSignsSceneDescription =
extras.observeSignsRequested && state.worldType
? [
baseSceneDescription,
'Observed entity pool:',
buildSceneEntityCatalogText(
state.worldType,
state.currentScenePreset?.id ?? null,
),
]
.filter(Boolean)
.join('\n')
: baseSceneDescription;
return {
playerHp: state.playerHp,
playerMaxHp: state.playerMaxHp,
playerMana: state.playerMana,
playerMaxMana: state.playerMaxMana,
inBattle: state.inBattle,
playerX: state.playerX,
playerFacing: state.playerFacing,
playerAnimation: state.animationState,
skillCooldowns: state.playerSkillCooldowns,
sceneId: state.currentScenePreset?.id ?? null,
sceneName: state.currentScenePreset?.name ?? null,
sceneDescription: observeSignsSceneDescription,
pendingSceneEncounter: extras.pendingSceneEncounter ?? false,
lastFunctionId: extras.lastFunctionId ?? null,
observeSignsRequested: extras.observeSignsRequested ?? false,
lastObserveSignsReport:
state.lastObserveSignsSceneId === (state.currentScenePreset?.id ?? null)
? (state.lastObserveSignsReport ?? null)
: null,
encounterKind: state.currentEncounter?.kind ?? null,
encounterName: state.currentEncounter?.npcName ?? null,
encounterDescription: state.currentEncounter?.npcDescription ?? null,
encounterContext: state.currentEncounter?.context ?? null,
encounterCharacterId: state.currentEncounter?.characterId ?? null,
encounterGender: state.currentEncounter?.gender ?? null,
encounterAffinity: encounterDirective?.affinity ?? null,
encounterAffinityText,
encounterConversationStyle: encounterDirective?.style ?? null,
encounterDisclosureStage: encounterDirective?.disclosureStage ?? null,
encounterWarmthStage: encounterDirective?.warmthStage ?? null,
encounterAnswerMode: encounterDirective?.answerMode ?? null,
encounterAllowedTopics: encounterDirective?.allowTopics ?? null,
encounterBlockedTopics: encounterDirective?.blockedTopics ?? null,
isFirstMeaningfulContact,
firstContactRelationStance,
conversationSituation,
conversationPressure,
recentSharedEvent:
recentSharedEvent ?? describeConversationSituation(conversationSituation),
talkPriority: describeConversationTalkPriority(conversationSituation),
encounterRelationshipSummary: state.currentEncounter?.characterId
? getCharacterChatRecord(state, state.currentEncounter.characterId)
.summary || null
: null,
partyRelationshipNotes: buildPartyRelationshipNotes(state),
customWorldProfile: state.customWorldProfile ?? null,
openingCampBackground: extras.openingCampBackground ?? null,
openingCampDialogue: extras.openingCampDialogue ?? null,
};
}
function buildNpcPreviewStory(
state: GameState,
character: Character,
encounter: Encounter,
overrideText?: string,
): StoryMoment {
if (!state.worldType) {
return {
text:
overrideText ??
`${encounter.npcName} waits ahead, as if letting you decide whether to engage first.`,
options: [buildNpcPreviewTalkOption(encounter)],
};
}
const functionContext = {
worldType: state.worldType,
playerCharacter: character,
inBattle: false,
currentSceneId: state.currentScenePreset?.id ?? null,
currentSceneName: state.currentScenePreset?.name ?? null,
monsters: [],
playerHp: state.playerHp,
playerMaxHp: state.playerMaxHp,
playerMana: state.playerMana,
playerMaxMana: state.playerMaxMana,
};
const locationOptions = getDefaultFunctionIdsForContext(functionContext)
.filter((functionId) => functionId !== 'idle_call_out')
.map((functionId) => resolveFunctionOption(functionId, functionContext))
.filter((option): option is StoryOption => Boolean(option));
return {
text:
overrideText ??
encounter.npcName +
' appears near ' +
(state.currentScenePreset?.name ?? 'the path ahead') +
', but you have not fully committed your attention to them yet.',
options: [buildNpcPreviewTalkOption(encounter), ...locationOptions],
};
}
function getStoryGenerationHostileNpcs(state: GameState) {
return state.inBattle ? getResolvedSceneHostileNpcs(state) : [];
}
function getResolvedSceneHostileNpcs(state: GameState) {
return state.sceneHostileNpcs ?? state.sceneMonsters;
}
function sanitizeOptions(
options: StoryOption[],
character: Character,
state: GameState,
) {
const normalizedOptions = dedupeStoryOptions(
options.map((option) => normalizeSkillProbabilities(option, character)),
);
if (normalizedOptions.length === 0) {
return buildFallbackStoryMoment(state, character).options;
}
if (normalizedOptions.length >= MIN_OPTION_POOL_SIZE) {
return normalizedOptions;
}
return sortStoryOptionsByPriority(
dedupeStoryOptions([
...normalizedOptions,
...buildFallbackStoryMoment(state, character).options,
]).slice(0, MIN_OPTION_POOL_SIZE),
);
}
function escapeRegExp(value: string) {
const specialChars = [
'\\',
'^',
'$',
'*',
'+',
'?',
'.',
'(',
')',
'|',
'[',
']',
'{',
'}',
];
return specialChars.reduce(
(escaped, char) => escaped.split(char).join('\\' + char),
value,
);
}
function normalizeDialogueSpeakerName(rawSpeakerName: string) {
return rawSpeakerName
.trim()
.replace(
/^[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+/u,
'',
)
.replace(
/[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+$/u,
'',
)
.replace(/^(?:\u540c\u4f34|\u961f\u53cb)\s*/u, '')
.trim();
}
function parseDialogueTurns(
text: string,
npcName: string,
): StoryDialogueTurn[] {
const turns: StoryDialogueTurn[] = [];
const dialogueColonPattern = '(?:\\uFF1A|:)';
const playerPrefixPattern = new RegExp(
'^(?:\\\\u4f60|\\\\u73a9\\\\u5bb6|\\\\u4e3b\\\\u89d2)\\\\s*' +
dialogueColonPattern +
'\\\\s*(.+)$',
'u',
);
const npcPrefixPattern = new RegExp(
'^' +
escapeRegExp(npcName) +
'\\\\s*' +
dialogueColonPattern +
'\\\\s*(.+)$',
'u',
);
const namedSpeakerPattern = new RegExp(
'^(.{1,24}?)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$',
'u',
);
const lines = text
.replace(/\r/g, '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
const playerMatch = line.match(playerPrefixPattern);
const playerText = playerMatch?.[1]?.trim();
if (playerText) {
turns.push({ speaker: 'player', text: playerText });
continue;
}
const npcMatch = line.match(npcPrefixPattern);
const npcText = npcMatch?.[1]?.trim();
if (npcText) {
turns.push({ speaker: 'npc', speakerName: npcName, text: npcText });
continue;
}
const namedSpeakerMatch = line.match(namedSpeakerPattern);
if (namedSpeakerMatch) {
const rawSpeakerName = namedSpeakerMatch[1];
const rawSpeakerText = namedSpeakerMatch[2];
if (!rawSpeakerName || !rawSpeakerText) {
continue;
}
const speakerName = normalizeDialogueSpeakerName(rawSpeakerName);
const speakerText = rawSpeakerText.trim();
if (speakerName && speakerText) {
turns.push({
speaker: speakerName === npcName ? 'npc' : 'companion',
speakerName,
text: speakerText,
});
continue;
}
}
if (line.startsWith('你:') || line.startsWith('你:')) {
turns.push({ speaker: 'player', text: line.slice(2).trim() });
continue;
}
if (line.startsWith(npcName + ':') || line.startsWith(npcName + '')) {
turns.push({
speaker: 'npc',
text: line.slice(npcName.length + 1).trim(),
});
continue;
}
if (line.startsWith('主角:') || line.startsWith('主角:')) {
turns.push({ speaker: 'player', text: line.slice(3).trim() });
continue;
}
if (turns.length > 0) {
const lastTurnIndex = turns.length - 1;
const lastTurn = turns[lastTurnIndex];
if (lastTurn) {
turns[lastTurnIndex] = {
...lastTurn,
text: lastTurn.text + line,
};
}
}
}
return turns.filter((turn) => turn.text.length > 0);
}
function buildDialogueStoryMoment(
npcName: string,
text: string,
options: StoryOption[],
streaming = false,
): StoryMoment {
return {
text,
options,
displayMode: 'dialogue',
dialogue: parseDialogueTurns(text, npcName),
streaming,
};
}
function hasRenderableDialogueTurns(text: string, npcName: string) {
return parseDialogueTurns(text, npcName).length >= 2;
}
function getTypewriterDelay(char: string) {
if (/[!?]/u.test(char)) return 240;
if (/[;:]/u.test(char)) return 150;
if (/\s/u.test(char)) return 45;
return 90;
}
function isCampCompanionEncounter(
encounter: GameState['currentEncounter'],
): encounter is CampCompanionEncounter {
return Boolean(
encounter?.kind === 'npc' && encounter.specialBehavior === 'camp_companion',
);
}
function isInitialCompanionEncounter(
encounter: GameState['currentEncounter'],
): encounter is Encounter {
return Boolean(
encounter?.kind === 'npc' &&
encounter.specialBehavior === 'initial_companion',
);
}
function _buildInitialCompanionResultText(
character: Character,
encounter: Encounter,
worldType: WorldType | null,
) {
const opening = getCharacterAdventureOpening(character, worldType);
if (!opening) {
return `${encounter.npcName}从不远处走近,先静静打量着你的反应。`;
}
return `${encounter.npcName}现身在你眼前,周围的局势也随之悄然变化。`;
}
function buildInitialCompanionDialogueText(
character: Character,
encounter: Encounter,
worldType: WorldType | null,
) {
const opening = getCharacterAdventureOpening(character, worldType);
const surfaceHook =
opening?.surfaceHook ?? '这个地方与我来此的目的息息相关。';
const immediateConcern =
opening?.immediateConcern ?? '前方似乎有些不对劲,我们不能贸然前进。';
const guardedMotive =
opening?.guardedMotive ?? '我并非偶然来到这里,但我还不准备全盘托出。';
return [
`你:${surfaceHook}`,
`${encounter.npcName}:那就不要说得太快太多。前方的道路并不稳定,贸然冲进去只会最先遇到最糟糕的情况。`,
`你:${immediateConcern}`,
`${encounter.npcName}:我能看出你不是误闯此地的。${guardedMotive} 剩下的我们可以在路上再梳理清楚。`,
].join('\n');
}
function buildCampCompanionOpeningResultText(
character: Character,
encounter: Encounter,
worldType: WorldType | null,
) {
const opening = getCharacterAdventureOpening(character, worldType);
if (!opening) {
return `${encounter.npcName} 已经来到你身边。在营地,你稍作停顿,决定下一步去向何方。`;
}
return `${encounter.npcName} 在营地来到你身边。你们首先就“${opening.immediateConcern ?? '前方不稳定的道路'}”交换了意见,而双方都暂时保留了部分真相。`;
}
function _buildCampCompanionChatResultText(
encounter: Encounter,
affinityGain: number,
nextAffinity: number,
) {
const teamworkText =
affinityGain > 0
? '你也对接下来的合作感到更加自信了一些。'
: '至少你们为接下来的行动重新调整了节奏。';
return `${encounter.npcName}和你交换了一番想法,${describeNpcAffinityInWords(encounter, nextAffinity)}${teamworkText}`;
}
function isNpcEncounter(
encounter: GameState['currentEncounter'],
): encounter is Encounter {
return Boolean(encounter?.kind === 'npc');
}
function isRegularNpcEncounter(
encounter: GameState['currentEncounter'],
): encounter is Encounter {
return Boolean(encounter?.kind === 'npc' && !encounter.specialBehavior);
}
function getNpcEncounterKey(encounter: Encounter) {
return encounter.id ?? encounter.npcName;
}
function cloneInventoryItemForOwner(
item: InventoryItem,
owner: 'player' | 'npc',
quantity = 1,
) {
const preserveIdentity = Boolean(
item.runtimeMetadata ||
item.buildProfile ||
item.equipmentSlotId ||
item.statProfile ||
item.attributeResonance,
);
return {
...item,
id: preserveIdentity
? `${owner}:${item.id}:${quantity}`
: `${owner}:${encodeURIComponent(`${item.category}-${item.name}`)}`,
quantity,
runtimeMetadata: item.runtimeMetadata
? {
...item.runtimeMetadata,
seedKey: `${item.runtimeMetadata.seedKey}:${owner}`,
}
: item.runtimeMetadata,
};
}
function tickCooldownMap(cooldowns: Record<string, number>) {
return Object.fromEntries(
Object.entries(cooldowns).map(([skillId, turns]) => [
skillId,
Math.max(0, turns - 1),
]),
);
}
export function useStoryGeneration({
gameState,
setGameState,
buildResolvedChoiceState,
playResolvedChoice,
}: {
gameState: GameState;
setGameState: Dispatch<SetStateAction<GameState>>;
buildResolvedChoiceState: (
state: GameState,
option: StoryOption,
character: Character,
) => ResolvedChoiceState;
playResolvedChoice: (
state: GameState,
option: StoryOption,
character: Character,
resolvedChoice: ResolvedChoiceState,
sync?: ResolvedChoicePlaybackSync,
) => Promise<GameState>;
}) {
const [currentStory, setCurrentStory] = useState<StoryMoment | null>(null);
const [aiError, setAiError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [battleReward, setBattleReward] = useState<BattleRewardSummary | null>(
null,
);
const [preparedOpeningAdventure, setPreparedOpeningAdventure] =
useState<PreparedOpeningAdventure | null>(null);
const { characterChatUi, clearCharacterChatModal } = useCharacterChatFlow({
gameState,
setGameState,
buildStoryContextFromState,
});
const getResolvedNpcState = (state: GameState, encounter: Encounter) =>
state.npcStates[getNpcEncounterKey(encounter)] ??
buildInitialNpcState(encounter, state.worldType, state);
const buildNpcStory = useCallback(
(
state: GameState,
character: Character,
encounter: Encounter,
overrideText?: string,
) =>
buildNpcEncounterStoryMoment({
state,
encounter,
npcState: getResolvedNpcState(state, encounter),
playerCharacter: character,
playerInventory: state.playerInventory,
activeQuests: state.quests,
scene: state.currentScenePreset,
partySize: state.companions.length,
overrideText,
worldType: state.worldType,
}),
[],
);
const getCampCompanionHomeScene = (
state: GameState,
character: Character,
) => {
if (!state.worldType) return null;
const sceneId = getCharacterHomeSceneId(state.worldType, character.id);
return getScenePresetById(state.worldType, sceneId);
};
const getCampCompanionTravelScene = useCallback(
(state: GameState, character: Character) => {
if (!state.worldType) return null;
const campScene = getWorldCampScenePreset(state.worldType);
const homeScene = getCampCompanionHomeScene(state, character);
if (
homeScene &&
homeScene.id !== campScene?.id &&
homeScene.id !== state.currentScenePreset?.id
) {
return homeScene;
}
const fallbackSceneId =
campScene?.id ?? state.currentScenePreset?.id ?? null;
return (
getForwardScenePreset(state.worldType, fallbackSceneId) ??
getTravelScenePreset(state.worldType, fallbackSceneId) ??
homeScene
);
},
[],
);
const buildCampCompanionOpeningOptions = useCallback(
(state: GameState, character: Character, encounter: Encounter) => {
const targetScene = getCampCompanionTravelScene(state, character);
const baseOptions = buildNpcStory(state, character, encounter).options;
const chatOptions = baseOptions
.filter((option) => option.functionId === NPC_CHAT_FUNCTION.id)
.slice(0, 1);
const recruitOption =
baseOptions.find(
(option) => option.functionId === NPC_RECRUIT_FUNCTION.id,
) ?? null;
const openingOptions = recruitOption
? [...chatOptions, recruitOption]
: chatOptions;
if (!targetScene) {
return openingOptions;
}
return [...openingOptions, buildCampTravelHomeOption(targetScene.name)];
},
[buildNpcStory, getCampCompanionTravelScene],
);
const inferOpeningCampFollowupOptions = useCallback(
async (
state: GameState,
character: Character,
baseOptions: StoryOption[],
openingBackground: string,
openingDialogue: string,
) => {
if (!state.worldType || baseOptions.length === 0) {
return baseOptions;
}
try {
const response = await generateNextStep(
state.worldType,
character,
getStoryGenerationHostileNpcs(state),
state.storyHistory,
'继续承接营地中的这段交谈,并整理出眼下最自然的后续行动。',
buildStoryContextFromState(state, {
openingCampBackground: openingBackground,
openingCampDialogue: openingDialogue,
}),
{
availableOptions: baseOptions,
},
);
return sortStoryOptionsByPriority(response.options);
} catch (error) {
console.error('Failed to infer opening camp follow-up options:', error);
return baseOptions;
}
},
[],
);
const buildOpeningCampChatContext = (
state: GameState,
character: Character,
encounter: Encounter,
) => {
if (encounter.specialBehavior !== 'camp_companion') {
return {};
}
const npcState =
state.npcStates[getNpcEncounterKey(encounter)] ??
buildInitialNpcState(encounter, state.worldType, state);
if (npcState.chattedCount > 2) {
return {};
}
const openingActionText = `在营地与 ${encounter.npcName} 交换开场判断`;
let openingDialogue: string | null = null;
for (let index = 0; index < state.storyHistory.length - 1; index += 1) {
const entry = state.storyHistory[index];
if (!entry) {
continue;
}
if (entry.historyRole !== 'action' || entry.text !== openingActionText) {
continue;
}
for (
let nextIndex = index + 1;
nextIndex < state.storyHistory.length;
nextIndex += 1
) {
const nextEntry = state.storyHistory[nextIndex];
if (!nextEntry) {
continue;
}
if (nextEntry.historyRole === 'action') {
break;
}
if (nextEntry.text.trim()) {
openingDialogue = nextEntry.text;
break;
}
}
if (openingDialogue) {
break;
}
}
if (!openingDialogue) {
return {};
}
return {
openingCampBackground: buildCampCompanionOpeningResultText(
character,
encounter,
state.worldType,
),
openingCampDialogue: openingDialogue,
};
};
const buildCampCompanionIdleOptions = useCallback(
(
state: GameState,
character: Character,
encounter: Encounter,
overrideText?: string,
): StoryMoment => {
const targetScene = getCampCompanionTravelScene(state, character);
const baseStory = buildNpcStory(
state,
character,
encounter,
overrideText,
);
const filteredOptions = baseStory.options.filter(
(option) =>
option.functionId !== NPC_LEAVE_FUNCTION.id &&
option.functionId !== NPC_FIGHT_FUNCTION.id,
);
if (!targetScene) {
return {
...baseStory,
options: filteredOptions,
};
}
return {
...baseStory,
options: [
...filteredOptions.slice(0, 2),
buildCampTravelHomeOption(targetScene.name),
...filteredOptions.slice(2),
],
};
},
[buildNpcStory, getCampCompanionTravelScene],
);
const getAvailableOptionsForState = useCallback(
(state: GameState, character: Character) => {
if (isCampCompanionEncounter(state.currentEncounter) && !state.inBattle) {
return buildCampCompanionIdleOptions(
state,
character,
state.currentEncounter,
).options;
}
if (
isInitialCompanionEncounter(state.currentEncounter) &&
!state.inBattle &&
!state.npcInteractionActive
) {
return buildNpcPreviewStory(state, character, state.currentEncounter)
.options;
}
if (isRegularNpcEncounter(state.currentEncounter) && !state.inBattle) {
if (!state.npcInteractionActive) {
return buildNpcPreviewStory(state, character, state.currentEncounter)
.options;
}
return buildNpcStory(state, character, state.currentEncounter).options;
}
if (isNpcEncounter(state.currentEncounter) && !state.inBattle) {
return buildNpcStory(state, character, state.currentEncounter).options;
}
if (isTreasureEncounter(state.currentEncounter) && !state.inBattle) {
return buildTreasureStory(state, character, state.currentEncounter)
.options;
}
return null;
},
[buildCampCompanionIdleOptions, buildNpcStory],
);
const buildFallbackStoryForState = useCallback(
(state: GameState, character: Character, fallbackText?: string) => {
if (isCampCompanionEncounter(state.currentEncounter) && !state.inBattle) {
return buildCampCompanionIdleOptions(
state,
character,
state.currentEncounter,
fallbackText,
);
}
if (
isInitialCompanionEncounter(state.currentEncounter) &&
!state.inBattle &&
!state.npcInteractionActive
) {
return buildNpcPreviewStory(
state,
character,
state.currentEncounter,
fallbackText,
);
}
if (isRegularNpcEncounter(state.currentEncounter) && !state.inBattle) {
if (!state.npcInteractionActive) {
return buildNpcPreviewStory(
state,
character,
state.currentEncounter,
fallbackText,
);
}
return buildNpcStory(
state,
character,
state.currentEncounter,
fallbackText,
);
}
if (isNpcEncounter(state.currentEncounter) && !state.inBattle) {
return buildNpcStory(
state,
character,
state.currentEncounter,
fallbackText,
);
}
if (isTreasureEncounter(state.currentEncounter) && !state.inBattle) {
return buildTreasureStory(
state,
character,
state.currentEncounter,
fallbackText,
);
}
const fallback = buildFallbackStoryMoment(state, character);
return fallbackText
? {
...fallback,
text: fallbackText,
}
: fallback;
},
[buildCampCompanionIdleOptions, buildNpcStory],
);
const buildStoryFromResponse = useCallback(
(
state: GameState,
character: Character,
response: StoryMoment,
availableOptions: StoryOption[] | null,
optionCatalog: StoryOption[] | null = null,
) => ({
text: response.text,
options: sortStoryOptionsByPriority(
availableOptions
? isCampCompanionEncounter(state.currentEncounter)
? availableOptions
: response.options
: optionCatalog
? response.options.length > 0
? response.options
: optionCatalog
: sanitizeOptions(response.options, character, state),
),
}),
[],
);
const generateStoryForState = useCallback(
async ({
state,
character,
history,
choice,
lastFunctionId,
optionCatalog,
}: {
state: GameState;
character: Character;
history: StoryMoment[];
choice?: string;
lastFunctionId?: string | null;
optionCatalog?: StoryOption[] | null;
}) => {
if (!state.worldType) {
throw new Error(
'The current world is not initialized, so story generation cannot continue.',
);
}
const resolvedOptionCatalog =
optionCatalog && optionCatalog.length > 0 ? optionCatalog : null;
const availableOptions = resolvedOptionCatalog
? null
: getAvailableOptionsForState(state, character);
const response = choice
? await generateNextStep(
state.worldType,
character,
getStoryGenerationHostileNpcs(state),
history,
choice,
buildStoryContextFromState(state, {
lastFunctionId,
}),
availableOptions
? { availableOptions }
: resolvedOptionCatalog
? { optionCatalog: resolvedOptionCatalog }
: undefined,
)
: await generateInitialStory(
state.worldType,
character,
getStoryGenerationHostileNpcs(state),
buildStoryContextFromState(state),
availableOptions
? { availableOptions }
: resolvedOptionCatalog
? { optionCatalog: resolvedOptionCatalog }
: undefined,
);
return buildStoryFromResponse(
state,
character,
{
text: response.storyText,
options: response.options,
},
availableOptions,
resolvedOptionCatalog,
);
},
[buildStoryFromResponse, getAvailableOptionsForState],
);
const updateNpcState = (
state: GameState,
encounter: Encounter,
updater: (
npcState: ReturnType<typeof getResolvedNpcState>,
) => ReturnType<typeof getResolvedNpcState>,
) => ({
...state,
npcStates: {
...state.npcStates,
[getNpcEncounterKey(encounter)]: normalizeNpcPersistentState(
updater(getResolvedNpcState(state, encounter)),
),
},
});
const updateQuestLog = (
state: GameState,
updater: (quests: GameState['quests']) => GameState['quests'],
) => ({
...state,
quests: updater(state.quests),
});
const incrementRuntimeStats = (
state: GameState,
increments: Parameters<typeof incrementGameRuntimeStats>[1],
) => ({
...state,
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, increments),
});
const progressTreasureQuest = (state: GameState, sceneId: string | null) =>
updateQuestLog(state, (quests) =>
applyQuestProgressFromTreasure(quests, sceneId),
);
const appendHistory = useCallback(appendStoryHistory, []);
const prepareOpeningAdventure = useCallback(
(state: GameState, character: Character): PreparedOpeningAdventure | null =>
buildPreparedOpeningAdventureState({
state,
character,
getNpcEncounterKey,
appendHistory,
buildCampCompanionOpeningOptions,
buildCampCompanionOpeningResultText,
buildInitialCompanionDialogueText,
}),
[appendHistory, buildCampCompanionOpeningOptions],
);
useEffect(() => {
if (
gameState.currentScene !== 'Story' ||
!gameState.playerCharacter ||
gameState.storyHistory.length > 0 ||
currentStory ||
!isNpcEncounter(gameState.currentEncounter) ||
gameState.currentEncounter.specialBehavior !== 'initial_companion'
) {
setPreparedOpeningAdventure(null);
return;
}
setPreparedOpeningAdventure(
prepareOpeningAdventure(gameState, gameState.playerCharacter),
);
}, [
prepareOpeningAdventure,
currentStory,
gameState,
gameState.currentEncounter,
gameState.currentScene,
gameState.playerCharacter,
gameState.storyHistory,
]);
const { commitGeneratedState, commitGeneratedStateWithEncounterEntry } =
createStoryProgressionActions({
gameState,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
generateStoryForState,
buildFallbackStoryForState,
});
const startOpeningAdventure = useCallback(async () => {
if (
!gameState.playerCharacter ||
!isNpcEncounter(gameState.currentEncounter)
) {
return;
}
const encounter = gameState.currentEncounter;
if (encounter.specialBehavior !== 'initial_companion') {
return;
}
const preparedStory =
preparedOpeningAdventure?.encounterKey === getNpcEncounterKey(encounter)
? preparedOpeningAdventure
: prepareOpeningAdventure(gameState, gameState.playerCharacter);
if (!preparedStory) {
return;
}
await playOpeningAdventureSequence({
gameState,
character: gameState.playerCharacter,
encounter,
preparedStory,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
buildDialogueStoryMoment,
buildStoryContextFromState,
getStoryGenerationHostileNpcs,
hasRenderableDialogueTurns,
inferOpeningCampFollowupOptions,
getTypewriterDelay,
});
}, [
gameState,
inferOpeningCampFollowupOptions,
prepareOpeningAdventure,
preparedOpeningAdventure,
setGameState,
]);
const { handleTreasureInteraction } = useTreasureFlow({
gameState,
commitGeneratedState,
progressTreasureQuest,
});
const { inventoryUi } = useStoryInventoryActions({
gameState,
commitGeneratedState,
tickCooldowns: tickCooldownMap,
});
const npcInteractionFlow = useStoryNpcInteractionFlow({
gameState,
setGameState,
commitGeneratedState,
getNpcEncounterKey,
getResolvedNpcState,
updateNpcState,
cloneInventoryItemForOwner,
runtime: {
setCurrentStory,
setAiError,
setIsLoading,
buildStoryContextFromState,
buildFallbackStoryForState,
buildDialogueStoryMoment,
generateStoryForState,
getStoryGenerationHostileNpcs,
getTypewriterDelay,
},
});
const { enterNpcInteraction, handleNpcInteraction, finalizeNpcBattleResult } =
createStoryNpcEncounterActions({
gameState,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
commitGeneratedState,
commitGeneratedStateWithEncounterEntry,
appendHistory,
buildOpeningCampChatContext,
buildStoryContextFromState,
buildFallbackStoryForState,
buildDialogueStoryMoment,
getStoryGenerationHostileNpcs,
getTypewriterDelay,
getAvailableOptionsForState,
sanitizeOptions,
sortOptions: sortStoryOptionsByPriority,
buildContinueAdventureOption,
getNpcEncounterKey,
getResolvedNpcState,
updateNpcState,
cloneInventoryItemForOwner,
resolveNpcInteractionDecision,
npcInteractionFlow,
});
useEffect(() => {
const startStory = async () => {
if (
gameState.currentScene !== 'Story' ||
!gameState.worldType ||
!gameState.playerCharacter ||
currentStory ||
isLoading
) {
return;
}
if (
gameState.storyHistory.length === 0 &&
isInitialCompanionEncounter(gameState.currentEncounter) &&
!gameState.npcInteractionActive
) {
setAiError(null);
void startOpeningAdventure();
return;
}
setIsLoading(true);
try {
setAiError(null);
const nextStory = await generateStoryForState({
state: gameState,
character: gameState.playerCharacter,
history: [],
});
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to start story:', error);
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(
buildFallbackStoryForState(gameState, gameState.playerCharacter),
);
} finally {
setIsLoading(false);
}
};
startStory();
}, [
buildFallbackStoryForState,
generateStoryForState,
currentStory,
gameState,
gameState.currentEncounter,
gameState.currentScene,
gameState.inBattle,
gameState.playerCharacter,
gameState.playerX,
gameState.sceneHostileNpcs,
gameState.worldType,
isLoading,
startOpeningAdventure,
]);
const {
displayedOptions,
canRefreshOptions,
handleRefreshOptions,
resetStoryOptions,
} = useStoryOptions(currentStory);
const { handleChoice } = createStoryChoiceActions({
gameState,
currentStory,
isLoading,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
setBattleReward,
buildResolvedChoiceState,
playResolvedChoice,
buildStoryContextFromState,
buildStoryFromResponse,
buildFallbackStoryForState,
generateStoryForState,
getAvailableOptionsForState,
getStoryGenerationHostileNpcs,
getResolvedSceneHostileNpcs,
buildNpcStory,
updateQuestLog,
incrementRuntimeStats,
getCampCompanionTravelScene,
startOpeningAdventure,
enterNpcInteraction,
handleNpcInteraction,
handleTreasureInteraction,
commitGeneratedStateWithEncounterEntry,
finalizeNpcBattleResult,
isContinueAdventureOption,
isCampTravelHomeOption,
isInitialCompanionEncounter,
isRegularNpcEncounter,
isNpcEncounter,
npcPreviewTalkFunctionId: NPC_PREVIEW_TALK_FUNCTION_ID,
fallbackCompanionName: FALLBACK_COMPANION_NAME,
turnVisualMs: TURN_VISUAL_MS,
});
const clearStoryRuntimeUi = useCallback(() => {
resetStoryOptions();
setAiError(null);
setIsLoading(false);
setPreparedOpeningAdventure(null);
setBattleReward(null);
npcInteractionFlow.clearNpcInteractionUi();
clearCharacterChatModal();
}, [clearCharacterChatModal, npcInteractionFlow, resetStoryOptions]);
const {
acknowledgeQuestCompletion,
claimQuestReward,
resetStoryState,
hydrateStoryState,
travelToSceneFromMap,
} = createStorySessionActions({
gameState,
isLoading,
setGameState,
setCurrentStory,
clearStoryRuntimeUi,
commitGeneratedState,
buildFallbackStoryForState,
});
return {
currentStory,
isLoading,
aiError,
displayedOptions,
canRefreshOptions,
handleRefreshOptions,
handleChoice,
startOpeningAdventure,
isOpeningAdventureReady: Boolean(preparedOpeningAdventure),
resetStoryState,
hydrateStoryState,
travelToSceneFromMap,
battleRewardUi: {
reward: battleReward,
dismiss: () => setBattleReward(null),
} satisfies BattleRewardUi,
questUi: {
acknowledgeQuestCompletion,
claimQuestReward,
} satisfies QuestFlowUi,
npcUi: npcInteractionFlow.npcUi,
characterChatUi,
inventoryUi,
};
}