初始仓库迁移
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-04 23:57:06 +08:00
parent 80986b790d
commit c49c64896a
18446 changed files with 532435 additions and 2 deletions

View File

@@ -0,0 +1,376 @@
import {type Dispatch, type SetStateAction,useState} from 'react';
import {
generateCharacterPanelChatSuggestions,
generateCharacterPanelChatSummary,
streamCharacterPanelChatReply,
} from '../../services/ai';
import type {StoryGenerationContext} from '../../services/aiTypes';
import type {
Character,
CharacterChatRecord,
CharacterChatTurn,
GameState,
} from '../../types';
const MAX_CHARACTER_CHAT_TURNS = 24;
export type CharacterChatTarget = {
character: Character;
npcId: string | null;
roleLabel: string;
hp: number;
maxHp: number;
mana: number;
maxMana: number;
affinity?: number | null;
};
export type CharacterChatModalState = {
target: CharacterChatTarget;
draft: string;
messages: CharacterChatTurn[];
suggestions: string[];
summary: string;
isSending: boolean;
isLoadingSuggestions: boolean;
error: string | null;
};
export interface CharacterChatUi {
modal: CharacterChatModalState | null;
openChat: (target: CharacterChatTarget) => void;
closeChat: () => void;
setDraft: (value: string) => void;
useSuggestion: (value: string) => void;
refreshSuggestions: () => void;
sendDraft: () => void;
}
export function getCharacterChatRecord(state: GameState, characterId: string): CharacterChatRecord {
return state.characterChats[characterId] ?? {
history: [],
summary: '',
updatedAt: null,
};
}
export function trimCharacterChatHistory(history: CharacterChatTurn[]) {
return history.slice(-MAX_CHARACTER_CHAT_TURNS);
}
export function buildLocalCharacterChatSummary(
character: Character,
history: CharacterChatTurn[],
previousSummary: string,
) {
const latestTurns = history
.slice(-4)
.map(turn => `${turn.speaker === 'player' ? '玩家' : character.name}: ${turn.text}`)
.join(' ');
const currentSummary = latestTurns
? `${character.name} 在私下对话中变得更加开放。最近的交流:${latestTurns}`
: `${character.name} 愿意继续私下对话,并逐渐更加信任玩家。`;
if (!previousSummary) {
return currentSummary.slice(0, 118);
}
return `${previousSummary} ${currentSummary}`.slice(0, 118);
}
export function buildLocalCharacterChatSuggestions(character: Character) {
return [
'请更清楚地告诉我你的意思。',
`${character.name},你真正担心的是什么?`,
'暂时放下大局。我想更好地了解你。',
];
}
function buildCharacterChatRecordUpdate(
state: GameState,
characterId: string,
record: CharacterChatRecord,
) {
return {
...state,
characterChats: {
...state.characterChats,
[characterId]: record,
},
};
}
type CharacterChatTargetStatus = {
roleLabel: string;
hp: number;
maxHp: number;
mana: number;
maxMana: number;
affinity?: number | null;
};
function buildTargetStatus(target: CharacterChatTarget): CharacterChatTargetStatus {
return {
roleLabel: target.roleLabel,
hp: target.hp,
maxHp: target.maxHp,
mana: target.mana,
maxMana: target.maxMana,
affinity: target.affinity ?? null,
};
}
export function useCharacterChatFlow({
gameState,
setGameState,
buildStoryContextFromState,
}: {
gameState: GameState;
setGameState: Dispatch<SetStateAction<GameState>>;
buildStoryContextFromState: (state: GameState) => StoryGenerationContext;
}) {
const [characterChatModal, setCharacterChatModal] = useState<CharacterChatModalState | null>(null);
const loadCharacterChatSuggestions = async (
target: CharacterChatTarget,
messages: CharacterChatTurn[],
summary: string,
) => {
if (!gameState.worldType || !gameState.playerCharacter) {
setCharacterChatModal(current =>
current && current.target.character.id === target.character.id
? {
...current,
suggestions: buildLocalCharacterChatSuggestions(target.character),
isLoadingSuggestions: false,
}
: current,
);
return;
}
setCharacterChatModal(current =>
current && current.target.character.id === target.character.id
? {
...current,
isLoadingSuggestions: true,
}
: current,
);
try {
const suggestions = await generateCharacterPanelChatSuggestions(
gameState.worldType,
gameState.playerCharacter,
target.character,
gameState.storyHistory,
buildStoryContextFromState(gameState),
messages,
summary,
buildTargetStatus(target),
);
setCharacterChatModal(current =>
current && current.target.character.id === target.character.id
? {
...current,
suggestions,
isLoadingSuggestions: false,
}
: current,
);
} catch (error) {
console.error('Failed to generate character chat suggestions:', error);
setCharacterChatModal(current =>
current && current.target.character.id === target.character.id
? {
...current,
suggestions: buildLocalCharacterChatSuggestions(target.character),
isLoadingSuggestions: false,
}
: current,
);
}
};
const openCharacterChat = (target: CharacterChatTarget) => {
const record = getCharacterChatRecord(gameState, target.character.id);
setCharacterChatModal({
target,
draft: '',
messages: record.history,
suggestions: buildLocalCharacterChatSuggestions(target.character),
summary: record.summary,
isSending: false,
isLoadingSuggestions: true,
error: null,
});
void loadCharacterChatSuggestions(target, record.history, record.summary);
};
const sendCharacterChatDraft = async () => {
if (!characterChatModal || !gameState.worldType || !gameState.playerCharacter) {
return;
}
const draft = characterChatModal.draft.trim();
if (!draft) {
return;
}
const target = characterChatModal.target;
const existingRecord = getCharacterChatRecord(gameState, target.character.id);
const baseMessages = trimCharacterChatHistory(characterChatModal.messages);
const nextMessages = trimCharacterChatHistory([
...baseMessages,
{
speaker: 'player' as const,
text: draft,
},
]);
setCharacterChatModal(current =>
current && current.target.character.id === target.character.id
? {
...current,
draft: '',
messages: [...nextMessages, {speaker: 'character', text: ''}],
suggestions: [],
isSending: true,
isLoadingSuggestions: true,
error: null,
}
: current,
);
let replyText = '';
try {
replyText = await streamCharacterPanelChatReply(
gameState.worldType,
gameState.playerCharacter,
target.character,
gameState.storyHistory,
buildStoryContextFromState(gameState),
nextMessages,
existingRecord.summary,
draft,
buildTargetStatus(target),
{
onUpdate: text => {
setCharacterChatModal(current =>
current && current.target.character.id === target.character.id
? {
...current,
messages: [...nextMessages, {speaker: 'character', text}],
}
: current,
);
},
},
);
} catch (error) {
console.error('Failed to stream character panel chat reply:', error);
setCharacterChatModal(current =>
current && current.target.character.id === target.character.id
? {
...current,
draft,
messages: baseMessages,
isSending: false,
isLoadingSuggestions: false,
error: error instanceof Error ? error.message : '未知 AI 错误',
suggestions: current.suggestions.length > 0
? current.suggestions
: buildLocalCharacterChatSuggestions(target.character),
}
: current,
);
return;
}
const finalMessages = trimCharacterChatHistory([
...nextMessages,
{
speaker: 'character' as const,
text: replyText,
},
]);
let nextSummary = existingRecord.summary;
try {
nextSummary = await generateCharacterPanelChatSummary(
gameState.worldType,
gameState.playerCharacter,
target.character,
gameState.storyHistory,
buildStoryContextFromState(gameState),
finalMessages,
existingRecord.summary,
buildTargetStatus(target),
);
} catch (error) {
console.error('Failed to summarize character chat:', error);
nextSummary = buildLocalCharacterChatSummary(target.character, finalMessages, existingRecord.summary);
}
const nextRecord: CharacterChatRecord = {
history: finalMessages,
summary: nextSummary,
updatedAt: new Date().toISOString(),
};
setGameState(current =>
buildCharacterChatRecordUpdate(current, target.character.id, nextRecord),
);
setCharacterChatModal(current =>
current && current.target.character.id === target.character.id
? {
...current,
messages: finalMessages,
summary: nextSummary,
isSending: false,
error: null,
}
: current,
);
await loadCharacterChatSuggestions(target, finalMessages, nextSummary);
};
const characterChatUi: CharacterChatUi = {
modal: characterChatModal,
openChat: openCharacterChat,
closeChat: () => setCharacterChatModal(null),
setDraft: (value: string) => setCharacterChatModal(current => (current ? {...current, draft: value} : current)),
useSuggestion: (value: string) => setCharacterChatModal(current => (current ? {...current, draft: value} : current)),
refreshSuggestions: () => {
if (!characterChatModal) {
return;
}
void loadCharacterChatSuggestions(
characterChatModal.target,
characterChatModal.messages,
characterChatModal.summary,
);
},
sendDraft: () => {
void sendCharacterChatDraft();
},
};
return {
characterChatModal,
setCharacterChatModal,
characterChatUi,
openCharacterChat,
sendCharacterChatDraft,
loadCharacterChatSuggestions,
clearCharacterChatModal: () => setCharacterChatModal(null),
};
}

View File

@@ -0,0 +1,546 @@
import type {
Dispatch,
SetStateAction,
} from 'react';
import {
buildEncounterEntryState,
hasEncounterEntity,
} from '../../data/encounterTransition';
import { rollHostileNpcLoot } from '../../data/hostileNpcPresets';
import { addInventoryItems } from '../../data/npcInteractions';
import { applyQuestProgressFromHostileNpcDefeat } from '../../data/questFlow';
import {
CALL_OUT_ENTRY_X_METERS,
createSceneEncounterPreview,
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import { generateNextStep } from '../../services/ai';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { createHistoryMoment } from '../../services/storyHistory';
import {
AnimationState,
type Character,
type Encounter,
type GameState,
type StoryMoment,
type StoryOption,
} from '../../types';
import type { EscapePlaybackSync } from '../combat/escapeFlow';
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
import type {
CommitGeneratedStateWithEncounterEntry,
GenerateStoryForState,
} from './progressionActions';
import type { BattleRewardSummary } from './uiTypes';
type RuntimeStatsIncrements = Partial<Pick<GameState['runtimeStats'], 'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'>>;
type BuildFallbackStoryForState = (
state: GameState,
character: Character,
fallbackText?: string,
) => StoryMoment;
type BuildStoryFromResponse = (
state: GameState,
character: Character,
response: StoryMoment,
availableOptions: StoryOption[] | null,
optionCatalog?: StoryOption[] | null,
) => StoryMoment;
type BuildNpcStory = (
state: GameState,
character: Character,
encounter: Encounter,
overrideText?: string,
) => StoryMoment;
type BuildStoryContextFromState = (
state: GameState,
extras?: { lastFunctionId?: string | null; observeSignsRequested?: boolean },
) => StoryGenerationContext;
type UpdateQuestLog = (
state: GameState,
updater: (quests: GameState['quests']) => GameState['quests'],
) => GameState;
type IncrementRuntimeStats = (
state: GameState,
increments: RuntimeStatsIncrements,
) => GameState;
function sleep(ms: number) {
return new Promise(resolve => window.setTimeout(resolve, ms));
}
function buildReasonedOptionCatalog(options: StoryOption[]) {
const seenFunctionIds = new Set<string>();
return options.filter(option => {
if (seenFunctionIds.has(option.functionId)) {
return false;
}
seenFunctionIds.add(option.functionId);
return true;
});
}
function buildHostileNpcBattleReward(
state: GameState,
afterSequence: GameState,
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneMonsters'],
): BattleRewardSummary | null {
if (!state.worldType || state.currentBattleNpcId || !state.inBattle || afterSequence.inBattle) {
return null;
}
const activeHostileNpcs = getResolvedSceneHostileNpcs(state);
const nextHostileNpcs = getResolvedSceneHostileNpcs(afterSequence);
const defeatedHostileNpcs = activeHostileNpcs.filter(hostileNpc =>
!nextHostileNpcs.some(nextHostileNpc => nextHostileNpc.id === hostileNpc.id),
);
if (defeatedHostileNpcs.length === 0) {
return null;
}
const rolledItems = rollHostileNpcLoot(
state,
defeatedHostileNpcs.map(hostileNpc => ({
id: hostileNpc.id,
name: hostileNpc.name,
})),
);
return {
id: `battle-reward-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
defeatedHostileNpcs: defeatedHostileNpcs.map(hostileNpc => ({
id: hostileNpc.id,
name: hostileNpc.name,
})),
items: addInventoryItems([], rolledItems),
};
}
export function 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,
fallbackCompanionName,
turnVisualMs,
}: {
gameState: GameState;
currentStory: StoryMoment | null;
isLoading: boolean;
setGameState: Dispatch<SetStateAction<GameState>>;
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
setBattleReward: Dispatch<SetStateAction<BattleRewardSummary | null>>;
buildResolvedChoiceState: (state: GameState, option: StoryOption, character: Character) => ResolvedChoiceState;
playResolvedChoice: (
state: GameState,
option: StoryOption,
character: Character,
resolvedChoice: ResolvedChoiceState,
sync?: EscapePlaybackSync,
) => Promise<GameState>;
buildStoryContextFromState: BuildStoryContextFromState;
buildStoryFromResponse: BuildStoryFromResponse;
buildFallbackStoryForState: BuildFallbackStoryForState;
generateStoryForState: GenerateStoryForState;
getAvailableOptionsForState: (state: GameState, character: Character) => StoryOption[] | null;
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneMonsters'];
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneMonsters'];
buildNpcStory: BuildNpcStory;
updateQuestLog: UpdateQuestLog;
incrementRuntimeStats: IncrementRuntimeStats;
getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
startOpeningAdventure: () => Promise<void>;
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
handleNpcInteraction: (option: StoryOption) => boolean;
handleTreasureInteraction: (option: StoryOption) => void | Promise<void> | boolean;
commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry;
finalizeNpcBattleResult: (
state: GameState,
character: Character,
battleMode: NonNullable<GameState['currentNpcBattleMode']>,
battleOutcome: GameState['currentNpcBattleOutcome'],
) => { nextState: GameState; resultText: string } | null;
isContinueAdventureOption: (option: StoryOption) => boolean;
isCampTravelHomeOption: (option: StoryOption) => boolean;
isInitialCompanionEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
isNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
npcPreviewTalkFunctionId: string;
fallbackCompanionName: string;
turnVisualMs: number;
}) {
const handleCampTravelHome = async (option: StoryOption, character: Character) => {
const targetScene = getCampCompanionTravelScene(gameState, character);
if (!targetScene) {
return;
}
setBattleReward(null);
setAiError(null);
const companionName = isNpcEncounter(gameState.currentEncounter)
? gameState.currentEncounter.npcName
: fallbackCompanionName;
const travelRunState: GameState = {
...gameState,
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.RUN,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: true,
inBattle: false,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
const travelBaseState: GameState = incrementRuntimeStats({
...gameState,
ambientIdleMode: undefined,
currentScenePreset: targetScene,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: false,
inBattle: false,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
}, {
scenesTraveled: 1,
});
const travelPreviewState: GameState = {
...travelBaseState,
...createSceneEncounterPreview(travelBaseState),
};
const resolvedState = hasEncounterEntity(travelPreviewState)
? resolveSceneEncounterPreview(travelPreviewState)
: travelBaseState;
const entryState = buildEncounterEntryState(resolvedState, CALL_OUT_ENTRY_X_METERS);
setIsLoading(true);
setGameState(travelRunState);
await sleep(turnVisualMs);
await commitGeneratedStateWithEncounterEntry(
entryState,
resolvedState,
character,
option.actionText,
`You and ${companionName} leave camp and formally step into ${targetScene.name} to begin the adventure.`,
option.functionId,
);
return;
};
const handleChoice = async (option: StoryOption) => {
const character = gameState.playerCharacter;
if (!gameState.worldType || !character || isLoading) return;
if (currentStory?.deferredOptions?.length && isContinueAdventureOption(option)) {
setCurrentStory({
...currentStory,
options: currentStory.deferredOptions,
deferredOptions: undefined,
});
return;
}
if (isCampTravelHomeOption(option)) {
await handleCampTravelHome(option, character);
return;
}
if (
option.functionId === npcPreviewTalkFunctionId
&& isInitialCompanionEncounter(gameState.currentEncounter)
&& !gameState.npcInteractionActive
) {
setAiError(null);
void startOpeningAdventure();
return;
}
if (
option.functionId === npcPreviewTalkFunctionId
&& isRegularNpcEncounter(gameState.currentEncounter)
&& !gameState.npcInteractionActive
) {
setAiError(null);
enterNpcInteraction(gameState.currentEncounter, option.actionText);
return;
}
if (option.interaction?.kind === 'npc') {
setAiError(null);
handleNpcInteraction(option);
return;
}
if (option.interaction?.kind === 'treasure') {
setAiError(null);
handleTreasureInteraction(option);
return;
}
setBattleReward(null);
setAiError(null);
setIsLoading(true);
const baseChoiceState = (
isRegularNpcEncounter(gameState.currentEncounter)
&& !gameState.npcInteractionActive
&& !option.interaction
)
? {
...gameState,
currentEncounter: null,
npcInteractionActive: false,
}
: gameState;
let fallbackState = baseChoiceState;
try {
const history = baseChoiceState.storyHistory;
const resolvedChoice = buildResolvedChoiceState(baseChoiceState, option, character);
const projectedState = resolvedChoice.afterSequence;
const shouldUseLocalNpcVictory = Boolean(
baseChoiceState.currentBattleNpcId &&
resolvedChoice.optionKind === 'battle' &&
(
projectedState.currentNpcBattleOutcome ||
(baseChoiceState.currentNpcBattleMode === 'fight' && !projectedState.inBattle)
),
);
const projectedBattleReward = shouldUseLocalNpcVictory
? null
: buildHostileNpcBattleReward(baseChoiceState, projectedState, getResolvedSceneHostileNpcs);
const projectedStateWithBattleReward = projectedBattleReward
? {
...projectedState,
playerInventory: addInventoryItems(projectedState.playerInventory, projectedBattleReward.items),
}
: projectedState;
fallbackState = projectedStateWithBattleReward;
const projectedAvailableOptions = getAvailableOptionsForState(
projectedStateWithBattleReward,
character,
);
const responsePromise = shouldUseLocalNpcVictory
? Promise.resolve(null)
: generateNextStep(
gameState.worldType,
character,
getStoryGenerationHostileNpcs(projectedStateWithBattleReward),
history,
option.actionText,
buildStoryContextFromState(projectedStateWithBattleReward, {
lastFunctionId: option.functionId,
observeSignsRequested: option.functionId === 'idle_observe_signs',
}),
projectedAvailableOptions ? { availableOptions: projectedAvailableOptions } : undefined,
);
const responseSettledPromise = responsePromise.then(() => undefined, () => undefined);
const playbackSync: EscapePlaybackSync | undefined = resolvedChoice.optionKind === 'escape'
? { waitForStoryResponse: responseSettledPromise }
: undefined;
const actionPromise = playResolvedChoice(
baseChoiceState,
option,
character,
resolvedChoice,
playbackSync,
);
const [actionResult, responseResult] = await Promise.allSettled([actionPromise, responsePromise]);
if (actionResult.status === 'rejected') {
throw actionResult.reason;
}
let afterSequence = shouldUseLocalNpcVictory ? resolvedChoice.afterSequence : actionResult.value;
if (projectedBattleReward) {
afterSequence = {
...afterSequence,
playerInventory: addInventoryItems(afterSequence.playerInventory, projectedBattleReward.items),
};
}
fallbackState = afterSequence;
if (shouldUseLocalNpcVictory) {
const victory = finalizeNpcBattleResult(
afterSequence,
character,
baseChoiceState.currentNpcBattleMode!,
afterSequence.currentNpcBattleOutcome,
);
if (victory) {
const historyBase = baseChoiceState.currentNpcBattleMode === 'spar'
? (afterSequence.sparStoryHistoryBefore ?? [])
: baseChoiceState.storyHistory;
const nextHistory = [
...historyBase,
createHistoryMoment(victory.resultText, 'result'),
];
const nextState = {
...victory.nextState,
storyHistory: nextHistory,
};
const postBattleOptionCatalog = baseChoiceState.currentNpcBattleMode === 'spar' && nextState.currentEncounter
? buildReasonedOptionCatalog(
buildNpcStory(
nextState,
character,
nextState.currentEncounter,
).options,
)
: null;
fallbackState = nextState;
setGameState(nextState);
try {
const nextStory = await generateStoryForState({
state: nextState,
character,
history: nextHistory,
choice: option.actionText,
lastFunctionId: option.functionId,
optionCatalog: postBattleOptionCatalog,
});
setCurrentStory(nextStory);
} catch (storyError) {
console.error('Failed to continue npc battle resolution story:', storyError);
setAiError(storyError instanceof Error ? storyError.message : '未知 AI 错误');
setCurrentStory(buildFallbackStoryForState(nextState, character, victory.resultText));
}
return;
}
}
if (responseResult.status === 'rejected') {
throw responseResult.reason;
}
const response = responseResult.value!;
const defeatedHostileNpcIds = baseChoiceState.currentBattleNpcId
? []
: getResolvedSceneHostileNpcs(baseChoiceState)
.map(hostileNpc => hostileNpc.id)
.filter(hostileNpcId => !getResolvedSceneHostileNpcs(afterSequence).some(hostileNpc => hostileNpc.id === hostileNpcId));
const nextHistory = [
...baseChoiceState.storyHistory,
createHistoryMoment(option.actionText, 'action'),
createHistoryMoment(response.storyText, 'result', response.options),
];
const nextState = incrementRuntimeStats({
...updateQuestLog(
afterSequence,
quests => applyQuestProgressFromHostileNpcDefeat(
quests,
baseChoiceState.currentScenePreset?.id ?? null,
defeatedHostileNpcIds,
),
),
lastObserveSignsSceneId: option.functionId === 'idle_observe_signs'
? (afterSequence.currentScenePreset?.id ?? null)
: afterSequence.lastObserveSignsSceneId ?? null,
lastObserveSignsReport: option.functionId === 'idle_observe_signs'
? response.storyText
: afterSequence.lastObserveSignsReport ?? null,
storyHistory: nextHistory,
}, {
hostileNpcsDefeated: defeatedHostileNpcIds.length,
});
setGameState(nextState);
if (projectedBattleReward) {
setBattleReward(projectedBattleReward);
}
setCurrentStory(
buildStoryFromResponse(
nextState,
character,
{
text: response.storyText,
options: response.options,
},
projectedAvailableOptions,
),
);
} catch (error) {
console.error('Failed to get next step:', error);
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
setCurrentStory(buildFallbackStoryForState(fallbackState, character));
} finally {
setIsLoading(false);
}
};
return {
handleChoice,
};
}

View File

@@ -0,0 +1,44 @@
import type { GameState } from '../../types';
import type { CommitGeneratedState } from '../generatedState';
import { useEquipmentFlow } from '../useEquipmentFlow';
import { useForgeFlow } from '../useForgeFlow';
import { useInventoryFlow } from '../useInventoryFlow';
import type { InventoryFlowUi } from './uiTypes';
type TickCooldowns = (cooldowns: Record<string, number>) => Record<string, number>;
export function useStoryInventoryActions({
gameState,
commitGeneratedState,
tickCooldowns,
}: {
gameState: GameState;
commitGeneratedState: CommitGeneratedState;
tickCooldowns: TickCooldowns;
}) {
const inventoryFlow = useInventoryFlow({
gameState,
commitGeneratedState,
tickCooldowns,
});
const equipmentFlow = useEquipmentFlow({
gameState,
commitGeneratedState,
});
const forgeFlow = useForgeFlow({
gameState,
commitGeneratedState,
});
return {
inventoryUi: {
useInventoryItem: inventoryFlow.handleUseInventoryItem,
equipInventoryItem: equipmentFlow.handleEquipInventoryItem,
unequipItem: equipmentFlow.handleUnequipItem,
forgeRecipes: forgeFlow.forgeRecipes,
craftRecipe: forgeFlow.handleCraftRecipe,
dismantleItem: forgeFlow.handleDismantleItem,
reforgeItem: forgeFlow.handleReforgeItem,
} satisfies InventoryFlowUi,
};
}

View File

@@ -0,0 +1,837 @@
import type { Dispatch, SetStateAction } from 'react';
import { buildRelationState } from '../../data/attributeResolver';
import { hasEncounterEntity } from '../../data/encounterTransition';
import { NPC_PREVIEW_TALK_FUNCTION } from '../../data/functionCatalog';
import {
addInventoryItems,
buildNpcChatResultText,
buildNpcHelpResultText,
buildNpcHelpReward,
buildNpcLeaveResultText,
buildNpcSparResultText,
createNpcBattleMonster,
getChatAffinityOutcome,
getNpcLootItems,
getNpcSparMaxHp,
markNpcFirstMeaningfulContactResolved,
NPC_SPAR_AFFINITY_GAIN,
removeInventoryItem,
} from '../../data/npcInteractions';
import {
acceptQuest,
applyQuestProgressFromHostileNpcDefeat,
applyQuestProgressFromSpar,
buildQuestAcceptResultText,
buildQuestForEncounter,
buildQuestTurnInResultText,
findQuestById,
getQuestForIssuer,
markQuestTurnedIn,
} from '../../data/questFlow';
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
import {
createSceneCallOutEncounter,
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import { generateNextStep, streamNpcChatDialogue } from '../../services/ai';
import type { StoryGenerationContext } from '../../services/aiTypes';
import type {
Character,
Encounter,
GameState,
InventoryItem,
NpcBattleMode,
NpcBattleOutcome,
StoryMoment,
StoryOption,
} from '../../types';
import { AnimationState } from '../../types';
import type { CommitGeneratedState } from '../generatedState';
type CommitGeneratedStateWithEncounterEntry = (
entryState: GameState,
resolvedState: GameState,
character: Character,
actionText: string,
resultText: string,
lastFunctionId?: string,
) => Promise<void> | void;
type NpcInteractionFlowActions = {
openTradeModal: (encounter: Encounter, actionText: string) => void;
openGiftModal: (encounter: Encounter, actionText: string) => void;
openRecruitModal: (encounter: Encounter, actionText: string) => void;
startRecruitmentSequence: (
encounter: Encounter,
actionText: string,
) => Promise<void>;
};
type BuildStoryContextExtras = {
lastFunctionId?: string | null;
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
};
function buildCampCompanionChatResultText(
encounter: Encounter,
affinityGain: number,
_nextAffinity: number,
) {
const teamworkText =
affinityGain > 0
? 'You also feel a little more confident about how you will work together next.'
: 'You at least realign your rhythm for what comes next.';
return `${encounter.npcName}閸滃奔缍樻禍銈嗗床娴滃棔绔存潪顔藉厒濞夋洩绱?{describeNpcAffinityInWords(encounter, nextAffinity)}${teamworkText}`;
}
function isNpcEncounter(
encounter: GameState['currentEncounter'],
): encounter is Encounter {
return Boolean(encounter?.kind === 'npc');
}
export function createStoryNpcEncounterActions({
gameState,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
commitGeneratedState,
commitGeneratedStateWithEncounterEntry,
appendHistory,
buildOpeningCampChatContext,
buildStoryContextFromState,
buildFallbackStoryForState,
buildDialogueStoryMoment,
getStoryGenerationHostileNpcs,
getTypewriterDelay,
getAvailableOptionsForState,
sanitizeOptions,
sortOptions,
buildContinueAdventureOption,
getNpcEncounterKey,
getResolvedNpcState,
updateNpcState,
cloneInventoryItemForOwner,
resolveNpcInteractionDecision,
npcInteractionFlow,
}: {
gameState: GameState;
setGameState: Dispatch<SetStateAction<GameState>>;
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
commitGeneratedState: CommitGeneratedState;
commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry;
appendHistory: (
state: GameState,
actionText: string,
resultText: string,
) => GameState['storyHistory'];
buildOpeningCampChatContext: (
state: GameState,
character: Character,
encounter: Encounter,
) => BuildStoryContextExtras;
buildStoryContextFromState: (
state: GameState,
extras?: BuildStoryContextExtras,
) => StoryGenerationContext;
buildFallbackStoryForState: (
state: GameState,
character: Character,
fallbackText?: string,
) => StoryMoment;
buildDialogueStoryMoment: (
npcName: string,
text: string,
options: StoryOption[],
streaming?: boolean,
) => StoryMoment;
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneMonsters'];
getTypewriterDelay: (char: string) => number;
getAvailableOptionsForState: (
state: GameState,
character: Character,
) => StoryOption[] | null;
sanitizeOptions: (
options: StoryOption[],
character: Character,
state: GameState,
) => StoryOption[];
sortOptions: (options: StoryOption[]) => StoryOption[];
buildContinueAdventureOption: () => StoryOption;
getNpcEncounterKey: (encounter: Encounter) => string;
getResolvedNpcState: (
state: GameState,
encounter: Encounter,
) => GameState['npcStates'][string];
updateNpcState: (
state: GameState,
encounter: Encounter,
updater: (
npcState: GameState['npcStates'][string],
) => GameState['npcStates'][string],
) => GameState;
cloneInventoryItemForOwner: (
item: InventoryItem,
owner: 'player' | 'npc',
quantity?: number,
) => InventoryItem;
resolveNpcInteractionDecision: (
state: GameState,
option: StoryOption,
) => { kind: string };
npcInteractionFlow: NpcInteractionFlowActions;
}) {
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 finalizeNpcBattleResult = (
state: GameState,
character: Character,
battleMode: NpcBattleMode,
battleOutcome: NpcBattleOutcome | null,
) => {
if (!state.currentBattleNpcId) return null;
const battleNpcId = state.currentBattleNpcId;
const npcState = state.npcStates[battleNpcId];
if (!npcState) return null;
if (battleMode === 'spar' && battleOutcome === 'spar_complete') {
const nextAffinity = npcState.affinity + NPC_SPAR_AFFINITY_GAIN;
const restoredEncounter = state.sparReturnEncounter;
const progressedQuests = applyQuestProgressFromSpar(
state.quests,
battleNpcId,
);
const nextState = {
...state,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
currentEncounter: restoredEncounter,
npcInteractionActive: true,
sceneHostileNpcs: [],
npcStates: {
...state.npcStates,
[battleNpcId]: {
...markNpcFirstMeaningfulContactResolved(npcState),
affinity: nextAffinity,
relationState: buildRelationState(nextAffinity),
},
},
quests: progressedQuests,
playerX: 0,
playerHp: state.sparPlayerHpBefore ?? state.playerHp,
playerMaxHp: state.sparPlayerMaxHpBefore ?? state.playerMaxHp,
playerFacing: 'right' as const,
animationState: state.animationState,
activeCombatEffects: [],
scrollWorld: false,
inBattle: false,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
return {
nextState,
resultText: buildNpcSparResultText(
NPC_SPAR_AFFINITY_GAIN,
nextAffinity,
),
};
}
const lootItems = getNpcLootItems(npcState, character).map((item) =>
cloneInventoryItemForOwner(item, 'player'),
);
const defeatedHostileNpcIds = (
state.sceneHostileNpcs ?? state.sceneMonsters
).map((hostileNpc) => hostileNpc.id);
const progressedQuests = applyQuestProgressFromHostileNpcDefeat(
state.quests,
state.currentScenePreset?.id ?? null,
defeatedHostileNpcIds,
);
let nextNpcInventory = npcState.inventory;
for (const item of lootItems) {
nextNpcInventory = removeInventoryItem(nextNpcInventory, item.id, 1);
}
const nextState: GameState = incrementRuntimeStats(
{
...state,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerInventory: addInventoryItems(state.playerInventory, lootItems),
quests: progressedQuests,
npcStates: {
...state.npcStates,
[battleNpcId]: {
...markNpcFirstMeaningfulContactResolved(npcState),
affinity: 0,
relationState: buildRelationState(0),
recruited: false,
inventory: nextNpcInventory,
},
},
playerX: 0,
playerFacing: 'right' as const,
animationState: state.animationState,
activeCombatEffects: [],
scrollWorld: false,
inBattle: false,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
},
{
hostileNpcsDefeated: defeatedHostileNpcIds.length,
},
);
const lootText =
lootItems.length > 0
? lootItems.map((item) => item.name).join(', ')
: '无战利品';
return {
nextState,
resultText: `胜利奖励:${lootText}`,
};
};
const commitNpcChatState = async (
nextState: GameState,
character: Character,
encounter: Encounter,
actionText: string,
resultText: string,
lastFunctionId?: string,
contextNpcStateOverride?: GameState['npcStates'][string] | null,
) => {
const provisionalHistory = appendHistory(gameState, actionText, resultText);
const provisionalState = {
...nextState,
storyHistory: provisionalHistory,
};
const provisionalOpeningCampContext = buildOpeningCampChatContext(
provisionalState,
character,
encounter,
);
setGameState(provisionalState);
setAiError(null);
setIsLoading(true);
setCurrentStory(buildDialogueStoryMoment(encounter.npcName, '', [], true));
let dialogueText = '';
let streamedTargetText = '';
let displayedText = '';
let streamCompleted = false;
const typewriterPromise = (async () => {
while (
!streamCompleted ||
displayedText.length < streamedTargetText.length
) {
if (displayedText.length >= streamedTargetText.length) {
await new Promise((resolve) => window.setTimeout(resolve, 40));
continue;
}
const nextChar = streamedTargetText[displayedText.length];
if (!nextChar) {
await new Promise((resolve) => window.setTimeout(resolve, 40));
continue;
}
displayedText += nextChar;
setCurrentStory(
buildDialogueStoryMoment(encounter.npcName, displayedText, [], true),
);
await new Promise((resolve) =>
window.setTimeout(resolve, getTypewriterDelay(nextChar)),
);
}
})();
try {
dialogueText = await streamNpcChatDialogue(
gameState.worldType!,
character,
encounter,
getStoryGenerationHostileNpcs(provisionalState),
gameState.storyHistory,
buildStoryContextFromState(provisionalState, {
lastFunctionId,
...provisionalOpeningCampContext,
encounterNpcStateOverride: contextNpcStateOverride,
}),
actionText,
resultText,
{
onUpdate: (text) => {
streamedTargetText = text;
},
},
);
streamedTargetText = dialogueText;
streamCompleted = true;
await typewriterPromise;
const finalHistory = appendHistory(
gameState,
actionText,
dialogueText || resultText,
);
const finalState = {
...nextState,
storyHistory: finalHistory,
};
const availableOptions = getAvailableOptionsForState(
finalState,
character,
);
const finalOpeningCampContext = buildOpeningCampChatContext(
finalState,
character,
encounter,
);
setGameState(finalState);
const response = await generateNextStep(
gameState.worldType!,
character,
getStoryGenerationHostileNpcs(finalState),
finalHistory,
actionText,
buildStoryContextFromState(finalState, {
lastFunctionId,
...finalOpeningCampContext,
}),
availableOptions ? { availableOptions } : undefined,
);
const resolvedOptions = sortOptions(
availableOptions
? response.options
: sanitizeOptions(response.options, character, finalState),
);
setCurrentStory({
...buildDialogueStoryMoment(
encounter.npcName,
dialogueText || resultText,
[buildContinueAdventureOption()],
false,
),
deferredOptions: resolvedOptions,
});
} catch (error) {
streamCompleted = true;
await typewriterPromise;
console.error('Failed to stream npc chat story:', error);
setAiError(
error instanceof Error ? error.message : 'NPC 对话 AI 不可用。',
);
const fallbackOptions =
getAvailableOptionsForState(provisionalState, character) ?? [];
setCurrentStory(
displayedText
? {
...buildDialogueStoryMoment(
encounter.npcName,
displayedText,
fallbackOptions.length > 0
? [buildContinueAdventureOption()]
: [],
false,
),
deferredOptions:
fallbackOptions.length > 0
? sortOptions(fallbackOptions)
: undefined,
}
: buildFallbackStoryForState(provisionalState, character, resultText),
);
} finally {
setIsLoading(false);
}
};
const enterNpcInteraction = (encounter: Encounter, actionText: string) => {
if (!gameState.playerCharacter) return false;
const nextState: GameState = {
...gameState,
npcInteractionActive: true,
};
void commitGeneratedState(
nextState,
gameState.playerCharacter,
actionText,
`${encounter.npcName} turns their attention toward you, as if waiting for you to speak first.`,
NPC_PREVIEW_TALK_FUNCTION.id,
);
return true;
};
const handleNpcInteraction = (option: StoryOption) => {
if (
!gameState.playerCharacter ||
!option.interaction ||
!isNpcEncounter(gameState.currentEncounter)
) {
return false;
}
const encounter = gameState.currentEncounter;
const npcState = getResolvedNpcState(gameState, encounter);
const interactionDecision = resolveNpcInteractionDecision(
gameState,
option,
);
if (interactionDecision.kind === 'trade_modal') {
npcInteractionFlow.openTradeModal(encounter, option.actionText);
return true;
}
if (interactionDecision.kind === 'gift_modal') {
npcInteractionFlow.openGiftModal(encounter, option.actionText);
return true;
}
if (interactionDecision.kind === 'recruit_modal') {
npcInteractionFlow.openRecruitModal(encounter, option.actionText);
return true;
}
if (interactionDecision.kind === 'recruit_immediate') {
void npcInteractionFlow.startRecruitmentSequence(
encounter,
option.actionText,
);
return true;
}
switch (option.interaction.action) {
case 'help': {
const reward = buildNpcHelpReward(encounter);
let cooldowns = gameState.playerSkillCooldowns;
for (let index = 0; index < (reward.cooldownBonus ?? 0); index += 1) {
cooldowns = Object.fromEntries(
Object.entries(cooldowns).map(([skillId, turns]) => [
skillId,
Math.max(0, turns - 1),
]),
);
}
let nextState = updateNpcState(
gameState,
encounter,
(currentNpcState) => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
helpUsed: true,
}),
);
nextState = {
...nextState,
playerHp: Math.min(
nextState.playerMaxHp,
nextState.playerHp + (reward.hp ?? 0),
),
playerMana: Math.min(
nextState.playerMaxMana,
nextState.playerMana + (reward.mana ?? 0),
),
playerSkillCooldowns: cooldowns,
playerInventory: reward.item
? addInventoryItems(nextState.playerInventory, [
cloneInventoryItemForOwner(reward.item, 'player'),
])
: nextState.playerInventory,
};
void commitGeneratedState(
nextState,
gameState.playerCharacter,
option.actionText,
buildNpcHelpResultText(encounter, reward),
option.functionId,
);
return true;
}
case 'chat': {
const chatOutcome = getChatAffinityOutcome({
playerCharacter: gameState.playerCharacter,
encounter,
npcState,
actionText: option.actionText,
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
});
const affinityGain = chatOutcome.affinityGain;
const attributeSummary = chatOutcome.summary;
let nextAffinity = npcState.affinity;
const nextState = updateNpcState(
gameState,
encounter,
(currentNpcState) => {
nextAffinity = currentNpcState.affinity + affinityGain;
return {
...markNpcFirstMeaningfulContactResolved(currentNpcState),
affinity: nextAffinity,
relationState: buildRelationState(nextAffinity),
chattedCount: currentNpcState.chattedCount + 1,
};
},
);
void commitNpcChatState(
nextState,
gameState.playerCharacter,
encounter,
option.actionText,
npcState.recruited
? buildCampCompanionChatResultText(
encounter,
affinityGain,
nextAffinity,
)
: buildNpcChatResultText(
encounter,
affinityGain,
nextAffinity,
attributeSummary,
),
option.functionId,
npcState,
);
return true;
}
case 'quest_accept': {
const existingQuest = getQuestForIssuer(
gameState.quests,
getNpcEncounterKey(encounter),
);
if (existingQuest) return true;
const quest = buildQuestForEncounter({
issuerNpcId: getNpcEncounterKey(encounter),
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene: gameState.currentScenePreset,
worldType: gameState.worldType,
});
if (!quest) return true;
const nextState = incrementRuntimeStats(
updateNpcState(
updateQuestLog(gameState, (quests) => acceptQuest(quests, quest)),
encounter,
(currentNpcState) =>
markNpcFirstMeaningfulContactResolved(currentNpcState),
),
{ questsAccepted: 1 },
);
void commitGeneratedState(
nextState,
gameState.playerCharacter,
option.actionText,
buildQuestAcceptResultText(quest),
option.functionId,
);
return true;
}
case 'quest_turn_in': {
const questId = option.interaction.questId;
const quest = questId ? findQuestById(gameState.quests, questId) : null;
if (!quest || quest.status !== 'completed') return true;
const nextState = {
...updateQuestLog(gameState, (quests) =>
markQuestTurnedIn(quests, quest.id),
),
npcStates: {
...gameState.npcStates,
[getNpcEncounterKey(encounter)]: {
...npcState,
...markNpcFirstMeaningfulContactResolved(npcState),
affinity: npcState.affinity + quest.reward.affinityBonus,
relationState: buildRelationState(
npcState.affinity + quest.reward.affinityBonus,
),
},
},
playerCurrency: gameState.playerCurrency + quest.reward.currency,
playerInventory: addInventoryItems(
gameState.playerInventory,
quest.reward.items,
),
};
void commitGeneratedState(
nextState,
gameState.playerCharacter,
option.actionText,
buildQuestTurnInResultText(quest),
option.functionId,
);
return true;
}
case 'leave': {
const baseState: GameState = {
...gameState,
ambientIdleMode: undefined,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: gameState.animationState,
scrollWorld: false,
inBattle: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
const entryState = {
...baseState,
...createSceneCallOutEncounter(baseState),
} as GameState;
const resolvedState = hasEncounterEntity(entryState)
? resolveSceneEncounterPreview(entryState)
: baseState;
void commitGeneratedStateWithEncounterEntry(
entryState,
resolvedState,
gameState.playerCharacter,
option.actionText,
buildNpcLeaveResultText(encounter),
option.functionId,
);
return true;
}
case 'fight': {
const nextState = {
...gameState,
npcStates: {
...gameState.npcStates,
[getNpcEncounterKey(encounter)]: markNpcFirstMeaningfulContactResolved(
npcState,
),
},
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [
createNpcBattleMonster(encounter, npcState, 'fight'),
],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
scrollWorld: false,
inBattle: true,
currentBattleNpcId: getNpcEncounterKey(encounter),
currentNpcBattleMode: 'fight' as const,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
void commitGeneratedState(
nextState,
gameState.playerCharacter,
option.actionText,
`You lunge at ${encounter.npcName} with clear hostile intent, and the atmosphere turns dangerous at once.`,
option.functionId,
);
return true;
}
case 'spar': {
const sparPlayerMaxHp = getNpcSparMaxHp(gameState.playerCharacter);
const nextState = {
...gameState,
npcStates: {
...gameState.npcStates,
[getNpcEncounterKey(encounter)]: markNpcFirstMeaningfulContactResolved(
npcState,
),
},
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [
createNpcBattleMonster(encounter, npcState, 'spar'),
],
playerX: 0,
playerHp: sparPlayerMaxHp,
playerMaxHp: sparPlayerMaxHp,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
scrollWorld: false,
inBattle: true,
currentBattleNpcId: getNpcEncounterKey(encounter),
currentNpcBattleMode: 'spar' as const,
currentNpcBattleOutcome: null,
sparReturnEncounter: encounter,
sparPlayerHpBefore: gameState.playerHp,
sparPlayerMaxHpBefore: gameState.playerMaxHp,
sparStoryHistoryBefore: gameState.storyHistory,
};
void commitGeneratedState(
nextState,
gameState.playerCharacter,
option.actionText,
`${encounter.npcName} salutes you and agrees to keep the spar controlled and respectful.`,
option.functionId,
);
return true;
}
default:
return false;
}
};
return {
enterNpcInteraction,
handleNpcInteraction,
finalizeNpcBattleResult,
};
}

View File

@@ -0,0 +1,668 @@
import type {
Dispatch,
SetStateAction,
} from 'react';
import { useState } from 'react';
import { buildRelationState } from '../../data/attributeResolver';
import {
buildCompanionState,
getCharacterById,
resolveEncounterRecruitCharacter,
} from '../../data/characterPresets';
import { recruitCompanionToParty } from '../../data/companionRoster';
import {
getNpcBuybackPrice,
getNpcPurchasePrice,
} from '../../data/economy';
import {
addInventoryItems,
buildNpcGiftResultText,
buildNpcRecruitResultText,
buildNpcTradeTransactionResultText,
getGiftCandidates,
markNpcFirstMeaningfulContactResolved,
removeInventoryItem,
} from '../../data/npcInteractions';
import { streamNpcRecruitDialogue } from '../../services/ai';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
Character,
Encounter,
GameState,
InventoryItem,
StoryMoment,
StoryOption,
} from '../../types';
import type { CommitGeneratedState } from '../generatedState';
import type {
GiftModalState,
RecruitModalState,
StoryGenerationNpcUi,
TradeModalState,
} from './uiTypes';
type GenerateStoryForState = (params: {
state: GameState;
character: Character;
history: StoryMoment[];
choice?: string;
lastFunctionId?: string | null;
optionCatalog?: StoryOption[] | null;
}) => Promise<StoryMoment>;
type StoryNpcInteractionRuntime = {
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
buildStoryContextFromState: (
state: GameState,
extras?: { lastFunctionId?: string | null },
) => StoryGenerationContext;
buildFallbackStoryForState: (
state: GameState,
character: Character,
fallbackText?: string,
) => StoryMoment;
buildDialogueStoryMoment: (
npcName: string,
text: string,
options: StoryOption[],
streaming?: boolean,
) => StoryMoment;
generateStoryForState: GenerateStoryForState;
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneMonsters'];
getTypewriterDelay: (char: string) => number;
};
function buildOfflineRecruitDialogue(encounter: Encounter, releasedCompanionName?: string | null) {
const releaseLine = releasedCompanionName
? `你:如果你愿意加入,我会先让${releasedCompanionName}暂时离队,把位置腾给你。`
: '你:如果你愿意加入,我希望接下来能和你并肩行动。';
return [
'你:我不是一时起意,我想正式邀请你加入我的队伍。',
`${encounter.npcName}:你这话说得够直接,不过我更在意,你是否真的清楚自己接下来要面对什么。`,
releaseLine,
`${encounter.npcName}:既然你已经想清楚了,那我就跟你走这一程,看看你能把这条路走到哪里。`,
].join('\n');
}
function normalizeRecruitDialogue(
encounter: Encounter,
dialogueText: string,
releasedCompanionName?: string | null,
) {
const rawLines = dialogueText
.replace(/\r/g, '')
.split('\n')
.map(line => line.trim())
.filter(Boolean);
const refusalPattern = /|||||||||||/u;
const sanitizedLines = rawLines.filter(line => !refusalPattern.test(line));
const npcPrefix = `${encounter.npcName}`;
const playerPrefix = '你:';
const releaseLine = releasedCompanionName
? `${playerPrefix}我会先让${releasedCompanionName}暂时离队,把位置腾给你。`
: `${playerPrefix}我会把接下来的路安排好,和你并肩前行。`;
const defaultLines = [
`${playerPrefix}我是真心邀请你加入队伍,不是随口一提。`,
`${npcPrefix}你的意思我已经听明白了,我更在意你是否真的准备好了。`,
releaseLine,
`${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`,
];
const workingLines = sanitizedLines.length > 0 ? sanitizedLines.slice(0, 5) : defaultLines.slice(0, 3);
if (!workingLines.some(line => line.startsWith(playerPrefix))) {
const firstDefaultLine = defaultLines[0];
if (firstDefaultLine) {
workingLines.unshift(firstDefaultLine);
}
}
if (!workingLines.some(line => line.startsWith(npcPrefix))) {
const secondDefaultLine = defaultLines[1];
if (secondDefaultLine) {
workingLines.push(secondDefaultLine);
}
}
const acceptanceLine = `${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`;
const lastWorkingLine = workingLines[workingLines.length - 1];
if (
workingLines.length === 0
|| !lastWorkingLine?.startsWith(npcPrefix)
|| refusalPattern.test(lastWorkingLine)
) {
workingLines.push(acceptanceLine);
} else {
workingLines[workingLines.length - 1] = acceptanceLine;
}
const compactLines = workingLines.slice(0, 5);
if (compactLines[compactLines.length - 1] !== acceptanceLine) {
compactLines.push(acceptanceLine);
}
return compactLines.slice(0, 6).join('\n');
}
export function useStoryNpcInteractionFlow({
gameState,
setGameState,
commitGeneratedState,
getNpcEncounterKey,
getResolvedNpcState,
updateNpcState,
cloneInventoryItemForOwner,
runtime,
}: {
gameState: GameState;
setGameState: Dispatch<SetStateAction<GameState>>;
commitGeneratedState: CommitGeneratedState;
getNpcEncounterKey: (encounter: Encounter) => string;
getResolvedNpcState: (state: GameState, encounter: Encounter) => GameState['npcStates'][string];
updateNpcState: (
state: GameState,
encounter: Encounter,
updater: (npcState: GameState['npcStates'][string]) => GameState['npcStates'][string],
) => GameState;
cloneInventoryItemForOwner: (
item: InventoryItem,
owner: 'player' | 'npc',
quantity?: number,
) => InventoryItem;
runtime: StoryNpcInteractionRuntime;
}) {
const [tradeModal, setTradeModal] = useState<TradeModalState | null>(null);
const [giftModal, setGiftModal] = useState<GiftModalState | null>(null);
const [recruitModal, setRecruitModal] = useState<RecruitModalState | null>(null);
const getTradeNpcItem = (state: GameState, modal: TradeModalState) => {
const npcState = getResolvedNpcState(state, modal.encounter);
return npcState.inventory.find(item => item.id === modal.selectedNpcItemId) ?? null;
};
const getTradePlayerItem = (state: GameState, modal: TradeModalState) =>
state.playerInventory.find(item => item.id === modal.selectedPlayerItemId) ?? null;
const getTradeUnitPrice = (state: GameState, modal: TradeModalState) => {
if (modal.mode === 'buy') {
const npcItem = getTradeNpcItem(state, modal);
const npcState = getResolvedNpcState(state, modal.encounter);
return npcItem ? getNpcPurchasePrice(npcItem, npcState.affinity) : 0;
}
const playerItem = getTradePlayerItem(state, modal);
const npcState = getResolvedNpcState(state, modal.encounter);
return playerItem ? getNpcBuybackPrice(playerItem, npcState.affinity) : 0;
};
const getTradeMaxQuantity = (state: GameState, modal: TradeModalState) => {
if (modal.mode === 'buy') {
return getTradeNpcItem(state, modal)?.quantity ?? 0;
}
return getTradePlayerItem(state, modal)?.quantity ?? 0;
};
const clampTradeQuantity = (state: GameState, modal: TradeModalState, quantity: number) => {
const maxQuantity = getTradeMaxQuantity(state, modal);
if (maxQuantity <= 0) return 1;
return Math.max(1, Math.min(maxQuantity, Math.floor(quantity)));
};
const buildRecruitmentOutcome = (
encounter: Encounter,
releasedNpcId?: string | null,
) => {
if (!gameState.playerCharacter) return null;
const npcState = getResolvedNpcState(gameState, encounter);
const recruitKey = getNpcEncounterKey(encounter);
let releasedCompanionName: string | null = null;
const nextNpcStates = {
...gameState.npcStates,
[recruitKey]: {
...markNpcFirstMeaningfulContactResolved(npcState),
recruited: true,
},
};
if (releasedNpcId) {
const releasedCompanion = gameState.companions.find(item => item.npcId === releasedNpcId);
releasedCompanionName = releasedCompanion?.characterId
? getCharacterById(releasedCompanion.characterId)?.name ?? null
: null;
}
const recruitCharacter = resolveEncounterRecruitCharacter(encounter);
if (!recruitCharacter) return null;
const recruitedCompanion = buildCompanionState(
recruitKey,
recruitCharacter,
npcState.affinity,
);
const rosterState = recruitCompanionToParty(gameState, recruitedCompanion, releasedNpcId);
const nextState: GameState = {
...rosterState,
npcStates: nextNpcStates,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: gameState.animationState,
scrollWorld: false,
inBattle: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
ambientIdleMode: undefined,
activeCombatEffects: [],
};
return {
nextState,
releasedCompanionName,
};
};
const executeRecruitment = (
encounter: Encounter,
actionText: string,
releasedNpcId?: string | null,
preludeText?: string | null,
) => {
if (!gameState.playerCharacter) return;
const outcome = buildRecruitmentOutcome(encounter, releasedNpcId);
if (!outcome) return;
const recruitResultText = buildNpcRecruitResultText(encounter, outcome.releasedCompanionName);
setRecruitModal(null);
if (!preludeText) {
void commitGeneratedState(
outcome.nextState,
gameState.playerCharacter,
actionText,
recruitResultText,
'npc_recruit',
);
return;
}
const nextHistory = [
...gameState.storyHistory,
createHistoryMoment(actionText, 'action'),
createHistoryMoment(preludeText, 'result'),
createHistoryMoment(recruitResultText, 'result'),
];
const stateWithHistory = {
...outcome.nextState,
storyHistory: nextHistory,
};
setGameState(stateWithHistory);
runtime.setAiError(null);
void runtime.generateStoryForState({
state: stateWithHistory,
character: gameState.playerCharacter,
history: nextHistory,
choice: actionText,
lastFunctionId: 'npc_recruit',
})
.then(nextStory => {
runtime.setCurrentStory(nextStory);
})
.catch(error => {
console.error('Failed to continue recruit story:', error);
runtime.setAiError(error instanceof Error ? error.message : '未知 AI 错误');
runtime.setCurrentStory(
runtime.buildFallbackStoryForState(stateWithHistory, gameState.playerCharacter!, recruitResultText),
);
})
.finally(() => {
runtime.setIsLoading(false);
});
};
const startRecruitmentSequence = async (
encounter: Encounter,
actionText: string,
releasedNpcId?: string | null,
) => {
if (!gameState.playerCharacter) return;
const releasedCompanionName = releasedNpcId
? (() => {
const releasedCompanion = gameState.companions.find(item => item.npcId === releasedNpcId);
return releasedCompanion?.characterId
? getCharacterById(releasedCompanion.characterId)?.name ?? null
: null;
})()
: null;
const provisionalState = {
...gameState,
};
const recruitPromptSummary = releasedCompanionName
? `如果对方答应加入,你会先让${releasedCompanionName}离队,为新同伴腾出位置。`
: '如果对方答应加入,你们将立刻结伴同行。';
setRecruitModal(null);
runtime.setAiError(null);
runtime.setIsLoading(true);
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true));
let dialogueText = '';
let streamedTargetText = '';
let displayedText = '';
let streamCompleted = false;
const typewriterPromise = (async () => {
while (!streamCompleted || displayedText.length < streamedTargetText.length) {
if (displayedText.length >= streamedTargetText.length) {
await new Promise(resolve => window.setTimeout(resolve, 40));
continue;
}
const nextChar = streamedTargetText[displayedText.length];
if (!nextChar) {
await new Promise(resolve => window.setTimeout(resolve, 40));
continue;
}
displayedText += nextChar;
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, displayedText, [], true));
await new Promise(resolve => window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar)));
}
})();
try {
dialogueText = await streamNpcRecruitDialogue(
gameState.worldType!,
gameState.playerCharacter,
encounter,
runtime.getStoryGenerationHostileNpcs(provisionalState),
gameState.storyHistory,
runtime.buildStoryContextFromState(provisionalState, {
lastFunctionId: 'npc_recruit',
}),
actionText,
recruitPromptSummary,
{
onUpdate: text => {
streamedTargetText = text;
},
},
);
streamedTargetText = dialogueText;
streamCompleted = true;
await typewriterPromise;
} catch (error) {
streamCompleted = true;
await typewriterPromise;
console.error('Failed to stream recruit dialogue:', error);
dialogueText = displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName);
runtime.setAiError(error instanceof Error ? error.message : '未知 AI 错误');
}
const finalDialogueText = normalizeRecruitDialogue(
encounter,
dialogueText || displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName),
releasedCompanionName,
);
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, finalDialogueText, [], false));
await new Promise(resolve => window.setTimeout(resolve, 260));
executeRecruitment(encounter, actionText, releasedNpcId, finalDialogueText);
};
const openTradeModal = (encounter: Encounter, actionText: string) => {
const npcState = getResolvedNpcState(gameState, encounter);
setTradeModal({
encounter,
actionText,
mode: 'buy',
selectedNpcItemId: npcState.inventory[0]?.id ?? null,
selectedPlayerItemId: gameState.playerInventory[0]?.id ?? null,
selectedQuantity: 1,
});
};
const openGiftModal = (encounter: Encounter, actionText: string) => {
setGiftModal({
encounter,
actionText,
selectedItemId: gameState.playerInventory[0]?.id ?? null,
});
};
const openRecruitModal = (encounter: Encounter, actionText: string) => {
setRecruitModal({
encounter,
actionText,
selectedReleaseNpcId: gameState.companions[0]?.npcId ?? null,
});
};
const clearNpcInteractionUi = () => {
setTradeModal(null);
setGiftModal(null);
setRecruitModal(null);
};
const confirmTrade = () => {
if (!tradeModal || !gameState.playerCharacter) return;
const encounter = tradeModal.encounter;
const quantity = clampTradeQuantity(gameState, tradeModal, tradeModal.selectedQuantity);
const unitPrice = getTradeUnitPrice(gameState, tradeModal);
const totalPrice = unitPrice * quantity;
if (tradeModal.mode === 'buy') {
const npcItem = getTradeNpcItem(gameState, tradeModal);
if (!npcItem || quantity <= 0) return;
if (npcItem.quantity < quantity || gameState.playerCurrency < totalPrice) return;
let nextState = updateNpcState(
gameState,
encounter,
currentNpcState => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
inventory: removeInventoryItem(currentNpcState.inventory, npcItem.id, quantity),
}),
);
nextState = {
...nextState,
playerCurrency: nextState.playerCurrency - totalPrice,
playerInventory: addInventoryItems(
nextState.playerInventory,
[cloneInventoryItemForOwner(npcItem, 'player', quantity)],
),
};
setTradeModal(null);
void commitGeneratedState(
nextState,
gameState.playerCharacter,
tradeModal.actionText,
buildNpcTradeTransactionResultText({
encounter,
mode: 'buy',
item: npcItem,
quantity,
totalPrice,
worldType: gameState.worldType,
}),
'npc_trade',
);
return;
}
const playerItem = getTradePlayerItem(gameState, tradeModal);
if (!playerItem || quantity <= 0) return;
if (playerItem.quantity < quantity) return;
let nextState = updateNpcState(
gameState,
encounter,
currentNpcState => ({
...markNpcFirstMeaningfulContactResolved(currentNpcState),
inventory: addInventoryItems(
currentNpcState.inventory,
[cloneInventoryItemForOwner(playerItem, 'npc', quantity)],
),
}),
);
nextState = {
...nextState,
playerCurrency: nextState.playerCurrency + totalPrice,
playerInventory: removeInventoryItem(nextState.playerInventory, playerItem.id, quantity),
};
setTradeModal(null);
void commitGeneratedState(
nextState,
gameState.playerCharacter,
tradeModal.actionText,
buildNpcTradeTransactionResultText({
encounter,
mode: 'sell',
item: playerItem,
quantity,
totalPrice,
worldType: gameState.worldType,
}),
'npc_trade',
);
};
const confirmGift = () => {
if (!giftModal || !gameState.playerCharacter) return;
const encounter = giftModal.encounter;
const giftItem = gameState.playerInventory.find(item => item.id === giftModal.selectedItemId);
if (!giftItem) return;
const giftCandidate = getGiftCandidates(gameState.playerInventory, encounter, {
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
})
.find(candidate => candidate.item.id === giftItem.id);
const affinityGain = giftCandidate?.affinityGain ?? 0;
const attributeSummary = giftCandidate?.attributeInsight?.reasonText ?? null;
let nextAffinity = 0;
let nextState = updateNpcState(
gameState,
encounter,
currentNpcState => {
nextAffinity = currentNpcState.affinity + affinityGain;
return {
...markNpcFirstMeaningfulContactResolved(currentNpcState),
affinity: nextAffinity,
relationState: buildRelationState(nextAffinity),
giftsGiven: currentNpcState.giftsGiven + 1,
inventory: addInventoryItems(
currentNpcState.inventory,
[cloneInventoryItemForOwner(giftItem, 'npc')],
),
};
},
);
nextState = {
...nextState,
playerInventory: removeInventoryItem(nextState.playerInventory, giftItem.id, 1),
};
setGiftModal(null);
void commitGeneratedState(
nextState,
gameState.playerCharacter,
giftModal.actionText,
buildNpcGiftResultText(encounter, giftItem, affinityGain, nextAffinity, attributeSummary ?? undefined),
'npc_gift',
);
};
return {
npcUi: {
tradeModal,
giftModal,
recruitModal,
setTradeMode: (mode: 'buy' | 'sell') => setTradeModal(current => {
if (!current) return current;
const nextModal = {
...current,
mode,
selectedNpcItemId: current.selectedNpcItemId ?? getResolvedNpcState(gameState, current.encounter).inventory[0]?.id ?? null,
selectedPlayerItemId: current.selectedPlayerItemId ?? gameState.playerInventory[0]?.id ?? null,
selectedQuantity: 1,
};
return {
...nextModal,
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
};
}),
selectTradeNpcItem: (itemId: string) => setTradeModal(current => {
if (!current) return current;
const nextModal = {
...current,
selectedNpcItemId: itemId,
};
return {
...nextModal,
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
};
}),
selectTradePlayerItem: (itemId: string) => setTradeModal(current => {
if (!current) return current;
const nextModal = {
...current,
selectedPlayerItemId: itemId,
};
return {
...nextModal,
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
};
}),
setTradeQuantity: (quantity: number) => setTradeModal(current => current
? {
...current,
selectedQuantity: clampTradeQuantity(gameState, current, quantity),
}
: current),
closeTradeModal: () => setTradeModal(null),
confirmTrade,
selectGiftItem: (itemId: string) => setGiftModal(current => current ? { ...current, selectedItemId: itemId } : current),
closeGiftModal: () => setGiftModal(null),
confirmGift,
selectRecruitRelease: (npcId: string) => setRecruitModal(current => current ? { ...current, selectedReleaseNpcId: npcId } : current),
closeRecruitModal: () => setRecruitModal(null),
confirmRecruit: () => {
if (!recruitModal) return;
void startRecruitmentSequence(
recruitModal.encounter,
recruitModal.actionText,
recruitModal.selectedReleaseNpcId,
);
},
} satisfies StoryGenerationNpcUi,
openTradeModal,
openGiftModal,
openRecruitModal,
startRecruitmentSequence,
clearNpcInteractionUi,
};
}

View File

@@ -0,0 +1,328 @@
import type { Dispatch, SetStateAction } from 'react';
import {
hasEncounterEntity,
interpolateEncounterTransitionState,
} from '../../data/encounterTransition';
import { STORY_OPENING_CAMP_DIALOGUE_FUNCTION } from '../../data/functionCatalog';
import {
CALL_OUT_ENTRY_X_METERS,
RESOLVED_ENTITY_X_METERS,
} from '../../data/sceneEncounterPreviews';
import { getWorldCampScenePreset } from '../../data/scenePresets';
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
import { generateNextStep } from '../../services/ai';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
Character,
Encounter,
GameState,
StoryMoment,
StoryOption,
} from '../../types';
const ENCOUNTER_ENTRY_DURATION_MS = 1800;
const ENCOUNTER_ENTRY_TICK_MS = 180;
const OPENING_CAMP_DIALOGUE_FUNCTION_ID =
STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
export type PreparedOpeningAdventure = {
encounterKey: string;
actionText: string;
resultText: string;
fallbackText: string;
openingOptions: StoryOption[];
};
export function buildPreparedOpeningAdventure({
state,
character,
getNpcEncounterKey,
appendHistory,
buildCampCompanionOpeningOptions,
buildCampCompanionOpeningResultText,
buildInitialCompanionDialogueText,
}: {
state: GameState;
character: Character;
getNpcEncounterKey: (encounter: Encounter) => string;
appendHistory: (
state: GameState,
actionText: string,
resultText: string,
) => GameState['storyHistory'];
buildCampCompanionOpeningOptions: (
state: GameState,
character: Character,
encounter: Encounter,
) => StoryOption[];
buildCampCompanionOpeningResultText: (
character: Character,
encounter: Encounter,
worldType: GameState['worldType'],
) => string;
buildInitialCompanionDialogueText: (
character: Character,
encounter: Encounter,
worldType: GameState['worldType'],
) => string;
}): PreparedOpeningAdventure | null {
const encounter = state.currentEncounter;
if (
!encounter ||
encounter.kind !== 'npc' ||
encounter.specialBehavior !== 'initial_companion'
) {
return null;
}
const campScene = state.worldType
? getWorldCampScenePreset(state.worldType)
: null;
const actionText = '开始冒险';
const resultText = buildCampCompanionOpeningResultText(
character,
encounter,
state.worldType,
);
const dialogueText = buildInitialCompanionDialogueText(
character,
encounter,
state.worldType,
);
const resolvedEncounter: Encounter = {
...encounter,
specialBehavior: 'camp_companion',
xMeters: RESOLVED_ENTITY_X_METERS,
};
const resolvedState: GameState = {
...state,
currentScenePreset: campScene ?? state.currentScenePreset,
currentEncounter: resolvedEncounter,
npcInteractionActive: false,
};
const nextHistory = appendHistory(state, actionText, resultText);
const stateWithHistory: GameState = {
...resolvedState,
storyHistory: nextHistory,
};
return {
encounterKey: getNpcEncounterKey(encounter),
actionText,
resultText,
fallbackText: dialogueText,
openingOptions: buildCampCompanionOpeningOptions(
stateWithHistory,
character,
resolvedEncounter,
),
};
}
export async function playOpeningAdventureSequence({
gameState,
character,
encounter,
preparedStory,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
buildDialogueStoryMoment,
buildStoryContextFromState,
getStoryGenerationHostileNpcs,
hasRenderableDialogueTurns,
inferOpeningCampFollowupOptions,
getTypewriterDelay,
}: {
gameState: GameState;
character: Character;
encounter: Encounter;
preparedStory: PreparedOpeningAdventure;
setGameState: Dispatch<SetStateAction<GameState>>;
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
buildDialogueStoryMoment: (
npcName: string,
text: string,
options: StoryOption[],
streaming?: boolean,
) => StoryMoment;
buildStoryContextFromState: (
state: GameState,
extras?: { lastFunctionId?: string | null },
) => StoryGenerationContext;
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneMonsters'];
hasRenderableDialogueTurns: (text: string, npcName: string) => boolean;
inferOpeningCampFollowupOptions: (
state: GameState,
character: Character,
baseOptions: StoryOption[],
openingBackground: string,
openingDialogue: string,
) => Promise<StoryOption[]>;
getTypewriterDelay: (char: string) => number;
}) {
const {
fallbackText,
openingOptions,
resultText: openingBackground,
} = preparedStory;
const actionText = `在营地与 ${encounter.npcName} 交换开场判断`;
const campScene = gameState.worldType
? getWorldCampScenePreset(gameState.worldType)
: null;
const entryState: GameState = {
...gameState,
currentScenePreset: campScene ?? gameState.currentScenePreset,
currentEncounter: {
...encounter,
xMeters: encounter.xMeters ?? CALL_OUT_ENTRY_X_METERS,
},
};
const resolvedEncounter: Encounter = {
...encounter,
xMeters: RESOLVED_ENTITY_X_METERS,
};
const storyEncounter: Encounter = {
...resolvedEncounter,
specialBehavior: 'camp_companion',
};
const resolvedState: GameState = {
...gameState,
currentScenePreset: campScene ?? gameState.currentScenePreset,
currentEncounter: resolvedEncounter,
npcInteractionActive: false,
};
setGameState(entryState);
setAiError(null);
setIsLoading(true);
try {
if (hasEncounterEntity(resolvedState)) {
const runTicks = Math.max(
1,
Math.ceil(ENCOUNTER_ENTRY_DURATION_MS / ENCOUNTER_ENTRY_TICK_MS),
);
const tickDurationMs = Math.max(
1,
Math.round(ENCOUNTER_ENTRY_DURATION_MS / runTicks),
);
for (let tick = 1; tick <= runTicks; tick += 1) {
const progress = tick / runTicks;
setGameState(
interpolateEncounterTransitionState(
entryState,
resolvedState,
progress,
),
);
await new Promise((resolve) =>
window.setTimeout(resolve, tickDurationMs),
);
}
}
const storyState: GameState = {
...resolvedState,
currentEncounter: storyEncounter,
npcInteractionActive: false,
};
setGameState(storyState);
setCurrentStory(buildDialogueStoryMoment(encounter.npcName, '', [], true));
let openingText = fallbackText;
let resolvedOpeningOptions = sortStoryOptionsByPriority(openingOptions);
try {
const response = await generateNextStep(
gameState.worldType!,
character,
getStoryGenerationHostileNpcs(storyState),
gameState.storyHistory,
actionText,
buildStoryContextFromState(storyState, {
lastFunctionId: OPENING_CAMP_DIALOGUE_FUNCTION_ID,
}),
{
availableOptions: openingOptions,
},
);
const generatedText = response.storyText.trim();
if (
generatedText &&
hasRenderableDialogueTurns(generatedText, encounter.npcName)
) {
openingText = generatedText;
}
if (response.options.length > 0) {
resolvedOpeningOptions = sortStoryOptionsByPriority(response.options);
}
} catch (error) {
console.error('Failed to infer opening camp dialogue:', error);
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
}
const finalHistory = [
...gameState.storyHistory,
createHistoryMoment(actionText, 'action'),
createHistoryMoment(openingText, 'result', openingOptions),
];
const finalState: GameState = {
...storyState,
storyHistory: finalHistory,
};
setGameState(finalState);
const openingOptionsPromise = inferOpeningCampFollowupOptions(
finalState,
character,
resolvedOpeningOptions,
openingBackground,
openingText,
);
let displayedText = '';
for (const nextChar of openingText) {
displayedText += nextChar;
setCurrentStory(
buildDialogueStoryMoment(encounter.npcName, displayedText, [], true),
);
await new Promise((resolve) =>
window.setTimeout(resolve, getTypewriterDelay(nextChar)),
);
}
const finalOpeningOptions = await openingOptionsPromise;
setCurrentStory(
buildDialogueStoryMoment(
encounter.npcName,
openingText,
finalOpeningOptions,
false,
),
);
} catch (error) {
console.error('Failed to play opening adventure sequence:', error);
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
setCurrentStory(
buildDialogueStoryMoment(
encounter.npcName,
fallbackText,
openingOptions,
false,
),
);
} finally {
setIsLoading(false);
}
}

View File

@@ -0,0 +1,163 @@
import type {
Dispatch,
SetStateAction,
} from 'react';
import {
hasEncounterEntity,
interpolateEncounterTransitionState,
} from '../../data/encounterTransition';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
Character,
GameState,
StoryMoment,
StoryOption,
} from '../../types';
import type { CommitGeneratedState } from '../generatedState';
const ENCOUNTER_ENTRY_DURATION_MS = 1800;
const ENCOUNTER_ENTRY_TICK_MS = 180;
export type GenerateStoryForState = (params: {
state: GameState;
character: Character;
history: GameState['storyHistory'];
choice?: string;
lastFunctionId?: string | null;
optionCatalog?: StoryOption[] | null;
}) => Promise<StoryMoment>;
type BuildFallbackStoryForState = (
state: GameState,
character: Character,
fallbackText?: string,
) => StoryMoment;
export type CommitGeneratedStateWithEncounterEntry = (
entryState: GameState,
resolvedState: GameState,
character: Character,
actionText: string,
resultText: string,
lastFunctionId?: string,
) => Promise<void>;
export function appendStoryHistory(
state: GameState,
actionText: string,
resultText: string,
): GameState['storyHistory'] {
return [
...state.storyHistory,
createHistoryMoment(actionText, 'action'),
createHistoryMoment(resultText, 'result'),
];
}
export function createStoryProgressionActions({
gameState,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
generateStoryForState,
buildFallbackStoryForState,
}: {
gameState: GameState;
setGameState: Dispatch<SetStateAction<GameState>>;
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
generateStoryForState: GenerateStoryForState;
buildFallbackStoryForState: BuildFallbackStoryForState;
}) {
const commitGeneratedState: CommitGeneratedState = async (
nextState,
character,
actionText,
resultText,
lastFunctionId,
) => {
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
const stateWithHistory: GameState = {
...nextState,
storyHistory: nextHistory,
};
setGameState(stateWithHistory);
setAiError(null);
setIsLoading(true);
try {
const nextStory = await generateStoryForState({
state: stateWithHistory,
character,
history: nextHistory,
choice: actionText,
lastFunctionId,
});
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to continue scripted story:', error);
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
setCurrentStory(buildFallbackStoryForState(stateWithHistory, character, resultText));
} finally {
setIsLoading(false);
}
};
const commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry = async (
entryState,
resolvedState,
character,
actionText,
resultText,
lastFunctionId,
) => {
setGameState(entryState);
setAiError(null);
setIsLoading(true);
if (hasEncounterEntity(resolvedState)) {
const runTicks = Math.max(1, Math.ceil(ENCOUNTER_ENTRY_DURATION_MS / ENCOUNTER_ENTRY_TICK_MS));
const tickDurationMs = Math.max(1, Math.round(ENCOUNTER_ENTRY_DURATION_MS / runTicks));
for (let tick = 1; tick <= runTicks; tick += 1) {
const progress = tick / runTicks;
setGameState(interpolateEncounterTransitionState(entryState, resolvedState, progress));
await new Promise(resolve => window.setTimeout(resolve, tickDurationMs));
}
}
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
const stateWithHistory: GameState = {
...resolvedState,
storyHistory: nextHistory,
};
setGameState(stateWithHistory);
try {
const nextStory = await generateStoryForState({
state: stateWithHistory,
character,
history: nextHistory,
choice: actionText,
lastFunctionId,
});
setCurrentStory(nextStory);
} catch (error) {
console.error('Failed to continue encounter-entry story:', error);
setAiError(error instanceof Error ? error.message : '未知 AI 错误');
setCurrentStory(buildFallbackStoryForState(stateWithHistory, character, resultText));
} finally {
setIsLoading(false);
}
};
return {
commitGeneratedState,
commitGeneratedStateWithEncounterEntry,
};
}

View File

@@ -0,0 +1,176 @@
import { describe, expect, it } from 'vitest';
import { buildInitialNpcState } from '../../data/npcInteractions';
import {
AnimationState,
type Character,
type GameState,
type InventoryItem,
type QuestLogEntry,
WorldType,
} from '../../types';
import {
acknowledgeQuestCompletionState,
applyQuestRewardClaim,
} from './sessionActions';
function createCharacter(): Character {
return {
id: 'hero',
name: 'Hero',
title: 'Wanderer',
description: 'A reliable test hero.',
backstory: 'Travels the land.',
avatar: '/hero.png',
portrait: '/hero-portrait.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 9,
intelligence: 8,
spirit: 7,
},
personality: 'steady',
skills: [],
adventureOpenings: {},
};
}
function createInventoryItem(id: string, name: string, quantity = 1): InventoryItem {
return {
id,
name,
description: `${name} description`,
quantity,
category: 'misc',
rarity: 'common',
tags: [],
value: 1,
};
}
function createQuest(status: QuestLogEntry['status']): QuestLogEntry {
return {
id: 'quest-1',
issuerNpcId: 'npc-trader',
issuerNpcName: 'Trader Lin',
sceneId: 'scene-1',
title: 'Deliver the cache',
description: 'Deliver the cache safely.',
summary: 'Help Trader Lin recover the cache.',
objective: {
kind: 'deliver_item',
targetItemId: 'cache',
requiredCount: 1,
},
progress: 1,
status,
reward: {
affinityBonus: 3,
currency: 12,
items: [createInventoryItem('reward-herb', 'Reward Herb', 2)],
},
rewardText: 'Trader Lin rewards you for the delivery.',
};
}
function createBaseState(): GameState {
const encounter = {
id: 'npc-trader',
kind: 'npc' as const,
npcName: 'Trader Lin',
npcDescription: 'A traveling merchant.',
npcAvatar: 'T',
context: 'merchant',
};
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: createCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: encounter,
npcInteractionActive: false,
currentScenePreset: null,
sceneMonsters: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 5,
playerInventory: [createInventoryItem('starter-potion', 'Starter Potion')],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {
'npc-trader': {
...buildInitialNpcState(encounter, WorldType.WUXIA),
affinity: 4,
},
},
quests: [createQuest('completed')],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
}
describe('sessionActions', () => {
it('marks quest completion notifications without changing unrelated quest state', () => {
const nextState = acknowledgeQuestCompletionState(createBaseState(), 'quest-1');
expect(nextState.quests[0]?.completionNotified).toBe(true);
expect(nextState.quests[0]?.status).toBe('completed');
});
it('returns null when trying to claim a reward for a quest that is not completed', () => {
const state = {
...createBaseState(),
quests: [createQuest('active')],
};
expect(applyQuestRewardClaim(state, 'quest-1')).toBeNull();
});
it('applies quest rewards to currency, inventory, and issuer affinity in one state transition', () => {
const nextState = applyQuestRewardClaim(createBaseState(), 'quest-1');
expect(nextState).not.toBeNull();
if (!nextState) {
throw new Error('Expected quest reward claim state');
}
expect(nextState.quests[0]?.status).toBe('turned_in');
expect(nextState.playerCurrency).toBe(17);
expect(nextState.playerInventory.find(item => item.id === 'reward-herb')?.quantity).toBe(2);
expect(nextState.npcStates['npc-trader']?.affinity).toBe(7);
});
});

View File

@@ -0,0 +1,139 @@
import type {
Dispatch,
SetStateAction,
} from 'react';
import { addInventoryItems } from '../../data/npcInteractions';
import {
findQuestById,
markQuestCompletionNotified,
markQuestTurnedIn,
} from '../../data/questFlow';
import type {
GameState,
StoryMoment,
} from '../../types';
import type { CommitGeneratedState } from '../generatedState';
import { buildMapTravelResolution } from './storyGenerationState';
type BuildFallbackStoryForState = (
state: GameState,
character: NonNullable<GameState['playerCharacter']>,
fallbackText?: string,
) => StoryMoment;
export function acknowledgeQuestCompletionState(
state: GameState,
questId: string,
): GameState {
return {
...state,
quests: markQuestCompletionNotified(state.quests, questId),
};
}
export function applyQuestRewardClaim(
state: GameState,
questId: string,
): GameState | null {
const quest = findQuestById(state.quests, questId);
if (!quest || quest.status !== 'completed') {
return null;
}
const issuerNpcState = state.npcStates[quest.issuerNpcId];
return {
...state,
quests: markQuestTurnedIn(state.quests, questId),
playerCurrency: state.playerCurrency + quest.reward.currency,
playerInventory: addInventoryItems(state.playerInventory, quest.reward.items),
npcStates: issuerNpcState
? {
...state.npcStates,
[quest.issuerNpcId]: {
...issuerNpcState,
affinity: issuerNpcState.affinity + quest.reward.affinityBonus,
},
}
: state.npcStates,
};
}
export function createStorySessionActions({
gameState,
isLoading,
setGameState,
setCurrentStory,
clearStoryRuntimeUi,
commitGeneratedState,
buildFallbackStoryForState,
}: {
gameState: GameState;
isLoading: boolean;
setGameState: Dispatch<SetStateAction<GameState>>;
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
clearStoryRuntimeUi: () => void;
commitGeneratedState: CommitGeneratedState;
buildFallbackStoryForState: BuildFallbackStoryForState;
}) {
const acknowledgeQuestCompletion = (questId: string) => {
setGameState(currentState => acknowledgeQuestCompletionState(currentState, questId));
};
const claimQuestReward = (questId: string) => {
const nextState = applyQuestRewardClaim(gameState, questId);
if (!nextState) {
return false;
}
setGameState(nextState);
return true;
};
const resetStoryState = () => {
setCurrentStory(null);
clearStoryRuntimeUi();
};
const hydrateStoryState = (story: StoryMoment | null) => {
setCurrentStory(story);
clearStoryRuntimeUi();
};
const travelToSceneFromMap = (sceneId: string) => {
if (!gameState.playerCharacter || isLoading || gameState.inBattle) {
return false;
}
const travelResolution = buildMapTravelResolution(gameState, sceneId);
if (!travelResolution) {
return false;
}
setCurrentStory(
buildFallbackStoryForState(
travelResolution.nextState,
gameState.playerCharacter,
travelResolution.travelResultText,
),
);
void commitGeneratedState(
travelResolution.nextState,
gameState.playerCharacter,
travelResolution.actionText,
travelResolution.travelResultText,
'idle_travel_next_scene',
);
return true;
};
return {
acknowledgeQuestCompletion,
claimQuestReward,
resetStoryState,
hydrateStoryState,
travelToSceneFromMap,
};
}

View File

@@ -0,0 +1,261 @@
import { describe, expect, it, vi } from 'vitest';
const { scenes } = vi.hoisted(() => ({
scenes: [
{
id: 'scene-1',
name: 'Camp',
description: 'A quiet camp.',
imageSrc: '/camp.png',
connectedSceneIds: ['scene-2'],
monsterIds: [],
npcs: [],
treasureHints: [],
},
{
id: 'scene-2',
name: 'Trail',
description: 'A mountain trail.',
imageSrc: '/trail.png',
connectedSceneIds: ['scene-1'],
monsterIds: [],
npcs: [],
treasureHints: [],
},
],
}));
vi.mock('../../data/scenePresets', () => ({
getScenePresetById: (_worldType: unknown, sceneId: string) =>
scenes.find(scene => scene.id === sceneId) ?? null,
getSceneFriendlyNpcs: (scene: { npcs?: unknown[] } | null | undefined) => scene?.npcs ?? [],
getSceneHostileNpcs: () => [],
getScenePresetsByWorld: () => scenes,
getWorldCampScenePreset: () => scenes[0] ?? null,
}));
import { buildInitialNpcState, MAX_COMPANIONS } from '../../data/npcInteractions';
import { getScenePresetsByWorld } from '../../data/scenePresets';
import {
AnimationState,
type Character,
type CompanionState,
type Encounter,
type GameState,
type InventoryItem,
type StoryOption,
WorldType,
} from '../../types';
import {
buildMapTravelResolution,
resolveNpcInteractionDecision,
} from './storyGenerationState';
function createCharacter(): Character {
return {
id: 'hero',
name: 'Hero',
title: 'Wanderer',
description: 'A reliable test hero.',
backstory: 'Travels the land.',
avatar: '/hero.png',
portrait: '/hero-portrait.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 9,
intelligence: 8,
spirit: 7,
},
personality: 'steady',
skills: [],
adventureOpenings: {},
};
}
function createInventoryItem(id: string, name: string): InventoryItem {
return {
id,
name,
description: `${name} description`,
quantity: 1,
category: 'misc',
rarity: 'common',
tags: [],
value: 1,
};
}
function createEncounter(): Encounter {
return {
id: 'npc-trader',
kind: 'npc',
npcName: 'Trader Lin',
npcDescription: 'A traveling merchant.',
npcAvatar: 'T',
context: 'merchant',
};
}
function createCompanion(npcId: string): CompanionState {
return {
npcId,
characterId: `character-${npcId}`,
joinedAtAffinity: 10,
hp: 10,
maxHp: 10,
mana: 5,
maxMana: 5,
skillCooldowns: {},
};
}
function createBaseState(): GameState {
const scenes = getScenePresetsByWorld(WorldType.WUXIA);
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: createCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: createEncounter(),
npcInteractionActive: false,
currentScenePreset: scenes[0] ?? null,
sceneMonsters: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 100,
playerMaxHp: 100,
playerMana: 20,
playerMaxMana: 20,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 10,
playerInventory: [createInventoryItem('player-potion', 'Potion')],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {
'npc-trader': {
...buildInitialNpcState(createEncounter(), WorldType.WUXIA),
inventory: [createInventoryItem('npc-herb', 'Herb')],
},
},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
};
}
function createInteractionOption(action: Extract<NonNullable<StoryOption['interaction']>, { kind: 'npc' }>['action']): StoryOption {
return {
functionId: `npc_${action}`,
actionText: action,
text: action,
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc',
npcId: 'npc-trader',
action,
},
};
}
describe('storyGenerationState', () => {
it('opens the trade modal with the first npc and player inventory items selected', () => {
const decision = resolveNpcInteractionDecision(
createBaseState(),
createInteractionOption('trade'),
);
expect(decision.kind).toBe('trade_modal');
if (decision.kind !== 'trade_modal') {
throw new Error('Expected trade modal decision');
}
expect(decision.modal.selectedNpcItemId).toBe('npc-herb');
expect(decision.modal.selectedPlayerItemId).toBe('player-potion');
expect(decision.modal.selectedQuantity).toBe(1);
});
it('forces a recruit replacement modal when the active party is full', () => {
const state = {
...createBaseState(),
companions: Array.from({ length: MAX_COMPANIONS }, (_, index) => createCompanion(`npc-${index + 1}`)),
};
const decision = resolveNpcInteractionDecision(
state,
createInteractionOption('recruit'),
);
expect(decision.kind).toBe('recruit_modal');
if (decision.kind !== 'recruit_modal') {
throw new Error('Expected recruit modal decision');
}
expect(decision.modal.selectedReleaseNpcId).toBe('npc-1');
});
it('builds a map travel transition that increments runtime stats and clears battle state', () => {
const scenes = getScenePresetsByWorld(WorldType.WUXIA);
const sourceScene = scenes[0];
const targetScene = scenes[1]!;
const state = {
...createBaseState(),
currentScenePreset: sourceScene ?? null,
inBattle: true,
currentBattleNpcId: 'battle-npc',
currentNpcBattleMode: 'fight' as const,
currentNpcBattleOutcome: 'fight_victory' as const,
sparReturnEncounter: createEncounter(),
};
const resolution = buildMapTravelResolution(state, targetScene.id);
expect(resolution).not.toBeNull();
if (!resolution) {
throw new Error('Expected map travel resolution');
}
expect(resolution.nextState.currentScenePreset?.id).toBe(targetScene.id);
expect(resolution.nextState.npcInteractionActive).toBe(false);
expect(resolution.nextState.inBattle).toBe(false);
expect(resolution.nextState.currentBattleNpcId).toBeNull();
expect(resolution.nextState.currentNpcBattleMode).toBeNull();
expect(resolution.nextState.runtimeStats.scenesTraveled).toBe(1);
});
});

View File

@@ -0,0 +1,150 @@
import {
buildNpcGiftModalState,
buildNpcRecruitModalState,
buildNpcTradeModalState,
NPC_GIFT_FUNCTION,
NPC_RECRUIT_FUNCTION,
NPC_TRADE_FUNCTION,
shouldNpcRecruitOpenModal,
} from '../../data/functionCatalog';
import {
buildInitialNpcState,
MAX_COMPANIONS,
} from '../../data/npcInteractions';
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
import { getScenePresetById } from '../../data/scenePresets';
import {
AnimationState,
type Encounter,
type GameState,
type StoryOption,
} from '../../types';
import type {
GiftModalState,
RecruitModalState,
TradeModalState,
} from './uiTypes';
export type NpcInteractionDecision =
| { kind: 'none' }
| { kind: 'trade_modal'; modal: TradeModalState }
| { kind: 'gift_modal'; modal: GiftModalState }
| { kind: 'recruit_modal'; modal: RecruitModalState }
| { kind: 'recruit_immediate' };
export type MapTravelResolution = {
nextState: GameState;
actionText: string;
travelResultText: string;
};
function isNpcEncounter(
encounter: GameState['currentEncounter'],
): encounter is Encounter {
return Boolean(encounter?.kind === 'npc');
}
export function getNpcEncounterKey(encounter: Encounter) {
return encounter.id ?? encounter.npcName;
}
function getResolvedNpcState(state: GameState, encounter: Encounter) {
return (
state.npcStates[getNpcEncounterKey(encounter)] ??
buildInitialNpcState(encounter, state.worldType)
);
}
export function resolveNpcInteractionDecision(
state: GameState,
option: StoryOption,
): NpcInteractionDecision {
if (
!state.playerCharacter ||
!option.interaction ||
!isNpcEncounter(state.currentEncounter)
) {
return { kind: 'none' };
}
const encounter = state.currentEncounter;
const npcState = getResolvedNpcState(state, encounter);
switch (option.functionId) {
case NPC_TRADE_FUNCTION.id:
return {
kind: 'trade_modal',
modal: buildNpcTradeModalState(
state,
encounter,
option.actionText,
npcState.inventory,
),
};
case NPC_GIFT_FUNCTION.id:
return {
kind: 'gift_modal',
modal: buildNpcGiftModalState(state, encounter, option.actionText),
};
case NPC_RECRUIT_FUNCTION.id:
if (shouldNpcRecruitOpenModal(state.companions.length, MAX_COMPANIONS)) {
return {
kind: 'recruit_modal',
modal: buildNpcRecruitModalState(state, encounter, option.actionText),
};
}
return { kind: 'recruit_immediate' };
default:
return { kind: 'none' };
}
}
export function buildMapTravelResolution(
state: GameState,
sceneId: string,
): MapTravelResolution | null {
if (!state.worldType || !state.playerCharacter) {
return null;
}
const targetScene = getScenePresetById(state.worldType, sceneId);
if (!targetScene || targetScene.id === state.currentScenePreset?.id) {
return null;
}
const nextState = ensureSceneEncounterPreview({
...state,
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, {
scenesTraveled: 1,
}),
currentScenePreset: targetScene,
currentEncounter: null,
npcInteractionActive: false,
sceneMonsters: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: AnimationState.IDLE,
playerActionMode: 'idle' as const,
activeCombatEffects: [],
scrollWorld: false,
inBattle: false,
lastObserveSignsSceneId: null,
lastObserveSignsReport: null,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
});
const travelResultText = `你离开${state.currentScenePreset?.name ?? '当前位置'},前往${targetScene.name}`;
return {
nextState,
actionText: `前往${targetScene.name}`,
travelResultText,
};
}

View File

@@ -0,0 +1,84 @@
import type {
Encounter,
InventoryItem,
} from '../../types';
export type TradeModalState = {
encounter: Encounter;
actionText: string;
mode: 'buy' | 'sell';
selectedNpcItemId: string | null;
selectedPlayerItemId: string | null;
selectedQuantity: number;
};
export type GiftModalState = {
encounter: Encounter;
actionText: string;
selectedItemId: string | null;
};
export type RecruitModalState = {
encounter: Encounter;
actionText: string;
selectedReleaseNpcId: string | null;
};
export interface StoryGenerationNpcUi {
tradeModal: TradeModalState | null;
giftModal: GiftModalState | null;
recruitModal: RecruitModalState | null;
setTradeMode: (mode: 'buy' | 'sell') => void;
selectTradeNpcItem: (itemId: string) => void;
selectTradePlayerItem: (itemId: string) => void;
setTradeQuantity: (quantity: number) => void;
closeTradeModal: () => void;
confirmTrade: () => void;
selectGiftItem: (itemId: string) => void;
closeGiftModal: () => void;
confirmGift: () => void;
selectRecruitRelease: (npcId: string) => void;
closeRecruitModal: () => void;
confirmRecruit: () => void;
}
export interface InventoryFlowUi {
useInventoryItem: (itemId: string) => Promise<boolean>;
equipInventoryItem: (itemId: string) => Promise<boolean>;
unequipItem: (slot: 'weapon' | 'armor' | 'relic') => Promise<boolean>;
forgeRecipes: Array<{
id: string;
name: string;
kind: 'synthesis' | 'forge';
description: string;
resultLabel: string;
currencyCost: number;
currencyText: string;
requirements: Array<{
id: string;
label: string;
quantity: number;
owned: number;
}>;
canCraft: boolean;
}>;
craftRecipe: (recipeId: string) => Promise<boolean>;
dismantleItem: (itemId: string) => Promise<boolean>;
reforgeItem: (itemId: string) => Promise<boolean>;
}
export interface QuestFlowUi {
acknowledgeQuestCompletion: (questId: string) => void;
claimQuestReward: (questId: string) => boolean;
}
export interface BattleRewardSummary {
id: string;
defeatedHostileNpcs: Array<{ id: string; name: string }>;
items: InventoryItem[];
}
export interface BattleRewardUi {
reward: BattleRewardSummary | null;
dismiss: () => void;
}