1
This commit is contained in:
@@ -1,10 +1,26 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../services/ai', () => ({
|
||||
vi.mock('../../services/aiService', () => ({
|
||||
generateNextStep: vi.fn(),
|
||||
}));
|
||||
|
||||
import { generateNextStep } from '../../services/ai';
|
||||
const {
|
||||
isServerRuntimeFunctionIdMock,
|
||||
resolveServerRuntimeChoiceMock,
|
||||
} = vi.hoisted(() => ({
|
||||
isServerRuntimeFunctionIdMock: vi.fn(() => false),
|
||||
resolveServerRuntimeChoiceMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./runtimeStoryCoordinator', () => ({
|
||||
resolveServerRuntimeChoice: resolveServerRuntimeChoiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/runtimeStoryService', () => ({
|
||||
isServerRuntimeFunctionId: isServerRuntimeFunctionIdMock,
|
||||
}));
|
||||
|
||||
import { generateNextStep } from '../../services/aiService';
|
||||
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
|
||||
import { createStoryChoiceActions } from './choiceActions';
|
||||
|
||||
@@ -133,6 +149,228 @@ const neverNpcEncounter = (
|
||||
): encounter is Encounter => false;
|
||||
|
||||
describe('createStoryChoiceActions', () => {
|
||||
beforeEach(() => {
|
||||
isServerRuntimeFunctionIdMock.mockReset();
|
||||
isServerRuntimeFunctionIdMock.mockReturnValue(false);
|
||||
resolveServerRuntimeChoiceMock.mockReset();
|
||||
});
|
||||
|
||||
it('routes task5 story choices through the server runtime action endpoint', async () => {
|
||||
const state = createBaseState();
|
||||
const option = createBattleOption('npc_chat');
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
|
||||
isServerRuntimeFunctionIdMock.mockReturnValue(true);
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValue({
|
||||
hydratedSnapshot: {
|
||||
gameState: {
|
||||
...state,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 1,
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
'npc-opponent': {
|
||||
...state.npcStates['npc-opponent'],
|
||||
affinity: 6,
|
||||
chattedCount: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
currentStory: {
|
||||
text: '后端已结算关系变化',
|
||||
options: [],
|
||||
},
|
||||
bottomTab: 'adventure',
|
||||
},
|
||||
nextStory: {
|
||||
text: '后端已结算关系变化',
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
text: '请求援手',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: {
|
||||
...state,
|
||||
currentEncounter: {
|
||||
id: 'npc-opponent',
|
||||
kind: 'npc',
|
||||
npcName: '山道客',
|
||||
npcDescription: '拦路的陌生人',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道相遇',
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
},
|
||||
currentStory: createFallbackStory('当前故事'),
|
||||
isLoading: false,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(),
|
||||
playResolvedChoice: vi.fn(),
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(() => null),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
startOpeningAdventure: vi.fn(),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(() => false),
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isInitialCompanionEncounter: neverNpcEncounter,
|
||||
isRegularNpcEncounter: (encounter): encounter is Encounter =>
|
||||
Boolean(encounter?.kind === 'npc'),
|
||||
isNpcEncounter: (encounter): encounter is Encounter =>
|
||||
Boolean(encounter?.kind === 'npc'),
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith({
|
||||
gameState: expect.objectContaining({
|
||||
currentEncounter: expect.objectContaining({
|
||||
id: 'npc-opponent',
|
||||
}),
|
||||
}),
|
||||
currentStory: createFallbackStory('当前故事'),
|
||||
option,
|
||||
});
|
||||
expect(setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtimeActionVersion: 1,
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '后端已结算关系变化',
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps npc trade choices on the local UI path so the trade modal can collect payload', async () => {
|
||||
const state: GameState = {
|
||||
...createBaseState(),
|
||||
currentEncounter: {
|
||||
id: 'npc-merchant',
|
||||
kind: 'npc' as const,
|
||||
npcName: '梁伯',
|
||||
npcDescription: '沿街商贩',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '沿街商贩',
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
};
|
||||
const option: StoryOption = {
|
||||
functionId: 'npc_trade',
|
||||
actionText: '交易',
|
||||
text: '交易',
|
||||
interaction: {
|
||||
kind: 'npc' as const,
|
||||
npcId: 'npc-merchant',
|
||||
action: 'trade' as const,
|
||||
},
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right' as const,
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
const handleNpcInteraction = vi.fn(() => true);
|
||||
|
||||
isServerRuntimeFunctionIdMock.mockReturnValue(true);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory: createFallbackStory('当前故事'),
|
||||
isLoading: false,
|
||||
setGameState: vi.fn(),
|
||||
setCurrentStory: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(),
|
||||
playResolvedChoice: vi.fn(),
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(() => null),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
startOpeningAdventure: vi.fn(),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isInitialCompanionEncounter: neverNpcEncounter,
|
||||
isRegularNpcEncounter: (encounter): encounter is Encounter =>
|
||||
Boolean(encounter?.kind === 'npc'),
|
||||
isNpcEncounter: (encounter): encounter is Encounter =>
|
||||
Boolean(encounter?.kind === 'npc'),
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(handleNpcInteraction).toHaveBeenCalledWith(option);
|
||||
expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the finishing action in history before npc victory follow-up generation', async () => {
|
||||
const state = createBaseState();
|
||||
const option = createBattleOption();
|
||||
|
||||
@@ -3,25 +3,9 @@ import type {
|
||||
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 { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import { generateNextStep } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import { isServerRuntimeFunctionId } from '../../services/runtimeStoryService';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
@@ -34,6 +18,12 @@ import type {
|
||||
CommitGeneratedStateWithEncounterEntry,
|
||||
GenerateStoryForState,
|
||||
} from './progressionActions';
|
||||
import { runLocalStoryChoiceContinuation } from './storyChoiceContinuation';
|
||||
import {
|
||||
runCampTravelHomeChoice,
|
||||
runServerRuntimeChoiceAction,
|
||||
shouldOpenLocalRuntimeNpcModal,
|
||||
} from './storyChoiceRuntime';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
|
||||
type RuntimeStatsIncrements = Partial<Pick<GameState['runtimeStats'], 'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'>>;
|
||||
@@ -78,112 +68,6 @@ type IncrementRuntimeStats = (
|
||||
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 buildCombatResolutionContextText(params: {
|
||||
baseState: GameState;
|
||||
afterSequence: GameState;
|
||||
optionKind: ResolvedChoiceState['optionKind'];
|
||||
projectedBattleReward: BattleRewardSummary | null;
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
}) {
|
||||
const {
|
||||
baseState,
|
||||
afterSequence,
|
||||
optionKind,
|
||||
projectedBattleReward,
|
||||
getResolvedSceneHostileNpcs,
|
||||
} = params;
|
||||
|
||||
if (optionKind === 'escape') {
|
||||
const hostileNames = getResolvedSceneHostileNpcs(baseState)
|
||||
.map((hostileNpc) => hostileNpc.name)
|
||||
.join('、');
|
||||
return hostileNames
|
||||
? `你已成功逃脱,与${hostileNames}的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。`
|
||||
: '你已成功逃脱刚才的交战,当前不再处于战斗状态。';
|
||||
}
|
||||
|
||||
if (
|
||||
!baseState.inBattle ||
|
||||
afterSequence.inBattle ||
|
||||
Boolean(baseState.currentBattleNpcId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostileNames = getResolvedSceneHostileNpcs(baseState)
|
||||
.map((hostileNpc) => hostileNpc.name)
|
||||
.join('、');
|
||||
const lootText =
|
||||
projectedBattleReward?.items.length
|
||||
? `战利品:${projectedBattleReward.items.map((item) => item.name).join('、')}。`
|
||||
: '';
|
||||
|
||||
return hostileNames
|
||||
? `你已经击败${hostileNames},眼前这一轮交战已经结束。${lootText}`
|
||||
: `眼前这一轮交战已经结束,当前不再处于战斗状态。${lootText}`;
|
||||
}
|
||||
|
||||
async function buildHostileNpcBattleReward(
|
||||
state: GameState,
|
||||
afterSequence: GameState,
|
||||
optionKind: ResolvedChoiceState['optionKind'],
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'],
|
||||
): Promise<BattleRewardSummary | null> {
|
||||
if (
|
||||
optionKind === 'escape'
|
||||
|| !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 = await 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,
|
||||
@@ -250,8 +134,10 @@ export function createStoryChoiceActions({
|
||||
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;
|
||||
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
|
||||
handleTreasureInteraction: (
|
||||
option: StoryOption,
|
||||
) => void | Promise<void> | boolean | Promise<boolean>;
|
||||
commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry;
|
||||
finalizeNpcBattleResult: (
|
||||
state: GameState,
|
||||
@@ -268,91 +154,6 @@ export function createStoryChoiceActions({
|
||||
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;
|
||||
@@ -367,7 +168,43 @@ export function createStoryChoiceActions({
|
||||
}
|
||||
|
||||
if (isCampTravelHomeOption(option)) {
|
||||
await handleCampTravelHome(option, character);
|
||||
await runCampTravelHomeChoice({
|
||||
gameState,
|
||||
option,
|
||||
character,
|
||||
setBattleReward,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setGameState,
|
||||
incrementRuntimeStats,
|
||||
getCampCompanionTravelScene,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
isNpcEncounter,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldOpenLocalRuntimeNpcModal(option)) {
|
||||
setAiError(null);
|
||||
await handleNpcInteraction(option);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isServerRuntimeFunctionId(option.functionId)) {
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
character,
|
||||
setBattleReward,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setGameState,
|
||||
setCurrentStory: (story) => setCurrentStory(story),
|
||||
buildFallbackStoryForState,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -393,238 +230,41 @@ export function createStoryChoiceActions({
|
||||
|
||||
if (option.interaction?.kind === 'npc') {
|
||||
setAiError(null);
|
||||
handleNpcInteraction(option);
|
||||
await handleNpcInteraction(option);
|
||||
return;
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'treasure') {
|
||||
setAiError(null);
|
||||
handleTreasureInteraction(option);
|
||||
await 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
|
||||
: await buildHostileNpcBattleReward(
|
||||
baseChoiceState,
|
||||
projectedState,
|
||||
resolvedChoice.optionKind,
|
||||
getResolvedSceneHostileNpcs,
|
||||
);
|
||||
const projectedStateWithBattleReward = projectedBattleReward
|
||||
? appendStoryEngineCarrierMemory({
|
||||
...projectedState,
|
||||
playerInventory: addInventoryItems(projectedState.playerInventory, projectedBattleReward.items),
|
||||
} as GameState, projectedBattleReward.items)
|
||||
: projectedState;
|
||||
fallbackState = projectedStateWithBattleReward;
|
||||
const projectedAvailableOptions = getAvailableOptionsForState(
|
||||
projectedStateWithBattleReward,
|
||||
character,
|
||||
);
|
||||
const combatResolutionContextText = buildCombatResolutionContextText({
|
||||
baseState: baseChoiceState,
|
||||
afterSequence: projectedStateWithBattleReward,
|
||||
optionKind: resolvedChoice.optionKind,
|
||||
projectedBattleReward,
|
||||
getResolvedSceneHostileNpcs,
|
||||
});
|
||||
const historyForStoryGeneration = combatResolutionContextText
|
||||
? [
|
||||
...history,
|
||||
createHistoryMoment(option.actionText, 'action'),
|
||||
createHistoryMoment(combatResolutionContextText, 'result'),
|
||||
]
|
||||
: history;
|
||||
|
||||
const responsePromise = shouldUseLocalNpcVictory
|
||||
? Promise.resolve(null)
|
||||
: generateNextStep(
|
||||
gameState.worldType,
|
||||
character,
|
||||
getStoryGenerationHostileNpcs(projectedStateWithBattleReward),
|
||||
historyForStoryGeneration,
|
||||
option.actionText,
|
||||
buildStoryContextFromState(projectedStateWithBattleReward, {
|
||||
lastFunctionId: option.functionId,
|
||||
observeSignsRequested: option.functionId === 'idle_observe_signs',
|
||||
recentActionResult: combatResolutionContextText,
|
||||
}),
|
||||
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 = appendStoryEngineCarrierMemory({
|
||||
...afterSequence,
|
||||
playerInventory: addInventoryItems(afterSequence.playerInventory, projectedBattleReward.items),
|
||||
} as GameState, 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(option.actionText, 'action'),
|
||||
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,
|
||||
});
|
||||
const recoveredState = applyStoryReasoningRecovery(nextState);
|
||||
setGameState(recoveredState);
|
||||
setCurrentStory(nextStory);
|
||||
} catch (storyError) {
|
||||
console.error('Failed to continue npc battle resolution story:', storyError);
|
||||
setAiError(storyError instanceof Error ? storyError.message : '未知智能生成错误');
|
||||
setCurrentStory(buildFallbackStoryForState(nextState, character, victory.resultText));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (responseResult.status === 'rejected') {
|
||||
throw responseResult.reason;
|
||||
}
|
||||
|
||||
const response = responseResult.value!;
|
||||
const defeatedHostileNpcIds = baseChoiceState.currentBattleNpcId || resolvedChoice.optionKind === 'escape'
|
||||
? []
|
||||
: getResolvedSceneHostileNpcs(baseChoiceState)
|
||||
.map(hostileNpc => hostileNpc.id)
|
||||
.filter(hostileNpcId => !getResolvedSceneHostileNpcs(afterSequence).some(hostileNpc => hostileNpc.id === hostileNpcId));
|
||||
const nextHistory = combatResolutionContextText
|
||||
? [
|
||||
...historyForStoryGeneration,
|
||||
createHistoryMoment(response.storyText, 'result', response.options),
|
||||
]
|
||||
: [
|
||||
...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,
|
||||
});
|
||||
|
||||
const recoveredState = applyStoryReasoningRecovery(nextState);
|
||||
setGameState(recoveredState);
|
||||
if (projectedBattleReward) {
|
||||
setBattleReward(projectedBattleReward);
|
||||
}
|
||||
|
||||
setCurrentStory(
|
||||
buildStoryFromResponse(
|
||||
recoveredState,
|
||||
character,
|
||||
{
|
||||
text: response.storyText,
|
||||
options: response.options,
|
||||
},
|
||||
projectedAvailableOptions,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to get next step:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setCurrentStory(buildFallbackStoryForState(fallbackState, character));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
await runLocalStoryChoiceContinuation({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
character,
|
||||
setGameState,
|
||||
setCurrentStory: (story) => setCurrentStory(story),
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setBattleReward,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
buildFallbackStoryForState,
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs,
|
||||
buildNpcStory,
|
||||
updateQuestLog,
|
||||
incrementRuntimeStats,
|
||||
finalizeNpcBattleResult,
|
||||
isRegularNpcEncounter,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
88
src/hooks/story/goalFlow.ts
Normal file
88
src/hooks/story/goalFlow.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildGoalStackState,
|
||||
createGoalPulseSnapshot,
|
||||
deriveGoalPulseEvent,
|
||||
} from '../../services/storyEngine/goalDirector';
|
||||
import type { GameState } from '../../types';
|
||||
import type { GoalFlowUi } from './uiTypes';
|
||||
|
||||
export function useStoryGoalFlow(gameState: GameState) {
|
||||
const [goalPulse, setGoalPulse] = useState<GoalFlowUi['pulse']>(null);
|
||||
const previousGoalPulseSnapshotRef =
|
||||
useRef<ReturnType<typeof createGoalPulseSnapshot> | null>(null);
|
||||
|
||||
const runtimeGoalStack = useMemo(
|
||||
() =>
|
||||
buildGoalStackState({
|
||||
quests: gameState.quests,
|
||||
worldType: gameState.worldType,
|
||||
currentSceneId: gameState.currentScenePreset?.id ?? null,
|
||||
chapterState:
|
||||
gameState.chapterState ??
|
||||
gameState.storyEngineMemory?.currentChapter ??
|
||||
null,
|
||||
journeyBeat: gameState.storyEngineMemory?.currentJourneyBeat ?? null,
|
||||
setpieceDirective:
|
||||
gameState.storyEngineMemory?.currentSetpieceDirective ?? null,
|
||||
currentCampEvent:
|
||||
gameState.storyEngineMemory?.currentCampEvent ?? null,
|
||||
currentSceneName: gameState.currentScenePreset?.name ?? null,
|
||||
}),
|
||||
[
|
||||
gameState.chapterState,
|
||||
gameState.currentScenePreset?.id,
|
||||
gameState.currentScenePreset?.name,
|
||||
gameState.quests,
|
||||
gameState.storyEngineMemory?.currentCampEvent,
|
||||
gameState.storyEngineMemory?.currentChapter,
|
||||
gameState.storyEngineMemory?.currentJourneyBeat,
|
||||
gameState.storyEngineMemory?.currentSetpieceDirective,
|
||||
gameState.worldType,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentSnapshot = createGoalPulseSnapshot(
|
||||
gameState.quests,
|
||||
runtimeGoalStack,
|
||||
);
|
||||
const previousSnapshot = previousGoalPulseSnapshotRef.current;
|
||||
|
||||
if (!previousSnapshot) {
|
||||
previousGoalPulseSnapshotRef.current = currentSnapshot;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPulse = deriveGoalPulseEvent({
|
||||
previous: previousSnapshot,
|
||||
quests: gameState.quests,
|
||||
goalStack: runtimeGoalStack,
|
||||
});
|
||||
if (nextPulse) {
|
||||
setGoalPulse(nextPulse);
|
||||
}
|
||||
|
||||
previousGoalPulseSnapshotRef.current = currentSnapshot;
|
||||
}, [gameState.quests, runtimeGoalStack]);
|
||||
|
||||
const dismissGoalPulse = useCallback(() => {
|
||||
setGoalPulse(null);
|
||||
}, []);
|
||||
|
||||
const resetGoalPulseTracking = useCallback(() => {
|
||||
previousGoalPulseSnapshotRef.current = null;
|
||||
setGoalPulse(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
runtimeGoalStack,
|
||||
goalUi: {
|
||||
goalStack: runtimeGoalStack,
|
||||
pulse: goalPulse,
|
||||
dismissPulse: dismissGoalPulse,
|
||||
} satisfies GoalFlowUi,
|
||||
resetGoalPulseTracking,
|
||||
};
|
||||
}
|
||||
@@ -1,44 +1,196 @@
|
||||
import type { GameState } from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
import { useEquipmentFlow } from '../useEquipmentFlow';
|
||||
import { useForgeFlow } from '../useForgeFlow';
|
||||
import { useInventoryFlow } from '../useInventoryFlow';
|
||||
import { useMemo, type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
EQUIPMENT_EQUIP_FUNCTION,
|
||||
EQUIPMENT_UNEQUIP_FUNCTION,
|
||||
FORGE_CRAFT_FUNCTION,
|
||||
FORGE_DISMANTLE_FUNCTION,
|
||||
FORGE_REFORGE_FUNCTION,
|
||||
INVENTORY_USE_FUNCTION,
|
||||
} from '../../data/functionCatalog';
|
||||
import { getForgeRecipeViews } from '../../data/forgeSystem';
|
||||
import type { Character, GameState, StoryMoment } from '../../types';
|
||||
import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator';
|
||||
import type { InventoryFlowUi } from './uiTypes';
|
||||
|
||||
type TickCooldowns = (cooldowns: Record<string, number>) => Record<string, number>;
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
export function useStoryInventoryActions({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
tickCooldowns,
|
||||
runtime,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
tickCooldowns: TickCooldowns;
|
||||
runtime: {
|
||||
currentStory: StoryMoment | null;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
};
|
||||
}) {
|
||||
const inventoryFlow = useInventoryFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
tickCooldowns,
|
||||
});
|
||||
const equipmentFlow = useEquipmentFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
});
|
||||
const forgeFlow = useForgeFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
});
|
||||
const {
|
||||
currentStory,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
buildFallbackStoryForState,
|
||||
} = runtime;
|
||||
const forgeRecipes = useMemo(
|
||||
() =>
|
||||
getForgeRecipeViews(
|
||||
gameState.playerInventory,
|
||||
gameState.playerCurrency,
|
||||
gameState.worldType,
|
||||
),
|
||||
[gameState.playerCurrency, gameState.playerInventory, gameState.worldType],
|
||||
);
|
||||
|
||||
const resolveServerInventoryAction = async (params: {
|
||||
functionId: string;
|
||||
actionText: string;
|
||||
payload: Record<string, unknown>;
|
||||
}) => {
|
||||
const character = gameState.playerCharacter;
|
||||
if (
|
||||
!character ||
|
||||
!gameState.worldType ||
|
||||
gameState.currentScene !== 'Story'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option: {
|
||||
functionId: params.functionId,
|
||||
actionText: params.actionText,
|
||||
},
|
||||
payload: params.payload,
|
||||
});
|
||||
|
||||
setGameState(hydratedSnapshot.gameState);
|
||||
setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve inventory runtime action on the server:', error);
|
||||
setAiError(error instanceof Error ? error.message : '背包动作执行失败');
|
||||
if (!currentStory) {
|
||||
setCurrentStory(buildFallbackStoryForState(gameState, character));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const useInventoryItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: INVENTORY_USE_FUNCTION.id,
|
||||
actionText: `使用${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
const equipInventoryItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: EQUIPMENT_EQUIP_FUNCTION.id,
|
||||
actionText: `装备${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
const unequipItem = async (slot: 'weapon' | 'armor' | 'relic') => {
|
||||
const equippedItem = gameState.playerEquipment[slot];
|
||||
if (!equippedItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: EQUIPMENT_UNEQUIP_FUNCTION.id,
|
||||
actionText: `卸下${equippedItem.name}`,
|
||||
payload: { slotId: slot },
|
||||
});
|
||||
};
|
||||
|
||||
const craftRecipe = async (recipeId: string) => {
|
||||
const recipe = forgeRecipes.find(
|
||||
(candidate) => candidate.id === recipeId,
|
||||
);
|
||||
if (!recipe) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: FORGE_CRAFT_FUNCTION.id,
|
||||
actionText: `制作${recipe.resultLabel}`,
|
||||
payload: { recipeId },
|
||||
});
|
||||
};
|
||||
|
||||
const dismantleItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: FORGE_DISMANTLE_FUNCTION.id,
|
||||
actionText: `拆解${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
const reforgeItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: FORGE_REFORGE_FUNCTION.id,
|
||||
actionText: `重铸${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
inventoryUi: {
|
||||
useInventoryItem: inventoryFlow.handleUseInventoryItem,
|
||||
equipInventoryItem: equipmentFlow.handleEquipInventoryItem,
|
||||
unequipItem: equipmentFlow.handleUnequipItem,
|
||||
forgeRecipes: forgeFlow.forgeRecipes,
|
||||
craftRecipe: forgeFlow.handleCraftRecipe,
|
||||
dismantleItem: forgeFlow.handleDismantleItem,
|
||||
reforgeItem: forgeFlow.handleReforgeItem,
|
||||
useInventoryItem,
|
||||
equipInventoryItem,
|
||||
unequipItem,
|
||||
forgeRecipes,
|
||||
craftRecipe,
|
||||
dismantleItem,
|
||||
reforgeItem,
|
||||
} satisfies InventoryFlowUi,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ import type {
|
||||
} from '../../types';
|
||||
import { AnimationState } from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator';
|
||||
|
||||
type CommitGeneratedStateWithEncounterEntry = (
|
||||
entryState: GameState,
|
||||
@@ -116,6 +117,7 @@ function isNpcEncounter(
|
||||
|
||||
export function createStoryNpcEncounterActions({
|
||||
gameState,
|
||||
currentStory,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
@@ -142,6 +144,7 @@ export function createStoryNpcEncounterActions({
|
||||
npcInteractionFlow,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
@@ -588,6 +591,46 @@ export function createStoryNpcEncounterActions({
|
||||
return true;
|
||||
};
|
||||
|
||||
const resolveServerNpcStoryAction = async (params: {
|
||||
option: StoryOption;
|
||||
encounter: Encounter;
|
||||
payload?: Record<string, unknown>;
|
||||
}) => {
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
if (
|
||||
!playerCharacter ||
|
||||
!gameState.worldType ||
|
||||
gameState.currentScene !== 'Story'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option: params.option,
|
||||
payload: params.payload,
|
||||
});
|
||||
|
||||
setGameState(hydratedSnapshot.gameState);
|
||||
setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve npc story action on the server:', error);
|
||||
setAiError(error instanceof Error ? error.message : 'NPC 动作执行失败');
|
||||
if (!currentStory) {
|
||||
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNpcInteraction = (option: StoryOption) => {
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
if (!playerCharacter || !option.interaction || !isNpcEncounter(gameState.currentEncounter)) {
|
||||
@@ -835,141 +878,23 @@ export function createStoryNpcEncounterActions({
|
||||
return true;
|
||||
}
|
||||
case 'quest_accept': {
|
||||
const existingQuest = getQuestForIssuer(
|
||||
gameState.quests,
|
||||
getNpcEncounterKey(encounter),
|
||||
);
|
||||
if (existingQuest) return true;
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
void (async () => {
|
||||
let committed = false;
|
||||
|
||||
try {
|
||||
const quest =
|
||||
(await generateQuestForNpcEncounter({
|
||||
state: gameState,
|
||||
encounter,
|
||||
})) ??
|
||||
buildQuestForEncounter({
|
||||
issuerNpcId: getNpcEncounterKey(encounter),
|
||||
issuerNpcName: encounter.npcName,
|
||||
roleText: encounter.context,
|
||||
scene: gameState.currentScenePreset,
|
||||
worldType: gameState.worldType,
|
||||
});
|
||||
if (!quest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextState = incrementRuntimeStats(
|
||||
updateNpcState(
|
||||
updateQuestLog(gameState, (quests) => acceptQuest(quests, quest)),
|
||||
encounter,
|
||||
(currentNpcState) => ({
|
||||
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
stanceProfile: applyStoryChoiceToStanceProfile(
|
||||
currentNpcState.stanceProfile,
|
||||
'npc_quest_accept',
|
||||
),
|
||||
}),
|
||||
),
|
||||
{questsAccepted: 1},
|
||||
);
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
playerCharacter,
|
||||
option.actionText,
|
||||
buildQuestAcceptResultText(quest),
|
||||
option.functionId,
|
||||
);
|
||||
committed = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to accept npc quest:', error);
|
||||
const fallbackQuest = buildQuestForEncounter({
|
||||
issuerNpcId: getNpcEncounterKey(encounter),
|
||||
issuerNpcName: encounter.npcName,
|
||||
roleText: encounter.context,
|
||||
scene: gameState.currentScenePreset,
|
||||
worldType: gameState.worldType,
|
||||
});
|
||||
if (!fallbackQuest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextState = incrementRuntimeStats(
|
||||
updateNpcState(
|
||||
updateQuestLog(gameState, (quests) =>
|
||||
acceptQuest(quests, fallbackQuest),
|
||||
),
|
||||
encounter,
|
||||
(currentNpcState) => ({
|
||||
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
stanceProfile: applyStoryChoiceToStanceProfile(
|
||||
currentNpcState.stanceProfile,
|
||||
'npc_quest_accept',
|
||||
),
|
||||
}),
|
||||
),
|
||||
{questsAccepted: 1},
|
||||
);
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
playerCharacter,
|
||||
option.actionText,
|
||||
buildQuestAcceptResultText(fallbackQuest),
|
||||
option.functionId,
|
||||
);
|
||||
committed = true;
|
||||
} finally {
|
||||
if (!committed) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
void resolveServerNpcStoryAction({
|
||||
option,
|
||||
encounter,
|
||||
});
|
||||
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 = appendStoryEngineCarrierMemory({
|
||||
...updateQuestLog(gameState, (quests) =>
|
||||
markQuestTurnedIn(quests, quest.id),
|
||||
),
|
||||
npcStates: {
|
||||
...gameState.npcStates,
|
||||
[getNpcEncounterKey(encounter)]: {
|
||||
...syncNpcNarrativeState({
|
||||
encounter,
|
||||
npcState: {
|
||||
...npcState,
|
||||
...markNpcFirstMeaningfulContactResolved(npcState),
|
||||
affinity: npcState.affinity + quest.reward.affinityBonus,
|
||||
relationState: buildRelationState(
|
||||
npcState.affinity + quest.reward.affinityBonus,
|
||||
),
|
||||
},
|
||||
customWorldProfile: gameState.customWorldProfile,
|
||||
storyEngineMemory: gameState.storyEngineMemory,
|
||||
}),
|
||||
},
|
||||
},
|
||||
playerCurrency: gameState.playerCurrency + quest.reward.currency,
|
||||
playerInventory: addInventoryItems(
|
||||
gameState.playerInventory,
|
||||
quest.reward.items,
|
||||
),
|
||||
} as GameState, quest.reward.items);
|
||||
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
playerCharacter,
|
||||
option.actionText,
|
||||
buildQuestTurnInResultText(quest),
|
||||
option.functionId,
|
||||
);
|
||||
void resolveServerNpcStoryAction({
|
||||
option,
|
||||
encounter,
|
||||
payload: questId
|
||||
? {
|
||||
questId,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case 'leave': {
|
||||
|
||||
@@ -50,6 +50,7 @@ import type {
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator';
|
||||
import type {
|
||||
GiftModalState,
|
||||
RecruitModalState,
|
||||
@@ -67,6 +68,7 @@ type GenerateStoryForState = (params: {
|
||||
}) => Promise<StoryMoment>;
|
||||
|
||||
type StoryNpcInteractionRuntime = {
|
||||
currentStory: StoryMoment | null;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -656,6 +658,60 @@ export function useStoryNpcInteractionFlow({
|
||||
setRecruitModal(null);
|
||||
};
|
||||
|
||||
const resolveServerNpcAction = async (params: {
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
functionId: string;
|
||||
action: 'trade' | 'gift' | 'quest_accept' | 'quest_turn_in';
|
||||
payload?: Record<string, unknown>;
|
||||
}) => {
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
if (
|
||||
!playerCharacter ||
|
||||
!gameState.worldType ||
|
||||
gameState.currentScene !== 'Story'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
runtime.setAiError(null);
|
||||
runtime.setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory: runtime.currentStory,
|
||||
option: {
|
||||
functionId: params.functionId,
|
||||
actionText: params.actionText,
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: params.encounter.id ?? getNpcEncounterKey(params.encounter),
|
||||
action: params.action,
|
||||
},
|
||||
},
|
||||
payload: params.payload,
|
||||
});
|
||||
|
||||
setGameState(hydratedSnapshot.gameState);
|
||||
runtime.setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve npc runtime action on the server:', error);
|
||||
runtime.setAiError(
|
||||
error instanceof Error ? error.message : 'NPC 交互执行失败',
|
||||
);
|
||||
if (!runtime.currentStory) {
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildFallbackStoryForState(gameState, playerCharacter),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
runtime.setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmTrade = () => {
|
||||
if (!tradeModal || !gameState.playerCharacter) return;
|
||||
|
||||
@@ -669,27 +725,8 @@ export function useStoryNpcInteractionFlow({
|
||||
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 = appendStoryEngineCarrierMemory({
|
||||
...nextState,
|
||||
playerCurrency: nextState.playerCurrency - totalPrice,
|
||||
playerInventory: addInventoryItems(
|
||||
nextState.playerInventory,
|
||||
[cloneInventoryItemForOwner(npcItem, 'player', quantity)],
|
||||
),
|
||||
} as GameState, [cloneInventoryItemForOwner(npcItem, 'player', quantity)]);
|
||||
|
||||
setTradeModal(null);
|
||||
void commitNpcReactionAndGenerate({
|
||||
nextState,
|
||||
void resolveServerNpcAction({
|
||||
encounter,
|
||||
actionText: buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
@@ -697,16 +734,13 @@ export function useStoryNpcInteractionFlow({
|
||||
item: npcItem,
|
||||
quantity,
|
||||
}),
|
||||
resultText: buildNpcTradeTransactionResultText({
|
||||
encounter,
|
||||
functionId: 'npc_trade',
|
||||
action: 'trade',
|
||||
payload: {
|
||||
mode: 'buy',
|
||||
item: npcItem,
|
||||
itemId: npcItem.id,
|
||||
quantity,
|
||||
totalPrice,
|
||||
worldType: gameState.worldType,
|
||||
}),
|
||||
lastFunctionId: 'npc_trade',
|
||||
contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -715,27 +749,8 @@ export function useStoryNpcInteractionFlow({
|
||||
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 commitNpcReactionAndGenerate({
|
||||
nextState,
|
||||
void resolveServerNpcAction({
|
||||
encounter,
|
||||
actionText: buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
@@ -743,16 +758,13 @@ export function useStoryNpcInteractionFlow({
|
||||
item: playerItem,
|
||||
quantity,
|
||||
}),
|
||||
resultText: buildNpcTradeTransactionResultText({
|
||||
encounter,
|
||||
functionId: 'npc_trade',
|
||||
action: 'trade',
|
||||
payload: {
|
||||
mode: 'sell',
|
||||
item: playerItem,
|
||||
itemId: playerItem.id,
|
||||
quantity,
|
||||
totalPrice,
|
||||
worldType: gameState.worldType,
|
||||
}),
|
||||
lastFunctionId: 'npc_trade',
|
||||
contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -763,57 +775,15 @@ export function useStoryNpcInteractionFlow({
|
||||
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,
|
||||
stanceProfile: applyStoryChoiceToStanceProfile(
|
||||
currentNpcState.stanceProfile,
|
||||
'npc_gift',
|
||||
{ affinityGain },
|
||||
),
|
||||
inventory: addInventoryItems(
|
||||
currentNpcState.inventory,
|
||||
[cloneInventoryItemForOwner(giftItem, 'npc')],
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
nextState = {
|
||||
...nextState,
|
||||
playerInventory: removeInventoryItem(nextState.playerInventory, giftItem.id, 1),
|
||||
};
|
||||
|
||||
setGiftModal(null);
|
||||
void commitNpcReactionAndGenerate({
|
||||
nextState,
|
||||
void resolveServerNpcAction({
|
||||
encounter,
|
||||
actionText: buildNpcGiftCommitActionText(encounter, giftItem),
|
||||
resultText: buildNpcGiftResultText(
|
||||
encounter,
|
||||
giftItem,
|
||||
affinityGain,
|
||||
nextAffinity,
|
||||
attributeSummary ?? undefined,
|
||||
),
|
||||
lastFunctionId: 'npc_gift',
|
||||
contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
|
||||
functionId: 'npc_gift',
|
||||
action: 'gift',
|
||||
payload: {
|
||||
itemId: giftItem.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
366
src/hooks/story/runtimeStoryCoordinator.test.ts
Normal file
366
src/hooks/story/runtimeStoryCoordinator.test.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
putSaveSnapshotMock,
|
||||
getRuntimeStoryStateMock,
|
||||
resolveRuntimeStoryActionMock,
|
||||
getRuntimeSessionIdMock,
|
||||
getRuntimeClientVersionMock,
|
||||
} = vi.hoisted(() => ({
|
||||
putSaveSnapshotMock: vi.fn(),
|
||||
getRuntimeStoryStateMock: vi.fn(),
|
||||
resolveRuntimeStoryActionMock: vi.fn(),
|
||||
getRuntimeSessionIdMock: vi.fn(() => 'runtime-main'),
|
||||
getRuntimeClientVersionMock: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/storageService', () => ({
|
||||
putSaveSnapshot: putSaveSnapshotMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/runtimeStoryService', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../../services/runtimeStoryService')>(
|
||||
'../../services/runtimeStoryService',
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getRuntimeStoryState: getRuntimeStoryStateMock,
|
||||
resolveRuntimeStoryAction: resolveRuntimeStoryActionMock,
|
||||
getRuntimeSessionId: getRuntimeSessionIdMock,
|
||||
getRuntimeClientVersion: getRuntimeClientVersionMock,
|
||||
};
|
||||
});
|
||||
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
loadServerRuntimeOptionCatalog,
|
||||
resumeServerRuntimeStory,
|
||||
resolveServerRuntimeChoice,
|
||||
} from './runtimeStoryCoordinator';
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createGameState(): GameState {
|
||||
return {
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 7,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('runtimeStoryCoordinator', () => {
|
||||
beforeEach(() => {
|
||||
putSaveSnapshotMock.mockReset();
|
||||
getRuntimeStoryStateMock.mockReset();
|
||||
resolveRuntimeStoryActionMock.mockReset();
|
||||
getRuntimeSessionIdMock.mockReset();
|
||||
getRuntimeSessionIdMock.mockReturnValue('runtime-main');
|
||||
getRuntimeClientVersionMock.mockReset();
|
||||
getRuntimeClientVersionMock.mockReturnValue(7);
|
||||
});
|
||||
|
||||
it('loads runtime option catalogs through the persisted server snapshot flow', async () => {
|
||||
const gameState = createGameState();
|
||||
const currentStory = createStory('当前故事');
|
||||
|
||||
getRuntimeStoryStateMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 3,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
scope: 'npc',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: true,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '服务端故事',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {},
|
||||
currentStory: null,
|
||||
},
|
||||
});
|
||||
|
||||
const options = await loadServerRuntimeOptionCatalog({
|
||||
gameState,
|
||||
currentStory,
|
||||
});
|
||||
|
||||
expect(putSaveSnapshotMock).toHaveBeenCalledWith({
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
});
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith('runtime-main');
|
||||
expect(options).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('hydrates runtime choices into snapshot state and presentation-safe story data', async () => {
|
||||
const gameState = createGameState();
|
||||
const currentStory = createStory('当前故事');
|
||||
const option = {
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
text: '继续交谈',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-opponent',
|
||||
action: 'chat',
|
||||
},
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
const hydratedSnapshot = {
|
||||
version: 8,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
gameState: {
|
||||
...gameState,
|
||||
runtimeActionVersion: 8,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
} as GameState,
|
||||
currentStory: createStory('快照中的故事'),
|
||||
bottomTab: 'adventure',
|
||||
} as HydratedSavedGameSnapshot;
|
||||
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 96,
|
||||
maxHp: 100,
|
||||
mana: 18,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
scope: 'npc',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: true,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '继续交谈',
|
||||
resultText: '关系已有变化',
|
||||
storyText: '',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: hydratedSnapshot,
|
||||
});
|
||||
|
||||
const result = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
payload: {
|
||||
note: 'server-runtime-test',
|
||||
},
|
||||
});
|
||||
|
||||
expect(putSaveSnapshotMock).toHaveBeenCalledWith({
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
});
|
||||
expect(resolveRuntimeStoryActionMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 7,
|
||||
option,
|
||||
targetId: 'npc-opponent',
|
||||
payload: {
|
||||
note: 'server-runtime-test',
|
||||
},
|
||||
});
|
||||
expect(result.hydratedSnapshot).toBe(hydratedSnapshot);
|
||||
expect(result.nextStory).toEqual(
|
||||
expect.objectContaining({
|
||||
text: '快照中的故事',
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshes resumable runtime stories from the server before hydrating the main flow', async () => {
|
||||
const localHydratedSnapshot = {
|
||||
version: 7,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
gameState: {
|
||||
currentScene: 'Story',
|
||||
worldType: 'wuxia',
|
||||
playerCharacter: {
|
||||
id: 'hero',
|
||||
},
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
runtimeActionVersion: 7,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
} as unknown as GameState,
|
||||
currentStory: createStory('本地快照故事'),
|
||||
bottomTab: 'inventory' as const,
|
||||
} as HydratedSavedGameSnapshot;
|
||||
const serverHydratedSnapshot = {
|
||||
version: 8,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
gameState: {
|
||||
currentScene: 'Story',
|
||||
worldType: 'wuxia',
|
||||
playerCharacter: {
|
||||
id: 'hero',
|
||||
},
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
runtimeActionVersion: 8,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
} as unknown as GameState,
|
||||
currentStory: createStory('服务端快照故事'),
|
||||
bottomTab: 'character' as const,
|
||||
} as HydratedSavedGameSnapshot;
|
||||
|
||||
getRuntimeStoryStateMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 90,
|
||||
maxHp: 100,
|
||||
mana: 16,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
scope: 'npc',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '服务端恢复后的故事',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: serverHydratedSnapshot,
|
||||
});
|
||||
|
||||
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
|
||||
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith('runtime-main');
|
||||
expect(result.hydratedSnapshot).toBe(serverHydratedSnapshot);
|
||||
expect(result.nextStory).toEqual(
|
||||
expect.objectContaining({
|
||||
text: '服务端恢复后的故事',
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps local snapshot hydration when the saved state is not an active runtime story', async () => {
|
||||
const localHydratedSnapshot = {
|
||||
version: 7,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
gameState: {
|
||||
currentScene: 'Home',
|
||||
worldType: null,
|
||||
playerCharacter: null,
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
runtimeActionVersion: 7,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
} as unknown as GameState,
|
||||
currentStory: createStory('本地快照故事'),
|
||||
bottomTab: 'adventure' as const,
|
||||
} as HydratedSavedGameSnapshot;
|
||||
|
||||
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
|
||||
|
||||
expect(getRuntimeStoryStateMock).not.toHaveBeenCalled();
|
||||
expect(result.hydratedSnapshot).toBe(localHydratedSnapshot);
|
||||
expect(result.nextStory).toBe(localHydratedSnapshot.currentStory);
|
||||
});
|
||||
});
|
||||
122
src/hooks/story/runtimeStoryCoordinator.ts
Normal file
122
src/hooks/story/runtimeStoryCoordinator.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
buildStoryMomentFromRuntimeOptions,
|
||||
getRuntimeClientVersion,
|
||||
getRuntimeSessionId,
|
||||
getRuntimeStoryState,
|
||||
resolveRuntimeStoryAction,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryResponse,
|
||||
} from '../../services/runtimeStoryService';
|
||||
import { putSaveSnapshot } from '../../services/storageService';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
|
||||
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||
return response.viewModel.availableOptions.length > 0
|
||||
? response.viewModel.availableOptions
|
||||
: response.presentation.options;
|
||||
}
|
||||
|
||||
async function syncRuntimeSnapshot(
|
||||
gameState: GameState,
|
||||
currentStory: StoryMoment | null,
|
||||
) {
|
||||
await putSaveSnapshot({
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadServerRuntimeOptionCatalog(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
}) {
|
||||
await syncRuntimeSnapshot(params.gameState, params.currentStory);
|
||||
|
||||
const response = await getRuntimeStoryState(
|
||||
getRuntimeSessionId(params.gameState),
|
||||
);
|
||||
const options = buildStoryMomentFromRuntimeOptions({
|
||||
storyText: response.presentation.storyText,
|
||||
options: getRuntimeResponseOptions(response),
|
||||
gameState: params.gameState,
|
||||
}).options;
|
||||
|
||||
return options.length > 0 ? options : null;
|
||||
}
|
||||
|
||||
export async function resumeServerRuntimeStory(
|
||||
snapshot: HydratedSavedGameSnapshot,
|
||||
) {
|
||||
const hydratedSnapshot = snapshot;
|
||||
const shouldRefreshFromServer =
|
||||
hydratedSnapshot.gameState.currentScene === 'Story' &&
|
||||
Boolean(hydratedSnapshot.gameState.worldType) &&
|
||||
Boolean(hydratedSnapshot.gameState.playerCharacter);
|
||||
|
||||
if (!shouldRefreshFromServer) {
|
||||
return {
|
||||
hydratedSnapshot,
|
||||
nextStory: hydratedSnapshot.currentStory,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await getRuntimeStoryState(
|
||||
getRuntimeSessionId(hydratedSnapshot.gameState),
|
||||
);
|
||||
const resumedSnapshot = response.snapshot;
|
||||
const runtimeOptions = getRuntimeResponseOptions(response);
|
||||
const nextStory =
|
||||
response.presentation.storyText || runtimeOptions.length > 0
|
||||
? buildStoryMomentFromRuntimeOptions({
|
||||
storyText:
|
||||
response.presentation.storyText ||
|
||||
resumedSnapshot.currentStory?.text ||
|
||||
hydratedSnapshot.currentStory?.text ||
|
||||
'',
|
||||
options: runtimeOptions,
|
||||
gameState: resumedSnapshot.gameState,
|
||||
})
|
||||
: resumedSnapshot.currentStory;
|
||||
|
||||
return {
|
||||
hydratedSnapshot: resumedSnapshot,
|
||||
nextStory,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveServerRuntimeChoice(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
option: Pick<StoryOption, 'functionId' | 'actionText'> &
|
||||
Partial<Pick<StoryOption, 'interaction'>>;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
}) {
|
||||
await syncRuntimeSnapshot(params.gameState, params.currentStory);
|
||||
|
||||
const response = await resolveRuntimeStoryAction({
|
||||
sessionId: getRuntimeSessionId(params.gameState),
|
||||
clientVersion: getRuntimeClientVersion(params.gameState),
|
||||
option: params.option,
|
||||
targetId:
|
||||
params.option.interaction?.kind === 'npc'
|
||||
? params.option.interaction.npcId
|
||||
: undefined,
|
||||
payload: params.payload,
|
||||
});
|
||||
const hydratedSnapshot = response.snapshot;
|
||||
|
||||
return {
|
||||
response,
|
||||
hydratedSnapshot,
|
||||
nextStory: buildStoryMomentFromRuntimeOptions({
|
||||
storyText:
|
||||
response.presentation.storyText ||
|
||||
hydratedSnapshot.currentStory?.text ||
|
||||
params.option.actionText,
|
||||
options: getRuntimeResponseOptions(response),
|
||||
gameState: hydratedSnapshot.gameState,
|
||||
}),
|
||||
};
|
||||
}
|
||||
249
src/hooks/story/storyBootstrap.ts
Normal file
249
src/hooks/story/storyBootstrap.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import type { Character, Encounter, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import {
|
||||
playOpeningAdventureSequence,
|
||||
type PreparedOpeningAdventure,
|
||||
} from './openingAdventure';
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type GenerateStoryForState = (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
|
||||
type BuildDialogueStoryMoment = (
|
||||
npcName: string,
|
||||
text: string,
|
||||
options: StoryOption[],
|
||||
streaming?: boolean,
|
||||
) => StoryMoment;
|
||||
|
||||
export function useStoryBootstrap(params: {
|
||||
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>>;
|
||||
prepareOpeningAdventure: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => PreparedOpeningAdventure | null;
|
||||
getNpcEncounterKey: (encounter: Encounter) => string;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
generateStoryForState: GenerateStoryForState;
|
||||
buildDialogueStoryMoment: BuildDialogueStoryMoment;
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
hasRenderableDialogueTurns: (text: string, npcName: string) => boolean;
|
||||
inferOpeningCampFollowupOptions: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
baseOptions: StoryOption[],
|
||||
openingBackground: string,
|
||||
openingDialogue: string,
|
||||
) => Promise<StoryOption[]>;
|
||||
getTypewriterDelay: (char: string) => number;
|
||||
isNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isInitialCompanionEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
}) {
|
||||
const {
|
||||
gameState,
|
||||
currentStory,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
prepareOpeningAdventure,
|
||||
getNpcEncounterKey,
|
||||
buildFallbackStoryForState,
|
||||
generateStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
buildStoryContextFromState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
hasRenderableDialogueTurns,
|
||||
inferOpeningCampFollowupOptions,
|
||||
getTypewriterDelay,
|
||||
isNpcEncounter,
|
||||
isInitialCompanionEncounter,
|
||||
} = params;
|
||||
|
||||
const [preparedOpeningAdventure, setPreparedOpeningAdventure] =
|
||||
useState<PreparedOpeningAdventure | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
gameState.currentScene !== 'Story' ||
|
||||
!gameState.playerCharacter ||
|
||||
gameState.storyHistory.length > 0 ||
|
||||
currentStory ||
|
||||
!isNpcEncounter(gameState.currentEncounter) ||
|
||||
gameState.currentEncounter.specialBehavior !== 'initial_companion'
|
||||
) {
|
||||
setPreparedOpeningAdventure(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setPreparedOpeningAdventure(
|
||||
prepareOpeningAdventure(gameState, gameState.playerCharacter),
|
||||
);
|
||||
}, [
|
||||
currentStory,
|
||||
gameState,
|
||||
isNpcEncounter,
|
||||
prepareOpeningAdventure,
|
||||
]);
|
||||
|
||||
const startOpeningAdventure = useCallback(async () => {
|
||||
if (
|
||||
!gameState.playerCharacter ||
|
||||
!isNpcEncounter(gameState.currentEncounter)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const encounter = gameState.currentEncounter;
|
||||
if (encounter.specialBehavior !== 'initial_companion') {
|
||||
return;
|
||||
}
|
||||
|
||||
const preparedStory =
|
||||
preparedOpeningAdventure?.encounterKey === getNpcEncounterKey(encounter)
|
||||
? preparedOpeningAdventure
|
||||
: prepareOpeningAdventure(gameState, gameState.playerCharacter);
|
||||
|
||||
if (!preparedStory) {
|
||||
return;
|
||||
}
|
||||
|
||||
await playOpeningAdventureSequence({
|
||||
gameState,
|
||||
character: gameState.playerCharacter,
|
||||
encounter,
|
||||
preparedStory,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
buildDialogueStoryMoment,
|
||||
buildStoryContextFromState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
hasRenderableDialogueTurns,
|
||||
inferOpeningCampFollowupOptions,
|
||||
getTypewriterDelay,
|
||||
});
|
||||
}, [
|
||||
buildDialogueStoryMoment,
|
||||
buildStoryContextFromState,
|
||||
gameState,
|
||||
getNpcEncounterKey,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
hasRenderableDialogueTurns,
|
||||
inferOpeningCampFollowupOptions,
|
||||
isNpcEncounter,
|
||||
prepareOpeningAdventure,
|
||||
preparedOpeningAdventure,
|
||||
setAiError,
|
||||
setCurrentStory,
|
||||
setGameState,
|
||||
setIsLoading,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const startStory = async () => {
|
||||
if (
|
||||
gameState.currentScene !== 'Story' ||
|
||||
!gameState.worldType ||
|
||||
!gameState.playerCharacter ||
|
||||
currentStory ||
|
||||
isLoading
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
gameState.storyHistory.length === 0 &&
|
||||
isInitialCompanionEncounter(gameState.currentEncounter) &&
|
||||
!gameState.npcInteractionActive
|
||||
) {
|
||||
setAiError(null);
|
||||
void startOpeningAdventure();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
setAiError(null);
|
||||
const nextStory = await generateStoryForState({
|
||||
state: gameState,
|
||||
character: gameState.playerCharacter,
|
||||
history: [],
|
||||
});
|
||||
setGameState(applyStoryReasoningRecovery(gameState));
|
||||
setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to start story:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setCurrentStory(
|
||||
buildFallbackStoryForState(gameState, gameState.playerCharacter),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void startStory();
|
||||
}, [
|
||||
buildFallbackStoryForState,
|
||||
currentStory,
|
||||
gameState,
|
||||
generateStoryForState,
|
||||
isInitialCompanionEncounter,
|
||||
isLoading,
|
||||
setAiError,
|
||||
setCurrentStory,
|
||||
setGameState,
|
||||
setIsLoading,
|
||||
startOpeningAdventure,
|
||||
]);
|
||||
|
||||
const resetPreparedOpeningAdventure = useCallback(() => {
|
||||
setPreparedOpeningAdventure(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
preparedOpeningAdventure,
|
||||
startOpeningAdventure,
|
||||
resetPreparedOpeningAdventure,
|
||||
};
|
||||
}
|
||||
337
src/hooks/story/storyCampCompanion.test.ts
Normal file
337
src/hooks/story/storyCampCompanion.test.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getWorldCampScenePreset } from '../../data/scenePresets';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import {
|
||||
buildCampCompanionOpeningResultText,
|
||||
buildInitialCompanionDialogueText,
|
||||
createCampCompanionStoryHelpers,
|
||||
} from './storyCampCompanion';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'sword-princess',
|
||||
name: '测试同伴',
|
||||
title: '试剑公主',
|
||||
description: '在营地观察局势的试炼者。',
|
||||
backstory: '她在旅途中始终保留自己的真正目标。',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 12,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: '谨慎冷静',
|
||||
skills: [],
|
||||
adventureOpenings: {
|
||||
[WorldType.WUXIA]: {
|
||||
reason: '调查旧路异动',
|
||||
goal: '查清前方局势',
|
||||
monologue: '风声里还藏着未说破的话。',
|
||||
surfaceHook: '我来这里,是为了确认旧路尽头到底出了什么事。',
|
||||
immediateConcern: '眼下的风向不对,我们不能直接把底牌亮出来。',
|
||||
guardedMotive: '我真正要找的东西,还不能让更多人知道。',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(
|
||||
functionId: string,
|
||||
actionText = functionId,
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
||||
return {
|
||||
id: 'camp-companion',
|
||||
kind: 'npc',
|
||||
npcName: '沈砺',
|
||||
npcDescription: '正靠在营地灯火旁观察风向。',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '营地夜谈',
|
||||
specialBehavior: 'camp_companion',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createStory(text: string, options: StoryOption[] = []): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
function createGameState(overrides: Partial<GameState> = {}): GameState {
|
||||
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: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: getWorldCampScenePreset(WorldType.WUXIA),
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 30,
|
||||
playerMaxMana: 30,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('storyCampCompanion', () => {
|
||||
it('builds opening dialogue from the character adventure opening', () => {
|
||||
const text = buildInitialCompanionDialogueText(
|
||||
createCharacter(),
|
||||
createEncounter(),
|
||||
WorldType.WUXIA,
|
||||
);
|
||||
|
||||
expect(text).toContain('我来这里,是为了确认旧路尽头到底出了什么事。');
|
||||
expect(text).toContain('沈砺:那就不要说得太快太多。');
|
||||
expect(text).toContain('我真正要找的东西,还不能让更多人知道。');
|
||||
});
|
||||
|
||||
it('summarizes the camp opening result with the current concern', () => {
|
||||
const text = buildCampCompanionOpeningResultText(
|
||||
createCharacter(),
|
||||
createEncounter(),
|
||||
WorldType.WUXIA,
|
||||
);
|
||||
|
||||
expect(text).toContain('沈砺 在');
|
||||
expect(text).toContain('眼下的风向不对');
|
||||
});
|
||||
|
||||
it('keeps chat and recruit options while appending the travel action for camp openings', () => {
|
||||
const buildNpcStory = vi.fn(() =>
|
||||
createStory('营地开场', [
|
||||
createOption('npc_chat', '继续交谈'),
|
||||
createOption('npc_recruit', '邀请同行'),
|
||||
createOption('npc_trade', '查看货物'),
|
||||
]),
|
||||
);
|
||||
const helpers = createCampCompanionStoryHelpers({
|
||||
buildNpcStory,
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getNpcEncounterKey: vi.fn(() => 'camp-companion'),
|
||||
generateNextStep: vi.fn(),
|
||||
});
|
||||
|
||||
const options = helpers.buildCampCompanionOpeningOptions(
|
||||
createGameState(),
|
||||
createCharacter(),
|
||||
createEncounter(),
|
||||
);
|
||||
|
||||
expect(options.map((option) => option.functionId)).toEqual([
|
||||
'npc_chat',
|
||||
'npc_recruit',
|
||||
'camp_travel_home_scene',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses AI follow-up options when the camp follow-up request succeeds and falls back on errors', async () => {
|
||||
const baseOptions = [createOption('npc_chat', '继续交谈')];
|
||||
const generateNextStep = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
storyText: '继续营地交谈',
|
||||
options: [
|
||||
createOption('npc_trade', '先看对方带来的东西'),
|
||||
createOption('npc_chat', '继续交谈'),
|
||||
],
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('llm failed'));
|
||||
const buildStoryContextFromState = vi.fn(() => ({
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 30,
|
||||
playerMaxMana: 30,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
sceneId: 'camp',
|
||||
sceneName: '营地',
|
||||
sceneDescription: '营火微亮。',
|
||||
pendingSceneEncounter: false,
|
||||
}));
|
||||
const helpers = createCampCompanionStoryHelpers({
|
||||
buildNpcStory: vi.fn(),
|
||||
buildStoryContextFromState,
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getNpcEncounterKey: vi.fn(() => 'camp-companion'),
|
||||
generateNextStep,
|
||||
});
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
const resolvedOptions = await helpers.inferOpeningCampFollowupOptions(
|
||||
state,
|
||||
character,
|
||||
baseOptions,
|
||||
'营地里风声微沉。',
|
||||
'你们刚交换完第一轮判断。',
|
||||
);
|
||||
const fallbackOptions = await helpers.inferOpeningCampFollowupOptions(
|
||||
state,
|
||||
character,
|
||||
baseOptions,
|
||||
'营地里风声微沉。',
|
||||
'你们刚交换完第一轮判断。',
|
||||
);
|
||||
|
||||
expect(buildStoryContextFromState).toHaveBeenCalledWith(
|
||||
state,
|
||||
expect.objectContaining({
|
||||
openingCampBackground: '营地里风声微沉。',
|
||||
openingCampDialogue: '你们刚交换完第一轮判断。',
|
||||
}),
|
||||
);
|
||||
expect(resolvedOptions.map((option) => option.functionId)).toEqual([
|
||||
'npc_trade',
|
||||
'npc_chat',
|
||||
]);
|
||||
expect(fallbackOptions).toBe(baseOptions);
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reconstructs the opening camp chat context from story history and filters idle camp options', () => {
|
||||
const encounter = createEncounter();
|
||||
const buildNpcStory = vi.fn(() =>
|
||||
createStory('营地常态', [
|
||||
createOption('npc_chat', '继续交谈'),
|
||||
createOption('npc_leave', '结束对话'),
|
||||
createOption('npc_fight', '直接切磋'),
|
||||
createOption('npc_trade', '查看货物'),
|
||||
]),
|
||||
);
|
||||
const helpers = createCampCompanionStoryHelpers({
|
||||
buildNpcStory,
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getNpcEncounterKey: vi.fn(() => 'camp-companion'),
|
||||
generateNextStep: vi.fn(),
|
||||
});
|
||||
const state = createGameState({
|
||||
currentEncounter: encounter,
|
||||
npcStates: {
|
||||
'camp-companion': {
|
||||
affinity: 16,
|
||||
helpUsed: false,
|
||||
chattedCount: 1,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
storyHistory: [
|
||||
{
|
||||
text: `在营地与 ${encounter.npcName} 交换开场判断`,
|
||||
options: [],
|
||||
historyRole: 'action',
|
||||
},
|
||||
{
|
||||
text: '你们先对了一遍眼前局势。',
|
||||
options: [],
|
||||
historyRole: 'result',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const chatContext = helpers.buildOpeningCampChatContext(
|
||||
state,
|
||||
createCharacter(),
|
||||
encounter,
|
||||
);
|
||||
const idleStory = helpers.buildCampCompanionIdleStory(
|
||||
state,
|
||||
createCharacter(),
|
||||
encounter,
|
||||
);
|
||||
|
||||
expect(chatContext).toEqual(
|
||||
expect.objectContaining({
|
||||
openingCampBackground: expect.stringContaining('沈砺 在'),
|
||||
openingCampDialogue: '你们先对了一遍眼前局势。',
|
||||
}),
|
||||
);
|
||||
expect(idleStory.options.map((option) => option.functionId)).toEqual([
|
||||
'npc_chat',
|
||||
'npc_trade',
|
||||
'camp_travel_home_scene',
|
||||
]);
|
||||
});
|
||||
});
|
||||
300
src/hooks/story/storyCampCompanion.ts
Normal file
300
src/hooks/story/storyCampCompanion.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import {
|
||||
getCharacterAdventureOpening,
|
||||
getCharacterHomeSceneId,
|
||||
} from '../../data/characterPresets';
|
||||
import {
|
||||
buildCampTravelHomeOption,
|
||||
NPC_CHAT_FUNCTION,
|
||||
NPC_FIGHT_FUNCTION,
|
||||
NPC_LEAVE_FUNCTION,
|
||||
NPC_RECRUIT_FUNCTION,
|
||||
} from '../../data/functionCatalog';
|
||||
import { buildInitialNpcState } from '../../data/npcInteractions';
|
||||
import {
|
||||
getForwardScenePreset,
|
||||
getScenePresetById,
|
||||
getTravelScenePreset,
|
||||
getWorldCampScenePreset,
|
||||
} from '../../data/scenePresets';
|
||||
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
|
||||
import type { StoryGenerationContext } from '../../services/aiService';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
|
||||
type BuildNpcStory = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
type GetStoryGenerationHostileNpcs = (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
|
||||
type GetNpcEncounterKey = (encounter: Encounter) => string;
|
||||
|
||||
type GenerateNextStep =
|
||||
(typeof import('../../services/aiService'))['generateNextStep'];
|
||||
|
||||
export function buildInitialCompanionDialogueText(
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
worldType: WorldType | null,
|
||||
) {
|
||||
const opening = getCharacterAdventureOpening(character, worldType);
|
||||
const surfaceHook =
|
||||
opening?.surfaceHook ?? '这个地方与我来此的目的息息相关。';
|
||||
const immediateConcern =
|
||||
opening?.immediateConcern ?? '前方似乎有些不对劲,我们不能贸然前进。';
|
||||
const guardedMotive =
|
||||
opening?.guardedMotive ?? '我并非偶然来到这里,但我还不准备全盘托出。';
|
||||
|
||||
return [
|
||||
`你:${surfaceHook}`,
|
||||
`${encounter.npcName}:那就不要说得太快太多。前方的道路并不稳定,贸然冲进去只会最先遇到最糟糕的情况。`,
|
||||
`你:${immediateConcern}`,
|
||||
`${encounter.npcName}:我能看出你不是误闯此地的。${guardedMotive} 剩下的我们可以在路上再梳理清楚。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCampCompanionOpeningResultText(
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
worldType: WorldType | null,
|
||||
) {
|
||||
const opening = getCharacterAdventureOpening(character, worldType);
|
||||
const campSceneName = worldType
|
||||
? (getWorldCampScenePreset(worldType)?.name ?? '归处')
|
||||
: '归处';
|
||||
if (!opening) {
|
||||
return `${encounter.npcName} 已经来到你身边。在${campSceneName},你稍作停顿,决定下一步去向何方。`;
|
||||
}
|
||||
|
||||
return `${encounter.npcName} 在${campSceneName}来到你身边。你们首先就“${opening.immediateConcern ?? '前方不稳定的道路'}”交换了意见,而双方都暂时保留了部分真相。`;
|
||||
}
|
||||
|
||||
function getCampCompanionHomeScene(state: GameState, character: Character) {
|
||||
if (!state.worldType) return null;
|
||||
const sceneId = getCharacterHomeSceneId(state.worldType, character.id);
|
||||
return getScenePresetById(state.worldType, sceneId);
|
||||
}
|
||||
|
||||
export function createCampCompanionStoryHelpers(params: {
|
||||
buildNpcStory: BuildNpcStory;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
getStoryGenerationHostileNpcs: GetStoryGenerationHostileNpcs;
|
||||
getNpcEncounterKey: GetNpcEncounterKey;
|
||||
generateNextStep: GenerateNextStep;
|
||||
}) {
|
||||
const getCampCompanionTravelScene = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => {
|
||||
if (!state.worldType) return null;
|
||||
|
||||
const campScene = getWorldCampScenePreset(state.worldType);
|
||||
const homeScene = getCampCompanionHomeScene(state, character);
|
||||
if (
|
||||
homeScene &&
|
||||
homeScene.id !== campScene?.id &&
|
||||
homeScene.id !== state.currentScenePreset?.id
|
||||
) {
|
||||
return homeScene;
|
||||
}
|
||||
|
||||
const fallbackSceneId =
|
||||
campScene?.id ?? state.currentScenePreset?.id ?? null;
|
||||
return (
|
||||
getForwardScenePreset(state.worldType, fallbackSceneId) ??
|
||||
getTravelScenePreset(state.worldType, fallbackSceneId) ??
|
||||
homeScene
|
||||
);
|
||||
};
|
||||
|
||||
const buildCampCompanionOpeningOptions = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
) => {
|
||||
const targetScene = getCampCompanionTravelScene(state, character);
|
||||
const baseOptions = params.buildNpcStory(
|
||||
state,
|
||||
character,
|
||||
encounter,
|
||||
).options;
|
||||
const chatOptions = baseOptions
|
||||
.filter((option) => option.functionId === NPC_CHAT_FUNCTION.id)
|
||||
.slice(0, 1);
|
||||
const recruitOption =
|
||||
baseOptions.find(
|
||||
(option) => option.functionId === NPC_RECRUIT_FUNCTION.id,
|
||||
) ?? null;
|
||||
const openingOptions = recruitOption
|
||||
? [...chatOptions, recruitOption]
|
||||
: chatOptions;
|
||||
|
||||
if (!targetScene) {
|
||||
return openingOptions;
|
||||
}
|
||||
|
||||
return [...openingOptions, buildCampTravelHomeOption(targetScene.name)];
|
||||
};
|
||||
|
||||
const inferOpeningCampFollowupOptions = async (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
baseOptions: StoryOption[],
|
||||
openingBackground: string,
|
||||
openingDialogue: string,
|
||||
) => {
|
||||
if (!state.worldType || baseOptions.length === 0) {
|
||||
return baseOptions;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await params.generateNextStep(
|
||||
state.worldType,
|
||||
character,
|
||||
params.getStoryGenerationHostileNpcs(state),
|
||||
state.storyHistory,
|
||||
'继续承接营地中的这段交谈,并整理出眼下最自然的后续行动。',
|
||||
params.buildStoryContextFromState(state, {
|
||||
openingCampBackground: openingBackground,
|
||||
openingCampDialogue: openingDialogue,
|
||||
}),
|
||||
{
|
||||
availableOptions: baseOptions,
|
||||
},
|
||||
);
|
||||
|
||||
return sortStoryOptionsByPriority(response.options);
|
||||
} catch (error) {
|
||||
console.error('Failed to infer opening camp follow-up options:', error);
|
||||
return baseOptions;
|
||||
}
|
||||
};
|
||||
|
||||
const buildOpeningCampChatContext = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
) => {
|
||||
if (encounter.specialBehavior !== 'camp_companion') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const npcState =
|
||||
state.npcStates[params.getNpcEncounterKey(encounter)] ??
|
||||
buildInitialNpcState(encounter, state.worldType, state);
|
||||
if (npcState.chattedCount > 2) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const openingActionText = `在营地与 ${encounter.npcName} 交换开场判断`;
|
||||
let openingDialogue: string | null = null;
|
||||
|
||||
for (let index = 0; index < state.storyHistory.length - 1; index += 1) {
|
||||
const entry = state.storyHistory[index];
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
if (entry.historyRole !== 'action' || entry.text !== openingActionText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (
|
||||
let nextIndex = index + 1;
|
||||
nextIndex < state.storyHistory.length;
|
||||
nextIndex += 1
|
||||
) {
|
||||
const nextEntry = state.storyHistory[nextIndex];
|
||||
if (!nextEntry) {
|
||||
continue;
|
||||
}
|
||||
if (nextEntry.historyRole === 'action') {
|
||||
break;
|
||||
}
|
||||
if (nextEntry.text.trim()) {
|
||||
openingDialogue = nextEntry.text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (openingDialogue) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!openingDialogue) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
openingCampBackground: buildCampCompanionOpeningResultText(
|
||||
character,
|
||||
encounter,
|
||||
state.worldType,
|
||||
),
|
||||
openingCampDialogue: openingDialogue,
|
||||
};
|
||||
};
|
||||
|
||||
const buildCampCompanionIdleStory = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
): StoryMoment => {
|
||||
const targetScene = getCampCompanionTravelScene(state, character);
|
||||
const baseStory = params.buildNpcStory(
|
||||
state,
|
||||
character,
|
||||
encounter,
|
||||
overrideText,
|
||||
);
|
||||
const filteredOptions = baseStory.options.filter(
|
||||
(option) =>
|
||||
option.functionId !== NPC_LEAVE_FUNCTION.id &&
|
||||
option.functionId !== NPC_FIGHT_FUNCTION.id,
|
||||
);
|
||||
|
||||
if (!targetScene) {
|
||||
return {
|
||||
...baseStory,
|
||||
options: filteredOptions,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...baseStory,
|
||||
options: [
|
||||
...filteredOptions.slice(0, 2),
|
||||
buildCampTravelHomeOption(targetScene.name),
|
||||
...filteredOptions.slice(2),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
getCampCompanionTravelScene,
|
||||
buildCampCompanionOpeningOptions,
|
||||
inferOpeningCampFollowupOptions,
|
||||
buildOpeningCampChatContext,
|
||||
buildCampCompanionIdleStory,
|
||||
};
|
||||
}
|
||||
407
src/hooks/story/storyChoiceContinuation.ts
Normal file
407
src/hooks/story/storyChoiceContinuation.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import { addInventoryItems } from '../../data/npcInteractions';
|
||||
import { applyQuestProgressFromHostileNpcDefeat } from '../../data/questFlow';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import { generateNextStep } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { EscapePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import {
|
||||
buildCombatResolutionContextText,
|
||||
buildHostileNpcBattleReward,
|
||||
buildReasonedOptionCatalog,
|
||||
} from './storyChoiceRuntime';
|
||||
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;
|
||||
recentActionResult?: string | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
type UpdateQuestLog = (
|
||||
state: GameState,
|
||||
updater: (quests: GameState['quests']) => GameState['quests'],
|
||||
) => GameState;
|
||||
|
||||
type IncrementRuntimeStats = (
|
||||
state: GameState,
|
||||
increments: RuntimeStatsIncrements,
|
||||
) => GameState;
|
||||
|
||||
export async function runLocalStoryChoiceContinuation(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
setGameState: (state: GameState) => void;
|
||||
setCurrentStory: (story: StoryMoment) => void;
|
||||
setAiError: (message: string | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setBattleReward: (reward: BattleRewardSummary | null) => void;
|
||||
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: (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
getAvailableOptionsForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
buildNpcStory: BuildNpcStory;
|
||||
updateQuestLog: UpdateQuestLog;
|
||||
incrementRuntimeStats: IncrementRuntimeStats;
|
||||
finalizeNpcBattleResult: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
battleMode: NonNullable<GameState['currentNpcBattleMode']>,
|
||||
battleOutcome: GameState['currentNpcBattleOutcome'],
|
||||
) => { nextState: GameState; resultText: string } | null;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
}) {
|
||||
params.setBattleReward(null);
|
||||
params.setAiError(null);
|
||||
params.setIsLoading(true);
|
||||
|
||||
const baseChoiceState =
|
||||
params.isRegularNpcEncounter(params.gameState.currentEncounter) &&
|
||||
!params.gameState.npcInteractionActive &&
|
||||
!params.option.interaction
|
||||
? {
|
||||
...params.gameState,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
}
|
||||
: params.gameState;
|
||||
|
||||
let fallbackState = baseChoiceState;
|
||||
|
||||
try {
|
||||
const history = baseChoiceState.storyHistory;
|
||||
const resolvedChoice = params.buildResolvedChoiceState(
|
||||
baseChoiceState,
|
||||
params.option,
|
||||
params.character,
|
||||
);
|
||||
const projectedState = resolvedChoice.afterSequence;
|
||||
const shouldUseLocalNpcVictory = Boolean(
|
||||
baseChoiceState.currentBattleNpcId &&
|
||||
resolvedChoice.optionKind === 'battle' &&
|
||||
(projectedState.currentNpcBattleOutcome ||
|
||||
(baseChoiceState.currentNpcBattleMode === 'fight' &&
|
||||
!projectedState.inBattle)),
|
||||
);
|
||||
const projectedBattleReward = shouldUseLocalNpcVictory
|
||||
? null
|
||||
: await buildHostileNpcBattleReward(
|
||||
baseChoiceState,
|
||||
projectedState,
|
||||
resolvedChoice.optionKind,
|
||||
params.getResolvedSceneHostileNpcs,
|
||||
);
|
||||
const projectedStateWithBattleReward = projectedBattleReward
|
||||
? appendStoryEngineCarrierMemory(
|
||||
{
|
||||
...projectedState,
|
||||
playerInventory: addInventoryItems(
|
||||
projectedState.playerInventory,
|
||||
projectedBattleReward.items,
|
||||
),
|
||||
} as GameState,
|
||||
projectedBattleReward.items,
|
||||
)
|
||||
: projectedState;
|
||||
fallbackState = projectedStateWithBattleReward;
|
||||
const projectedAvailableOptions = params.getAvailableOptionsForState(
|
||||
projectedStateWithBattleReward,
|
||||
params.character,
|
||||
);
|
||||
const combatResolutionContextText = buildCombatResolutionContextText({
|
||||
baseState: baseChoiceState,
|
||||
afterSequence: projectedStateWithBattleReward,
|
||||
optionKind: resolvedChoice.optionKind,
|
||||
projectedBattleReward,
|
||||
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
|
||||
});
|
||||
const historyForStoryGeneration = combatResolutionContextText
|
||||
? [
|
||||
...history,
|
||||
createHistoryMoment(params.option.actionText, 'action'),
|
||||
createHistoryMoment(combatResolutionContextText, 'result'),
|
||||
]
|
||||
: history;
|
||||
|
||||
const responsePromise = shouldUseLocalNpcVictory
|
||||
? Promise.resolve(null)
|
||||
: generateNextStep(
|
||||
params.gameState.worldType!,
|
||||
params.character,
|
||||
params.getStoryGenerationHostileNpcs(projectedStateWithBattleReward),
|
||||
historyForStoryGeneration,
|
||||
params.option.actionText,
|
||||
params.buildStoryContextFromState(projectedStateWithBattleReward, {
|
||||
lastFunctionId: params.option.functionId,
|
||||
observeSignsRequested:
|
||||
params.option.functionId === 'idle_observe_signs',
|
||||
recentActionResult: combatResolutionContextText,
|
||||
}),
|
||||
projectedAvailableOptions
|
||||
? { availableOptions: projectedAvailableOptions }
|
||||
: undefined,
|
||||
);
|
||||
const responseSettledPromise = responsePromise.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
const playbackSync: EscapePlaybackSync | undefined =
|
||||
resolvedChoice.optionKind === 'escape'
|
||||
? { waitForStoryResponse: responseSettledPromise }
|
||||
: undefined;
|
||||
const actionPromise = params.playResolvedChoice(
|
||||
baseChoiceState,
|
||||
params.option,
|
||||
params.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 = appendStoryEngineCarrierMemory(
|
||||
{
|
||||
...afterSequence,
|
||||
playerInventory: addInventoryItems(
|
||||
afterSequence.playerInventory,
|
||||
projectedBattleReward.items,
|
||||
),
|
||||
} as GameState,
|
||||
projectedBattleReward.items,
|
||||
);
|
||||
}
|
||||
fallbackState = afterSequence;
|
||||
|
||||
if (shouldUseLocalNpcVictory) {
|
||||
const victory = params.finalizeNpcBattleResult(
|
||||
afterSequence,
|
||||
params.character,
|
||||
baseChoiceState.currentNpcBattleMode!,
|
||||
afterSequence.currentNpcBattleOutcome,
|
||||
);
|
||||
if (victory) {
|
||||
const historyBase =
|
||||
baseChoiceState.currentNpcBattleMode === 'spar'
|
||||
? (afterSequence.sparStoryHistoryBefore ?? [])
|
||||
: baseChoiceState.storyHistory;
|
||||
const nextHistory = [
|
||||
...historyBase,
|
||||
createHistoryMoment(params.option.actionText, 'action'),
|
||||
createHistoryMoment(victory.resultText, 'result'),
|
||||
];
|
||||
const nextState = {
|
||||
...victory.nextState,
|
||||
storyHistory: nextHistory,
|
||||
};
|
||||
const postBattleOptionCatalog =
|
||||
baseChoiceState.currentNpcBattleMode === 'spar' &&
|
||||
nextState.currentEncounter
|
||||
? buildReasonedOptionCatalog(
|
||||
params.buildNpcStory(
|
||||
nextState,
|
||||
params.character,
|
||||
nextState.currentEncounter,
|
||||
).options,
|
||||
)
|
||||
: null;
|
||||
fallbackState = nextState;
|
||||
params.setGameState(nextState);
|
||||
try {
|
||||
const nextStory = await params.generateStoryForState({
|
||||
state: nextState,
|
||||
character: params.character,
|
||||
history: nextHistory,
|
||||
choice: params.option.actionText,
|
||||
lastFunctionId: params.option.functionId,
|
||||
optionCatalog: postBattleOptionCatalog,
|
||||
});
|
||||
const recoveredState = applyStoryReasoningRecovery(nextState);
|
||||
params.setGameState(recoveredState);
|
||||
params.setCurrentStory(nextStory);
|
||||
} catch (storyError) {
|
||||
console.error(
|
||||
'Failed to continue npc battle resolution story:',
|
||||
storyError,
|
||||
);
|
||||
params.setAiError(
|
||||
storyError instanceof Error
|
||||
? storyError.message
|
||||
: '未知智能生成错误',
|
||||
);
|
||||
params.setCurrentStory(
|
||||
params.buildFallbackStoryForState(
|
||||
nextState,
|
||||
params.character,
|
||||
victory.resultText,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (responseResult.status === 'rejected') {
|
||||
throw responseResult.reason;
|
||||
}
|
||||
|
||||
const response = responseResult.value!;
|
||||
const defeatedHostileNpcIds =
|
||||
baseChoiceState.currentBattleNpcId ||
|
||||
resolvedChoice.optionKind === 'escape'
|
||||
? []
|
||||
: params
|
||||
.getResolvedSceneHostileNpcs(baseChoiceState)
|
||||
.map((hostileNpc) => hostileNpc.id)
|
||||
.filter(
|
||||
(hostileNpcId) =>
|
||||
!params
|
||||
.getResolvedSceneHostileNpcs(afterSequence)
|
||||
.some((hostileNpc) => hostileNpc.id === hostileNpcId),
|
||||
);
|
||||
const nextHistory = combatResolutionContextText
|
||||
? [
|
||||
...historyForStoryGeneration,
|
||||
createHistoryMoment(response.storyText, 'result', response.options),
|
||||
]
|
||||
: [
|
||||
...baseChoiceState.storyHistory,
|
||||
createHistoryMoment(params.option.actionText, 'action'),
|
||||
createHistoryMoment(response.storyText, 'result', response.options),
|
||||
];
|
||||
|
||||
const nextState = params.incrementRuntimeStats(
|
||||
{
|
||||
...params.updateQuestLog(afterSequence, (quests) =>
|
||||
applyQuestProgressFromHostileNpcDefeat(
|
||||
quests,
|
||||
baseChoiceState.currentScenePreset?.id ?? null,
|
||||
defeatedHostileNpcIds,
|
||||
),
|
||||
),
|
||||
lastObserveSignsSceneId:
|
||||
params.option.functionId === 'idle_observe_signs'
|
||||
? (afterSequence.currentScenePreset?.id ?? null)
|
||||
: afterSequence.lastObserveSignsSceneId ?? null,
|
||||
lastObserveSignsReport:
|
||||
params.option.functionId === 'idle_observe_signs'
|
||||
? response.storyText
|
||||
: afterSequence.lastObserveSignsReport ?? null,
|
||||
storyHistory: nextHistory,
|
||||
},
|
||||
{
|
||||
hostileNpcsDefeated: defeatedHostileNpcIds.length,
|
||||
},
|
||||
);
|
||||
|
||||
const recoveredState = applyStoryReasoningRecovery(nextState);
|
||||
params.setGameState(recoveredState);
|
||||
if (projectedBattleReward) {
|
||||
params.setBattleReward(projectedBattleReward);
|
||||
}
|
||||
|
||||
params.setCurrentStory(
|
||||
params.buildStoryFromResponse(
|
||||
recoveredState,
|
||||
params.character,
|
||||
{
|
||||
text: response.storyText,
|
||||
options: response.options,
|
||||
},
|
||||
projectedAvailableOptions,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to get next step:', error);
|
||||
params.setAiError(
|
||||
error instanceof Error ? error.message : '未知智能生成错误',
|
||||
);
|
||||
params.setCurrentStory(
|
||||
params.buildFallbackStoryForState(fallbackState, params.character),
|
||||
);
|
||||
} finally {
|
||||
params.setIsLoading(false);
|
||||
}
|
||||
}
|
||||
137
src/hooks/story/storyChoiceCoordinator.test.ts
Normal file
137
src/hooks/story/storyChoiceCoordinator.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { createStoryChoiceCoordinatorConfig } from './storyChoiceCoordinator';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试角色',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: '谨慎',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createOption(functionId = 'npc_chat', actionText = '继续交谈'): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createState(): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
currentScene: 'Story',
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
const neverNpcEncounter = (
|
||||
_encounter: GameState['currentEncounter'],
|
||||
): _encounter is Encounter => false;
|
||||
|
||||
describe('storyChoiceCoordinator', () => {
|
||||
it('builds one config object for createStoryChoiceActions from runtime controller and support', () => {
|
||||
const runtimeController = {
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
buildStoryFromResponse: vi.fn(),
|
||||
buildFallbackStoryForState: vi.fn(),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(),
|
||||
getCampCompanionTravelScene: vi.fn(),
|
||||
startOpeningAdventure: vi.fn(),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
};
|
||||
const runtimeSupport = {
|
||||
buildNpcStory: vi.fn(),
|
||||
updateQuestLog: vi.fn(),
|
||||
updateRuntimeStats: vi.fn(),
|
||||
};
|
||||
|
||||
const config = createStoryChoiceCoordinatorConfig({
|
||||
gameState: createState(),
|
||||
currentStory: createStory('当前故事'),
|
||||
isLoading: false,
|
||||
setGameState: vi.fn(),
|
||||
setCurrentStory: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(),
|
||||
playResolvedChoice: vi.fn(),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn(() => []),
|
||||
runtimeController: runtimeController as never,
|
||||
runtimeSupport: runtimeSupport as never,
|
||||
enterNpcInteraction: vi.fn(),
|
||||
handleNpcInteraction: vi.fn(),
|
||||
handleTreasureInteraction: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(),
|
||||
sortOptions: vi.fn((options: StoryOption[]) => options),
|
||||
buildContinueAdventureOption: vi.fn(() => createOption('continue')),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isInitialCompanionEncounter: neverNpcEncounter,
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
expect(config).toEqual(
|
||||
expect.objectContaining({
|
||||
buildStoryContextFromState: runtimeController.buildStoryContextFromState,
|
||||
buildStoryFromResponse: runtimeController.buildStoryFromResponse,
|
||||
buildFallbackStoryForState: runtimeController.buildFallbackStoryForState,
|
||||
generateStoryForState: runtimeController.generateStoryForState,
|
||||
getAvailableOptionsForState: runtimeController.getAvailableOptionsForState,
|
||||
buildNpcStory: runtimeSupport.buildNpcStory,
|
||||
updateQuestLog: runtimeSupport.updateQuestLog,
|
||||
incrementRuntimeStats: runtimeSupport.updateRuntimeStats,
|
||||
getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene,
|
||||
startOpeningAdventure: runtimeController.startOpeningAdventure,
|
||||
commitGeneratedStateWithEncounterEntry:
|
||||
runtimeController.commitGeneratedStateWithEncounterEntry,
|
||||
}),
|
||||
);
|
||||
|
||||
void createCharacter();
|
||||
});
|
||||
});
|
||||
175
src/hooks/story/storyChoiceCoordinator.ts
Normal file
175
src/hooks/story/storyChoiceCoordinator.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
|
||||
export type ChoiceRuntimeController = {
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
buildStoryFromResponse: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog?: StoryOption[] | null,
|
||||
) => StoryMoment;
|
||||
buildFallbackStoryForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
generateStoryForState: (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
getAvailableOptionsForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
getCampCompanionTravelScene: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => GameState['currentScenePreset'] | null;
|
||||
startOpeningAdventure: () => Promise<void>;
|
||||
commitGeneratedStateWithEncounterEntry: (
|
||||
entryState: GameState,
|
||||
resolvedState: GameState,
|
||||
character: Character,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
lastFunctionId?: string,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export type ChoiceRuntimeSupport = Pick<
|
||||
StoryRuntimeSupport,
|
||||
'buildNpcStory' | 'updateQuestLog' | 'updateRuntimeStats'
|
||||
>;
|
||||
|
||||
export type StoryChoiceCoordinatorParams = {
|
||||
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?: ResolvedChoicePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
runtimeController: ChoiceRuntimeController;
|
||||
runtimeSupport: ChoiceRuntimeSupport;
|
||||
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
|
||||
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
|
||||
handleTreasureInteraction: (
|
||||
option: StoryOption,
|
||||
) => void | Promise<void> | boolean | Promise<boolean>;
|
||||
finalizeNpcBattleResult: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
battleMode: NonNullable<GameState['currentNpcBattleMode']>,
|
||||
battleOutcome: GameState['currentNpcBattleOutcome'],
|
||||
) => { nextState: GameState; resultText: string } | null;
|
||||
sortOptions: (options: StoryOption[]) => StoryOption[];
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
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;
|
||||
};
|
||||
|
||||
export function createStoryChoiceCoordinatorConfig(
|
||||
params: StoryChoiceCoordinatorParams,
|
||||
) {
|
||||
return {
|
||||
gameState: params.gameState,
|
||||
currentStory: params.currentStory,
|
||||
isLoading: params.isLoading,
|
||||
setGameState: params.setGameState,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
setBattleReward: params.setBattleReward,
|
||||
buildResolvedChoiceState: params.buildResolvedChoiceState,
|
||||
playResolvedChoice: params.playResolvedChoice,
|
||||
buildStoryContextFromState:
|
||||
params.runtimeController.buildStoryContextFromState,
|
||||
buildStoryFromResponse: params.runtimeController.buildStoryFromResponse,
|
||||
buildFallbackStoryForState:
|
||||
params.runtimeController.buildFallbackStoryForState,
|
||||
generateStoryForState: params.runtimeController.generateStoryForState,
|
||||
getAvailableOptionsForState:
|
||||
params.runtimeController.getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
|
||||
buildNpcStory: params.runtimeSupport.buildNpcStory,
|
||||
updateQuestLog: params.runtimeSupport.updateQuestLog,
|
||||
incrementRuntimeStats: params.runtimeSupport.updateRuntimeStats,
|
||||
getCampCompanionTravelScene:
|
||||
params.runtimeController.getCampCompanionTravelScene,
|
||||
startOpeningAdventure: params.runtimeController.startOpeningAdventure,
|
||||
enterNpcInteraction: params.enterNpcInteraction,
|
||||
handleNpcInteraction: params.handleNpcInteraction,
|
||||
handleTreasureInteraction: params.handleTreasureInteraction,
|
||||
commitGeneratedStateWithEncounterEntry:
|
||||
params.runtimeController.commitGeneratedStateWithEncounterEntry,
|
||||
finalizeNpcBattleResult: params.finalizeNpcBattleResult,
|
||||
isContinueAdventureOption: params.isContinueAdventureOption,
|
||||
isCampTravelHomeOption: params.isCampTravelHomeOption,
|
||||
isInitialCompanionEncounter: params.isInitialCompanionEncounter,
|
||||
isRegularNpcEncounter: params.isRegularNpcEncounter,
|
||||
isNpcEncounter: params.isNpcEncounter,
|
||||
npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName: params.fallbackCompanionName,
|
||||
turnVisualMs: params.turnVisualMs,
|
||||
};
|
||||
}
|
||||
338
src/hooks/story/storyChoiceRuntime.test.ts
Normal file
338
src/hooks/story/storyChoiceRuntime.test.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
rollHostileNpcLootMock,
|
||||
resolveServerRuntimeChoiceMock,
|
||||
} = vi.hoisted(() => ({
|
||||
rollHostileNpcLootMock: vi.fn(),
|
||||
resolveServerRuntimeChoiceMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../data/hostileNpcPresets', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../../data/hostileNpcPresets')>(
|
||||
'../../data/hostileNpcPresets',
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
rollHostileNpcLoot: rollHostileNpcLootMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./runtimeStoryCoordinator', () => ({
|
||||
resolveServerRuntimeChoice: resolveServerRuntimeChoiceMock,
|
||||
}));
|
||||
|
||||
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import {
|
||||
buildCombatResolutionContextText,
|
||||
buildHostileNpcBattleReward,
|
||||
buildReasonedOptionCatalog,
|
||||
runServerRuntimeChoiceAction,
|
||||
shouldOpenLocalRuntimeNpcModal,
|
||||
} from './storyChoiceRuntime';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试角色',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 10,
|
||||
spirit: 10,
|
||||
},
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(
|
||||
functionId: string,
|
||||
interaction?: StoryOption['interaction'],
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText: functionId,
|
||||
text: functionId,
|
||||
interaction,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
function createState(overrides: Partial<GameState> = {}): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: 'idle',
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('storyChoiceRuntime', () => {
|
||||
beforeEach(() => {
|
||||
rollHostileNpcLootMock.mockReset();
|
||||
resolveServerRuntimeChoiceMock.mockReset();
|
||||
});
|
||||
|
||||
it('deduplicates option catalogs by function id for post-battle recovery', () => {
|
||||
const options = buildReasonedOptionCatalog([
|
||||
createOption('npc_chat'),
|
||||
createOption('npc_chat'),
|
||||
createOption('npc_help'),
|
||||
]);
|
||||
|
||||
expect(options.map((option) => option.functionId)).toEqual([
|
||||
'npc_chat',
|
||||
'npc_help',
|
||||
]);
|
||||
});
|
||||
|
||||
it('identifies npc trade and gift as local runtime modal actions', () => {
|
||||
expect(
|
||||
shouldOpenLocalRuntimeNpcModal(
|
||||
createOption('npc_trade', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-merchant',
|
||||
action: 'trade',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldOpenLocalRuntimeNpcModal(
|
||||
createOption('npc_gift', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-friend',
|
||||
action: 'gift',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldOpenLocalRuntimeNpcModal(createOption('npc_chat')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('builds escape and victory context text for local battle resolution', () => {
|
||||
const baseState = createState({
|
||||
inBattle: true,
|
||||
sceneHostileNpcs: [
|
||||
{ id: 'wolf', name: '山狼' },
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
});
|
||||
|
||||
expect(
|
||||
buildCombatResolutionContextText({
|
||||
baseState,
|
||||
afterSequence: {
|
||||
...baseState,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
},
|
||||
optionKind: 'escape',
|
||||
projectedBattleReward: null,
|
||||
getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs,
|
||||
}),
|
||||
).toContain('你已成功逃脱');
|
||||
|
||||
expect(
|
||||
buildCombatResolutionContextText({
|
||||
baseState: {
|
||||
...baseState,
|
||||
currentBattleNpcId: null,
|
||||
},
|
||||
afterSequence: {
|
||||
...baseState,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
},
|
||||
optionKind: 'battle',
|
||||
projectedBattleReward: {
|
||||
id: 'reward-1',
|
||||
defeatedHostileNpcs: [{ id: 'wolf', name: '山狼' }],
|
||||
items: [
|
||||
{ id: 'loot-1', category: '材料', name: '狼牙', quantity: 1, rarity: 'common', tags: [] },
|
||||
],
|
||||
},
|
||||
getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs,
|
||||
}),
|
||||
).toContain('战利品:狼牙。');
|
||||
});
|
||||
|
||||
it('builds defeated hostile rewards from locally resolved battle states', async () => {
|
||||
rollHostileNpcLootMock.mockResolvedValue([
|
||||
{
|
||||
id: 'loot-1',
|
||||
category: '材料',
|
||||
name: '狼牙',
|
||||
quantity: 1,
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const reward = await buildHostileNpcBattleReward(
|
||||
createState({
|
||||
inBattle: true,
|
||||
sceneHostileNpcs: [
|
||||
{ id: 'wolf', name: '山狼' },
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
currentBattleNpcId: null,
|
||||
}),
|
||||
createState({
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
}),
|
||||
'battle',
|
||||
(state) => state.sceneHostileNpcs,
|
||||
);
|
||||
|
||||
expect(rollHostileNpcLootMock).toHaveBeenCalledTimes(1);
|
||||
expect(reward?.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
name: '狼牙',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('applies server runtime responses and falls back locally when the request fails', async () => {
|
||||
const gameState = createState();
|
||||
const currentStory = createStory('当前故事');
|
||||
const setBattleReward = vi.fn();
|
||||
const setAiError = vi.fn();
|
||||
const setIsLoading = vi.fn();
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
hydratedSnapshot: {
|
||||
gameState: {
|
||||
...gameState,
|
||||
runtimeActionVersion: 3,
|
||||
},
|
||||
},
|
||||
nextStory: createStory('服务端故事'),
|
||||
});
|
||||
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory,
|
||||
option: createOption('npc_chat'),
|
||||
character: createCharacter(),
|
||||
setBattleReward,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setGameState,
|
||||
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
|
||||
buildFallbackStoryForState: () => createStory('fallback'),
|
||||
});
|
||||
|
||||
expect(setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtimeActionVersion: 3,
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '服务端故事',
|
||||
}),
|
||||
);
|
||||
|
||||
resolveServerRuntimeChoiceMock.mockRejectedValueOnce(new Error('boom'));
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory: null,
|
||||
option: createOption('npc_chat'),
|
||||
character: createCharacter(),
|
||||
setBattleReward,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setGameState,
|
||||
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
|
||||
buildFallbackStoryForState: () => createStory('fallback'),
|
||||
});
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(setAiError).toHaveBeenCalledWith('boom');
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: 'fallback',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
322
src/hooks/story/storyChoiceRuntime.ts
Normal file
322
src/hooks/story/storyChoiceRuntime.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
buildEncounterEntryState,
|
||||
hasEncounterEntity,
|
||||
} from '../../data/encounterTransition';
|
||||
import { rollHostileNpcLoot } from '../../data/hostileNpcPresets';
|
||||
import { addInventoryItems } from '../../data/npcInteractions';
|
||||
import {
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
createSceneEncounterPreview,
|
||||
resolveSceneEncounterPreview,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator';
|
||||
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 IncrementRuntimeStats = (
|
||||
state: GameState,
|
||||
increments: RuntimeStatsIncrements,
|
||||
) => GameState;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export 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;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCombatResolutionContextText(params: {
|
||||
baseState: GameState;
|
||||
afterSequence: GameState;
|
||||
optionKind: 'battle' | 'escape' | 'idle';
|
||||
projectedBattleReward: BattleRewardSummary | null;
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
}) {
|
||||
const {
|
||||
baseState,
|
||||
afterSequence,
|
||||
optionKind,
|
||||
projectedBattleReward,
|
||||
getResolvedSceneHostileNpcs,
|
||||
} = params;
|
||||
|
||||
if (optionKind === 'escape') {
|
||||
const hostileNames = getResolvedSceneHostileNpcs(baseState)
|
||||
.map((hostileNpc) => hostileNpc.name)
|
||||
.join('、');
|
||||
return hostileNames
|
||||
? `你已成功逃脱,与${hostileNames}的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。`
|
||||
: '你已成功逃脱刚才的交战,当前不再处于战斗状态。';
|
||||
}
|
||||
|
||||
if (
|
||||
!baseState.inBattle ||
|
||||
afterSequence.inBattle ||
|
||||
Boolean(baseState.currentBattleNpcId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostileNames = getResolvedSceneHostileNpcs(baseState)
|
||||
.map((hostileNpc) => hostileNpc.name)
|
||||
.join('、');
|
||||
const lootText =
|
||||
projectedBattleReward?.items.length
|
||||
? `战利品:${projectedBattleReward.items
|
||||
.map((item) => item.name)
|
||||
.join('、')}。`
|
||||
: '';
|
||||
|
||||
return hostileNames
|
||||
? `你已经击败${hostileNames},眼前这一轮交战已经结束。${lootText}`
|
||||
: `眼前这一轮交战已经结束,当前不再处于战斗状态。${lootText}`;
|
||||
}
|
||||
|
||||
export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) {
|
||||
return (
|
||||
option.interaction?.kind === 'npc' &&
|
||||
(option.functionId === 'npc_trade' || option.functionId === 'npc_gift')
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildHostileNpcBattleReward(
|
||||
state: GameState,
|
||||
afterSequence: GameState,
|
||||
optionKind: 'battle' | 'escape' | 'idle',
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'],
|
||||
): Promise<BattleRewardSummary | null> {
|
||||
if (
|
||||
optionKind === 'escape' ||
|
||||
!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 = await 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 async function runCampTravelHomeChoice(params: {
|
||||
gameState: GameState;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
setBattleReward: (reward: BattleRewardSummary | null) => void;
|
||||
setAiError: (message: string | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setGameState: (state: GameState) => void;
|
||||
incrementRuntimeStats: IncrementRuntimeStats;
|
||||
getCampCompanionTravelScene: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => GameState['currentScenePreset'] | null;
|
||||
commitGeneratedStateWithEncounterEntry: (
|
||||
entryState: GameState,
|
||||
resolvedState: GameState,
|
||||
character: Character,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
lastFunctionId?: string,
|
||||
) => Promise<void> | void;
|
||||
isNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
}) {
|
||||
const targetScene = params.getCampCompanionTravelScene(
|
||||
params.gameState,
|
||||
params.character,
|
||||
);
|
||||
if (!targetScene) {
|
||||
return false;
|
||||
}
|
||||
|
||||
params.setBattleReward(null);
|
||||
params.setAiError(null);
|
||||
|
||||
const companionName = params.isNpcEncounter(params.gameState.currentEncounter)
|
||||
? params.gameState.currentEncounter.npcName
|
||||
: params.fallbackCompanionName;
|
||||
const travelRunState: GameState = {
|
||||
...params.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 = params.incrementRuntimeStats(
|
||||
{
|
||||
...params.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,
|
||||
);
|
||||
|
||||
params.setIsLoading(true);
|
||||
params.setGameState(travelRunState);
|
||||
await sleep(params.turnVisualMs);
|
||||
|
||||
await params.commitGeneratedStateWithEncounterEntry(
|
||||
entryState,
|
||||
resolvedState,
|
||||
params.character,
|
||||
params.option.actionText,
|
||||
`You and ${companionName} leave camp and formally step into ${targetScene.name} to begin the adventure.`,
|
||||
params.option.functionId,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function runServerRuntimeChoiceAction(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
setBattleReward: (reward: BattleRewardSummary | null) => void;
|
||||
setAiError: (message: string | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setGameState: (state: GameState) => void;
|
||||
setCurrentStory: (story: StoryMoment) => void;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
}) {
|
||||
params.setBattleReward(null);
|
||||
params.setAiError(null);
|
||||
params.setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({
|
||||
gameState: params.gameState,
|
||||
currentStory: params.currentStory,
|
||||
option: params.option,
|
||||
});
|
||||
|
||||
params.setGameState(hydratedSnapshot.gameState);
|
||||
params.setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve runtime action on the server:', error);
|
||||
params.setAiError(
|
||||
error instanceof Error ? error.message : '运行时动作执行失败',
|
||||
);
|
||||
if (!params.currentStory) {
|
||||
params.setCurrentStory(
|
||||
params.buildFallbackStoryForState(
|
||||
params.gameState,
|
||||
params.character,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
params.setIsLoading(false);
|
||||
}
|
||||
}
|
||||
625
src/hooks/story/storyContextBuilder.ts
Normal file
625
src/hooks/story/storyContextBuilder.ts
Normal file
@@ -0,0 +1,625 @@
|
||||
import { getCharacterById } from '../../data/characterPresets';
|
||||
import {
|
||||
NPC_CHAT_FUNCTION,
|
||||
STORY_OPENING_CAMP_DIALOGUE_FUNCTION,
|
||||
} from '../../data/functionCatalog';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
describeNpcAffinityInWords,
|
||||
getNpcConversationDirective,
|
||||
isNpcFirstMeaningfulContact,
|
||||
} from '../../data/npcInteractions';
|
||||
import { buildSceneEntityCatalogText } from '../../data/scenePresets';
|
||||
import { hasMixedNarrativeLanguage } from '../../services/narrativeLanguage';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
} from '../../services/storyEngine/actorNarrativeProfile';
|
||||
import { applyAdaptiveTuningToPromptContext } from '../../services/storyEngine/adaptiveNarrativeTuner';
|
||||
import { compileCampaignFromWorldProfile } from '../../services/storyEngine/campaignPackCompiler';
|
||||
import {
|
||||
buildCampEvent,
|
||||
evaluateCampEventOpportunity,
|
||||
} from '../../services/storyEngine/campEventDirector';
|
||||
import {
|
||||
advanceChapterState,
|
||||
resolveCurrentChapterState,
|
||||
} from '../../services/storyEngine/chapterDirector';
|
||||
import {
|
||||
advanceCompanionArc,
|
||||
buildCompanionArcStates,
|
||||
} from '../../services/storyEngine/companionArcDirector';
|
||||
import { buildGoalStackState } from '../../services/storyEngine/goalDirector';
|
||||
import { resolveCurrentJourneyBeat } from '../../services/storyEngine/journeyBeatPlanner';
|
||||
import { buildVisibilitySliceFromFacts } from '../../services/storyEngine/knowledgeContract';
|
||||
import { buildKnowledgeGraph } from '../../services/storyEngine/knowledgeGraph';
|
||||
import { buildRecentCarrierEchoes } from '../../services/storyEngine/narrativeCarrierCatalog';
|
||||
import { buildChapterRecap } from '../../services/storyEngine/recapDigest';
|
||||
import { resolveScenarioPack } from '../../services/storyEngine/scenarioPackRegistry';
|
||||
import { buildSceneNarrativeDirective } from '../../services/storyEngine/sceneNarrativeDirector';
|
||||
import {
|
||||
buildSetpieceDirective,
|
||||
evaluateSetpieceOpportunity,
|
||||
} from '../../services/storyEngine/setpieceDirector';
|
||||
import { buildChronicleSummary } from '../../services/storyEngine/storyChronicle';
|
||||
import { buildThemePackFromWorldProfile } from '../../services/storyEngine/themePack';
|
||||
import {
|
||||
buildEncounterVisibilitySlice,
|
||||
createEmptyStoryEngineMemoryState,
|
||||
} from '../../services/storyEngine/visibilityEngine';
|
||||
import { buildFallbackWorldStoryGraph } from '../../services/storyEngine/worldStoryGraph';
|
||||
import type { GameState } from '../../types';
|
||||
import { getCharacterChatRecord } from './characterChat';
|
||||
import { getNpcEncounterKey } from './storyGenerationState';
|
||||
|
||||
const OPENING_CAMP_DIALOGUE_FUNCTION_ID =
|
||||
STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
|
||||
|
||||
export type StoryContextBuilderExtras = {
|
||||
pendingSceneEncounter?: boolean;
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
};
|
||||
|
||||
function buildPartyRelationshipNotes(state: GameState) {
|
||||
const lines: string[] = [];
|
||||
const seenCharacterIds = new Set<string>();
|
||||
|
||||
const appendNote = (characterId: string, roleLabel: string) => {
|
||||
if (seenCharacterIds.has(characterId)) return;
|
||||
const character = getCharacterById(characterId);
|
||||
const summary = getCharacterChatRecord(state, characterId).summary.trim();
|
||||
if (hasMixedNarrativeLanguage(summary)) return;
|
||||
if (!character || !summary) return;
|
||||
|
||||
seenCharacterIds.add(characterId);
|
||||
lines.push(
|
||||
`- ${character.name} (${character.title} / ${roleLabel}): ${summary}`,
|
||||
);
|
||||
};
|
||||
|
||||
state.companions.forEach((companion) =>
|
||||
appendNote(companion.characterId, '当前同行'),
|
||||
);
|
||||
state.roster.forEach((companion) =>
|
||||
appendNote(companion.characterId, '营地待命'),
|
||||
);
|
||||
|
||||
return lines.length > 0 ? lines.join('\n') : null;
|
||||
}
|
||||
|
||||
function describeScenePressureLevel(
|
||||
pressureLevel: 'low' | 'medium' | 'high' | 'extreme' | null | undefined,
|
||||
) {
|
||||
switch (pressureLevel) {
|
||||
case 'low':
|
||||
return '低';
|
||||
case 'medium':
|
||||
return '中';
|
||||
case 'high':
|
||||
return '高';
|
||||
case 'extreme':
|
||||
return '极高';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRecentConversationEventText(state: GameState) {
|
||||
const recentText = state.storyHistory
|
||||
.slice(-6)
|
||||
.map((item) => item.text)
|
||||
.join('\n');
|
||||
if (
|
||||
/击败|怪物|战斗|切磋|交手|脱身/u.test(recentText)
|
||||
) {
|
||||
return '你们刚经历过一场交锋或切磋,空气里的紧张感还没有完全散去。';
|
||||
}
|
||||
if (/携手|相助|帮你|并肩/u.test(recentText)) {
|
||||
return '你们刚并肩配合过一次,彼此之间的距离感稍微淡了一些。';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferConversationSituation(
|
||||
state: GameState,
|
||||
extras: Pick<
|
||||
StoryContextBuilderExtras,
|
||||
'lastFunctionId' | 'openingCampDialogue'
|
||||
>,
|
||||
) {
|
||||
if (state.inBattle) return 'shared_danger_coordination' as const;
|
||||
if (extras.lastFunctionId === OPENING_CAMP_DIALOGUE_FUNCTION_ID)
|
||||
return 'camp_first_contact' as const;
|
||||
if (
|
||||
state.currentEncounter?.specialBehavior === 'camp_companion' &&
|
||||
extras.openingCampDialogue?.trim()
|
||||
) {
|
||||
return 'camp_followup' as const;
|
||||
}
|
||||
const recentText = state.storyHistory
|
||||
.slice(-6)
|
||||
.map((item) => item.text)
|
||||
.join('\n');
|
||||
if (
|
||||
/击败|怪物|战斗|切磋|交手|脱身/u.test(recentText)
|
||||
) {
|
||||
return 'post_battle_breath' as const;
|
||||
}
|
||||
if (extras.lastFunctionId === NPC_CHAT_FUNCTION.id)
|
||||
return 'private_followup' as const;
|
||||
return 'first_contact_cautious' as const;
|
||||
}
|
||||
|
||||
function inferConversationPressure(
|
||||
state: GameState,
|
||||
situation: ReturnType<typeof inferConversationSituation>,
|
||||
) {
|
||||
const hpRatio = state.playerHp / Math.max(state.playerMaxHp, 1);
|
||||
if (state.inBattle || hpRatio < 0.35) return 'high' as const;
|
||||
if (
|
||||
situation === 'post_battle_breath' ||
|
||||
situation === 'shared_danger_coordination'
|
||||
)
|
||||
return 'medium' as const;
|
||||
if (situation === 'camp_first_contact' || situation === 'camp_followup')
|
||||
return 'low' as const;
|
||||
return 'medium' as const;
|
||||
}
|
||||
|
||||
function describeConversationSituation(
|
||||
situation: ReturnType<typeof inferConversationSituation>,
|
||||
) {
|
||||
switch (situation) {
|
||||
case 'camp_first_contact':
|
||||
return '这是营地里第一次真正静下来对话的时刻,语气要保持谨慎、观察和轻微试探。';
|
||||
case 'camp_followup':
|
||||
return '营地里的第一轮试探已经发生过了,这一轮应当顺着刚才的话头稍微往深处接。';
|
||||
case 'post_battle_breath':
|
||||
return '一场交锋刚结束,眼前危险稍缓,但双方都还带着余悸和紧绷。';
|
||||
case 'shared_danger_coordination':
|
||||
return '危险还没过去,对话应当短、准、直接,优先服务眼前判断。';
|
||||
case 'private_followup':
|
||||
return '这已经不是严格意义上的初见,更适合作为刚才未说完那句话的延续。';
|
||||
default:
|
||||
return '双方才刚真正对上话,此刻仍在判断彼此能信到什么程度。';
|
||||
}
|
||||
}
|
||||
|
||||
function describeConversationTalkPriority(
|
||||
situation: ReturnType<typeof inferConversationSituation>,
|
||||
) {
|
||||
switch (situation) {
|
||||
case 'camp_first_contact':
|
||||
return '优先写眼前印象、彼此态度和营地气氛,不要一上来就把动机讲透。';
|
||||
case 'camp_followup':
|
||||
return '先接住上一轮还没说透的话头,再决定要不要继续往下追问。';
|
||||
case 'post_battle_breath':
|
||||
return '先谈刚刚那次交锋以及彼此的判断,再视情况往更深处推进。';
|
||||
case 'shared_danger_coordination':
|
||||
return '先说最有用的判断、危险和下一步,不要扩成大段背景说明。';
|
||||
case 'private_followup':
|
||||
return '承接当前话头和关系变化,不要把对话又写回刚见面时的节奏。';
|
||||
default:
|
||||
return '先试探态度和现场判断,不要急着把来意和秘密一次摊开。';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEncounterNarrativeProfile(state: GameState) {
|
||||
const encounter = state.currentEncounter;
|
||||
if (!encounter || encounter.kind !== 'npc') {
|
||||
return null;
|
||||
}
|
||||
if (encounter.narrativeProfile) {
|
||||
return encounter.narrativeProfile;
|
||||
}
|
||||
if (!state.customWorldProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const role =
|
||||
state.customWorldProfile.storyNpcs.find((npc) =>
|
||||
npc.id === encounter.id || npc.name === encounter.npcName,
|
||||
)
|
||||
?? state.customWorldProfile.playableNpcs.find((npc) =>
|
||||
npc.id === encounter.id || npc.name === encounter.npcName,
|
||||
);
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const themePack =
|
||||
state.customWorldProfile.themePack
|
||||
?? buildThemePackFromWorldProfile(state.customWorldProfile);
|
||||
const storyGraph =
|
||||
state.customWorldProfile.storyGraph
|
||||
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
|
||||
|
||||
return normalizeActorNarrativeProfile(
|
||||
role.narrativeProfile,
|
||||
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveActiveThreadIds(
|
||||
state: GameState,
|
||||
encounterNarrativeProfile: ReturnType<typeof resolveEncounterNarrativeProfile>,
|
||||
) {
|
||||
if (state.storyEngineMemory?.activeThreadIds?.length) {
|
||||
return state.storyEngineMemory.activeThreadIds.slice(0, 4);
|
||||
}
|
||||
if (encounterNarrativeProfile?.relatedThreadIds.length) {
|
||||
return encounterNarrativeProfile.relatedThreadIds.slice(0, 4);
|
||||
}
|
||||
if (!state.customWorldProfile) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const themePack =
|
||||
state.customWorldProfile.themePack
|
||||
?? buildThemePackFromWorldProfile(state.customWorldProfile);
|
||||
const storyGraph =
|
||||
state.customWorldProfile.storyGraph
|
||||
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
|
||||
|
||||
return storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id);
|
||||
}
|
||||
|
||||
export function buildStoryContextFromState(
|
||||
state: GameState,
|
||||
extras: StoryContextBuilderExtras = {},
|
||||
): StoryGenerationContext {
|
||||
const conversationSituation = inferConversationSituation(state, extras);
|
||||
const conversationPressure = inferConversationPressure(
|
||||
state,
|
||||
conversationSituation,
|
||||
);
|
||||
const recentSharedEvent = buildRecentConversationEventText(state);
|
||||
const encounterNpcState =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const encounter = state.currentEncounter;
|
||||
return extras.encounterNpcStateOverride
|
||||
?? state.npcStates[getNpcEncounterKey(encounter)]
|
||||
?? buildInitialNpcState(encounter, state.worldType, state);
|
||||
})()
|
||||
: null;
|
||||
const encounterDirective =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const encounter = state.currentEncounter;
|
||||
return encounterNpcState
|
||||
? getNpcConversationDirective(encounter, encounterNpcState)
|
||||
: null;
|
||||
})()
|
||||
: null;
|
||||
const isFirstMeaningfulContact =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const encounter = state.currentEncounter;
|
||||
return encounterNpcState
|
||||
? isNpcFirstMeaningfulContact(encounter, encounterNpcState)
|
||||
: false;
|
||||
})()
|
||||
: false;
|
||||
const firstContactRelationStance = (() => {
|
||||
if (
|
||||
!isFirstMeaningfulContact ||
|
||||
!state.currentEncounter ||
|
||||
state.currentEncounter.kind !== 'npc'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stance = encounterNpcState?.relationState?.stance ?? null;
|
||||
if (
|
||||
stance === 'guarded' ||
|
||||
stance === 'neutral' ||
|
||||
stance === 'cooperative' ||
|
||||
stance === 'bonded'
|
||||
) {
|
||||
return stance;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const encounterAffinityText =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const encounter = state.currentEncounter;
|
||||
return encounterNpcState
|
||||
? describeNpcAffinityInWords(encounter, encounterNpcState.affinity, {
|
||||
recruited: encounterNpcState.recruited,
|
||||
})
|
||||
: null;
|
||||
})()
|
||||
: null;
|
||||
const baseSceneDescription = state.currentScenePreset?.description ?? null;
|
||||
const sceneMutationDescription = [
|
||||
state.currentScenePreset?.mutationStateText
|
||||
? `最新世界变化:${state.currentScenePreset.mutationStateText}`
|
||||
: null,
|
||||
describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)
|
||||
? `当前区域压力等级:${describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
const observeSignsSceneDescription =
|
||||
extras.observeSignsRequested && state.worldType
|
||||
? [
|
||||
baseSceneDescription,
|
||||
sceneMutationDescription,
|
||||
'当前可观察实体池:',
|
||||
buildSceneEntityCatalogText(
|
||||
state.worldType,
|
||||
state.currentScenePreset?.id ?? null,
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: [baseSceneDescription, sceneMutationDescription].filter(Boolean).join('\n');
|
||||
const storyEngineMemory =
|
||||
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
const knowledgeFacts =
|
||||
state.customWorldProfile?.knowledgeFacts
|
||||
?? (state.customWorldProfile ? buildKnowledgeGraph(state.customWorldProfile) : []);
|
||||
const encounterNarrativeProfile = resolveEncounterNarrativeProfile(state);
|
||||
const activeThreadIds = resolveActiveThreadIds(
|
||||
{
|
||||
...state,
|
||||
storyEngineMemory,
|
||||
} as GameState,
|
||||
encounterNarrativeProfile,
|
||||
);
|
||||
const visibilitySlice =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const relevantFacts = knowledgeFacts.filter((fact) =>
|
||||
fact.ownerActorIds.includes(state.currentEncounter?.id ?? '')
|
||||
|| fact.ownerActorIds.includes(state.currentEncounter?.npcName ?? '')
|
||||
|| fact.relatedThreadIds.some((threadId) => activeThreadIds.includes(threadId)),
|
||||
);
|
||||
return relevantFacts.length > 0
|
||||
? buildVisibilitySliceFromFacts({
|
||||
facts: relevantFacts,
|
||||
discoveredFactIds: [
|
||||
...storyEngineMemory.discoveredFactIds,
|
||||
...(encounterNpcState?.revealedFacts ?? []),
|
||||
...(encounterNpcState?.seenBackstoryChapterIds ?? []).map(
|
||||
(chapterId) =>
|
||||
relevantFacts.find((fact) =>
|
||||
fact.aliases?.includes(chapterId) || fact.id.includes(chapterId),
|
||||
)?.id ?? '',
|
||||
),
|
||||
],
|
||||
activeThreadIds,
|
||||
disclosureStage: encounterDirective?.disclosureStage ?? null,
|
||||
isFirstMeaningfulContact,
|
||||
})
|
||||
: buildEncounterVisibilitySlice({
|
||||
narrativeProfile: encounterNarrativeProfile,
|
||||
backstoryReveal: state.currentEncounter.backstoryReveal ?? null,
|
||||
disclosureStage: encounterDirective?.disclosureStage ?? null,
|
||||
isFirstMeaningfulContact,
|
||||
seenBackstoryChapterIds: encounterNpcState?.seenBackstoryChapterIds ?? [],
|
||||
storyEngineMemory,
|
||||
activeThreadIds,
|
||||
});
|
||||
})()
|
||||
: null;
|
||||
const sceneNarrativeDirective = buildSceneNarrativeDirective({
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
sceneName: state.currentScenePreset?.name ?? null,
|
||||
encounterId: state.currentEncounter?.id ?? null,
|
||||
encounterName: state.currentEncounter?.npcName ?? null,
|
||||
recentActions: state.storyHistory.slice(-3).map((moment) => moment.text),
|
||||
activeThreadIds,
|
||||
visibilitySlice,
|
||||
encounterNarrativeProfile,
|
||||
disclosureStage: encounterDirective?.disclosureStage ?? null,
|
||||
isFirstMeaningfulContact,
|
||||
affinity: encounterNpcState?.affinity ?? null,
|
||||
});
|
||||
const chapterState = advanceChapterState({
|
||||
previousChapter: state.chapterState ?? storyEngineMemory.currentChapter ?? null,
|
||||
nextChapter: resolveCurrentChapterState({
|
||||
state: {
|
||||
...state,
|
||||
storyEngineMemory,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const journeyBeat = resolveCurrentJourneyBeat({
|
||||
state: {
|
||||
...state,
|
||||
chapterState,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
currentChapter: chapterState,
|
||||
},
|
||||
} as GameState,
|
||||
chapterState,
|
||||
});
|
||||
const companionArcStates = advanceCompanionArc({
|
||||
previous: storyEngineMemory.companionArcStates,
|
||||
next: buildCompanionArcStates({
|
||||
state,
|
||||
reactions: storyEngineMemory.recentCompanionReactions,
|
||||
}),
|
||||
});
|
||||
const currentCampEvent = evaluateCampEventOpportunity({
|
||||
state,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
companionArcStates,
|
||||
})
|
||||
? buildCampEvent({
|
||||
state,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
companionArcStates,
|
||||
})
|
||||
: null;
|
||||
const setpieceDirective = evaluateSetpieceOpportunity({
|
||||
state,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
})
|
||||
? buildSetpieceDirective({
|
||||
state,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
})
|
||||
: null;
|
||||
const recentWorldMutations = storyEngineMemory.worldMutations ?? [];
|
||||
const recentChronicleSummary = buildChronicleSummary({
|
||||
...state,
|
||||
chapterState,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
currentChapter: chapterState,
|
||||
companionArcStates,
|
||||
},
|
||||
} as GameState);
|
||||
const compiledPacks = state.customWorldProfile
|
||||
? compileCampaignFromWorldProfile({ profile: state.customWorldProfile })
|
||||
: null;
|
||||
const goalStack = buildGoalStackState({
|
||||
quests: state.quests,
|
||||
worldType: state.worldType,
|
||||
currentSceneId: state.currentScenePreset?.id ?? null,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
setpieceDirective,
|
||||
currentCampEvent,
|
||||
currentSceneName: state.currentScenePreset?.name ?? null,
|
||||
});
|
||||
const activeScenarioPack =
|
||||
resolveScenarioPack(state.activeScenarioPackId)
|
||||
?? compiledPacks?.scenarioPack
|
||||
?? null;
|
||||
const activeCampaignPack = compiledPacks?.campaignPack ?? null;
|
||||
|
||||
const fallbackChapterRecap = buildChapterRecap({
|
||||
state: { ...state, chapterState } as GameState,
|
||||
});
|
||||
const safeEncounterRelationshipSummary =
|
||||
state.currentEncounter?.characterId
|
||||
? getCharacterChatRecord(state, state.currentEncounter.characterId)
|
||||
.summary
|
||||
.trim()
|
||||
: '';
|
||||
|
||||
return applyAdaptiveTuningToPromptContext({
|
||||
context: {
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
playerMana: state.playerMana,
|
||||
playerMaxMana: state.playerMaxMana,
|
||||
inBattle: state.inBattle,
|
||||
playerX: state.playerX,
|
||||
playerFacing: state.playerFacing,
|
||||
playerAnimation: state.animationState,
|
||||
skillCooldowns: state.playerSkillCooldowns,
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
sceneName: state.currentScenePreset?.name ?? null,
|
||||
sceneDescription: observeSignsSceneDescription,
|
||||
pendingSceneEncounter: extras.pendingSceneEncounter ?? false,
|
||||
lastFunctionId: extras.lastFunctionId ?? null,
|
||||
observeSignsRequested: extras.observeSignsRequested ?? false,
|
||||
recentActionResult: extras.recentActionResult ?? null,
|
||||
lastObserveSignsReport:
|
||||
state.lastObserveSignsSceneId === (state.currentScenePreset?.id ?? null)
|
||||
? (state.lastObserveSignsReport ?? null)
|
||||
: null,
|
||||
encounterKind: state.currentEncounter?.kind ?? null,
|
||||
encounterName: state.currentEncounter?.npcName ?? null,
|
||||
encounterDescription: state.currentEncounter?.npcDescription ?? null,
|
||||
encounterContext: state.currentEncounter?.context ?? null,
|
||||
encounterId: state.currentEncounter?.id ?? null,
|
||||
encounterCharacterId: state.currentEncounter?.characterId ?? null,
|
||||
encounterGender: state.currentEncounter?.gender ?? null,
|
||||
encounterCustomProfile: state.currentEncounter
|
||||
? {
|
||||
title: state.currentEncounter.title ?? '',
|
||||
description: state.currentEncounter.npcDescription ?? '',
|
||||
backstory: state.currentEncounter.backstory ?? '',
|
||||
personality: state.currentEncounter.personality ?? '',
|
||||
motivation: state.currentEncounter.motivation ?? '',
|
||||
combatStyle: state.currentEncounter.combatStyle ?? '',
|
||||
relationshipHooks: [...(state.currentEncounter.relationshipHooks ?? [])],
|
||||
tags: [...(state.currentEncounter.tags ?? [])],
|
||||
backstoryReveal: state.currentEncounter.backstoryReveal,
|
||||
skills: [...(state.currentEncounter.skills ?? [])],
|
||||
initialItems: [...(state.currentEncounter.initialItems ?? [])],
|
||||
imageSrc: state.currentEncounter.imageSrc,
|
||||
visual: state.currentEncounter.visual,
|
||||
narrativeProfile: state.currentEncounter.narrativeProfile,
|
||||
}
|
||||
: null,
|
||||
encounterAffinity: encounterDirective?.affinity ?? null,
|
||||
encounterAffinityText,
|
||||
encounterStanceProfile: encounterNpcState?.stanceProfile ?? null,
|
||||
encounterConversationStyle: encounterDirective?.style ?? null,
|
||||
encounterDisclosureStage: encounterDirective?.disclosureStage ?? null,
|
||||
encounterWarmthStage: encounterDirective?.warmthStage ?? null,
|
||||
encounterAnswerMode: encounterDirective?.answerMode ?? null,
|
||||
encounterAllowedTopics: encounterDirective?.allowTopics ?? null,
|
||||
encounterBlockedTopics: encounterDirective?.blockedTopics ?? null,
|
||||
isFirstMeaningfulContact,
|
||||
firstContactRelationStance,
|
||||
conversationSituation,
|
||||
conversationPressure,
|
||||
recentSharedEvent:
|
||||
recentSharedEvent ?? describeConversationSituation(conversationSituation),
|
||||
talkPriority: describeConversationTalkPriority(conversationSituation),
|
||||
visibilitySlice,
|
||||
sceneNarrativeDirective,
|
||||
campaignState: state.campaignState ?? storyEngineMemory.campaignState ?? null,
|
||||
actState: storyEngineMemory.actState ?? null,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
goalStack,
|
||||
currentCampEvent,
|
||||
setpieceDirective,
|
||||
activeScenarioPack,
|
||||
activeCampaignPack,
|
||||
encounterNarrativeProfile,
|
||||
knowledgeFacts,
|
||||
activeThreadIds,
|
||||
companionArcStates,
|
||||
companionResolutions: storyEngineMemory.companionResolutions ?? [],
|
||||
consequenceLedger: storyEngineMemory.consequenceLedger ?? [],
|
||||
authorialConstraintPack: storyEngineMemory.authorialConstraintPack ?? null,
|
||||
playerStyleProfile: storyEngineMemory.playerStyleProfile ?? null,
|
||||
recentCompanionReactions: storyEngineMemory.recentCompanionReactions ?? [],
|
||||
recentCarrierEchoes: buildRecentCarrierEchoes(state),
|
||||
recentWorldMutations,
|
||||
recentFactionTensionStates: storyEngineMemory.factionTensionStates ?? [],
|
||||
recentChronicleSummary:
|
||||
recentChronicleSummary.trim() &&
|
||||
!hasMixedNarrativeLanguage(recentChronicleSummary)
|
||||
? recentChronicleSummary
|
||||
: fallbackChapterRecap,
|
||||
narrativeQaReport: storyEngineMemory.narrativeQaReport ?? null,
|
||||
releaseGateReport: storyEngineMemory.releaseGateReport ?? null,
|
||||
simulationRunResults: storyEngineMemory.simulationRunResults ?? [],
|
||||
branchBudgetPressure: storyEngineMemory.branchBudgetStatus?.pressure ?? null,
|
||||
encounterRelationshipSummary: state.currentEncounter?.characterId
|
||||
? !hasMixedNarrativeLanguage(safeEncounterRelationshipSummary)
|
||||
? safeEncounterRelationshipSummary || null
|
||||
: null
|
||||
: null,
|
||||
partyRelationshipNotes: buildPartyRelationshipNotes(state),
|
||||
customWorldProfile: state.customWorldProfile ?? null,
|
||||
openingCampBackground: extras.openingCampBackground ?? null,
|
||||
openingCampDialogue: extras.openingCampDialogue ?? null,
|
||||
},
|
||||
profile: storyEngineMemory.playerStyleProfile ?? null,
|
||||
});
|
||||
}
|
||||
172
src/hooks/story/storyEncounterState.test.ts
Normal file
172
src/hooks/story/storyEncounterState.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
} from '../../types';
|
||||
import { createStoryStateResolvers } from './storyEncounterState';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试主角',
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createGameState(overrides: Partial<GameState> = {}): GameState {
|
||||
return {
|
||||
worldType: null,
|
||||
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: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createNpcEncounter(
|
||||
overrides: Partial<Encounter> = {},
|
||||
): Encounter {
|
||||
return {
|
||||
id: 'npc-guard',
|
||||
kind: 'npc',
|
||||
npcName: '山道客',
|
||||
npcDescription: '守在路口的陌生人',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道相遇',
|
||||
...overrides,
|
||||
} as Encounter;
|
||||
}
|
||||
|
||||
describe('storyEncounterState', () => {
|
||||
it('delegates camp companion option pools to the dedicated builder', () => {
|
||||
const character = createCharacter();
|
||||
const state = createGameState({
|
||||
currentEncounter: createNpcEncounter({
|
||||
specialBehavior: 'camp_companion',
|
||||
}),
|
||||
});
|
||||
const campStory: StoryMoment = {
|
||||
text: '营地同伴剧情',
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
text: '继续交谈',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const buildCampCompanionIdleOptions = vi.fn(() => campStory);
|
||||
const buildNpcStory = vi.fn();
|
||||
|
||||
const { getAvailableOptionsForState } = createStoryStateResolvers({
|
||||
buildCampCompanionIdleOptions,
|
||||
buildNpcStory,
|
||||
});
|
||||
|
||||
expect(getAvailableOptionsForState(state, character)).toEqual(
|
||||
campStory.options,
|
||||
);
|
||||
expect(buildCampCompanionIdleOptions).toHaveBeenCalledWith(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
undefined,
|
||||
);
|
||||
expect(buildNpcStory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses preview talk options for initial companion encounters before formal interaction starts', () => {
|
||||
const character = createCharacter();
|
||||
const state = createGameState({
|
||||
currentEncounter: createNpcEncounter({
|
||||
specialBehavior: 'initial_companion',
|
||||
}),
|
||||
});
|
||||
const { getAvailableOptionsForState } = createStoryStateResolvers({
|
||||
buildCampCompanionIdleOptions: vi.fn(),
|
||||
buildNpcStory: vi.fn(),
|
||||
});
|
||||
|
||||
const options = getAvailableOptionsForState(state, character);
|
||||
|
||||
expect(options).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_preview_talk',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves explicit fallback text when the state falls back to the generic story moment', () => {
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const { buildFallbackStoryForState } = createStoryStateResolvers({
|
||||
buildCampCompanionIdleOptions: vi.fn(),
|
||||
buildNpcStory: vi.fn(),
|
||||
});
|
||||
|
||||
const story = buildFallbackStoryForState(state, character, '手动兜底文本');
|
||||
|
||||
expect(story.text).toBe('手动兜底文本');
|
||||
expect(story.options.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
238
src/hooks/story/storyEncounterState.ts
Normal file
238
src/hooks/story/storyEncounterState.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { buildNpcPreviewTalkOption } from '../../data/functionCatalog';
|
||||
import {
|
||||
getDefaultFunctionIdsForContext,
|
||||
resolveFunctionOption,
|
||||
} from '../../data/stateFunctions';
|
||||
import { buildTreasureEncounterStoryMoment } from '../../data/treasureInteractions';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { buildFallbackStoryMoment } from '../combatStoryUtils';
|
||||
|
||||
type CampCompanionEncounter = Encounter & {
|
||||
specialBehavior: 'camp_companion';
|
||||
};
|
||||
|
||||
type EncounterStoryBuilder = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
export function buildNpcPreviewStory(
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
): StoryMoment {
|
||||
if (!state.worldType) {
|
||||
return {
|
||||
text:
|
||||
overrideText ??
|
||||
`${encounter.npcName}正停在前方,像是在等你先决定要不要真正把注意力落到他身上。`,
|
||||
options: [buildNpcPreviewTalkOption(encounter)],
|
||||
};
|
||||
}
|
||||
|
||||
const functionContext = {
|
||||
worldType: state.worldType,
|
||||
playerCharacter: character,
|
||||
inBattle: false,
|
||||
currentSceneId: state.currentScenePreset?.id ?? null,
|
||||
currentSceneName: state.currentScenePreset?.name ?? null,
|
||||
monsters: [],
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
playerMana: state.playerMana,
|
||||
playerMaxMana: state.playerMaxMana,
|
||||
};
|
||||
|
||||
const locationOptions = getDefaultFunctionIdsForContext(functionContext)
|
||||
.filter((functionId) => functionId !== 'idle_call_out')
|
||||
.map((functionId) => resolveFunctionOption(functionId, functionContext))
|
||||
.filter((option): option is StoryOption => Boolean(option));
|
||||
|
||||
return {
|
||||
text:
|
||||
overrideText ??
|
||||
`${encounter.npcName}出现在${state.currentScenePreset?.name ?? '前方道路'}附近,但你还没有真正把全部注意力落到对方身上。`,
|
||||
options: [buildNpcPreviewTalkOption(encounter), ...locationOptions],
|
||||
};
|
||||
}
|
||||
|
||||
export function getResolvedSceneHostileNpcs(state: GameState) {
|
||||
return state.sceneHostileNpcs;
|
||||
}
|
||||
|
||||
export function getStoryGenerationHostileNpcs(state: GameState) {
|
||||
return state.inBattle ? getResolvedSceneHostileNpcs(state) : [];
|
||||
}
|
||||
|
||||
export function isCampCompanionEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is CampCompanionEncounter {
|
||||
return Boolean(
|
||||
encounter?.kind === 'npc' && encounter.specialBehavior === 'camp_companion',
|
||||
);
|
||||
}
|
||||
|
||||
export function isInitialCompanionEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return Boolean(
|
||||
encounter?.kind === 'npc' &&
|
||||
encounter.specialBehavior === 'initial_companion',
|
||||
);
|
||||
}
|
||||
|
||||
export function isNpcEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return Boolean(encounter?.kind === 'npc');
|
||||
}
|
||||
|
||||
export function isRegularNpcEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return Boolean(encounter?.kind === 'npc' && !encounter.specialBehavior);
|
||||
}
|
||||
|
||||
export function isTreasureEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return Boolean(encounter?.kind === 'treasure');
|
||||
}
|
||||
|
||||
export function buildTreasureStory(
|
||||
state: GameState,
|
||||
_character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
): StoryMoment {
|
||||
return buildTreasureEncounterStoryMoment({
|
||||
state,
|
||||
encounter,
|
||||
overrideText,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEncounterStory(params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
buildCampCompanionIdleOptions: EncounterStoryBuilder;
|
||||
buildNpcStory: EncounterStoryBuilder;
|
||||
fallbackText?: string;
|
||||
}) {
|
||||
const { state, character, fallbackText } = params;
|
||||
|
||||
if (isCampCompanionEncounter(state.currentEncounter) && !state.inBattle) {
|
||||
return params.buildCampCompanionIdleOptions(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isInitialCompanionEncounter(state.currentEncounter) &&
|
||||
!state.inBattle &&
|
||||
!state.npcInteractionActive
|
||||
) {
|
||||
return buildNpcPreviewStory(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
if (isRegularNpcEncounter(state.currentEncounter) && !state.inBattle) {
|
||||
if (!state.npcInteractionActive) {
|
||||
return buildNpcPreviewStory(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
return params.buildNpcStory(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
if (isNpcEncounter(state.currentEncounter) && !state.inBattle) {
|
||||
return params.buildNpcStory(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
if (isTreasureEncounter(state.currentEncounter) && !state.inBattle) {
|
||||
return buildTreasureStory(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createStoryStateResolvers(params: {
|
||||
buildCampCompanionIdleOptions: EncounterStoryBuilder;
|
||||
buildNpcStory: EncounterStoryBuilder;
|
||||
}) {
|
||||
const getAvailableOptionsForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) =>
|
||||
resolveEncounterStory({
|
||||
state,
|
||||
character,
|
||||
buildCampCompanionIdleOptions: params.buildCampCompanionIdleOptions,
|
||||
buildNpcStory: params.buildNpcStory,
|
||||
})?.options ?? null;
|
||||
|
||||
const buildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => {
|
||||
const resolvedStory = resolveEncounterStory({
|
||||
state,
|
||||
character,
|
||||
fallbackText,
|
||||
buildCampCompanionIdleOptions: params.buildCampCompanionIdleOptions,
|
||||
buildNpcStory: params.buildNpcStory,
|
||||
});
|
||||
if (resolvedStory) {
|
||||
return resolvedStory;
|
||||
}
|
||||
|
||||
const fallback = buildFallbackStoryMoment(state, character);
|
||||
return fallbackText
|
||||
? {
|
||||
...fallback,
|
||||
text: fallbackText,
|
||||
}
|
||||
: fallback;
|
||||
};
|
||||
|
||||
return {
|
||||
getAvailableOptionsForState,
|
||||
buildFallbackStoryForState,
|
||||
};
|
||||
}
|
||||
155
src/hooks/story/storyInteractionCoordinator.test.ts
Normal file
155
src/hooks/story/storyInteractionCoordinator.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import { createStoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
|
||||
|
||||
function createOption(
|
||||
functionId = 'npc_chat',
|
||||
actionText = '继续交谈',
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createState(): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
currentScene: 'Story',
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('storyInteractionCoordinator', () => {
|
||||
it('builds shared interaction configs for treasure, inventory and npc flows', () => {
|
||||
const gameState = createState();
|
||||
const currentStory = createStory('当前故事');
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
const setAiError = vi.fn();
|
||||
const setIsLoading = vi.fn();
|
||||
const buildFallbackStoryForState = vi.fn();
|
||||
const buildStoryContextFromState = vi.fn();
|
||||
const buildDialogueStoryMoment = vi.fn();
|
||||
const generateStoryForState = vi.fn();
|
||||
const getStoryGenerationHostileNpcs = vi.fn(() => []);
|
||||
const getAvailableOptionsForState = vi.fn(() => [createOption()]);
|
||||
const getTypewriterDelay = (_char: string) => 90 as const;
|
||||
const commitGeneratedState = vi.fn();
|
||||
const commitGeneratedStateWithEncounterEntry = vi.fn();
|
||||
const appendHistory = vi.fn();
|
||||
const buildOpeningCampChatContext = vi.fn();
|
||||
const sortOptions = vi.fn((options: StoryOption[]) => options);
|
||||
const buildContinueAdventureOption = vi.fn(() => createOption('continue'));
|
||||
const sanitizeOptions = vi.fn((options: StoryOption[]) => options);
|
||||
const resolveNpcInteractionDecision = vi.fn(() => ({ kind: 'chat' }));
|
||||
const runtimeSupport = {
|
||||
buildNpcStory: vi.fn(),
|
||||
cloneInventoryItemForOwner: vi.fn(),
|
||||
getNpcEncounterKey: vi.fn(),
|
||||
getResolvedNpcState: vi.fn(),
|
||||
updateNpcState: vi.fn(),
|
||||
updateQuestLog: vi.fn(),
|
||||
updateRuntimeStats: vi.fn(),
|
||||
};
|
||||
|
||||
const config = createStoryInteractionCoordinatorConfig({
|
||||
gameState,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
currentStory,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
runtimeSupport,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
buildOpeningCampChatContext,
|
||||
sortOptions,
|
||||
buildContinueAdventureOption,
|
||||
sanitizeOptions,
|
||||
resolveNpcInteractionDecision,
|
||||
});
|
||||
|
||||
expect(config.treasureFlow.runtime).toBe(config.inventoryFlow.runtime);
|
||||
expect(config.treasureFlow).toEqual({
|
||||
gameState,
|
||||
runtime: config.inventoryFlow.runtime,
|
||||
});
|
||||
expect(config.npcInteractionFlow).toEqual(
|
||||
expect.objectContaining({
|
||||
gameState,
|
||||
setGameState,
|
||||
commitGeneratedState,
|
||||
getNpcEncounterKey: runtimeSupport.getNpcEncounterKey,
|
||||
getResolvedNpcState: runtimeSupport.getResolvedNpcState,
|
||||
updateNpcState: runtimeSupport.updateNpcState,
|
||||
cloneInventoryItemForOwner: runtimeSupport.cloneInventoryItemForOwner,
|
||||
runtime: expect.objectContaining({
|
||||
currentStory,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(config.npcEncounterActions).toEqual(
|
||||
expect.objectContaining({
|
||||
gameState,
|
||||
currentStory,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
buildOpeningCampChatContext,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
getAvailableOptionsForState,
|
||||
sanitizeOptions,
|
||||
sortOptions,
|
||||
buildContinueAdventureOption,
|
||||
getNpcEncounterKey: runtimeSupport.getNpcEncounterKey,
|
||||
getResolvedNpcState: runtimeSupport.getResolvedNpcState,
|
||||
updateNpcState: runtimeSupport.updateNpcState,
|
||||
cloneInventoryItemForOwner: runtimeSupport.cloneInventoryItemForOwner,
|
||||
resolveNpcInteractionDecision,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
137
src/hooks/story/storyInteractionCoordinator.ts
Normal file
137
src/hooks/story/storyInteractionCoordinator.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
|
||||
import type { StoryRuntimeControllerResult } from './useStoryRuntimeController';
|
||||
|
||||
type StoryInteractionCoordinatorParams = {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
currentStory: StoryMoment | null;
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
buildFallbackStoryForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
buildDialogueStoryMoment: StoryRuntimeControllerResult['buildDialogueStoryMoment'];
|
||||
generateStoryForState: StoryRuntimeControllerResult['generateStoryForState'];
|
||||
getAvailableOptionsForState: StoryRuntimeControllerResult['getAvailableOptionsForState'];
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getTypewriterDelay: StoryRuntimeControllerResult['getTypewriterDelay'];
|
||||
runtimeSupport: StoryRuntimeSupport;
|
||||
commitGeneratedState: StoryRuntimeControllerResult['commitGeneratedState'];
|
||||
commitGeneratedStateWithEncounterEntry: StoryRuntimeControllerResult['commitGeneratedStateWithEncounterEntry'];
|
||||
appendHistory: StoryRuntimeControllerResult['appendHistory'];
|
||||
buildOpeningCampChatContext: StoryRuntimeControllerResult['buildOpeningCampChatContext'];
|
||||
sortOptions: (options: StoryOption[]) => StoryOption[];
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
sanitizeOptions: (
|
||||
options: StoryOption[],
|
||||
character: Character,
|
||||
state: GameState,
|
||||
) => StoryOption[];
|
||||
resolveNpcInteractionDecision: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
) => { kind: string };
|
||||
};
|
||||
|
||||
export function createStoryInteractionCoordinatorConfig(
|
||||
params: StoryInteractionCoordinatorParams,
|
||||
) {
|
||||
const sharedRuntime = {
|
||||
currentStory: params.currentStory,
|
||||
setGameState: params.setGameState,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
buildFallbackStoryForState: params.buildFallbackStoryForState,
|
||||
};
|
||||
|
||||
return {
|
||||
treasureFlow: {
|
||||
gameState: params.gameState,
|
||||
runtime: sharedRuntime,
|
||||
},
|
||||
inventoryFlow: {
|
||||
gameState: params.gameState,
|
||||
runtime: sharedRuntime,
|
||||
},
|
||||
npcInteractionFlow: {
|
||||
gameState: params.gameState,
|
||||
setGameState: params.setGameState,
|
||||
commitGeneratedState: params.commitGeneratedState,
|
||||
getNpcEncounterKey: params.runtimeSupport.getNpcEncounterKey,
|
||||
getResolvedNpcState: params.runtimeSupport.getResolvedNpcState,
|
||||
updateNpcState: params.runtimeSupport.updateNpcState,
|
||||
cloneInventoryItemForOwner:
|
||||
params.runtimeSupport.cloneInventoryItemForOwner,
|
||||
runtime: {
|
||||
currentStory: params.currentStory,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
buildStoryContextFromState: params.buildStoryContextFromState,
|
||||
buildFallbackStoryForState: params.buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment: params.buildDialogueStoryMoment,
|
||||
generateStoryForState: params.generateStoryForState,
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay: params.getTypewriterDelay,
|
||||
},
|
||||
},
|
||||
npcEncounterActions: {
|
||||
gameState: params.gameState,
|
||||
currentStory: params.currentStory,
|
||||
setGameState: params.setGameState,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
commitGeneratedState: params.commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry:
|
||||
params.commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory: params.appendHistory,
|
||||
buildOpeningCampChatContext: params.buildOpeningCampChatContext,
|
||||
buildStoryContextFromState: params.buildStoryContextFromState,
|
||||
buildFallbackStoryForState: params.buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment: params.buildDialogueStoryMoment,
|
||||
generateStoryForState: params.generateStoryForState,
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay: params.getTypewriterDelay,
|
||||
getAvailableOptionsForState: params.getAvailableOptionsForState,
|
||||
sanitizeOptions: params.sanitizeOptions,
|
||||
sortOptions: params.sortOptions,
|
||||
buildContinueAdventureOption: params.buildContinueAdventureOption,
|
||||
getNpcEncounterKey: params.runtimeSupport.getNpcEncounterKey,
|
||||
getResolvedNpcState: params.runtimeSupport.getResolvedNpcState,
|
||||
updateNpcState: params.runtimeSupport.updateNpcState,
|
||||
cloneInventoryItemForOwner:
|
||||
params.runtimeSupport.cloneInventoryItemForOwner,
|
||||
resolveNpcInteractionDecision: params.resolveNpcInteractionDecision,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type StoryInteractionCoordinatorConfig = ReturnType<
|
||||
typeof createStoryInteractionCoordinatorConfig
|
||||
>;
|
||||
149
src/hooks/story/storyPresentation.test.ts
Normal file
149
src/hooks/story/storyPresentation.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
} from '../../types';
|
||||
import { buildStoryFromResponse } from './storyPresentation';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试主角',
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createGameState(overrides: Partial<GameState> = {}): GameState {
|
||||
return {
|
||||
worldType: null,
|
||||
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: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createOption(
|
||||
functionId = 'npc_chat',
|
||||
actionText = '继续交谈',
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
function createStory(
|
||||
text: string,
|
||||
options: StoryOption[] = [],
|
||||
): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
describe('storyPresentation', () => {
|
||||
it('keeps provided available options when the AI response omits them', () => {
|
||||
const availableOptions = [createOption('npc_help', '请求援手')];
|
||||
|
||||
const story = buildStoryFromResponse({
|
||||
state: createGameState(),
|
||||
character: createCharacter(),
|
||||
response: createStory('服务端返回正文'),
|
||||
availableOptions,
|
||||
});
|
||||
|
||||
expect(story.text).toBe('服务端返回正文');
|
||||
expect(story.options).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates repeated response options before padding local fallbacks', () => {
|
||||
const duplicatedOptions = [
|
||||
createOption('npc_chat', '继续交谈'),
|
||||
createOption('npc_chat', '继续交谈'),
|
||||
];
|
||||
|
||||
const story = buildStoryFromResponse({
|
||||
state: createGameState(),
|
||||
character: createCharacter(),
|
||||
response: createStory('需要本地归一化', duplicatedOptions),
|
||||
availableOptions: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
story.options.filter(
|
||||
(option) =>
|
||||
option.functionId === 'npc_chat' &&
|
||||
option.actionText === '继续交谈',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(story.options.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
245
src/hooks/story/storyPresentation.ts
Normal file
245
src/hooks/story/storyPresentation.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryDialogueTurn,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import {
|
||||
buildFallbackStoryMoment,
|
||||
normalizeSkillProbabilities,
|
||||
} from '../combatStoryUtils';
|
||||
import { resolveStoryResponseOptions } from './storyResponseOptions';
|
||||
|
||||
const MIN_OPTION_POOL_SIZE = 6;
|
||||
|
||||
function dedupeStoryOptions(options: StoryOption[]) {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return options.filter((option) => {
|
||||
const identity = `${option.functionId}::${option.actionText}::${option.text}`;
|
||||
if (seen.has(identity)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(identity);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
const specialChars = [
|
||||
'\\',
|
||||
'^',
|
||||
'$',
|
||||
'*',
|
||||
'+',
|
||||
'?',
|
||||
'.',
|
||||
'(',
|
||||
')',
|
||||
'|',
|
||||
'[',
|
||||
']',
|
||||
'{',
|
||||
'}',
|
||||
];
|
||||
return specialChars.reduce(
|
||||
(escaped, char) => escaped.split(char).join('\\' + char),
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDialogueSpeakerName(rawSpeakerName: string) {
|
||||
return rawSpeakerName
|
||||
.trim()
|
||||
.replace(
|
||||
/^[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+/u,
|
||||
'',
|
||||
)
|
||||
.replace(
|
||||
/[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+$/u,
|
||||
'',
|
||||
)
|
||||
.replace(/^(?:\u540c\u4f34|\u961f\u53cb)\s*/u, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function sanitizeStoryOptions(
|
||||
options: StoryOption[],
|
||||
character: Character,
|
||||
state: GameState,
|
||||
) {
|
||||
const normalizedOptions = dedupeStoryOptions(
|
||||
options.map((option) => normalizeSkillProbabilities(option, character)),
|
||||
);
|
||||
|
||||
if (normalizedOptions.length === 0) {
|
||||
return buildFallbackStoryMoment(state, character).options;
|
||||
}
|
||||
|
||||
if (normalizedOptions.length >= MIN_OPTION_POOL_SIZE) {
|
||||
return normalizedOptions;
|
||||
}
|
||||
|
||||
return sortStoryOptionsByPriority(
|
||||
dedupeStoryOptions([
|
||||
...normalizedOptions,
|
||||
...buildFallbackStoryMoment(state, character).options,
|
||||
]).slice(0, MIN_OPTION_POOL_SIZE),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildStoryFromResponse(params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
response: StoryMoment;
|
||||
availableOptions: StoryOption[] | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) {
|
||||
return {
|
||||
text: params.response.text,
|
||||
options: resolveStoryResponseOptions({
|
||||
responseOptions: params.response.options,
|
||||
availableOptions: params.availableOptions,
|
||||
optionCatalog: params.optionCatalog ?? null,
|
||||
getSanitizedOptions: () =>
|
||||
sanitizeStoryOptions(
|
||||
params.response.options,
|
||||
params.character,
|
||||
params.state,
|
||||
),
|
||||
}),
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
export function parseDialogueTurns(
|
||||
text: string,
|
||||
npcName: string,
|
||||
): StoryDialogueTurn[] {
|
||||
const turns: StoryDialogueTurn[] = [];
|
||||
const dialogueColonPattern = '(?:\\uFF1A|:)';
|
||||
const playerPrefixPattern = new RegExp(
|
||||
'^(?:\\\\u4f60|\\\\u73a9\\\\u5bb6|\\\\u4e3b\\\\u89d2)\\\\s*' +
|
||||
dialogueColonPattern +
|
||||
'\\\\s*(.+)$',
|
||||
'u',
|
||||
);
|
||||
const npcPrefixPattern = new RegExp(
|
||||
'^' +
|
||||
escapeRegExp(npcName) +
|
||||
'\\\\s*' +
|
||||
dialogueColonPattern +
|
||||
'\\\\s*(.+)$',
|
||||
'u',
|
||||
);
|
||||
const namedSpeakerPattern = new RegExp(
|
||||
'^(.{1,24}?)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$',
|
||||
'u',
|
||||
);
|
||||
const lines = text
|
||||
.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const playerMatch = line.match(playerPrefixPattern);
|
||||
const playerText = playerMatch?.[1]?.trim();
|
||||
if (playerText) {
|
||||
turns.push({ speaker: 'player', text: playerText });
|
||||
continue;
|
||||
}
|
||||
|
||||
const npcMatch = line.match(npcPrefixPattern);
|
||||
const npcText = npcMatch?.[1]?.trim();
|
||||
if (npcText) {
|
||||
turns.push({ speaker: 'npc', speakerName: npcName, text: npcText });
|
||||
continue;
|
||||
}
|
||||
|
||||
const namedSpeakerMatch = line.match(namedSpeakerPattern);
|
||||
if (namedSpeakerMatch) {
|
||||
const rawSpeakerName = namedSpeakerMatch[1];
|
||||
const rawSpeakerText = namedSpeakerMatch[2];
|
||||
if (!rawSpeakerName || !rawSpeakerText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const speakerName = normalizeDialogueSpeakerName(rawSpeakerName);
|
||||
const speakerText = rawSpeakerText.trim();
|
||||
|
||||
if (speakerName && speakerText) {
|
||||
turns.push({
|
||||
speaker: speakerName === npcName ? 'npc' : 'companion',
|
||||
speakerName,
|
||||
text: speakerText,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (line.startsWith('你:') || line.startsWith('你:')) {
|
||||
turns.push({ speaker: 'player', text: line.slice(2).trim() });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith(npcName + ':') || line.startsWith(npcName + ':')) {
|
||||
turns.push({
|
||||
speaker: 'npc',
|
||||
text: line.slice(npcName.length + 1).trim(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('主角:') || line.startsWith('主角:')) {
|
||||
turns.push({ speaker: 'player', text: line.slice(3).trim() });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (turns.length > 0) {
|
||||
const lastTurnIndex = turns.length - 1;
|
||||
const lastTurn = turns[lastTurnIndex];
|
||||
if (lastTurn) {
|
||||
turns[lastTurnIndex] = {
|
||||
...lastTurn,
|
||||
text: lastTurn.text + line,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return turns.filter((turn) => turn.text.length > 0);
|
||||
}
|
||||
|
||||
export function buildDialogueStoryMoment(
|
||||
npcName: string,
|
||||
text: string,
|
||||
options: StoryOption[],
|
||||
streaming = false,
|
||||
): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options,
|
||||
displayMode: 'dialogue',
|
||||
dialogue: parseDialogueTurns(text, npcName),
|
||||
streaming,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasRenderableDialogueTurns(text: string, npcName: string) {
|
||||
return parseDialogueTurns(text, npcName).length >= 2;
|
||||
}
|
||||
|
||||
export function getTypewriterDelay(char: string) {
|
||||
if (/[。!?!?]/u.test(char)) {
|
||||
return 240;
|
||||
}
|
||||
if (/[,、;;:]/u.test(char)) {
|
||||
return 150;
|
||||
}
|
||||
if (/\s/u.test(char)) {
|
||||
return 45;
|
||||
}
|
||||
return 90;
|
||||
}
|
||||
241
src/hooks/story/storyRenderingHelpers.ts
Normal file
241
src/hooks/story/storyRenderingHelpers.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryDialogueTurn,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import {
|
||||
buildFallbackStoryMoment,
|
||||
normalizeSkillProbabilities,
|
||||
} from '../combatStoryUtils';
|
||||
|
||||
const MIN_OPTION_POOL_SIZE = 6;
|
||||
|
||||
export function dedupeStoryOptions(options: StoryOption[]) {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return options.filter((option) => {
|
||||
const identity = `${option.functionId}::${option.actionText}::${option.text}`;
|
||||
if (seen.has(identity)) return false;
|
||||
seen.add(identity);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildLocalCharacterChatSummary(
|
||||
character: Character,
|
||||
history: Array<{ speaker: 'player' | 'character'; text: string }>,
|
||||
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},你现在真正担心的是什么?`,
|
||||
'先把外面的局势放一放,我想更了解你一些。',
|
||||
];
|
||||
}
|
||||
|
||||
export function sanitizeOptions(
|
||||
options: StoryOption[],
|
||||
character: Character,
|
||||
state: GameState,
|
||||
) {
|
||||
const normalizedOptions = dedupeStoryOptions(
|
||||
options.map((option) => normalizeSkillProbabilities(option, character)),
|
||||
);
|
||||
|
||||
if (normalizedOptions.length === 0) {
|
||||
return buildFallbackStoryMoment(state, character).options;
|
||||
}
|
||||
|
||||
if (normalizedOptions.length >= MIN_OPTION_POOL_SIZE) {
|
||||
return normalizedOptions;
|
||||
}
|
||||
|
||||
return dedupeStoryOptions([
|
||||
...normalizedOptions,
|
||||
...buildFallbackStoryMoment(state, character).options,
|
||||
]).slice(0, MIN_OPTION_POOL_SIZE);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
const specialChars = [
|
||||
'\\',
|
||||
'^',
|
||||
'$',
|
||||
'*',
|
||||
'+',
|
||||
'?',
|
||||
'.',
|
||||
'(',
|
||||
')',
|
||||
'|',
|
||||
'[',
|
||||
']',
|
||||
'{',
|
||||
'}',
|
||||
];
|
||||
return specialChars.reduce(
|
||||
(escaped, char) => escaped.split(char).join('\\' + char),
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDialogueSpeakerName(rawSpeakerName: string) {
|
||||
return rawSpeakerName
|
||||
.trim()
|
||||
.replace(
|
||||
/^[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+/u,
|
||||
'',
|
||||
)
|
||||
.replace(
|
||||
/[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+$/u,
|
||||
'',
|
||||
)
|
||||
.replace(/^(?:\u540c\u4f34|\u961f\u53cb)\s*/u, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseDialogueTurns(
|
||||
text: string,
|
||||
npcName: string,
|
||||
): StoryDialogueTurn[] {
|
||||
const turns: StoryDialogueTurn[] = [];
|
||||
const dialogueColonPattern = '(?:\\uFF1A|:)';
|
||||
const playerPrefixPattern = new RegExp(
|
||||
'^(?:\\\\u4f60|\\\\u73a9\\\\u5bb6|\\\\u4e3b\\\\u89d2)\\\\s*' +
|
||||
dialogueColonPattern +
|
||||
'\\\\s*(.+)$',
|
||||
'u',
|
||||
);
|
||||
const npcPrefixPattern = new RegExp(
|
||||
'^' +
|
||||
escapeRegExp(npcName) +
|
||||
'\\\\s*' +
|
||||
dialogueColonPattern +
|
||||
'\\\\s*(.+)$',
|
||||
'u',
|
||||
);
|
||||
const namedSpeakerPattern = new RegExp(
|
||||
'^(.{1,24}?)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$',
|
||||
'u',
|
||||
);
|
||||
const lines = text
|
||||
.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const playerMatch = line.match(playerPrefixPattern);
|
||||
const playerText = playerMatch?.[1]?.trim();
|
||||
if (playerText) {
|
||||
turns.push({ speaker: 'player', text: playerText });
|
||||
continue;
|
||||
}
|
||||
|
||||
const npcMatch = line.match(npcPrefixPattern);
|
||||
const npcText = npcMatch?.[1]?.trim();
|
||||
if (npcText) {
|
||||
turns.push({ speaker: 'npc', speakerName: npcName, text: npcText });
|
||||
continue;
|
||||
}
|
||||
|
||||
const namedSpeakerMatch = line.match(namedSpeakerPattern);
|
||||
if (namedSpeakerMatch) {
|
||||
const rawSpeakerName = namedSpeakerMatch[1];
|
||||
const rawSpeakerText = namedSpeakerMatch[2];
|
||||
if (!rawSpeakerName || !rawSpeakerText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const speakerName = normalizeDialogueSpeakerName(rawSpeakerName);
|
||||
const speakerText = rawSpeakerText.trim();
|
||||
|
||||
if (speakerName && speakerText) {
|
||||
turns.push({
|
||||
speaker: speakerName === npcName ? 'npc' : 'companion',
|
||||
speakerName,
|
||||
text: speakerText,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (line.startsWith('你:') || line.startsWith('你:')) {
|
||||
turns.push({ speaker: 'player', text: line.slice(2).trim() });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith(npcName + ':') || line.startsWith(npcName + ':')) {
|
||||
turns.push({
|
||||
speaker: 'npc',
|
||||
text: line.slice(npcName.length + 1).trim(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('主角:') || line.startsWith('主角:')) {
|
||||
turns.push({ speaker: 'player', text: line.slice(3).trim() });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (turns.length > 0) {
|
||||
const lastTurnIndex = turns.length - 1;
|
||||
const lastTurn = turns[lastTurnIndex];
|
||||
if (lastTurn) {
|
||||
turns[lastTurnIndex] = {
|
||||
...lastTurn,
|
||||
text: lastTurn.text + line,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return turns.filter((turn) => turn.text.length > 0);
|
||||
}
|
||||
|
||||
export function buildDialogueStoryMoment(
|
||||
npcName: string,
|
||||
text: string,
|
||||
options: StoryOption[],
|
||||
streaming = false,
|
||||
): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options,
|
||||
displayMode: 'dialogue',
|
||||
dialogue: parseDialogueTurns(text, npcName),
|
||||
streaming,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasRenderableDialogueTurns(text: string, npcName: string) {
|
||||
return parseDialogueTurns(text, npcName).length >= 2;
|
||||
}
|
||||
|
||||
export function getTypewriterDelay(char: string) {
|
||||
if (/[。!?!?]/u.test(char)) return 240;
|
||||
if (/[,、;;:]/u.test(char)) return 150;
|
||||
if (/\s/u.test(char)) return 45;
|
||||
return 90;
|
||||
}
|
||||
192
src/hooks/story/storyRequestCoordinator.test.ts
Normal file
192
src/hooks/story/storyRequestCoordinator.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiService';
|
||||
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import {
|
||||
generateStoryForStateWithCoordinator,
|
||||
resolveStoryRequestOptions,
|
||||
} from './storyRequestCoordinator';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '在风声里辨认危险的旅人。',
|
||||
personality: '谨慎而果断',
|
||||
skills: [],
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createGameState(): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 3,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(functionId = 'npc_chat', actionText = '继续交谈'): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
describe('storyRequestCoordinator', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('switches to server runtime option catalogs when the local option pool is fully server-backed', async () => {
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const currentStory = createStory('当前故事');
|
||||
const getAvailableOptionsForState = vi.fn(() => [createOption('npc_chat')]);
|
||||
const loadRuntimeOptionCatalog = vi
|
||||
.fn()
|
||||
.mockResolvedValue([createOption('npc_help', '请求援手')]);
|
||||
|
||||
const result = await resolveStoryRequestOptions({
|
||||
state,
|
||||
character,
|
||||
currentStory,
|
||||
getAvailableOptionsForState,
|
||||
loadRuntimeOptionCatalog,
|
||||
});
|
||||
|
||||
expect(loadRuntimeOptionCatalog).toHaveBeenCalledWith({
|
||||
gameState: state,
|
||||
currentStory,
|
||||
});
|
||||
expect(result.availableOptions).toBeNull();
|
||||
expect(result.optionCatalog).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps explicit option catalogs without reloading server runtime options', async () => {
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const currentStory = createStory('当前故事');
|
||||
const optionCatalog = [createOption('npc_help', '请求援手')];
|
||||
const getAvailableOptionsForState = vi.fn(() => [createOption('npc_chat')]);
|
||||
const loadRuntimeOptionCatalog = vi.fn();
|
||||
|
||||
const result = await resolveStoryRequestOptions({
|
||||
state,
|
||||
character,
|
||||
currentStory,
|
||||
optionCatalog,
|
||||
getAvailableOptionsForState,
|
||||
loadRuntimeOptionCatalog,
|
||||
});
|
||||
|
||||
expect(loadRuntimeOptionCatalog).not.toHaveBeenCalled();
|
||||
expect(getAvailableOptionsForState).not.toHaveBeenCalled();
|
||||
expect(result.optionCatalog).toBe(optionCatalog);
|
||||
expect(result.availableOptions).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to local available options when server runtime catalog refresh fails during续写', async () => {
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const currentStory = createStory('当前故事');
|
||||
const history = [createStory('上一轮剧情')];
|
||||
const localOptions = [createOption('npc_chat', '继续交谈')];
|
||||
const getAvailableOptionsForState = vi.fn(() => localOptions);
|
||||
const getStoryGenerationHostileNpcs = vi.fn(() => []);
|
||||
const buildStoryContextFromState = vi.fn(
|
||||
(_state, extras) =>
|
||||
({
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 30,
|
||||
playerMaxMana: 30,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: 'idle',
|
||||
skillCooldowns: {},
|
||||
sceneId: 'inn_room',
|
||||
sceneName: '客栈内室',
|
||||
sceneDescription: '屋里安静得只剩风声。',
|
||||
pendingSceneEncounter: false,
|
||||
lastFunctionId: extras?.lastFunctionId ?? null,
|
||||
}) as StoryGenerationContext,
|
||||
);
|
||||
const buildStoryFromResponse = vi.fn(
|
||||
(
|
||||
_state: GameState,
|
||||
_character: Character,
|
||||
response: StoryMoment,
|
||||
) => response,
|
||||
);
|
||||
const requestInitialStory = vi.fn();
|
||||
const requestNextStep = vi.fn().mockResolvedValue({
|
||||
storyText: '服务端续写完成',
|
||||
options: [createOption('npc_help', '顺势追问')],
|
||||
});
|
||||
const loadRuntimeOptionCatalog = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('server option catalog failed'));
|
||||
const onServerOptionCatalogLoadError = vi.fn();
|
||||
|
||||
const result = await generateStoryForStateWithCoordinator({
|
||||
state,
|
||||
character,
|
||||
history,
|
||||
currentStory,
|
||||
choice: '继续交谈',
|
||||
lastFunctionId: 'npc_chat',
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
requestInitialStory,
|
||||
requestNextStep,
|
||||
loadRuntimeOptionCatalog,
|
||||
onServerOptionCatalogLoadError,
|
||||
});
|
||||
|
||||
expect(onServerOptionCatalogLoadError).toHaveBeenCalledTimes(1);
|
||||
expect(requestInitialStory).not.toHaveBeenCalled();
|
||||
expect(requestNextStep).toHaveBeenCalledWith(
|
||||
'WUXIA',
|
||||
character,
|
||||
[],
|
||||
history,
|
||||
'继续交谈',
|
||||
expect.objectContaining({
|
||||
sceneId: 'inn_room',
|
||||
lastFunctionId: 'npc_chat',
|
||||
}),
|
||||
{
|
||||
availableOptions: localOptions,
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({
|
||||
text: '服务端续写完成',
|
||||
options: [createOption('npc_help', '顺势追问')],
|
||||
});
|
||||
});
|
||||
});
|
||||
196
src/hooks/story/storyRequestCoordinator.ts
Normal file
196
src/hooks/story/storyRequestCoordinator.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type {
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
} from '../../services/aiService';
|
||||
import { shouldUseServerRuntimeOptions } from '../../services/runtimeStoryService';
|
||||
import type {
|
||||
AIResponse,
|
||||
Character,
|
||||
GameState,
|
||||
SceneHostileNpc,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { loadServerRuntimeOptionCatalog } from './runtimeStoryCoordinator';
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
type BuildStoryFromResponse = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog?: StoryOption[] | null,
|
||||
) => StoryMoment;
|
||||
|
||||
type GetAvailableOptionsForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
|
||||
type GetStoryGenerationHostileNpcs = (state: GameState) => SceneHostileNpc[];
|
||||
|
||||
type RequestInitialStory = (
|
||||
worldType: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
requestOptions?: StoryRequestOptions,
|
||||
) => Promise<AIResponse>;
|
||||
|
||||
type RequestNextStep = (
|
||||
worldType: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
choice: string,
|
||||
context: StoryGenerationContext,
|
||||
requestOptions?: StoryRequestOptions,
|
||||
) => Promise<AIResponse>;
|
||||
|
||||
type LoadRuntimeOptionCatalog = typeof loadServerRuntimeOptionCatalog;
|
||||
|
||||
export type ResolvedStoryRequestOptions = {
|
||||
availableOptions: StoryOption[] | null;
|
||||
optionCatalog: StoryOption[] | null;
|
||||
};
|
||||
|
||||
export async function resolveStoryRequestOptions(params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
currentStory: StoryMoment | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
getAvailableOptionsForState: GetAvailableOptionsForState;
|
||||
loadRuntimeOptionCatalog?: LoadRuntimeOptionCatalog;
|
||||
onServerOptionCatalogLoadError?: (error: unknown) => void;
|
||||
}) {
|
||||
let optionCatalog =
|
||||
params.optionCatalog && params.optionCatalog.length > 0
|
||||
? params.optionCatalog
|
||||
: null;
|
||||
let availableOptions = optionCatalog
|
||||
? null
|
||||
: params.getAvailableOptionsForState(params.state, params.character);
|
||||
|
||||
if (optionCatalog || !shouldUseServerRuntimeOptions(availableOptions)) {
|
||||
return {
|
||||
availableOptions,
|
||||
optionCatalog,
|
||||
} satisfies ResolvedStoryRequestOptions;
|
||||
}
|
||||
|
||||
try {
|
||||
const serverOptionCatalog = await (
|
||||
params.loadRuntimeOptionCatalog ?? loadServerRuntimeOptionCatalog
|
||||
)({
|
||||
gameState: params.state,
|
||||
currentStory: params.currentStory,
|
||||
});
|
||||
|
||||
if (serverOptionCatalog && serverOptionCatalog.length > 0) {
|
||||
optionCatalog = serverOptionCatalog;
|
||||
availableOptions = null;
|
||||
}
|
||||
} catch (error) {
|
||||
params.onServerOptionCatalogLoadError?.(error);
|
||||
}
|
||||
|
||||
return {
|
||||
availableOptions,
|
||||
optionCatalog,
|
||||
} satisfies ResolvedStoryRequestOptions;
|
||||
}
|
||||
|
||||
export function buildAiStoryRequestOptions(
|
||||
options: ResolvedStoryRequestOptions,
|
||||
) {
|
||||
if (options.availableOptions) {
|
||||
return {
|
||||
availableOptions: options.availableOptions,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.optionCatalog) {
|
||||
return {
|
||||
optionCatalog: options.optionCatalog,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function generateStoryForStateWithCoordinator(params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
currentStory: StoryMoment | null;
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
getAvailableOptionsForState: GetAvailableOptionsForState;
|
||||
getStoryGenerationHostileNpcs: GetStoryGenerationHostileNpcs;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
buildStoryFromResponse: BuildStoryFromResponse;
|
||||
requestInitialStory: RequestInitialStory;
|
||||
requestNextStep: RequestNextStep;
|
||||
loadRuntimeOptionCatalog?: LoadRuntimeOptionCatalog;
|
||||
onServerOptionCatalogLoadError?: (error: unknown) => void;
|
||||
}) {
|
||||
if (!params.state.worldType) {
|
||||
throw new Error(
|
||||
'The current world is not initialized, so story generation cannot continue.',
|
||||
);
|
||||
}
|
||||
const worldType = params.state.worldType;
|
||||
|
||||
const resolvedOptions = await resolveStoryRequestOptions({
|
||||
state: params.state,
|
||||
character: params.character,
|
||||
currentStory: params.currentStory,
|
||||
optionCatalog: params.optionCatalog,
|
||||
getAvailableOptionsForState: params.getAvailableOptionsForState,
|
||||
loadRuntimeOptionCatalog: params.loadRuntimeOptionCatalog,
|
||||
onServerOptionCatalogLoadError: params.onServerOptionCatalogLoadError,
|
||||
});
|
||||
const requestOptions = buildAiStoryRequestOptions(resolvedOptions);
|
||||
const monsters = params.getStoryGenerationHostileNpcs(params.state);
|
||||
const context = params.choice
|
||||
? params.buildStoryContextFromState(params.state, {
|
||||
lastFunctionId: params.lastFunctionId,
|
||||
})
|
||||
: params.buildStoryContextFromState(params.state);
|
||||
const response = params.choice
|
||||
? await params.requestNextStep(
|
||||
worldType,
|
||||
params.character,
|
||||
monsters,
|
||||
params.history,
|
||||
params.choice,
|
||||
context,
|
||||
requestOptions,
|
||||
)
|
||||
: await params.requestInitialStory(
|
||||
worldType,
|
||||
params.character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
|
||||
return params.buildStoryFromResponse(
|
||||
params.state,
|
||||
params.character,
|
||||
{
|
||||
text: response.storyText,
|
||||
options: response.options,
|
||||
},
|
||||
resolvedOptions.availableOptions,
|
||||
resolvedOptions.optionCatalog,
|
||||
);
|
||||
}
|
||||
136
src/hooks/story/storyRequestRuntime.test.ts
Normal file
136
src/hooks/story/storyRequestRuntime.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { createGenerateStoryForState } from './storyRequestRuntime';
|
||||
|
||||
const { generateStoryForStateWithCoordinatorMock } = vi.hoisted(() => ({
|
||||
generateStoryForStateWithCoordinatorMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./storyRequestCoordinator', () => ({
|
||||
generateStoryForStateWithCoordinator:
|
||||
generateStoryForStateWithCoordinatorMock,
|
||||
}));
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '在风声里辨认危险的旅人。',
|
||||
backstory: '长年行走江湖。',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: '谨慎而果断',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createGameState(): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
currentScene: 'Story',
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(
|
||||
functionId = 'npc_chat',
|
||||
actionText = '继续交谈',
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
describe('storyRequestRuntime', () => {
|
||||
it('forwards runtime request dependencies and currentStory into the coordinator', async () => {
|
||||
const currentStory = createStory('当前故事');
|
||||
const getAvailableOptionsForState = vi.fn(() => [createOption()]);
|
||||
const getStoryGenerationHostileNpcs = vi.fn(() => []);
|
||||
const buildStoryContextFromState = vi.fn();
|
||||
const buildStoryFromResponse = vi.fn();
|
||||
const requestInitialStory = vi.fn();
|
||||
const requestNextStep = vi.fn();
|
||||
const onServerOptionCatalogLoadError = vi.fn();
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const history = [createStory('上一轮剧情')];
|
||||
|
||||
generateStoryForStateWithCoordinatorMock.mockResolvedValue({
|
||||
text: '生成完成',
|
||||
options: [],
|
||||
});
|
||||
|
||||
const generateStoryForState = createGenerateStoryForState({
|
||||
currentStory,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
requestInitialStory,
|
||||
requestNextStep,
|
||||
onServerOptionCatalogLoadError,
|
||||
});
|
||||
|
||||
const result = await generateStoryForState({
|
||||
state,
|
||||
character,
|
||||
history,
|
||||
choice: '继续交谈',
|
||||
lastFunctionId: 'npc_chat',
|
||||
optionCatalog: [createOption('npc_help', '请求援手')],
|
||||
});
|
||||
|
||||
expect(generateStoryForStateWithCoordinatorMock).toHaveBeenCalledWith({
|
||||
state,
|
||||
character,
|
||||
history,
|
||||
currentStory,
|
||||
choice: '继续交谈',
|
||||
lastFunctionId: 'npc_chat',
|
||||
optionCatalog: [createOption('npc_help', '请求援手')],
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
requestInitialStory,
|
||||
requestNextStep,
|
||||
onServerOptionCatalogLoadError,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
text: '生成完成',
|
||||
options: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
69
src/hooks/story/storyRequestRuntime.ts
Normal file
69
src/hooks/story/storyRequestRuntime.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { GenerateStoryForState } from './progressionActions';
|
||||
import { generateStoryForStateWithCoordinator } from './storyRequestCoordinator';
|
||||
|
||||
type GetAvailableOptionsForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
|
||||
type BuildStoryContextFromState = Parameters<
|
||||
typeof generateStoryForStateWithCoordinator
|
||||
>[0]['buildStoryContextFromState'];
|
||||
|
||||
type BuildStoryFromResponse = Parameters<
|
||||
typeof generateStoryForStateWithCoordinator
|
||||
>[0]['buildStoryFromResponse'];
|
||||
|
||||
type GetStoryGenerationHostileNpcs = Parameters<
|
||||
typeof generateStoryForStateWithCoordinator
|
||||
>[0]['getStoryGenerationHostileNpcs'];
|
||||
|
||||
type RequestInitialStory = Parameters<
|
||||
typeof generateStoryForStateWithCoordinator
|
||||
>[0]['requestInitialStory'];
|
||||
|
||||
type RequestNextStep = Parameters<
|
||||
typeof generateStoryForStateWithCoordinator
|
||||
>[0]['requestNextStep'];
|
||||
|
||||
export function createGenerateStoryForState(params: {
|
||||
currentStory: StoryMoment | null;
|
||||
getAvailableOptionsForState: GetAvailableOptionsForState;
|
||||
getStoryGenerationHostileNpcs: GetStoryGenerationHostileNpcs;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
buildStoryFromResponse: BuildStoryFromResponse;
|
||||
requestInitialStory: RequestInitialStory;
|
||||
requestNextStep: RequestNextStep;
|
||||
onServerOptionCatalogLoadError?: (error: unknown) => void;
|
||||
}): GenerateStoryForState {
|
||||
return async ({
|
||||
state,
|
||||
character,
|
||||
history,
|
||||
choice,
|
||||
lastFunctionId,
|
||||
optionCatalog,
|
||||
}) =>
|
||||
generateStoryForStateWithCoordinator({
|
||||
state,
|
||||
character,
|
||||
history,
|
||||
currentStory: params.currentStory,
|
||||
choice,
|
||||
lastFunctionId,
|
||||
optionCatalog,
|
||||
getAvailableOptionsForState: params.getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
buildStoryContextFromState: params.buildStoryContextFromState,
|
||||
buildStoryFromResponse: params.buildStoryFromResponse,
|
||||
requestInitialStory: params.requestInitialStory,
|
||||
requestNextStep: params.requestNextStep,
|
||||
onServerOptionCatalogLoadError: params.onServerOptionCatalogLoadError,
|
||||
});
|
||||
}
|
||||
123
src/hooks/story/storyRuntimeSupport.test.ts
Normal file
123
src/hooks/story/storyRuntimeSupport.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { GameState, InventoryItem } from '../../types';
|
||||
import {
|
||||
cloneInventoryItemForOwner,
|
||||
updateQuestLog,
|
||||
updateRuntimeStats,
|
||||
} from './storyRuntimeSupport';
|
||||
|
||||
function createGameState(): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
customWorldProfile: null,
|
||||
playerCharacter: null,
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: 'idle',
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 1,
|
||||
playerMaxHp: 1,
|
||||
playerMana: 1,
|
||||
playerMaxMana: 1,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('storyRuntimeSupport', () => {
|
||||
it('preserves identity-sensitive inventory items when cloning for another owner', () => {
|
||||
const item = {
|
||||
id: 'artifact-1',
|
||||
category: '饰品',
|
||||
name: '旧日秘匣',
|
||||
quantity: 1,
|
||||
rarity: 'epic',
|
||||
tags: ['relic'],
|
||||
runtimeMetadata: {
|
||||
seedKey: 'artifact-seed',
|
||||
},
|
||||
} as InventoryItem;
|
||||
|
||||
expect(cloneInventoryItemForOwner(item, 'npc', 2)).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'npc:artifact-1:2',
|
||||
quantity: 2,
|
||||
runtimeMetadata: expect.objectContaining({
|
||||
seedKey: 'artifact-seed:npc',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses synthetic ids for ordinary stackable items when cloning for another owner', () => {
|
||||
const item = {
|
||||
id: 'potion-1',
|
||||
category: '消耗品',
|
||||
name: '回气散',
|
||||
quantity: 3,
|
||||
rarity: 'common',
|
||||
tags: ['healing'],
|
||||
} as InventoryItem;
|
||||
|
||||
expect(cloneInventoryItemForOwner(item, 'player')).toEqual(
|
||||
expect.objectContaining({
|
||||
id: `player:${encodeURIComponent('消耗品-回气散')}`,
|
||||
quantity: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('updates quest logs and runtime stats without keeping that logic in the main hook', () => {
|
||||
const initialState = createGameState();
|
||||
const withQuest = updateQuestLog(initialState, () => [
|
||||
{
|
||||
id: 'quest-1',
|
||||
},
|
||||
] as GameState['quests']);
|
||||
const withStats = updateRuntimeStats(withQuest, {
|
||||
itemsUsed: 2,
|
||||
scenesTraveled: 1,
|
||||
});
|
||||
|
||||
expect(withStats.quests).toEqual([{ id: 'quest-1' }]);
|
||||
expect(withStats.runtimeStats.itemsUsed).toBe(2);
|
||||
expect(withStats.runtimeStats.scenesTraveled).toBe(1);
|
||||
});
|
||||
});
|
||||
127
src/hooks/story/storyRuntimeSupport.ts
Normal file
127
src/hooks/story/storyRuntimeSupport.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
buildNpcEncounterStoryMoment,
|
||||
normalizeNpcPersistentState,
|
||||
} from '../../data/npcInteractions';
|
||||
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
|
||||
import { syncNpcNarrativeState } from '../../services/storyEngine/echoMemory';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
} from '../../types';
|
||||
import { getNpcEncounterKey } from './storyGenerationState';
|
||||
|
||||
export function cloneInventoryItemForOwner(
|
||||
item: InventoryItem,
|
||||
owner: 'player' | 'npc',
|
||||
quantity = 1,
|
||||
) {
|
||||
const preserveIdentity = Boolean(
|
||||
item.runtimeMetadata ||
|
||||
item.buildProfile ||
|
||||
item.equipmentSlotId ||
|
||||
item.statProfile ||
|
||||
item.attributeResonance,
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
id: preserveIdentity
|
||||
? `${owner}:${item.id}:${quantity}`
|
||||
: `${owner}:${encodeURIComponent(`${item.category}-${item.name}`)}`,
|
||||
quantity,
|
||||
runtimeMetadata: item.runtimeMetadata
|
||||
? {
|
||||
...item.runtimeMetadata,
|
||||
seedKey: `${item.runtimeMetadata.seedKey}:${owner}`,
|
||||
}
|
||||
: item.runtimeMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
export function getResolvedNpcState(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
) {
|
||||
return (
|
||||
state.npcStates[getNpcEncounterKey(encounter)] ??
|
||||
buildInitialNpcState(encounter, state.worldType, state)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildNpcStory(
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
) {
|
||||
return buildNpcEncounterStoryMoment({
|
||||
state,
|
||||
encounter,
|
||||
npcState: getResolvedNpcState(state, encounter),
|
||||
playerCharacter: character,
|
||||
playerInventory: state.playerInventory,
|
||||
activeQuests: state.quests,
|
||||
scene: state.currentScenePreset,
|
||||
partySize: state.companions.length,
|
||||
overrideText,
|
||||
worldType: state.worldType,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateNpcState(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
updater: (
|
||||
npcState: ReturnType<typeof getResolvedNpcState>,
|
||||
) => ReturnType<typeof getResolvedNpcState>,
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
[getNpcEncounterKey(encounter)]: normalizeNpcPersistentState(
|
||||
syncNpcNarrativeState({
|
||||
encounter,
|
||||
npcState: updater(getResolvedNpcState(state, encounter)),
|
||||
customWorldProfile: state.customWorldProfile,
|
||||
storyEngineMemory: state.storyEngineMemory,
|
||||
}),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function updateQuestLog(
|
||||
state: GameState,
|
||||
updater: (quests: GameState['quests']) => GameState['quests'],
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
quests: updater(state.quests),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateRuntimeStats(
|
||||
state: GameState,
|
||||
increments: Parameters<typeof incrementGameRuntimeStats>[1],
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, increments),
|
||||
};
|
||||
}
|
||||
|
||||
export const storyRuntimeSupport = {
|
||||
cloneInventoryItemForOwner,
|
||||
getNpcEncounterKey,
|
||||
getResolvedNpcState,
|
||||
buildNpcStory,
|
||||
updateNpcState,
|
||||
updateQuestLog,
|
||||
updateRuntimeStats,
|
||||
};
|
||||
|
||||
export type StoryRuntimeSupport = typeof storyRuntimeSupport;
|
||||
16
src/hooks/story/useStoryChoiceCoordinator.test.ts
Normal file
16
src/hooks/story/useStoryChoiceCoordinator.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createClearStoryChoiceUi } from './useStoryChoiceCoordinator';
|
||||
|
||||
describe('useStoryChoiceCoordinator helpers', () => {
|
||||
it('clears choice ui by dismissing battle reward', () => {
|
||||
const calls: string[] = [];
|
||||
const clearStoryChoiceUi = createClearStoryChoiceUi({
|
||||
clearBattleReward: vi.fn(() => calls.push('battle')),
|
||||
});
|
||||
|
||||
clearStoryChoiceUi();
|
||||
|
||||
expect(calls).toEqual(['battle']);
|
||||
});
|
||||
});
|
||||
140
src/hooks/story/useStoryChoiceCoordinator.ts
Normal file
140
src/hooks/story/useStoryChoiceCoordinator.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { createStoryChoiceActions } from './choiceActions';
|
||||
import {
|
||||
createStoryChoiceCoordinatorConfig,
|
||||
type ChoiceRuntimeController,
|
||||
type ChoiceRuntimeSupport,
|
||||
} from './storyChoiceCoordinator';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
|
||||
|
||||
type StoryChoiceCoordinatorParams = {
|
||||
gameState: GameState;
|
||||
isLoading: boolean;
|
||||
setGameState: Parameters<typeof createStoryChoiceActions>[0]['setGameState'];
|
||||
setCurrentStory: Parameters<
|
||||
typeof createStoryChoiceActions
|
||||
>[0]['setCurrentStory'];
|
||||
setAiError: Parameters<typeof createStoryChoiceActions>[0]['setAiError'];
|
||||
setIsLoading: Parameters<typeof createStoryChoiceActions>[0]['setIsLoading'];
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: ResolvedChoicePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
runtimeController: ChoiceRuntimeController & {
|
||||
currentStory: StoryMoment | null;
|
||||
};
|
||||
runtimeSupport: ChoiceRuntimeSupport;
|
||||
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
|
||||
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
|
||||
handleTreasureInteraction: (
|
||||
option: StoryOption,
|
||||
) => void | Promise<void> | boolean | Promise<boolean>;
|
||||
finalizeNpcBattleResult: Parameters<
|
||||
typeof createStoryChoiceCoordinatorConfig
|
||||
>[0]['finalizeNpcBattleResult'];
|
||||
sortOptions: (options: StoryOption[]) => StoryOption[];
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
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;
|
||||
};
|
||||
|
||||
export function createClearStoryChoiceUi(params: {
|
||||
clearBattleReward: () => void;
|
||||
}) {
|
||||
return () => {
|
||||
params.clearBattleReward();
|
||||
};
|
||||
}
|
||||
|
||||
export function useStoryChoiceCoordinator(
|
||||
params: StoryChoiceCoordinatorParams,
|
||||
) {
|
||||
const [battleReward, setBattleReward] = useState<BattleRewardSummary | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions(
|
||||
createStoryChoiceCoordinatorConfig({
|
||||
gameState: params.gameState,
|
||||
currentStory: params.runtimeController.currentStory,
|
||||
isLoading: params.isLoading,
|
||||
setGameState: params.setGameState,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
setBattleReward,
|
||||
buildResolvedChoiceState: params.buildResolvedChoiceState,
|
||||
playResolvedChoice: params.playResolvedChoice,
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
|
||||
runtimeController: params.runtimeController,
|
||||
runtimeSupport: params.runtimeSupport,
|
||||
enterNpcInteraction: params.enterNpcInteraction,
|
||||
handleNpcInteraction: params.handleNpcInteraction,
|
||||
handleTreasureInteraction: params.handleTreasureInteraction,
|
||||
finalizeNpcBattleResult: params.finalizeNpcBattleResult,
|
||||
sortOptions: params.sortOptions,
|
||||
buildContinueAdventureOption: params.buildContinueAdventureOption,
|
||||
isContinueAdventureOption: params.isContinueAdventureOption,
|
||||
isCampTravelHomeOption: params.isCampTravelHomeOption,
|
||||
isInitialCompanionEncounter: params.isInitialCompanionEncounter,
|
||||
isRegularNpcEncounter: params.isRegularNpcEncounter,
|
||||
isNpcEncounter: params.isNpcEncounter,
|
||||
npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName: params.fallbackCompanionName,
|
||||
turnVisualMs: params.turnVisualMs,
|
||||
}),
|
||||
);
|
||||
|
||||
const clearStoryChoiceUi = useCallback(
|
||||
createClearStoryChoiceUi({
|
||||
clearBattleReward: () => setBattleReward(null),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
handleChoice,
|
||||
battleRewardUi: {
|
||||
reward: battleReward,
|
||||
dismiss: () => setBattleReward(null),
|
||||
},
|
||||
clearStoryChoiceUi,
|
||||
};
|
||||
}
|
||||
190
src/hooks/story/useStoryFlowCoordinator.ts
Normal file
190
src/hooks/story/useStoryFlowCoordinator.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { Character, Encounter, GameState, StoryOption } from '../../types';
|
||||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import { createStoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
|
||||
import { sanitizeStoryOptions } from './storyPresentation';
|
||||
import { useStoryGoalOptionCoordinator } from './useStoryGoalOptionCoordinator';
|
||||
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
|
||||
import { useStoryGoalSessionCoordinator } from './useStoryGoalSessionCoordinator';
|
||||
import { useStoryInteractionCoordinator } from './useStoryInteractionCoordinator';
|
||||
import type { StoryRuntimeControllerResult } from './useStoryRuntimeController';
|
||||
|
||||
type StoryFlowCoordinatorParams = {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: ResolvedChoicePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
runtimeController: StoryRuntimeControllerResult;
|
||||
runtimeSupport: StoryRuntimeSupport;
|
||||
sortOptions: (options: StoryOption[]) => StoryOption[];
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
resolveNpcInteractionDecision: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
) => { kind: string };
|
||||
clearCharacterChatModal: () => void;
|
||||
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;
|
||||
};
|
||||
|
||||
export function useStoryFlowCoordinator({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs,
|
||||
runtimeController,
|
||||
runtimeSupport,
|
||||
sortOptions,
|
||||
buildContinueAdventureOption,
|
||||
resolveNpcInteractionDecision,
|
||||
clearCharacterChatModal,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isInitialCompanionEncounter,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
}: StoryFlowCoordinatorParams) {
|
||||
const {
|
||||
currentStory,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
isLoading,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState,
|
||||
getTypewriterDelay,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
buildOpeningCampChatContext,
|
||||
resetPreparedOpeningAdventure,
|
||||
} = runtimeController;
|
||||
const interactionConfig = createStoryInteractionCoordinatorConfig({
|
||||
gameState,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
currentStory,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
runtimeSupport,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
buildOpeningCampChatContext,
|
||||
sortOptions,
|
||||
buildContinueAdventureOption,
|
||||
sanitizeOptions: sanitizeStoryOptions,
|
||||
resolveNpcInteractionDecision,
|
||||
});
|
||||
const {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
goalUi,
|
||||
clearStoryGoalOptionUi,
|
||||
} = useStoryGoalOptionCoordinator({
|
||||
gameState,
|
||||
currentStory,
|
||||
});
|
||||
const {
|
||||
handleChoice,
|
||||
battleRewardUi,
|
||||
npcUi,
|
||||
inventoryUi,
|
||||
clearStoryInteractionUi,
|
||||
} = useStoryInteractionCoordinator({
|
||||
gameState,
|
||||
isLoading,
|
||||
interactionConfig,
|
||||
runtimeSupport,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryFromResponse: runtimeController.buildStoryFromResponse,
|
||||
getResolvedSceneHostileNpcs,
|
||||
getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene,
|
||||
startOpeningAdventure: runtimeController.startOpeningAdventure,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isInitialCompanionEncounter,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
});
|
||||
const { questUi, resetStoryState, hydrateStoryState, travelToSceneFromMap } =
|
||||
useStoryGoalSessionCoordinator({
|
||||
gameState,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
commitGeneratedState,
|
||||
buildFallbackStoryForState,
|
||||
resetPreparedOpeningAdventure,
|
||||
clearStoryGoalOptionUi,
|
||||
clearStoryInteractionUi,
|
||||
clearCharacterChatModal,
|
||||
});
|
||||
|
||||
return {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
handleChoice,
|
||||
resetStoryState,
|
||||
hydrateStoryState,
|
||||
travelToSceneFromMap,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
goalUi,
|
||||
npcUi,
|
||||
inventoryUi,
|
||||
};
|
||||
}
|
||||
17
src/hooks/story/useStoryGoalOptionCoordinator.test.ts
Normal file
17
src/hooks/story/useStoryGoalOptionCoordinator.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createClearStoryGoalOptionUi } from './useStoryGoalOptionCoordinator';
|
||||
|
||||
describe('useStoryGoalOptionCoordinator helpers', () => {
|
||||
it('clears story goal and option ui together', () => {
|
||||
const calls: string[] = [];
|
||||
const clearStoryGoalOptionUi = createClearStoryGoalOptionUi({
|
||||
resetStoryOptions: vi.fn(() => calls.push('options')),
|
||||
resetGoalPulseTracking: vi.fn(() => calls.push('goal')),
|
||||
});
|
||||
|
||||
clearStoryGoalOptionUi();
|
||||
|
||||
expect(calls).toEqual(['options', 'goal']);
|
||||
});
|
||||
});
|
||||
50
src/hooks/story/useStoryGoalOptionCoordinator.ts
Normal file
50
src/hooks/story/useStoryGoalOptionCoordinator.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { GameState, StoryMoment } from '../../types';
|
||||
import { useStoryOptions } from '../useStoryOptions';
|
||||
import { useStoryGoalFlow } from './goalFlow';
|
||||
|
||||
export function createClearStoryGoalOptionUi(params: {
|
||||
resetStoryOptions: () => void;
|
||||
resetGoalPulseTracking: () => void;
|
||||
}) {
|
||||
return () => {
|
||||
params.resetStoryOptions();
|
||||
params.resetGoalPulseTracking();
|
||||
};
|
||||
}
|
||||
|
||||
export function useStoryGoalOptionCoordinator(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
}) {
|
||||
const { runtimeGoalStack, goalUi, resetGoalPulseTracking } = useStoryGoalFlow(
|
||||
params.gameState,
|
||||
);
|
||||
const {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
resetStoryOptions,
|
||||
} = useStoryOptions(params.currentStory, runtimeGoalStack);
|
||||
|
||||
const clearStoryGoalOptionUi = useCallback(
|
||||
createClearStoryGoalOptionUi({
|
||||
resetStoryOptions,
|
||||
resetGoalPulseTracking,
|
||||
}),
|
||||
[resetGoalPulseTracking, resetStoryOptions],
|
||||
);
|
||||
|
||||
return {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
goalUi,
|
||||
clearStoryGoalOptionUi,
|
||||
};
|
||||
}
|
||||
|
||||
export type StoryGoalOptionCoordinatorResult = ReturnType<
|
||||
typeof useStoryGoalOptionCoordinator
|
||||
>;
|
||||
28
src/hooks/story/useStoryGoalSessionCoordinator.test.ts
Normal file
28
src/hooks/story/useStoryGoalSessionCoordinator.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createClearStoryRuntimeUi } from './useStoryGoalSessionCoordinator';
|
||||
|
||||
describe('useStoryGoalSessionCoordinator helpers', () => {
|
||||
it('clears story runtime ui in the expected order', () => {
|
||||
const calls: string[] = [];
|
||||
const clearStoryRuntimeUi = createClearStoryRuntimeUi({
|
||||
clearStoryGoalOptionUi: vi.fn(() => calls.push('goal-option')),
|
||||
clearStoryInteractionUi: vi.fn(() => calls.push('interaction')),
|
||||
setAiError: vi.fn((value) => calls.push(`ai:${String(value)}`)),
|
||||
setIsLoading: vi.fn((value) => calls.push(`loading:${String(value)}`)),
|
||||
resetPreparedOpeningAdventure: vi.fn(() => calls.push('opening')),
|
||||
clearCharacterChatModal: vi.fn(() => calls.push('chat')),
|
||||
});
|
||||
|
||||
clearStoryRuntimeUi();
|
||||
|
||||
expect(calls).toEqual([
|
||||
'goal-option',
|
||||
'interaction',
|
||||
'ai:null',
|
||||
'loading:false',
|
||||
'opening',
|
||||
'chat',
|
||||
]);
|
||||
});
|
||||
});
|
||||
94
src/hooks/story/useStoryGoalSessionCoordinator.ts
Normal file
94
src/hooks/story/useStoryGoalSessionCoordinator.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useCallback, type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
import type { StoryMoment, GameState, Character } from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
import { createStorySessionActions } from './sessionActions';
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
export function createClearStoryRuntimeUi(params: {
|
||||
clearStoryGoalOptionUi: () => void;
|
||||
clearStoryInteractionUi: () => void;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
resetPreparedOpeningAdventure: () => void;
|
||||
clearCharacterChatModal: () => void;
|
||||
}) {
|
||||
return () => {
|
||||
params.clearStoryGoalOptionUi();
|
||||
params.clearStoryInteractionUi();
|
||||
params.setAiError(null);
|
||||
params.setIsLoading(false);
|
||||
params.resetPreparedOpeningAdventure();
|
||||
params.clearCharacterChatModal();
|
||||
};
|
||||
}
|
||||
|
||||
export function useStoryGoalSessionCoordinator(params: {
|
||||
gameState: GameState;
|
||||
isLoading: boolean;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
resetPreparedOpeningAdventure: () => void;
|
||||
clearStoryGoalOptionUi: () => void;
|
||||
clearStoryInteractionUi: () => void;
|
||||
clearCharacterChatModal: () => void;
|
||||
}) {
|
||||
const clearStoryRuntimeUi = useCallback(
|
||||
createClearStoryRuntimeUi({
|
||||
clearStoryGoalOptionUi: params.clearStoryGoalOptionUi,
|
||||
clearStoryInteractionUi: params.clearStoryInteractionUi,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
resetPreparedOpeningAdventure: params.resetPreparedOpeningAdventure,
|
||||
clearCharacterChatModal: params.clearCharacterChatModal,
|
||||
}),
|
||||
[
|
||||
params.clearCharacterChatModal,
|
||||
params.clearStoryGoalOptionUi,
|
||||
params.clearStoryInteractionUi,
|
||||
params.resetPreparedOpeningAdventure,
|
||||
params.setAiError,
|
||||
params.setIsLoading,
|
||||
],
|
||||
);
|
||||
|
||||
const {
|
||||
acknowledgeQuestCompletion,
|
||||
claimQuestReward,
|
||||
resetStoryState,
|
||||
hydrateStoryState,
|
||||
travelToSceneFromMap,
|
||||
} = createStorySessionActions({
|
||||
gameState: params.gameState,
|
||||
isLoading: params.isLoading,
|
||||
setGameState: params.setGameState,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
clearStoryRuntimeUi,
|
||||
commitGeneratedState: params.commitGeneratedState,
|
||||
buildFallbackStoryForState: params.buildFallbackStoryForState,
|
||||
});
|
||||
|
||||
return {
|
||||
questUi: {
|
||||
acknowledgeQuestCompletion,
|
||||
claimQuestReward,
|
||||
},
|
||||
resetStoryState,
|
||||
hydrateStoryState,
|
||||
travelToSceneFromMap,
|
||||
clearStoryRuntimeUi,
|
||||
};
|
||||
}
|
||||
|
||||
export type StoryGoalSessionCoordinatorResult = ReturnType<
|
||||
typeof useStoryGoalSessionCoordinator
|
||||
>;
|
||||
17
src/hooks/story/useStoryInteractionCoordinator.test.ts
Normal file
17
src/hooks/story/useStoryInteractionCoordinator.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createClearStoryInteractionUi } from './useStoryInteractionCoordinator';
|
||||
|
||||
describe('useStoryInteractionCoordinator helpers', () => {
|
||||
it('clears interaction ui in the expected order', () => {
|
||||
const calls: string[] = [];
|
||||
const clearStoryInteractionUi = createClearStoryInteractionUi({
|
||||
clearStoryChoiceUi: vi.fn(() => calls.push('choice')),
|
||||
clearNpcInteractionUi: vi.fn(() => calls.push('npc')),
|
||||
});
|
||||
|
||||
clearStoryInteractionUi();
|
||||
|
||||
expect(calls).toEqual(['choice', 'npc']);
|
||||
});
|
||||
});
|
||||
203
src/hooks/story/useStoryInteractionCoordinator.ts
Normal file
203
src/hooks/story/useStoryInteractionCoordinator.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
|
||||
import { useTreasureFlow } from '../useTreasureFlow';
|
||||
import { useStoryInventoryActions } from './inventoryActions';
|
||||
import { createStoryNpcEncounterActions } from './npcEncounterActions';
|
||||
import { useStoryNpcInteractionFlow } from './npcInteraction';
|
||||
import type { StoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
|
||||
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
|
||||
import type {
|
||||
ChoiceRuntimeController,
|
||||
StoryChoiceCoordinatorParams,
|
||||
} from './storyChoiceCoordinator';
|
||||
import { useStoryChoiceCoordinator } from './useStoryChoiceCoordinator';
|
||||
|
||||
type StoryInteractionCoordinatorParams = {
|
||||
gameState: GameState;
|
||||
isLoading: boolean;
|
||||
interactionConfig: StoryInteractionCoordinatorConfig;
|
||||
runtimeSupport: StoryRuntimeSupport;
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: ResolvedChoicePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
buildStoryFromResponse: ChoiceRuntimeController['buildStoryFromResponse'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getCampCompanionTravelScene: StoryChoiceCoordinatorParams['runtimeController']['getCampCompanionTravelScene'];
|
||||
startOpeningAdventure: StoryChoiceCoordinatorParams['runtimeController']['startOpeningAdventure'];
|
||||
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;
|
||||
};
|
||||
|
||||
export function createClearStoryInteractionUi(params: {
|
||||
clearStoryChoiceUi: () => void;
|
||||
clearNpcInteractionUi: () => void;
|
||||
}) {
|
||||
return () => {
|
||||
params.clearStoryChoiceUi();
|
||||
params.clearNpcInteractionUi();
|
||||
};
|
||||
}
|
||||
|
||||
export function useStoryInteractionCoordinator({
|
||||
gameState,
|
||||
isLoading,
|
||||
interactionConfig,
|
||||
runtimeSupport,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryFromResponse,
|
||||
getResolvedSceneHostileNpcs,
|
||||
getCampCompanionTravelScene,
|
||||
startOpeningAdventure,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isInitialCompanionEncounter,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
}: StoryInteractionCoordinatorParams) {
|
||||
const { buildNpcStory } = runtimeSupport;
|
||||
|
||||
const { handleTreasureInteraction } = useTreasureFlow(
|
||||
interactionConfig.treasureFlow,
|
||||
);
|
||||
const { inventoryUi } = useStoryInventoryActions(
|
||||
interactionConfig.inventoryFlow,
|
||||
);
|
||||
const npcInteractionFlow = useStoryNpcInteractionFlow(
|
||||
interactionConfig.npcInteractionFlow,
|
||||
);
|
||||
const { enterNpcInteraction, handleNpcInteraction, finalizeNpcBattleResult } =
|
||||
createStoryNpcEncounterActions({
|
||||
...interactionConfig.npcEncounterActions,
|
||||
npcInteractionFlow,
|
||||
});
|
||||
const choiceRuntimeController: Parameters<
|
||||
typeof useStoryChoiceCoordinator
|
||||
>[0]['runtimeController'] = {
|
||||
currentStory: interactionConfig.npcEncounterActions.currentStory,
|
||||
buildStoryContextFromState:
|
||||
interactionConfig.npcEncounterActions.buildStoryContextFromState,
|
||||
buildStoryFromResponse: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog?: StoryOption[] | null,
|
||||
) =>
|
||||
buildStoryFromResponse(
|
||||
state,
|
||||
character,
|
||||
response,
|
||||
availableOptions,
|
||||
optionCatalog,
|
||||
),
|
||||
buildFallbackStoryForState:
|
||||
interactionConfig.npcEncounterActions.buildFallbackStoryForState,
|
||||
generateStoryForState: async (params) =>
|
||||
interactionConfig.npcEncounterActions.generateStoryForState(params),
|
||||
getAvailableOptionsForState:
|
||||
interactionConfig.npcEncounterActions.getAvailableOptionsForState,
|
||||
getCampCompanionTravelScene: (state, character) =>
|
||||
getCampCompanionTravelScene(state, character),
|
||||
startOpeningAdventure: () => startOpeningAdventure(),
|
||||
commitGeneratedStateWithEncounterEntry: async (
|
||||
entryState,
|
||||
resolvedState,
|
||||
character,
|
||||
actionText,
|
||||
resultText,
|
||||
lastFunctionId,
|
||||
) => {
|
||||
await interactionConfig.npcEncounterActions.commitGeneratedStateWithEncounterEntry(
|
||||
entryState,
|
||||
resolvedState,
|
||||
character,
|
||||
actionText,
|
||||
resultText,
|
||||
lastFunctionId,
|
||||
);
|
||||
},
|
||||
};
|
||||
const { handleChoice, battleRewardUi, clearStoryChoiceUi } =
|
||||
useStoryChoiceCoordinator({
|
||||
gameState,
|
||||
isLoading,
|
||||
setGameState: interactionConfig.npcEncounterActions.setGameState,
|
||||
setCurrentStory: interactionConfig.npcEncounterActions.setCurrentStory,
|
||||
setAiError: interactionConfig.npcEncounterActions.setAiError,
|
||||
setIsLoading: interactionConfig.npcEncounterActions.setIsLoading,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
getStoryGenerationHostileNpcs:
|
||||
interactionConfig.npcEncounterActions.getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs,
|
||||
runtimeController: choiceRuntimeController,
|
||||
runtimeSupport,
|
||||
enterNpcInteraction,
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction,
|
||||
finalizeNpcBattleResult,
|
||||
sortOptions: interactionConfig.npcEncounterActions.sortOptions,
|
||||
buildContinueAdventureOption:
|
||||
interactionConfig.npcEncounterActions.buildContinueAdventureOption,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isInitialCompanionEncounter,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
});
|
||||
|
||||
const clearStoryInteractionUi = useCallback(
|
||||
createClearStoryInteractionUi({
|
||||
clearStoryChoiceUi,
|
||||
clearNpcInteractionUi: npcInteractionFlow.clearNpcInteractionUi,
|
||||
}),
|
||||
[clearStoryChoiceUi, npcInteractionFlow.clearNpcInteractionUi],
|
||||
);
|
||||
|
||||
return {
|
||||
handleChoice,
|
||||
battleRewardUi,
|
||||
npcUi: npcInteractionFlow.npcUi,
|
||||
inventoryUi,
|
||||
clearStoryInteractionUi,
|
||||
};
|
||||
}
|
||||
200
src/hooks/story/useStoryRuntimeController.ts
Normal file
200
src/hooks/story/useStoryRuntimeController.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
import { generateInitialStory, generateNextStep } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import { buildPreparedOpeningAdventure as buildPreparedOpeningAdventureState } from './openingAdventure';
|
||||
import {
|
||||
appendStoryHistory,
|
||||
createStoryProgressionActions,
|
||||
} from './progressionActions';
|
||||
import {
|
||||
buildCampCompanionOpeningResultText,
|
||||
buildInitialCompanionDialogueText,
|
||||
createCampCompanionStoryHelpers,
|
||||
} from './storyCampCompanion';
|
||||
import { useStoryBootstrap } from './storyBootstrap';
|
||||
import {
|
||||
createStoryStateResolvers,
|
||||
getStoryGenerationHostileNpcs,
|
||||
isInitialCompanionEncounter,
|
||||
isNpcEncounter,
|
||||
} from './storyEncounterState';
|
||||
import { getNpcEncounterKey } from './storyGenerationState';
|
||||
import {
|
||||
buildDialogueStoryMoment,
|
||||
buildStoryFromResponse as buildStoryFromResponseFromPresentation,
|
||||
getTypewriterDelay,
|
||||
hasRenderableDialogueTurns,
|
||||
} from './storyPresentation';
|
||||
import { buildNpcStory } from './storyRuntimeSupport';
|
||||
import { createGenerateStoryForState } from './storyRequestRuntime';
|
||||
import type { StoryContextBuilderExtras } from './storyContextBuilder';
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: StoryContextBuilderExtras,
|
||||
) => StoryGenerationContext;
|
||||
|
||||
export function useStoryRuntimeController(params: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
}) {
|
||||
const { gameState, setGameState, buildStoryContextFromState } = params;
|
||||
|
||||
const [currentStory, setCurrentStory] = useState<StoryMoment | null>(null);
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const {
|
||||
getCampCompanionTravelScene,
|
||||
buildCampCompanionOpeningOptions,
|
||||
inferOpeningCampFollowupOptions,
|
||||
buildOpeningCampChatContext,
|
||||
buildCampCompanionIdleStory,
|
||||
} = useMemo(
|
||||
() =>
|
||||
createCampCompanionStoryHelpers({
|
||||
buildNpcStory,
|
||||
buildStoryContextFromState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getNpcEncounterKey,
|
||||
generateNextStep,
|
||||
}),
|
||||
[buildStoryContextFromState],
|
||||
);
|
||||
|
||||
const { getAvailableOptionsForState, buildFallbackStoryForState } = useMemo(
|
||||
() =>
|
||||
createStoryStateResolvers({
|
||||
buildCampCompanionIdleOptions: buildCampCompanionIdleStory,
|
||||
buildNpcStory,
|
||||
}),
|
||||
[buildCampCompanionIdleStory],
|
||||
);
|
||||
|
||||
const buildStoryFromResponse = useCallback(
|
||||
(
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog: StoryOption[] | null = null,
|
||||
) =>
|
||||
buildStoryFromResponseFromPresentation({
|
||||
state,
|
||||
character,
|
||||
response,
|
||||
availableOptions,
|
||||
optionCatalog,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const generateStoryForState = useMemo(
|
||||
() =>
|
||||
createGenerateStoryForState({
|
||||
currentStory,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
requestInitialStory: generateInitialStory,
|
||||
requestNextStep: generateNextStep,
|
||||
onServerOptionCatalogLoadError: (error) => {
|
||||
console.warn(
|
||||
'[useStoryGeneration] failed to load server runtime option catalog',
|
||||
error,
|
||||
);
|
||||
},
|
||||
}),
|
||||
[
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
currentStory,
|
||||
getAvailableOptionsForState,
|
||||
],
|
||||
);
|
||||
|
||||
const appendHistory = useCallback(appendStoryHistory, []);
|
||||
|
||||
const prepareOpeningAdventure = useCallback(
|
||||
(state: GameState, character: Character) =>
|
||||
buildPreparedOpeningAdventureState({
|
||||
state,
|
||||
character,
|
||||
getNpcEncounterKey,
|
||||
appendHistory,
|
||||
buildCampCompanionOpeningOptions,
|
||||
buildCampCompanionOpeningResultText,
|
||||
buildInitialCompanionDialogueText,
|
||||
}),
|
||||
[appendHistory, buildCampCompanionOpeningOptions],
|
||||
);
|
||||
|
||||
const { commitGeneratedState, commitGeneratedStateWithEncounterEntry } =
|
||||
createStoryProgressionActions({
|
||||
gameState,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
generateStoryForState,
|
||||
buildFallbackStoryForState,
|
||||
});
|
||||
|
||||
const {
|
||||
preparedOpeningAdventure,
|
||||
startOpeningAdventure,
|
||||
resetPreparedOpeningAdventure,
|
||||
} = useStoryBootstrap({
|
||||
gameState,
|
||||
currentStory,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
prepareOpeningAdventure,
|
||||
getNpcEncounterKey,
|
||||
buildFallbackStoryForState,
|
||||
generateStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
buildStoryContextFromState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
hasRenderableDialogueTurns,
|
||||
inferOpeningCampFollowupOptions,
|
||||
getTypewriterDelay,
|
||||
isNpcEncounter,
|
||||
isInitialCompanionEncounter,
|
||||
});
|
||||
|
||||
return {
|
||||
currentStory,
|
||||
setCurrentStory,
|
||||
aiError,
|
||||
setAiError,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
preparedOpeningAdventure,
|
||||
startOpeningAdventure,
|
||||
resetPreparedOpeningAdventure,
|
||||
buildStoryContextFromState,
|
||||
buildDialogueStoryMoment,
|
||||
getTypewriterDelay,
|
||||
getCampCompanionTravelScene,
|
||||
buildOpeningCampChatContext,
|
||||
getAvailableOptionsForState,
|
||||
buildFallbackStoryForState,
|
||||
buildStoryFromResponse,
|
||||
generateStoryForState,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
};
|
||||
}
|
||||
|
||||
export type StoryRuntimeControllerResult = ReturnType<
|
||||
typeof useStoryRuntimeController
|
||||
>;
|
||||
@@ -1,150 +1,18 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getCharacterMaxHp, getCharacterMaxMana } from '../data/characterPresets';
|
||||
import { normalizeRoster } from '../data/companionRoster';
|
||||
import { getInitialPlayerCurrency } from '../data/economy';
|
||||
import {
|
||||
applyEquipmentLoadoutToState,
|
||||
buildInitialEquipmentLoadout,
|
||||
createEmptyEquipmentLoadout,
|
||||
} from '../data/equipmentEffects';
|
||||
import { normalizeNpcPersistentState } from '../data/npcInteractions';
|
||||
import { normalizeQuestLogEntries } from '../data/questFlow';
|
||||
import { normalizeGameRuntimeStats } from '../data/runtimeStats';
|
||||
import { ensureSceneEncounterPreview, TREASURE_ENCOUNTERS_ENABLED } from '../data/sceneEncounterPreviews';
|
||||
import type { SavedGameSnapshot } from '../persistence/gameSaveStorage';
|
||||
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
|
||||
import { isAbortError } from '../services/apiClient';
|
||||
import {
|
||||
deleteSaveSnapshot,
|
||||
getSaveSnapshot,
|
||||
putSaveSnapshot,
|
||||
} from '../services/storageService';
|
||||
import {
|
||||
applyStoryEngineMigration,
|
||||
buildSaveMigrationManifest,
|
||||
} from '../services/storyEngine/saveMigrationManifest';
|
||||
import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine';
|
||||
import { GameState, StoryMoment } from '../types';
|
||||
import { BottomTab } from './useGameFlow';
|
||||
import type { GameState, StoryMoment } from '../types';
|
||||
import type { BottomTab } from './useGameFlow';
|
||||
import { resumeServerRuntimeStory } from './story/runtimeStoryCoordinator';
|
||||
|
||||
const AUTO_SAVE_DELAY_MS = 400;
|
||||
|
||||
function normalizeSavedStory(story: StoryMoment | null) {
|
||||
if (!story) return null;
|
||||
return {
|
||||
...story,
|
||||
streaming: false,
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
function normalizeCharacterChats(gameState: GameState) {
|
||||
const entries = Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [
|
||||
characterId,
|
||||
{
|
||||
history: Array.isArray(record?.history)
|
||||
? record.history
|
||||
.filter(turn => turn && typeof turn.text === 'string' && (turn.speaker === 'player' || turn.speaker === 'character'))
|
||||
.map(turn => ({
|
||||
speaker: turn.speaker,
|
||||
text: turn.text,
|
||||
}))
|
||||
: [],
|
||||
summary: typeof record?.summary === 'string' ? record.summary : '',
|
||||
updatedAt: typeof record?.updatedAt === 'string' ? record.updatedAt : null,
|
||||
},
|
||||
] as const);
|
||||
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function normalizeSavedGameState(gameState: GameState) {
|
||||
const migrationManifest = buildSaveMigrationManifest({
|
||||
version: 'story-engine-v5',
|
||||
});
|
||||
const migratedState = applyStoryEngineMigration({
|
||||
state: gameState,
|
||||
manifest: migrationManifest,
|
||||
});
|
||||
const normalizedRoster = normalizeRoster(gameState.roster ?? [], gameState.companions ?? []);
|
||||
const normalizedEncounterState = !TREASURE_ENCOUNTERS_ENABLED && migratedState.currentEncounter?.kind === 'treasure'
|
||||
? ensureSceneEncounterPreview({
|
||||
...migratedState,
|
||||
currentEncounter: null,
|
||||
sceneHostileNpcs: [],
|
||||
inBattle: false,
|
||||
} as GameState)
|
||||
: migratedState;
|
||||
const normalizedRuntimeStats = normalizeGameRuntimeStats(normalizedEncounterState.runtimeStats, {
|
||||
isActiveRun: Boolean(
|
||||
normalizedEncounterState.playerCharacter &&
|
||||
normalizedEncounterState.currentScene === 'Story'
|
||||
),
|
||||
});
|
||||
const normalizedCommonState = {
|
||||
...normalizedEncounterState,
|
||||
customWorldProfile: normalizedEncounterState.customWorldProfile ?? null,
|
||||
runtimeStats: normalizedRuntimeStats,
|
||||
storyEngineMemory:
|
||||
normalizedEncounterState.storyEngineMemory ??
|
||||
createEmptyStoryEngineMemoryState(),
|
||||
chapterState:
|
||||
normalizedEncounterState.chapterState
|
||||
?? normalizedEncounterState.storyEngineMemory?.currentChapter
|
||||
?? null,
|
||||
campaignState:
|
||||
normalizedEncounterState.campaignState
|
||||
?? normalizedEncounterState.storyEngineMemory?.campaignState
|
||||
?? null,
|
||||
activeScenarioPackId:
|
||||
normalizedEncounterState.activeScenarioPackId
|
||||
?? normalizedEncounterState.customWorldProfile?.scenarioPackId
|
||||
?? null,
|
||||
activeCampaignPackId:
|
||||
normalizedEncounterState.activeCampaignPackId
|
||||
?? normalizedEncounterState.customWorldProfile?.campaignPackId
|
||||
?? null,
|
||||
npcInteractionActive: normalizedEncounterState.npcInteractionActive ?? false,
|
||||
playerCurrency: typeof gameState.playerCurrency === 'number'
|
||||
? gameState.playerCurrency
|
||||
: getInitialPlayerCurrency(gameState.worldType),
|
||||
quests: normalizeQuestLogEntries(normalizedEncounterState.quests ?? []),
|
||||
roster: normalizedRoster,
|
||||
npcStates: Object.fromEntries(
|
||||
Object.entries(normalizedEncounterState.npcStates ?? {}).map(([npcId, npcState]) => [
|
||||
npcId,
|
||||
normalizeNpcPersistentState(npcState),
|
||||
]),
|
||||
),
|
||||
characterChats: normalizeCharacterChats(normalizedEncounterState),
|
||||
activeBuildBuffs: normalizedEncounterState.activeBuildBuffs ?? [],
|
||||
} satisfies GameState;
|
||||
|
||||
if (!normalizedEncounterState.playerCharacter) {
|
||||
return {
|
||||
...normalizedCommonState,
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
} satisfies GameState;
|
||||
}
|
||||
|
||||
const resolvedEquipment = normalizedEncounterState.playerEquipment
|
||||
? normalizedEncounterState.playerEquipment
|
||||
: buildInitialEquipmentLoadout(normalizedEncounterState.playerCharacter);
|
||||
|
||||
const playerMaxHp = getCharacterMaxHp(
|
||||
normalizedEncounterState.playerCharacter,
|
||||
normalizedEncounterState.worldType,
|
||||
normalizedEncounterState.customWorldProfile,
|
||||
);
|
||||
|
||||
return applyEquipmentLoadoutToState({
|
||||
...normalizedCommonState,
|
||||
playerMaxHp,
|
||||
playerHp: Math.min(normalizedEncounterState.playerHp, playerMaxHp),
|
||||
playerMaxMana: getCharacterMaxMana(normalizedEncounterState.playerCharacter),
|
||||
playerMana: getCharacterMaxMana(normalizedEncounterState.playerCharacter),
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
} as GameState, resolvedEquipment);
|
||||
}
|
||||
|
||||
function canPersistSnapshot(gameState: GameState, story: StoryMoment | null) {
|
||||
return (
|
||||
gameState.currentScene === 'Story' &&
|
||||
@@ -154,6 +22,22 @@ function canPersistSnapshot(gameState: GameState, story: StoryMoment | null) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBottomTab(bottomTab: string | null | undefined): BottomTab {
|
||||
if (bottomTab === 'character' || bottomTab === 'inventory') {
|
||||
return bottomTab;
|
||||
}
|
||||
|
||||
return 'adventure';
|
||||
}
|
||||
|
||||
function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) {
|
||||
return {
|
||||
gameState: snapshot.gameState,
|
||||
currentStory: snapshot.currentStory ?? null,
|
||||
bottomTab: normalizeBottomTab(snapshot.bottomTab),
|
||||
};
|
||||
}
|
||||
|
||||
export function useGamePersistence({
|
||||
gameState,
|
||||
bottomTab,
|
||||
@@ -174,93 +58,202 @@ export function useGamePersistence({
|
||||
resetStoryState: () => void;
|
||||
}) {
|
||||
const [hasSavedGame, setHasSavedGame] = useState(false);
|
||||
const [savedSnapshot, setSavedSnapshot] = useState<SavedGameSnapshot | null>(null);
|
||||
const [savedSnapshot, setSavedSnapshot] =
|
||||
useState<HydratedSavedGameSnapshot | null>(null);
|
||||
const [isHydratingSnapshot, setIsHydratingSnapshot] = useState(true);
|
||||
const [isPersistingSnapshot, setIsPersistingSnapshot] = useState(false);
|
||||
const [persistenceError, setPersistenceError] = useState<string | null>(null);
|
||||
const hydrateControllerRef = useRef<AbortController | null>(null);
|
||||
const saveControllerRef = useRef<AbortController | null>(null);
|
||||
const saveRequestIdRef = useRef(0);
|
||||
|
||||
const abortActiveSave = useCallback(() => {
|
||||
saveControllerRef.current?.abort();
|
||||
saveControllerRef.current = null;
|
||||
setIsPersistingSnapshot(false);
|
||||
}, []);
|
||||
|
||||
const persistSnapshot = useCallback(
|
||||
async (params: {
|
||||
payload: {
|
||||
gameState: GameState;
|
||||
bottomTab: BottomTab;
|
||||
currentStory: StoryMoment | null;
|
||||
};
|
||||
logLabel: string;
|
||||
}) => {
|
||||
abortActiveSave();
|
||||
|
||||
const requestId = saveRequestIdRef.current + 1;
|
||||
saveRequestIdRef.current = requestId;
|
||||
const controller = new AbortController();
|
||||
saveControllerRef.current = controller;
|
||||
setIsPersistingSnapshot(true);
|
||||
setPersistenceError(null);
|
||||
|
||||
try {
|
||||
const snapshot = await putSaveSnapshot(
|
||||
{
|
||||
gameState: params.payload.gameState,
|
||||
bottomTab: params.payload.bottomTab,
|
||||
currentStory: params.payload.currentStory,
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
if (saveRequestIdRef.current !== requestId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setSavedSnapshot(snapshot);
|
||||
setHasSavedGame(true);
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message =
|
||||
error instanceof Error ? error.message : '远端存档同步失败';
|
||||
if (saveRequestIdRef.current === requestId) {
|
||||
setPersistenceError(message);
|
||||
}
|
||||
console.warn(`[useGamePersistence] ${params.logLabel}`, error);
|
||||
return null;
|
||||
} finally {
|
||||
if (saveControllerRef.current === controller) {
|
||||
saveControllerRef.current = null;
|
||||
setIsPersistingSnapshot(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[abortActiveSave],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
const controller = new AbortController();
|
||||
hydrateControllerRef.current = controller;
|
||||
setIsHydratingSnapshot(true);
|
||||
|
||||
void getSaveSnapshot()
|
||||
void getSaveSnapshot({ signal: controller.signal })
|
||||
.then((snapshot) => {
|
||||
if (!isActive) return;
|
||||
setSavedSnapshot(snapshot);
|
||||
setHasSavedGame(Boolean(snapshot));
|
||||
setPersistenceError(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[useGamePersistence] failed to load remote snapshot', error);
|
||||
if (isAbortError(error)) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : '读取远端存档失败';
|
||||
setPersistenceError(message);
|
||||
console.warn(
|
||||
'[useGamePersistence] failed to load remote snapshot',
|
||||
error,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (hydrateControllerRef.current === controller) {
|
||||
hydrateControllerRef.current = null;
|
||||
setIsHydratingSnapshot(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
controller.abort();
|
||||
if (hydrateControllerRef.current === controller) {
|
||||
hydrateControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
hydrateControllerRef.current?.abort();
|
||||
saveControllerRef.current?.abort();
|
||||
saveControllerRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const canPersist = !isLoading && canPersistSnapshot(gameState, currentStory);
|
||||
const canPersist =
|
||||
!isLoading && canPersistSnapshot(gameState, currentStory);
|
||||
|
||||
if (!canPersist) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void putSaveSnapshot({
|
||||
gameState,
|
||||
bottomTab,
|
||||
currentStory,
|
||||
})
|
||||
.then((snapshot) => {
|
||||
setSavedSnapshot(snapshot);
|
||||
setHasSavedGame(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[useGamePersistence] failed to autosave remote snapshot', error);
|
||||
});
|
||||
void persistSnapshot({
|
||||
payload: {
|
||||
gameState,
|
||||
bottomTab,
|
||||
currentStory,
|
||||
},
|
||||
logLabel: 'failed to autosave remote snapshot',
|
||||
});
|
||||
}, AUTO_SAVE_DELAY_MS);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [bottomTab, currentStory, gameState, isLoading]);
|
||||
}, [bottomTab, currentStory, gameState, isLoading, persistSnapshot]);
|
||||
|
||||
const saveCurrentGame = useCallback(async (override?: {
|
||||
gameState?: GameState;
|
||||
bottomTab?: BottomTab;
|
||||
currentStory?: StoryMoment | null;
|
||||
}) => {
|
||||
const nextGameState = override?.gameState ?? gameState;
|
||||
const nextBottomTab = override?.bottomTab ?? bottomTab;
|
||||
const nextStory = override?.currentStory ?? currentStory;
|
||||
const saveCurrentGame = useCallback(
|
||||
async (override?: {
|
||||
gameState?: GameState;
|
||||
bottomTab?: BottomTab;
|
||||
currentStory?: StoryMoment | null;
|
||||
}) => {
|
||||
const nextGameState = override?.gameState ?? gameState;
|
||||
const nextBottomTab = override?.bottomTab ?? bottomTab;
|
||||
const nextStory = override?.currentStory ?? currentStory;
|
||||
|
||||
if (!canPersistSnapshot(nextGameState, nextStory)) {
|
||||
return false;
|
||||
}
|
||||
if (!canPersistSnapshot(nextGameState, nextStory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await putSaveSnapshot({
|
||||
gameState: nextGameState,
|
||||
bottomTab: nextBottomTab,
|
||||
currentStory: nextStory,
|
||||
const snapshot = await persistSnapshot({
|
||||
payload: {
|
||||
gameState: nextGameState,
|
||||
bottomTab: nextBottomTab,
|
||||
currentStory: nextStory,
|
||||
},
|
||||
logLabel: 'failed to save remote snapshot',
|
||||
});
|
||||
setSavedSnapshot(snapshot);
|
||||
setHasSavedGame(true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('[useGamePersistence] failed to save remote snapshot', error);
|
||||
return false;
|
||||
}
|
||||
}, [bottomTab, currentStory, gameState]);
|
||||
|
||||
return Boolean(snapshot);
|
||||
},
|
||||
[bottomTab, currentStory, gameState, persistSnapshot],
|
||||
);
|
||||
|
||||
const clearSavedGame = useCallback(async () => {
|
||||
abortActiveSave();
|
||||
|
||||
try {
|
||||
await deleteSaveSnapshot();
|
||||
setPersistenceError(null);
|
||||
} catch (error) {
|
||||
console.warn('[useGamePersistence] failed to delete remote snapshot', error);
|
||||
console.warn(
|
||||
'[useGamePersistence] failed to delete remote snapshot',
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
setSavedSnapshot(null);
|
||||
setHasSavedGame(false);
|
||||
}, []);
|
||||
}, [abortActiveSave]);
|
||||
|
||||
const continueSavedGame = useCallback(async () => {
|
||||
const snapshot = savedSnapshot ?? await getSaveSnapshot().catch((error) => {
|
||||
console.warn('[useGamePersistence] failed to refetch remote snapshot', error);
|
||||
return null;
|
||||
});
|
||||
const snapshot =
|
||||
savedSnapshot ??
|
||||
(await getSaveSnapshot().catch((error) => {
|
||||
if (!isAbortError(error)) {
|
||||
console.warn(
|
||||
'[useGamePersistence] failed to refetch remote snapshot',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
if (!snapshot) {
|
||||
setSavedSnapshot(null);
|
||||
setHasSavedGame(false);
|
||||
@@ -268,16 +261,44 @@ export function useGamePersistence({
|
||||
}
|
||||
|
||||
resetStoryState();
|
||||
setGameState(normalizeSavedGameState(snapshot.gameState));
|
||||
setBottomTab(snapshot.bottomTab ?? 'adventure');
|
||||
hydrateStoryState(normalizeSavedStory(snapshot.currentStory));
|
||||
const fallbackHydration = resolveRemoteSnapshotState(snapshot);
|
||||
|
||||
const resumedState = await resumeServerRuntimeStory(snapshot).catch(
|
||||
(error) => {
|
||||
if (!isAbortError(error)) {
|
||||
console.warn(
|
||||
'[useGamePersistence] failed to refresh runtime story state from server',
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
hydratedSnapshot: fallbackHydration,
|
||||
nextStory: fallbackHydration.currentStory,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
setGameState(resumedState.hydratedSnapshot.gameState);
|
||||
setBottomTab(normalizeBottomTab(resumedState.hydratedSnapshot.bottomTab));
|
||||
hydrateStoryState(resumedState.nextStory);
|
||||
setSavedSnapshot(snapshot);
|
||||
setHasSavedGame(true);
|
||||
setPersistenceError(null);
|
||||
return true;
|
||||
}, [hydrateStoryState, resetStoryState, savedSnapshot, setBottomTab, setGameState]);
|
||||
}, [
|
||||
hydrateStoryState,
|
||||
resetStoryState,
|
||||
savedSnapshot,
|
||||
setBottomTab,
|
||||
setGameState,
|
||||
]);
|
||||
|
||||
return {
|
||||
hasSavedGame,
|
||||
isHydratingSnapshot,
|
||||
isPersistingSnapshot,
|
||||
persistenceError,
|
||||
saveCurrentGame,
|
||||
continueSavedGame,
|
||||
clearSavedGame,
|
||||
|
||||
@@ -4,37 +4,71 @@ import {
|
||||
clampVolume,
|
||||
DEFAULT_MUSIC_VOLUME,
|
||||
} from '../persistence/gameSettingsStorage';
|
||||
import { isAbortError } from '../services/apiClient';
|
||||
import { getSettings, putSettings } from '../services/storageService';
|
||||
|
||||
const SETTINGS_SYNC_DELAY_MS = 180;
|
||||
|
||||
export function useGameSettings() {
|
||||
const [musicVolume, setMusicVolumeState] = useState(DEFAULT_MUSIC_VOLUME);
|
||||
const [hasHydratedSettings, setHasHydratedSettings] = useState(false);
|
||||
const [isHydratingSettings, setIsHydratingSettings] = useState(true);
|
||||
const [isPersistingSettings, setIsPersistingSettings] = useState(false);
|
||||
const [settingsError, setSettingsError] = useState<string | null>(null);
|
||||
const lastSyncedVolumeRef = useRef(DEFAULT_MUSIC_VOLUME);
|
||||
const hydrateControllerRef = useRef<AbortController | null>(null);
|
||||
const persistControllerRef = useRef<AbortController | null>(null);
|
||||
const persistRequestIdRef = useRef(0);
|
||||
|
||||
const abortActivePersist = useCallback(() => {
|
||||
persistControllerRef.current?.abort();
|
||||
persistControllerRef.current = null;
|
||||
setIsPersistingSettings(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
const controller = new AbortController();
|
||||
hydrateControllerRef.current = controller;
|
||||
setIsHydratingSettings(true);
|
||||
|
||||
void getSettings()
|
||||
void getSettings({ signal: controller.signal })
|
||||
.then((settings) => {
|
||||
if (!isActive) return;
|
||||
const nextVolume = clampVolume(settings.musicVolume);
|
||||
lastSyncedVolumeRef.current = nextVolume;
|
||||
setMusicVolumeState(nextVolume);
|
||||
setSettingsError(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isAbortError(error)) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : '读取远端设置失败';
|
||||
setSettingsError(message);
|
||||
console.warn('[useGameSettings] failed to load remote settings', error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (isActive) {
|
||||
if (hydrateControllerRef.current === controller) {
|
||||
hydrateControllerRef.current = null;
|
||||
setIsHydratingSettings(false);
|
||||
setHasHydratedSettings(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
controller.abort();
|
||||
if (hydrateControllerRef.current === controller) {
|
||||
hydrateControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
hydrateControllerRef.current?.abort();
|
||||
persistControllerRef.current?.abort();
|
||||
persistControllerRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasHydratedSettings) {
|
||||
return;
|
||||
@@ -44,21 +78,49 @@ export function useGameSettings() {
|
||||
return;
|
||||
}
|
||||
|
||||
let isActive = true;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
abortActivePersist();
|
||||
|
||||
void putSettings({musicVolume})
|
||||
.then((settings) => {
|
||||
if (!isActive) return;
|
||||
lastSyncedVolumeRef.current = clampVolume(settings.musicVolume);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[useGameSettings] failed to persist remote settings', error);
|
||||
});
|
||||
const requestId = persistRequestIdRef.current + 1;
|
||||
persistRequestIdRef.current = requestId;
|
||||
const controller = new AbortController();
|
||||
persistControllerRef.current = controller;
|
||||
setIsPersistingSettings(true);
|
||||
setSettingsError(null);
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [hasHydratedSettings, musicVolume]);
|
||||
void putSettings({ musicVolume }, { signal: controller.signal })
|
||||
.then((settings) => {
|
||||
if (persistRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextVolume = clampVolume(settings.musicVolume);
|
||||
lastSyncedVolumeRef.current = nextVolume;
|
||||
setMusicVolumeState((currentValue) =>
|
||||
currentValue === nextVolume ? currentValue : nextVolume,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isAbortError(error)) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : '保存远端设置失败';
|
||||
if (persistRequestIdRef.current === requestId) {
|
||||
setSettingsError(message);
|
||||
}
|
||||
console.warn('[useGameSettings] failed to persist remote settings', error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (persistControllerRef.current === controller) {
|
||||
persistControllerRef.current = null;
|
||||
setIsPersistingSettings(false);
|
||||
}
|
||||
});
|
||||
}, SETTINGS_SYNC_DELAY_MS);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [abortActivePersist, hasHydratedSettings, musicVolume]);
|
||||
|
||||
const setMusicVolume = useCallback((value: number) => {
|
||||
setMusicVolumeState(clampVolume(value));
|
||||
@@ -67,5 +129,9 @@ export function useGameSettings() {
|
||||
return {
|
||||
musicVolume,
|
||||
setMusicVolume,
|
||||
hasHydratedSettings,
|
||||
isHydratingSettings,
|
||||
isPersistingSettings,
|
||||
settingsError,
|
||||
};
|
||||
}
|
||||
|
||||
179
src/hooks/useGameShellRuntime.ts
Normal file
179
src/hooks/useGameShellRuntime.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { GameShellProps } from '../components/game-shell/types';
|
||||
import { activateRosterCompanion, benchActiveCompanion } from '../data/companionRoster';
|
||||
import { syncGameStatePlayTime } from '../data/runtimeStats';
|
||||
import { useBackgroundMusic } from './useBackgroundMusic';
|
||||
import { useCombatFlow } from './useCombatFlow';
|
||||
import { useGameFlow } from './useGameFlow';
|
||||
import { useGamePersistence } from './useGamePersistence';
|
||||
import { useGameSettings } from './useGameSettings';
|
||||
import { useNpcInteractionFlow } from './useNpcInteractionFlow';
|
||||
import { useStoryGeneration } from './useStoryGeneration';
|
||||
|
||||
export function useGameShellRuntime(): GameShellProps {
|
||||
const {
|
||||
gameState,
|
||||
setGameState,
|
||||
bottomTab,
|
||||
setBottomTab,
|
||||
isMapOpen,
|
||||
setIsMapOpen,
|
||||
resetGame,
|
||||
handleCustomWorldSelect: selectCustomWorld,
|
||||
handleBackToWorldSelect: backToWorldSelect,
|
||||
handleCharacterSelect: selectCharacter,
|
||||
} = useGameFlow();
|
||||
|
||||
const combatFlow = useCombatFlow({
|
||||
setGameState,
|
||||
});
|
||||
|
||||
const storyFlow = useStoryGeneration({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildResolvedChoiceState: combatFlow.buildResolvedChoiceState,
|
||||
playResolvedChoice: combatFlow.playResolvedChoice,
|
||||
});
|
||||
|
||||
const { companionRenderStates, buildCompanionRenderStates } =
|
||||
useNpcInteractionFlow(gameState);
|
||||
const settings = useGameSettings();
|
||||
|
||||
const persistence = useGamePersistence({
|
||||
gameState,
|
||||
bottomTab,
|
||||
currentStory: storyFlow.currentStory,
|
||||
isLoading: storyFlow.isLoading,
|
||||
setGameState,
|
||||
setBottomTab,
|
||||
hydrateStoryState: storyFlow.hydrateStoryState,
|
||||
resetStoryState: storyFlow.resetStoryState,
|
||||
});
|
||||
|
||||
useBackgroundMusic({
|
||||
active: Boolean(
|
||||
gameState.playerCharacter && gameState.currentScene === 'Story',
|
||||
),
|
||||
volume: settings.musicVolume,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
setGameState((currentState) => {
|
||||
if (
|
||||
!currentState.playerCharacter ||
|
||||
currentState.currentScene !== 'Story'
|
||||
) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return syncGameStatePlayTime(currentState);
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [gameState.currentScene, gameState.playerCharacter, setGameState]);
|
||||
|
||||
const handleCustomWorldSelect = (
|
||||
customWorldProfile: Parameters<typeof selectCustomWorld>[0],
|
||||
) => {
|
||||
storyFlow.resetStoryState();
|
||||
selectCustomWorld(customWorldProfile);
|
||||
};
|
||||
|
||||
const handleCharacterSelect = (
|
||||
character: Parameters<typeof selectCharacter>[0],
|
||||
) => {
|
||||
storyFlow.resetStoryState();
|
||||
selectCharacter(character);
|
||||
};
|
||||
|
||||
const handleBackToWorldSelect = () => {
|
||||
storyFlow.resetStoryState();
|
||||
backToWorldSelect();
|
||||
};
|
||||
|
||||
const handleContinueGame = () => {
|
||||
void persistence.continueSavedGame();
|
||||
};
|
||||
|
||||
const handleStartNewGame = () => {
|
||||
void persistence.clearSavedGame();
|
||||
storyFlow.resetStoryState();
|
||||
resetGame();
|
||||
};
|
||||
|
||||
const handleSaveAndExit = () => {
|
||||
const syncedGameState = syncGameStatePlayTime(gameState);
|
||||
void persistence.saveCurrentGame({
|
||||
gameState: syncedGameState,
|
||||
bottomTab,
|
||||
currentStory: storyFlow.currentStory,
|
||||
});
|
||||
storyFlow.resetStoryState();
|
||||
resetGame();
|
||||
};
|
||||
|
||||
const handleBenchCompanion = (npcId: string) => {
|
||||
setGameState((currentState) => benchActiveCompanion(currentState, npcId));
|
||||
};
|
||||
|
||||
const handleActivateRosterCompanion = (
|
||||
npcId: string,
|
||||
swapNpcId?: string | null,
|
||||
) => {
|
||||
setGameState((currentState) =>
|
||||
activateRosterCompanion(currentState, npcId, swapNpcId),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
session: {
|
||||
gameState,
|
||||
currentStory: storyFlow.currentStory,
|
||||
isLoading: storyFlow.isLoading,
|
||||
aiError: storyFlow.aiError,
|
||||
bottomTab,
|
||||
setBottomTab,
|
||||
isMapOpen,
|
||||
setIsMapOpen,
|
||||
},
|
||||
story: {
|
||||
displayedOptions: storyFlow.displayedOptions,
|
||||
canRefreshOptions: storyFlow.canRefreshOptions,
|
||||
handleRefreshOptions: storyFlow.handleRefreshOptions,
|
||||
handleChoice: storyFlow.handleChoice,
|
||||
handleMapTravelToScene: storyFlow.travelToSceneFromMap,
|
||||
npcUi: storyFlow.npcUi,
|
||||
characterChatUi: storyFlow.characterChatUi,
|
||||
inventoryUi: storyFlow.inventoryUi,
|
||||
battleRewardUi: storyFlow.battleRewardUi,
|
||||
questUi: storyFlow.questUi,
|
||||
goalUi: storyFlow.goalUi,
|
||||
},
|
||||
entry: {
|
||||
hasSavedGame: persistence.hasSavedGame,
|
||||
handleContinueGame,
|
||||
handleStartNewGame,
|
||||
handleSaveAndExit,
|
||||
handleCustomWorldSelect,
|
||||
handleBackToWorldSelect,
|
||||
handleCharacterSelect,
|
||||
},
|
||||
companions: {
|
||||
companionRenderStates,
|
||||
buildCompanionRenderStates,
|
||||
onBenchCompanion: handleBenchCompanion,
|
||||
onActivateRosterCompanion: handleActivateRosterCompanion,
|
||||
},
|
||||
audio: {
|
||||
musicVolume: settings.musicVolume,
|
||||
onMusicVolumeChange: settings.setMusicVolume,
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,97 +1,73 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { addInventoryItems } from '../data/npcInteractions';
|
||||
import {
|
||||
buildTreasureEncounterStoryMoment,
|
||||
buildTreasureResultText,
|
||||
resolveTreasureReward,
|
||||
} from '../data/treasureInteractions';
|
||||
import { appendStoryEngineCarrierMemory } from '../services/storyEngine/echoMemory';
|
||||
import { Character, Encounter, GameState, StoryMoment, StoryOption } from '../types';
|
||||
import type {CommitGeneratedState} from './generatedState';
|
||||
|
||||
type ProgressTreasureQuest = (state: GameState, sceneId: string | null) => GameState;
|
||||
|
||||
export function isTreasureEncounter(encounter: GameState['currentEncounter']): encounter is Encounter {
|
||||
return Boolean(encounter?.kind === 'treasure');
|
||||
}
|
||||
|
||||
export function buildTreasureStory(
|
||||
state: GameState,
|
||||
_character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
): StoryMoment {
|
||||
return buildTreasureEncounterStoryMoment({
|
||||
state,
|
||||
encounter,
|
||||
overrideText,
|
||||
});
|
||||
}
|
||||
import { resolveServerRuntimeChoice } from './story/runtimeStoryCoordinator';
|
||||
import { Character, GameState, StoryMoment, StoryOption } from '../types';
|
||||
|
||||
export function useTreasureFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
progressTreasureQuest,
|
||||
runtime,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
progressTreasureQuest: ProgressTreasureQuest;
|
||||
runtime: {
|
||||
currentStory: StoryMoment | null;
|
||||
setGameState: (state: GameState) => void;
|
||||
setCurrentStory: (story: StoryMoment) => void;
|
||||
setAiError: (message: string | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
buildFallbackStoryForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
};
|
||||
}) {
|
||||
const handleTreasureInteraction = useCallback((option: StoryOption) => {
|
||||
if (!gameState.playerCharacter || option.interaction?.kind !== 'treasure' || gameState.currentEncounter?.kind !== 'treasure') {
|
||||
return false;
|
||||
}
|
||||
const handleTreasureInteraction = useCallback(
|
||||
async (option: StoryOption) => {
|
||||
if (
|
||||
!gameState.playerCharacter ||
|
||||
option.interaction?.kind !== 'treasure' ||
|
||||
gameState.currentEncounter?.kind !== 'treasure'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const encounter = gameState.currentEncounter;
|
||||
const action = option.interaction.action;
|
||||
const reward = action === 'leave'
|
||||
? null
|
||||
: resolveTreasureReward(gameState, encounter, action);
|
||||
const progressedState = action === 'leave'
|
||||
? gameState
|
||||
: progressTreasureQuest(gameState, gameState.currentScenePreset?.id ?? null);
|
||||
runtime.setAiError(null);
|
||||
runtime.setIsLoading(true);
|
||||
|
||||
const nextState: GameState = appendStoryEngineCarrierMemory({
|
||||
...progressedState,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: progressedState.animationState,
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: reward
|
||||
? Math.min(progressedState.playerMaxHp, progressedState.playerHp + reward.hp)
|
||||
: progressedState.playerHp,
|
||||
playerMana: reward
|
||||
? Math.min(progressedState.playerMaxMana, progressedState.playerMana + reward.mana)
|
||||
: progressedState.playerMana,
|
||||
playerCurrency: reward
|
||||
? progressedState.playerCurrency + reward.currency
|
||||
: progressedState.playerCurrency,
|
||||
playerInventory: reward
|
||||
? addInventoryItems(progressedState.playerInventory, reward.items)
|
||||
: progressedState.playerInventory,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
}, reward?.items ?? []);
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } =
|
||||
await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory: runtime.currentStory,
|
||||
option,
|
||||
});
|
||||
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
option.actionText,
|
||||
buildTreasureResultText(encounter, action, reward ?? undefined),
|
||||
option.functionId,
|
||||
);
|
||||
return true;
|
||||
}, [commitGeneratedState, gameState, progressTreasureQuest]);
|
||||
runtime.setGameState(hydratedSnapshot.gameState);
|
||||
runtime.setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to resolve treasure runtime action on the server:',
|
||||
error,
|
||||
);
|
||||
runtime.setAiError(
|
||||
error instanceof Error ? error.message : '宝藏动作执行失败',
|
||||
);
|
||||
if (!runtime.currentStory) {
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildFallbackStoryForState(
|
||||
gameState,
|
||||
gameState.playerCharacter,
|
||||
),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
runtime.setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[gameState, runtime],
|
||||
);
|
||||
|
||||
return {
|
||||
handleTreasureInteraction,
|
||||
|
||||
Reference in New Issue
Block a user