376
src/hooks/story/characterChat.ts
Normal file
376
src/hooks/story/characterChat.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
546
src/hooks/story/choiceActions.ts
Normal file
546
src/hooks/story/choiceActions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
44
src/hooks/story/inventoryActions.ts
Normal file
44
src/hooks/story/inventoryActions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
837
src/hooks/story/npcEncounterActions.ts
Normal file
837
src/hooks/story/npcEncounterActions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
668
src/hooks/story/npcInteraction.ts
Normal file
668
src/hooks/story/npcInteraction.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
328
src/hooks/story/openingAdventure.ts
Normal file
328
src/hooks/story/openingAdventure.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
163
src/hooks/story/progressionActions.ts
Normal file
163
src/hooks/story/progressionActions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
176
src/hooks/story/sessionActions.test.ts
Normal file
176
src/hooks/story/sessionActions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
139
src/hooks/story/sessionActions.ts
Normal file
139
src/hooks/story/sessionActions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
261
src/hooks/story/storyGenerationState.test.ts
Normal file
261
src/hooks/story/storyGenerationState.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
150
src/hooks/story/storyGenerationState.ts
Normal file
150
src/hooks/story/storyGenerationState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
84
src/hooks/story/uiTypes.ts
Normal file
84
src/hooks/story/uiTypes.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user