1
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import {type Dispatch, type SetStateAction,useState} from 'react';
|
||||
import {type Dispatch, type SetStateAction,useState} from 'react';
|
||||
|
||||
import {
|
||||
generateCharacterPanelChatSuggestions,
|
||||
@@ -1,23 +1,17 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../services/aiService', () => ({
|
||||
generateNextStep: vi.fn(),
|
||||
}));
|
||||
|
||||
const {
|
||||
isServerRuntimeFunctionIdMock,
|
||||
resolveServerRuntimeChoiceMock,
|
||||
isRpgRuntimeServerFunctionIdMock,
|
||||
} = vi.hoisted(() => ({
|
||||
isServerRuntimeFunctionIdMock: vi.fn(() => false),
|
||||
resolveServerRuntimeChoiceMock: vi.fn(),
|
||||
isRpgRuntimeServerFunctionIdMock: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock('./runtimeStoryCoordinator', () => ({
|
||||
resolveServerRuntimeChoice: resolveServerRuntimeChoiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/runtimeStoryService', () => ({
|
||||
isServerRuntimeFunctionId: isServerRuntimeFunctionIdMock,
|
||||
vi.mock('../../services/rpg-runtime', () => ({
|
||||
isRpgRuntimeServerFunctionId: isRpgRuntimeServerFunctionIdMock,
|
||||
}));
|
||||
|
||||
import { generateNextStep } from '../../services/aiService';
|
||||
@@ -150,9 +144,8 @@ const neverNpcEncounter = (
|
||||
|
||||
describe('createStoryChoiceActions', () => {
|
||||
beforeEach(() => {
|
||||
isServerRuntimeFunctionIdMock.mockReset();
|
||||
isServerRuntimeFunctionIdMock.mockReturnValue(false);
|
||||
resolveServerRuntimeChoiceMock.mockReset();
|
||||
isRpgRuntimeServerFunctionIdMock.mockReset();
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('reveals deferred adventure options when story_continue_adventure is selected', async () => {
|
||||
@@ -251,7 +244,6 @@ describe('createStoryChoiceActions', () => {
|
||||
});
|
||||
expect(generateStoryForState).not.toHaveBeenCalled();
|
||||
expect(handleNpcInteraction).not.toHaveBeenCalled();
|
||||
expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps npc chat choices on the local UI path so chat mode can continue streaming locally', async () => {
|
||||
@@ -261,7 +253,7 @@ describe('createStoryChoiceActions', () => {
|
||||
const setCurrentStory = vi.fn();
|
||||
const handleNpcInteraction = vi.fn(() => true);
|
||||
|
||||
isServerRuntimeFunctionIdMock.mockReturnValue(true);
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: {
|
||||
@@ -324,7 +316,6 @@ describe('createStoryChoiceActions', () => {
|
||||
functionId: 'npc_chat',
|
||||
}),
|
||||
);
|
||||
expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
|
||||
expect(setGameState).not.toHaveBeenCalled();
|
||||
expect(setCurrentStory).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -366,7 +357,7 @@ describe('createStoryChoiceActions', () => {
|
||||
};
|
||||
const handleNpcInteraction = vi.fn(() => true);
|
||||
|
||||
isServerRuntimeFunctionIdMock.mockReturnValue(true);
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
@@ -410,7 +401,6 @@ describe('createStoryChoiceActions', () => {
|
||||
await handleChoice(option);
|
||||
|
||||
expect(handleNpcInteraction).toHaveBeenCalledWith(option);
|
||||
expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reopens npc chat instead of running generic follow-up after local npc victory', async () => {
|
||||
@@ -1,10 +1,10 @@
|
||||
import type {
|
||||
import type {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { isServerRuntimeFunctionId } from '../../services/runtimeStoryService';
|
||||
import { isRpgRuntimeServerFunctionId } from '../../services/rpg-runtime';
|
||||
import {
|
||||
type Character,
|
||||
type Encounter,
|
||||
@@ -200,7 +200,7 @@ export function createStoryChoiceActions({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isServerRuntimeFunctionId(option.functionId)) {
|
||||
if (isRpgRuntimeServerFunctionId(option.functionId)) {
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildGoalStackState,
|
||||
52
src/hooks/rpg-runtime-story/index.ts
Normal file
52
src/hooks/rpg-runtime-story/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export {
|
||||
loadRpgRuntimeOptionCatalog,
|
||||
resolveRpgRuntimeChoice,
|
||||
resumeRpgRuntimeStory,
|
||||
type LoadRpgRuntimeOptionCatalogParams,
|
||||
type ResolveRpgRuntimeChoiceParams,
|
||||
} from './rpgRuntimeStoryGateway';
|
||||
export {
|
||||
useRpgRuntimeInteractionFlow,
|
||||
createRpgRuntimeInteractionUiResetter,
|
||||
type RpgRuntimeInteractionFlowResult,
|
||||
type UseRpgRuntimeInteractionFlowParams,
|
||||
} from './useRpgRuntimeInteractionFlow';
|
||||
export {
|
||||
useRpgRuntimeNpcInteraction,
|
||||
type RpgRuntimeNpcInteractionResult,
|
||||
type UseRpgRuntimeNpcInteractionParams,
|
||||
} from './useRpgRuntimeNpcInteraction';
|
||||
export {
|
||||
useRpgRuntimeStory,
|
||||
type BattleRewardSummary,
|
||||
type BattleRewardUi,
|
||||
type CharacterChatModalState,
|
||||
type CharacterChatTarget,
|
||||
type CharacterChatUi,
|
||||
type GiftModalState,
|
||||
type GoalFlowUi,
|
||||
type InventoryFlowUi,
|
||||
type NpcChatQuestOfferUi,
|
||||
type QuestFlowUi,
|
||||
type RecruitModalState,
|
||||
type RpgRuntimeStoryResult,
|
||||
type StoryGenerationNpcUi,
|
||||
type TradeModalState,
|
||||
type UseRpgRuntimeStoryParams,
|
||||
} from './useRpgRuntimeStory';
|
||||
export {
|
||||
useRpgRuntimeStoryController,
|
||||
type RpgRuntimeStoryControllerResult,
|
||||
type UseRpgRuntimeStoryControllerParams,
|
||||
} from './useRpgRuntimeStoryController';
|
||||
export {
|
||||
useRpgRuntimeStoryFlow,
|
||||
type RpgRuntimeStoryFlowResult,
|
||||
type UseRpgRuntimeStoryFlowParams,
|
||||
} from './useRpgRuntimeStoryFlow';
|
||||
export {
|
||||
createRpgRuntimeStoryUiResetter,
|
||||
useRpgRuntimeStoryState,
|
||||
type RpgRuntimeStoryStateResult,
|
||||
type UseRpgRuntimeStoryStateParams,
|
||||
} from './useRpgRuntimeStoryState';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, type Dispatch, type SetStateAction } from 'react';
|
||||
import { useMemo, type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
EQUIPMENT_EQUIP_FUNCTION,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../../data/functionCatalog';
|
||||
import { getForgeRecipeViews } from '../../data/forgeSystem';
|
||||
import type { Character, GameState, StoryMoment } from '../../types';
|
||||
import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator';
|
||||
import { resolveRpgRuntimeChoice } from '.';
|
||||
import type { InventoryFlowUi } from './uiTypes';
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
@@ -69,7 +69,7 @@ export function useStoryInventoryActions({
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({
|
||||
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option: {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
resolveServerRuntimeChoiceMock,
|
||||
@@ -8,8 +8,8 @@ const {
|
||||
streamNpcChatTurnMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./runtimeStoryCoordinator', () => ({
|
||||
resolveServerRuntimeChoice: resolveServerRuntimeChoiceMock,
|
||||
vi.mock('./rpgRuntimeStoryGateway', () => ({
|
||||
resolveRpgRuntimeChoice: resolveServerRuntimeChoiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/aiService', () => ({
|
||||
@@ -1423,7 +1423,6 @@ describe('npcEncounterActions', () => {
|
||||
'更换任务',
|
||||
'放弃任务',
|
||||
]);
|
||||
expect(lastStory.text).toContain('断桥夜巡');
|
||||
});
|
||||
|
||||
it('forwards pending quest offer acceptance to the server runtime resolver', async () => {
|
||||
6
src/hooks/rpg-runtime-story/npcEncounterActions.ts
Normal file
6
src/hooks/rpg-runtime-story/npcEncounterActions.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createRpgRuntimeNpcEncounterActions as createStoryNpcEncounterActions,
|
||||
useRpgRuntimeNpcInteraction,
|
||||
type RpgRuntimeNpcInteractionResult,
|
||||
type UseRpgRuntimeNpcInteractionParams,
|
||||
} from './useRpgRuntimeNpcInteraction';
|
||||
@@ -1,16 +1,12 @@
|
||||
import type {
|
||||
import type {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { buildRelationState } from '../../data/attributeResolver';
|
||||
import {
|
||||
buildCompanionState,
|
||||
getCharacterById,
|
||||
resolveEncounterRecruitCharacter,
|
||||
} from '../../data/characterPresets';
|
||||
import { recruitCompanionToParty } from '../../data/companionRoster';
|
||||
import {
|
||||
getNpcBuybackPrice,
|
||||
getNpcPurchasePrice,
|
||||
@@ -22,24 +18,17 @@ import {
|
||||
} from '../../data/functionCatalog';
|
||||
import {
|
||||
addInventoryItems,
|
||||
applyStoryChoiceToStanceProfile,
|
||||
buildNpcGiftCommitActionText,
|
||||
buildNpcGiftResultText,
|
||||
buildNpcRecruitResultText,
|
||||
buildNpcTradeTransactionActionText,
|
||||
buildNpcTradeTransactionResultText,
|
||||
getGiftCandidates,
|
||||
getPreferredGiftItemId,
|
||||
markNpcFirstMeaningfulContactResolved,
|
||||
removeInventoryItem,
|
||||
syncNpcTradeInventory,
|
||||
} from '../../data/npcInteractions';
|
||||
import { streamNpcChatDialogue, streamNpcRecruitDialogue } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import {
|
||||
appendStoryEngineCarrierMemory,
|
||||
syncNpcNarrativeState,
|
||||
} from '../../services/storyEngine/echoMemory';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
Character,
|
||||
@@ -49,8 +38,7 @@ import type {
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator';
|
||||
import { resolveRpgRuntimeChoice } from '.';
|
||||
import type {
|
||||
GiftModalState,
|
||||
RecruitModalState,
|
||||
@@ -169,7 +157,6 @@ function normalizeRecruitDialogue(
|
||||
export function useStoryNpcInteractionFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
commitGeneratedState,
|
||||
getNpcEncounterKey,
|
||||
getResolvedNpcState,
|
||||
updateNpcState,
|
||||
@@ -178,7 +165,6 @@ export function useStoryNpcInteractionFlow({
|
||||
}: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
getNpcEncounterKey: (encounter: Encounter) => string;
|
||||
getResolvedNpcState: (state: GameState, encounter: Encounter) => GameState['npcStates'][string];
|
||||
updateNpcState: (
|
||||
@@ -375,142 +361,65 @@ export function useStoryNpcInteractionFlow({
|
||||
}
|
||||
};
|
||||
|
||||
const buildRecruitmentOutcome = (
|
||||
encounter: Encounter,
|
||||
releasedNpcId?: string | null,
|
||||
) => {
|
||||
if (!gameState.playerCharacter) return null;
|
||||
const resolveRecruitmentOnServer = async (params: {
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
releasedNpcId?: string | null;
|
||||
preludeText?: string | null;
|
||||
}) => {
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
if (
|
||||
!playerCharacter ||
|
||||
!gameState.worldType ||
|
||||
gameState.currentScene !== 'Story'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const npcState = getResolvedNpcState(gameState, encounter);
|
||||
const recruitKey = getNpcEncounterKey(encounter);
|
||||
let releasedCompanionName: string | null = null;
|
||||
|
||||
const nextNpcStates = {
|
||||
...gameState.npcStates,
|
||||
[recruitKey]: {
|
||||
...syncNpcNarrativeState({
|
||||
encounter,
|
||||
npcState: {
|
||||
...markNpcFirstMeaningfulContactResolved(npcState),
|
||||
recruited: true,
|
||||
stanceProfile: applyStoryChoiceToStanceProfile(
|
||||
npcState.stanceProfile,
|
||||
'npc_recruit',
|
||||
{ recruited: true },
|
||||
),
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
||||
gameState,
|
||||
currentStory: runtime.currentStory,
|
||||
option: {
|
||||
functionId: 'npc_recruit',
|
||||
actionText: params.actionText,
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: params.encounter.id ?? getNpcEncounterKey(params.encounter),
|
||||
action: 'recruit',
|
||||
},
|
||||
customWorldProfile: gameState.customWorldProfile,
|
||||
storyEngineMemory: gameState.storyEngineMemory,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
if (releasedNpcId) {
|
||||
const releasedCompanion = gameState.companions.find(item => item.npcId === releasedNpcId);
|
||||
releasedCompanionName = releasedCompanion?.characterId
|
||||
? getCharacterById(releasedCompanion.characterId)?.name ?? null
|
||||
: null;
|
||||
}
|
||||
|
||||
const recruitCharacter = resolveEncounterRecruitCharacter(encounter);
|
||||
if (!recruitCharacter) return null;
|
||||
|
||||
const recruitedCompanion = buildCompanionState(
|
||||
recruitKey,
|
||||
recruitCharacter,
|
||||
npcState.affinity,
|
||||
gameState.worldType,
|
||||
gameState.customWorldProfile,
|
||||
);
|
||||
const rosterState = recruitCompanionToParty(gameState, recruitedCompanion, releasedNpcId);
|
||||
|
||||
const nextState: GameState = {
|
||||
...rosterState,
|
||||
npcStates: nextNpcStates,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: gameState.animationState,
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
ambientIdleMode: undefined,
|
||||
activeCombatEffects: [],
|
||||
};
|
||||
|
||||
return {
|
||||
nextState,
|
||||
releasedCompanionName,
|
||||
};
|
||||
};
|
||||
|
||||
const executeRecruitment = (
|
||||
encounter: Encounter,
|
||||
actionText: string,
|
||||
releasedNpcId?: string | null,
|
||||
preludeText?: string | null,
|
||||
) => {
|
||||
if (!gameState.playerCharacter) return;
|
||||
|
||||
const outcome = buildRecruitmentOutcome(encounter, releasedNpcId);
|
||||
if (!outcome) return;
|
||||
|
||||
const recruitResultText = buildNpcRecruitResultText(encounter, outcome.releasedCompanionName);
|
||||
setRecruitModal(null);
|
||||
|
||||
if (!preludeText) {
|
||||
void commitGeneratedState(
|
||||
outcome.nextState,
|
||||
gameState.playerCharacter,
|
||||
actionText,
|
||||
recruitResultText,
|
||||
'npc_recruit',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextHistory = [
|
||||
...gameState.storyHistory,
|
||||
createHistoryMoment(actionText, 'action'),
|
||||
createHistoryMoment(preludeText, 'result'),
|
||||
createHistoryMoment(recruitResultText, 'result'),
|
||||
];
|
||||
const stateWithHistory = {
|
||||
...outcome.nextState,
|
||||
storyHistory: nextHistory,
|
||||
};
|
||||
|
||||
setGameState(stateWithHistory);
|
||||
runtime.setAiError(null);
|
||||
|
||||
void runtime.generateStoryForState({
|
||||
state: stateWithHistory,
|
||||
character: gameState.playerCharacter,
|
||||
history: nextHistory,
|
||||
choice: actionText,
|
||||
lastFunctionId: 'npc_recruit',
|
||||
})
|
||||
.then(nextStory => {
|
||||
runtime.setCurrentStory(nextStory);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to continue recruit story:', error);
|
||||
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildFallbackStoryForState(stateWithHistory, gameState.playerCharacter!, recruitResultText),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
runtime.setIsLoading(false);
|
||||
},
|
||||
payload: {
|
||||
...(params.releasedNpcId
|
||||
? {
|
||||
releaseNpcId: params.releasedNpcId,
|
||||
}
|
||||
: {}),
|
||||
...(params.preludeText
|
||||
? {
|
||||
preludeText: params.preludeText,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
setGameState(hydratedSnapshot.gameState);
|
||||
runtime.setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve npc recruit 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 startRecruitmentSequence = async (
|
||||
@@ -599,7 +508,12 @@ export function useStoryNpcInteractionFlow({
|
||||
);
|
||||
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, finalDialogueText, [], false));
|
||||
await new Promise(resolve => window.setTimeout(resolve, 260));
|
||||
executeRecruitment(encounter, actionText, releasedNpcId, finalDialogueText);
|
||||
await resolveRecruitmentOnServer({
|
||||
encounter,
|
||||
actionText,
|
||||
releasedNpcId,
|
||||
preludeText: finalDialogueText,
|
||||
});
|
||||
};
|
||||
|
||||
const openTradeModal = (encounter: Encounter, actionText: string) => {
|
||||
@@ -678,7 +592,7 @@ export function useStoryNpcInteractionFlow({
|
||||
runtime.setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({
|
||||
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
||||
gameState,
|
||||
currentStory: runtime.currentStory,
|
||||
option: {
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
hasEncounterEntity,
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
acceptQuest,
|
||||
@@ -1,15 +1,15 @@
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
getRuntimeClientVersion,
|
||||
getRuntimeSessionId,
|
||||
getRuntimeStoryState,
|
||||
resolveRuntimeStoryAction,
|
||||
getRpgRuntimeClientVersion,
|
||||
getRpgRuntimeSessionId,
|
||||
getRpgRuntimeStoryState,
|
||||
resolveRpgRuntimeStoryAction,
|
||||
type RuntimeStorySnapshotRequest,
|
||||
resolveRuntimeStoryMoment,
|
||||
resolveRpgRuntimeStoryMoment,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryResponse,
|
||||
} from '../../services/runtimeStoryService';
|
||||
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
|
||||
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||
@@ -21,24 +21,28 @@ function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||
function buildRuntimeSnapshotRequest(
|
||||
gameState: GameState,
|
||||
currentStory: StoryMoment | null,
|
||||
) {
|
||||
): RuntimeStorySnapshotRequest {
|
||||
return {
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
} satisfies RuntimeStorySnapshotRequest;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端访问服务端 runtime story 的统一网关。
|
||||
* 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。
|
||||
*/
|
||||
export async function loadServerRuntimeOptionCatalog(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
}) {
|
||||
const response = await getRuntimeStoryState({
|
||||
sessionId: getRuntimeSessionId(params.gameState),
|
||||
clientVersion: getRuntimeClientVersion(params.gameState),
|
||||
const response = await getRpgRuntimeStoryState({
|
||||
sessionId: getRpgRuntimeSessionId(params.gameState),
|
||||
clientVersion: getRpgRuntimeClientVersion(params.gameState),
|
||||
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
|
||||
});
|
||||
const options = resolveRuntimeStoryMoment({
|
||||
const options = resolveRpgRuntimeStoryMoment({
|
||||
response,
|
||||
hydratedSnapshot: response.snapshot,
|
||||
fallbackGameState: params.gameState,
|
||||
@@ -64,14 +68,14 @@ export async function resumeServerRuntimeStory(
|
||||
};
|
||||
}
|
||||
|
||||
const response = await getRuntimeStoryState({
|
||||
sessionId: getRuntimeSessionId(hydratedSnapshot.gameState),
|
||||
const response = await getRpgRuntimeStoryState({
|
||||
sessionId: getRpgRuntimeSessionId(hydratedSnapshot.gameState),
|
||||
});
|
||||
const resumedSnapshot = rehydrateSavedSnapshot(response.snapshot);
|
||||
const runtimeOptions = getRuntimeResponseOptions(response);
|
||||
const nextStory =
|
||||
response.presentation.storyText || runtimeOptions.length > 0
|
||||
? resolveRuntimeStoryMoment({
|
||||
? resolveRpgRuntimeStoryMoment({
|
||||
response,
|
||||
hydratedSnapshot: resumedSnapshot,
|
||||
fallbackGameState: hydratedSnapshot.gameState,
|
||||
@@ -96,9 +100,9 @@ export async function resolveServerRuntimeChoice(params: {
|
||||
Partial<Pick<StoryOption, 'interaction'>>;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
}) {
|
||||
const response = await resolveRuntimeStoryAction({
|
||||
sessionId: getRuntimeSessionId(params.gameState),
|
||||
clientVersion: getRuntimeClientVersion(params.gameState),
|
||||
const response = await resolveRpgRuntimeStoryAction({
|
||||
sessionId: getRpgRuntimeSessionId(params.gameState),
|
||||
clientVersion: getRpgRuntimeClientVersion(params.gameState),
|
||||
option: params.option,
|
||||
targetId:
|
||||
params.option.interaction?.kind === 'npc'
|
||||
@@ -112,7 +116,7 @@ export async function resolveServerRuntimeChoice(params: {
|
||||
return {
|
||||
response,
|
||||
hydratedSnapshot,
|
||||
nextStory: resolveRuntimeStoryMoment({
|
||||
nextStory: resolveRpgRuntimeStoryMoment({
|
||||
response,
|
||||
hydratedSnapshot,
|
||||
fallbackGameState: params.gameState,
|
||||
@@ -123,3 +127,14 @@ export async function resolveServerRuntimeChoice(params: {
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export type LoadRpgRuntimeOptionCatalogParams = Parameters<
|
||||
typeof loadServerRuntimeOptionCatalog
|
||||
>[0];
|
||||
export type ResolveRpgRuntimeChoiceParams = Parameters<
|
||||
typeof resolveServerRuntimeChoice
|
||||
>[0];
|
||||
|
||||
export const loadRpgRuntimeOptionCatalog = loadServerRuntimeOptionCatalog;
|
||||
export const resumeRpgRuntimeStory = resumeServerRuntimeStory;
|
||||
export const resolveRpgRuntimeChoice = resolveServerRuntimeChoice;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
getRuntimeStoryStateMock,
|
||||
@@ -12,14 +12,20 @@ const {
|
||||
getRuntimeClientVersionMock: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/runtimeStoryService', async () => {
|
||||
vi.mock('../../services/rpg-runtime/rpgRuntimeStoryClient', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../../services/runtimeStoryService')>(
|
||||
'../../services/runtimeStoryService',
|
||||
await vi.importActual<
|
||||
typeof import('../../services/rpg-runtime/rpgRuntimeStoryClient')
|
||||
>(
|
||||
'../../services/rpg-runtime/rpgRuntimeStoryClient',
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getRpgRuntimeStoryState: getRuntimeStoryStateMock,
|
||||
resolveRpgRuntimeStoryAction: resolveRuntimeStoryActionMock,
|
||||
getRpgRuntimeSessionId: getRuntimeSessionIdMock,
|
||||
getRpgRuntimeClientVersion: getRuntimeClientVersionMock,
|
||||
getRuntimeStoryState: getRuntimeStoryStateMock,
|
||||
resolveRuntimeStoryAction: resolveRuntimeStoryActionMock,
|
||||
getRuntimeSessionId: getRuntimeSessionIdMock,
|
||||
7
src/hooks/rpg-runtime-story/runtimeStoryCoordinator.ts
Normal file
7
src/hooks/rpg-runtime-story/runtimeStoryCoordinator.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
loadRpgRuntimeOptionCatalog as loadServerRuntimeOptionCatalog,
|
||||
resolveRpgRuntimeChoice as resolveServerRuntimeChoice,
|
||||
resumeRpgRuntimeStory as resumeServerRuntimeStory,
|
||||
type LoadRpgRuntimeOptionCatalogParams,
|
||||
type ResolveRpgRuntimeChoiceParams,
|
||||
} from './rpgRuntimeStoryGateway';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildInitialNpcState } from '../../data/npcInteractions';
|
||||
import {
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {
|
||||
import type {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getWorldCampScenePreset } from '../../data/scenePresets';
|
||||
import {
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import {
|
||||
getCharacterAdventureOpening,
|
||||
getCharacterHomeSceneId,
|
||||
} from '../../data/characterPresets';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { addInventoryItems } from '../../data/npcInteractions';
|
||||
import { addInventoryItems } from '../../data/npcInteractions';
|
||||
import { applyQuestProgressFromHostileNpcDefeat } from '../../data/questFlow';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import { generateNextStep } from '../../services/aiService';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
rollHostileNpcLootMock,
|
||||
@@ -20,8 +20,8 @@ vi.mock('../../data/hostileNpcPresets', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./runtimeStoryCoordinator', () => ({
|
||||
resolveServerRuntimeChoice: resolveServerRuntimeChoiceMock,
|
||||
vi.mock('.', () => ({
|
||||
resolveRpgRuntimeChoice: resolveServerRuntimeChoiceMock,
|
||||
}));
|
||||
|
||||
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import {
|
||||
buildEncounterEntryState,
|
||||
hasEncounterEntity,
|
||||
} from '../../data/encounterTransition';
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator';
|
||||
import { resolveRpgRuntimeChoice } from '.';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
|
||||
type RuntimeStatsIncrements = Partial<
|
||||
@@ -302,7 +302,7 @@ export async function runServerRuntimeChoiceAction(params: {
|
||||
params.setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({
|
||||
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
||||
gameState: params.gameState,
|
||||
currentStory: params.currentStory,
|
||||
option: params.option,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getCharacterById } from '../../data/characterPresets';
|
||||
import { getCharacterById } from '../../data/characterPresets';
|
||||
import {
|
||||
NPC_CHAT_FUNCTION,
|
||||
STORY_OPENING_CAMP_DIALOGUE_FUNCTION,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildNpcPreviewTalkOption } from '../../data/functionCatalog';
|
||||
import { buildNpcPreviewTalkOption } from '../../data/functionCatalog';
|
||||
import {
|
||||
getDefaultFunctionIdsForContext,
|
||||
resolveFunctionOption,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { scenes } = vi.hoisted(() => ({
|
||||
scenes: [
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import {
|
||||
buildNpcGiftModalState,
|
||||
buildNpcRecruitModalState,
|
||||
buildNpcTradeModalState,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import { createStoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
|
||||
@@ -104,7 +104,6 @@ describe('storyInteractionCoordinator', () => {
|
||||
expect.objectContaining({
|
||||
gameState,
|
||||
setGameState,
|
||||
commitGeneratedState,
|
||||
getNpcEncounterKey: runtimeSupport.getNpcEncounterKey,
|
||||
getResolvedNpcState: runtimeSupport.getResolvedNpcState,
|
||||
updateNpcState: runtimeSupport.updateNpcState,
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type {
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
|
||||
import type { StoryRuntimeControllerResult } from './useStoryRuntimeController';
|
||||
import type { RpgRuntimeStoryControllerResult } from './useRpgRuntimeStoryController';
|
||||
|
||||
type StoryInteractionCoordinatorParams = {
|
||||
gameState: GameState;
|
||||
@@ -32,18 +32,18 @@ type StoryInteractionCoordinatorParams = {
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
buildDialogueStoryMoment: StoryRuntimeControllerResult['buildDialogueStoryMoment'];
|
||||
generateStoryForState: StoryRuntimeControllerResult['generateStoryForState'];
|
||||
getAvailableOptionsForState: StoryRuntimeControllerResult['getAvailableOptionsForState'];
|
||||
buildDialogueStoryMoment: RpgRuntimeStoryControllerResult['buildDialogueStoryMoment'];
|
||||
generateStoryForState: RpgRuntimeStoryControllerResult['generateStoryForState'];
|
||||
getAvailableOptionsForState: RpgRuntimeStoryControllerResult['getAvailableOptionsForState'];
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getTypewriterDelay: StoryRuntimeControllerResult['getTypewriterDelay'];
|
||||
getTypewriterDelay: RpgRuntimeStoryControllerResult['getTypewriterDelay'];
|
||||
runtimeSupport: StoryRuntimeSupport;
|
||||
commitGeneratedState: StoryRuntimeControllerResult['commitGeneratedState'];
|
||||
commitGeneratedStateWithEncounterEntry: StoryRuntimeControllerResult['commitGeneratedStateWithEncounterEntry'];
|
||||
appendHistory: StoryRuntimeControllerResult['appendHistory'];
|
||||
buildOpeningCampChatContext: StoryRuntimeControllerResult['buildOpeningCampChatContext'];
|
||||
commitGeneratedState: RpgRuntimeStoryControllerResult['commitGeneratedState'];
|
||||
commitGeneratedStateWithEncounterEntry: RpgRuntimeStoryControllerResult['commitGeneratedStateWithEncounterEntry'];
|
||||
appendHistory: RpgRuntimeStoryControllerResult['appendHistory'];
|
||||
buildOpeningCampChatContext: RpgRuntimeStoryControllerResult['buildOpeningCampChatContext'];
|
||||
sortOptions: (options: StoryOption[]) => StoryOption[];
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
sanitizeOptions: (
|
||||
@@ -81,7 +81,6 @@ export function createStoryInteractionCoordinatorConfig(
|
||||
npcInteractionFlow: {
|
||||
gameState: params.gameState,
|
||||
setGameState: params.setGameState,
|
||||
commitGeneratedState: params.commitGeneratedState,
|
||||
getNpcEncounterKey: params.runtimeSupport.getNpcEncounterKey,
|
||||
getResolvedNpcState: params.runtimeSupport.getResolvedNpcState,
|
||||
updateNpcState: params.runtimeSupport.updateNpcState,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
|
||||
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryDialogueTurn,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiService';
|
||||
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
@@ -1,8 +1,8 @@
|
||||
import type {
|
||||
import type {
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
} from '../../services/aiService';
|
||||
import { shouldUseServerRuntimeOptions } from '../../services/runtimeStoryService';
|
||||
import { shouldUseRpgRuntimeServerOptions } from '../../services/rpg-runtime';
|
||||
import type {
|
||||
AIResponse,
|
||||
Character,
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { loadServerRuntimeOptionCatalog } from './runtimeStoryCoordinator';
|
||||
import { loadRpgRuntimeOptionCatalog } from '.';
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
@@ -54,7 +54,7 @@ type RequestNextStep = (
|
||||
requestOptions?: StoryRequestOptions,
|
||||
) => Promise<AIResponse>;
|
||||
|
||||
type LoadRuntimeOptionCatalog = typeof loadServerRuntimeOptionCatalog;
|
||||
type LoadRuntimeOptionCatalog = typeof loadRpgRuntimeOptionCatalog;
|
||||
|
||||
export type ResolvedStoryRequestOptions = {
|
||||
availableOptions: StoryOption[] | null;
|
||||
@@ -78,7 +78,7 @@ export async function resolveStoryRequestOptions(params: {
|
||||
? null
|
||||
: params.getAvailableOptionsForState(params.state, params.character);
|
||||
|
||||
if (optionCatalog || !shouldUseServerRuntimeOptions(availableOptions)) {
|
||||
if (optionCatalog || !shouldUseRpgRuntimeServerOptions(availableOptions)) {
|
||||
return {
|
||||
availableOptions,
|
||||
optionCatalog,
|
||||
@@ -87,7 +87,7 @@ export async function resolveStoryRequestOptions(params: {
|
||||
|
||||
try {
|
||||
const serverOptionCatalog = await (
|
||||
params.loadRuntimeOptionCatalog ?? loadServerRuntimeOptionCatalog
|
||||
params.loadRuntimeOptionCatalog ?? loadRpgRuntimeOptionCatalog
|
||||
)({
|
||||
gameState: params.state,
|
||||
currentStory: params.currentStory,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AnimationState, type StoryOption } from '../../types';
|
||||
import { resolveStoryResponseOptions } from './storyResponseOptions';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
|
||||
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
|
||||
import type { StoryOption } from '../../types';
|
||||
|
||||
type ResolveStoryResponseOptionsParams = {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { GameState, InventoryItem } from '../../types';
|
||||
import {
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
buildNpcEncounterStoryMoment,
|
||||
normalizeNpcPersistentState,
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {
|
||||
import type {
|
||||
Encounter,
|
||||
GoalHandoff,
|
||||
GoalPulseEvent,
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createClearStoryInteractionUi } from './useStoryInteractionCoordinator';
|
||||
import { createRpgRuntimeInteractionUiResetter } from './useRpgRuntimeInteractionFlow';
|
||||
|
||||
describe('useStoryInteractionCoordinator helpers', () => {
|
||||
describe('useRpgRuntimeInteractionFlow helpers', () => {
|
||||
it('clears interaction ui in the expected order', () => {
|
||||
const calls: string[] = [];
|
||||
const clearStoryInteractionUi = createClearStoryInteractionUi({
|
||||
const clearStoryInteractionUi = createRpgRuntimeInteractionUiResetter({
|
||||
clearStoryChoiceUi: vi.fn(() => calls.push('choice')),
|
||||
clearNpcInteractionUi: vi.fn(() => calls.push('npc')),
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
@@ -11,7 +11,7 @@ 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 { createRpgRuntimeNpcEncounterActions } from './useRpgRuntimeNpcInteraction';
|
||||
import { useStoryNpcInteractionFlow } from './npcInteraction';
|
||||
import type { StoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
|
||||
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
} from './storyChoiceCoordinator';
|
||||
import { useStoryChoiceCoordinator } from './useStoryChoiceCoordinator';
|
||||
|
||||
type StoryInteractionCoordinatorParams = {
|
||||
type RpgRuntimeInteractionFlowParams = {
|
||||
gameState: GameState;
|
||||
isLoading: boolean;
|
||||
interactionConfig: StoryInteractionCoordinatorConfig;
|
||||
@@ -67,7 +67,11 @@ export function createClearStoryInteractionUi(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function useStoryInteractionCoordinator({
|
||||
/**
|
||||
* RPG runtime 交互分发层。
|
||||
* 统一串起宝箱、背包、NPC 交互与 story choice 的正式分发。
|
||||
*/
|
||||
export function useRpgRuntimeInteractionFlow({
|
||||
gameState,
|
||||
isLoading,
|
||||
interactionConfig,
|
||||
@@ -84,7 +88,7 @@ export function useStoryInteractionCoordinator({
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
}: StoryInteractionCoordinatorParams) {
|
||||
}: RpgRuntimeInteractionFlowParams) {
|
||||
const { handleTreasureInteraction } = useTreasureFlow(
|
||||
interactionConfig.treasureFlow,
|
||||
);
|
||||
@@ -104,7 +108,7 @@ export function useStoryInteractionCoordinator({
|
||||
replacePendingNpcQuestOffer,
|
||||
abandonPendingNpcQuestOffer,
|
||||
acceptPendingNpcQuestOffer,
|
||||
} = createStoryNpcEncounterActions({
|
||||
} = createRpgRuntimeNpcEncounterActions({
|
||||
...interactionConfig.npcEncounterActions,
|
||||
buildNpcStory: runtimeSupport.buildNpcStory,
|
||||
npcInteractionFlow,
|
||||
@@ -255,3 +259,13 @@ export function useStoryInteractionCoordinator({
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type UseRpgRuntimeInteractionFlowParams = Parameters<
|
||||
typeof useRpgRuntimeInteractionFlow
|
||||
>[0];
|
||||
export type RpgRuntimeInteractionFlowResult = ReturnType<
|
||||
typeof useRpgRuntimeInteractionFlow
|
||||
>;
|
||||
|
||||
export const createRpgRuntimeInteractionUiResetter =
|
||||
createClearStoryInteractionUi;
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import { buildRelationState } from '../../data/attributeResolver';
|
||||
import { NPC_FIGHT_FUNCTION } from '../../data/functionCatalog';
|
||||
@@ -39,7 +39,7 @@ import type {
|
||||
} from '../../types';
|
||||
import { AnimationState } from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator';
|
||||
import { resolveRpgRuntimeChoice } from './rpgRuntimeStoryGateway';
|
||||
|
||||
type CommitGeneratedStateWithEncounterEntry = (
|
||||
entryState: GameState,
|
||||
@@ -103,6 +103,10 @@ const NPC_CHAT_QUEST_OFFER_FUNCTION_IDS = {
|
||||
type NpcChatQuestOfferPayloadAction =
|
||||
keyof typeof NPC_CHAT_QUEST_OFFER_FUNCTION_IDS;
|
||||
|
||||
/**
|
||||
* RPG runtime NPC 交互主链。
|
||||
* 负责 NPC 对话、委托处理、战斗后续对话重开,以及需要服务端结算的正式动作派发。
|
||||
*/
|
||||
export function createStoryNpcEncounterActions({
|
||||
gameState,
|
||||
currentStory,
|
||||
@@ -1535,7 +1539,7 @@ export function createStoryNpcEncounterActions({
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({
|
||||
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option: params.option,
|
||||
@@ -1801,3 +1805,19 @@ export function createStoryNpcEncounterActions({
|
||||
acceptPendingNpcQuestOffer,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseRpgRuntimeNpcInteractionParams = Parameters<
|
||||
typeof createStoryNpcEncounterActions
|
||||
>[0];
|
||||
export type RpgRuntimeNpcInteractionResult = ReturnType<
|
||||
typeof createStoryNpcEncounterActions
|
||||
>;
|
||||
|
||||
export const createRpgRuntimeNpcEncounterActions =
|
||||
createStoryNpcEncounterActions;
|
||||
|
||||
export function useRpgRuntimeNpcInteraction(
|
||||
params: UseRpgRuntimeNpcInteractionParams,
|
||||
) {
|
||||
return createStoryNpcEncounterActions(params);
|
||||
}
|
||||
@@ -1,33 +1,30 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
buildContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isContinueAdventureOption,
|
||||
NPC_PREVIEW_TALK_FUNCTION,
|
||||
} from '../data/functionCatalog';
|
||||
import { sortStoryOptionsByPriority } from '../data/stateFunctions';
|
||||
import type { GameState, StoryOption } from '../types';
|
||||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from './combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from './combat/resolvedChoice';
|
||||
import { useCharacterChatFlow } from './story/characterChat';
|
||||
import { buildStoryContextFromState } from './story/storyContextBuilder';
|
||||
} from '../../data/functionCatalog';
|
||||
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
|
||||
import type { GameState, StoryOption } from '../../types';
|
||||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import { useCharacterChatFlow } from './characterChat';
|
||||
import { buildStoryContextFromState } from './storyContextBuilder';
|
||||
import {
|
||||
getResolvedSceneHostileNpcs,
|
||||
getStoryGenerationHostileNpcs,
|
||||
isNpcEncounter,
|
||||
isRegularNpcEncounter,
|
||||
} from './story/storyEncounterState';
|
||||
} from './storyEncounterState';
|
||||
import {
|
||||
getNpcEncounterKey,
|
||||
resolveNpcInteractionDecision,
|
||||
} from './story/storyGenerationState';
|
||||
import {
|
||||
storyRuntimeSupport,
|
||||
} from './story/storyRuntimeSupport';
|
||||
import { useStoryFlowCoordinator } from './story/useStoryFlowCoordinator';
|
||||
import { useStoryRuntimeController } from './story/useStoryRuntimeController';
|
||||
import type { BattleRewardUi, QuestFlowUi } from './story/uiTypes';
|
||||
} from './storyGenerationState';
|
||||
import { storyRuntimeSupport } from './storyRuntimeSupport';
|
||||
import { useRpgRuntimeStoryFlow } from './useRpgRuntimeStoryFlow';
|
||||
import { useRpgRuntimeStoryController } from './useRpgRuntimeStoryController';
|
||||
import type { BattleRewardUi, QuestFlowUi } from './uiTypes';
|
||||
|
||||
const TURN_VISUAL_MS = 820;
|
||||
const NPC_PREVIEW_TALK_FUNCTION_ID = NPC_PREVIEW_TALK_FUNCTION.id;
|
||||
@@ -37,7 +34,7 @@ export type {
|
||||
CharacterChatModalState,
|
||||
CharacterChatTarget,
|
||||
CharacterChatUi,
|
||||
} from './story/characterChat';
|
||||
} from './characterChat';
|
||||
export type {
|
||||
BattleRewardSummary,
|
||||
BattleRewardUi,
|
||||
@@ -49,9 +46,14 @@ export type {
|
||||
RecruitModalState,
|
||||
StoryGenerationNpcUi,
|
||||
TradeModalState,
|
||||
} from './story/uiTypes';
|
||||
} from './uiTypes';
|
||||
|
||||
export function useStoryGeneration({
|
||||
/**
|
||||
* RPG runtime story 顶层装配入口。
|
||||
* 这里负责收口角色聊天、story controller 与 story flow 三层能力,
|
||||
* 让运行态主链直接消费 RPG 域命名,不再保留旧 story hook 入口。
|
||||
*/
|
||||
export function useRpgRuntimeStory({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildResolvedChoiceState,
|
||||
@@ -78,7 +80,7 @@ export function useStoryGeneration({
|
||||
buildStoryContextFromState,
|
||||
});
|
||||
|
||||
const runtimeController = useStoryRuntimeController({
|
||||
const runtimeController = useRpgRuntimeStoryController({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildStoryContextFromState,
|
||||
@@ -100,7 +102,7 @@ export function useStoryGeneration({
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
npcChatQuestOfferUi,
|
||||
} = useStoryFlowCoordinator({
|
||||
} = useRpgRuntimeStoryFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildResolvedChoiceState,
|
||||
@@ -144,3 +146,6 @@ export function useStoryGeneration({
|
||||
npcChatQuestOfferUi,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseRpgRuntimeStoryParams = Parameters<typeof useRpgRuntimeStory>[0];
|
||||
export type RpgRuntimeStoryResult = ReturnType<typeof useRpgRuntimeStory>;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
import { generateInitialStory, generateNextStep } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
@@ -25,7 +25,11 @@ type BuildStoryContextFromState = (
|
||||
extras?: StoryContextBuilderExtras,
|
||||
) => StoryGenerationContext;
|
||||
|
||||
export function useStoryRuntimeController(params: {
|
||||
/**
|
||||
* RPG runtime story controller。
|
||||
* 统一管理当前故事、AI 请求状态和生成后的状态提交。
|
||||
*/
|
||||
export function useRpgRuntimeStoryController(params: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
@@ -74,7 +78,7 @@ export function useStoryRuntimeController(params: {
|
||||
requestNextStep: generateNextStep,
|
||||
onServerOptionCatalogLoadError: (error) => {
|
||||
console.warn(
|
||||
'[useStoryGeneration] failed to load server runtime option catalog',
|
||||
'[useRpgRuntimeStory] failed to load server runtime option catalog',
|
||||
error,
|
||||
);
|
||||
},
|
||||
@@ -125,6 +129,9 @@ export function useStoryRuntimeController(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export type StoryRuntimeControllerResult = ReturnType<
|
||||
typeof useStoryRuntimeController
|
||||
export type UseRpgRuntimeStoryControllerParams = Parameters<
|
||||
typeof useRpgRuntimeStoryController
|
||||
>[0];
|
||||
export type RpgRuntimeStoryControllerResult = ReturnType<
|
||||
typeof useRpgRuntimeStoryController
|
||||
>;
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { Character, Encounter, GameState, StoryOption } from '../../types';
|
||||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
|
||||
@@ -7,11 +7,11 @@ import { createStoryInteractionCoordinatorConfig } from './storyInteractionCoord
|
||||
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';
|
||||
import { useRpgRuntimeStoryState } from './useRpgRuntimeStoryState';
|
||||
import { useRpgRuntimeInteractionFlow } from './useRpgRuntimeInteractionFlow';
|
||||
import type { RpgRuntimeStoryControllerResult } from './useRpgRuntimeStoryController';
|
||||
|
||||
type StoryFlowCoordinatorParams = {
|
||||
type RpgRuntimeStoryFlowParams = {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
buildResolvedChoiceState: (
|
||||
@@ -32,7 +32,7 @@ type StoryFlowCoordinatorParams = {
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
runtimeController: StoryRuntimeControllerResult;
|
||||
runtimeController: RpgRuntimeStoryControllerResult;
|
||||
runtimeSupport: StoryRuntimeSupport;
|
||||
sortOptions: (options: StoryOption[]) => StoryOption[];
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
@@ -54,7 +54,11 @@ type StoryFlowCoordinatorParams = {
|
||||
turnVisualMs: number;
|
||||
};
|
||||
|
||||
export function useStoryFlowCoordinator({
|
||||
/**
|
||||
* RPG runtime story 主编排层。
|
||||
* 这里把 option 展示、正式交互分发和 story/session 状态动作收束成稳定出口。
|
||||
*/
|
||||
export function useRpgRuntimeStoryFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildResolvedChoiceState,
|
||||
@@ -74,7 +78,7 @@ export function useStoryFlowCoordinator({
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
}: StoryFlowCoordinatorParams) {
|
||||
}: RpgRuntimeStoryFlowParams) {
|
||||
const {
|
||||
currentStory,
|
||||
setCurrentStory,
|
||||
@@ -136,7 +140,7 @@ export function useStoryFlowCoordinator({
|
||||
handleNpcChatInput,
|
||||
exitNpcChat,
|
||||
npcChatQuestOfferUi,
|
||||
} = useStoryInteractionCoordinator({
|
||||
} = useRpgRuntimeInteractionFlow({
|
||||
gameState,
|
||||
isLoading,
|
||||
interactionConfig,
|
||||
@@ -155,7 +159,7 @@ export function useStoryFlowCoordinator({
|
||||
turnVisualMs,
|
||||
});
|
||||
const { questUi, resetStoryState, hydrateStoryState, travelToSceneFromMap } =
|
||||
useStoryGoalSessionCoordinator({
|
||||
useRpgRuntimeStoryState({
|
||||
gameState,
|
||||
isLoading,
|
||||
setGameState,
|
||||
@@ -188,3 +192,10 @@ export function useStoryFlowCoordinator({
|
||||
npcChatQuestOfferUi,
|
||||
};
|
||||
}
|
||||
|
||||
export type UseRpgRuntimeStoryFlowParams = Parameters<
|
||||
typeof useRpgRuntimeStoryFlow
|
||||
>[0];
|
||||
export type RpgRuntimeStoryFlowResult = ReturnType<
|
||||
typeof useRpgRuntimeStoryFlow
|
||||
>;
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createClearStoryRuntimeUi } from './useStoryGoalSessionCoordinator';
|
||||
import { createRpgRuntimeStoryUiResetter } from './useRpgRuntimeStoryState';
|
||||
|
||||
describe('useStoryGoalSessionCoordinator helpers', () => {
|
||||
describe('useRpgRuntimeStoryState helpers', () => {
|
||||
it('clears story runtime ui in the expected order', () => {
|
||||
const calls: string[] = [];
|
||||
const clearStoryRuntimeUi = createClearStoryRuntimeUi({
|
||||
const clearStoryRuntimeUi = createRpgRuntimeStoryUiResetter({
|
||||
clearStoryGoalOptionUi: vi.fn(() => calls.push('goal-option')),
|
||||
clearStoryInteractionUi: vi.fn(() => calls.push('interaction')),
|
||||
setAiError: vi.fn((value) => calls.push(`ai:${String(value)}`)),
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, type Dispatch, type SetStateAction } from 'react';
|
||||
import { useCallback, type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
import type { StoryMoment, GameState, Character } from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
@@ -28,7 +28,11 @@ export function createClearStoryRuntimeUi(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function useStoryGoalSessionCoordinator(params: {
|
||||
/**
|
||||
* RPG runtime story 状态层。
|
||||
* 负责 story reset、hydration、地图跳转,以及 quest 领取/确认 UI 的收口。
|
||||
*/
|
||||
export function useRpgRuntimeStoryState(params: {
|
||||
gameState: GameState;
|
||||
isLoading: boolean;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
@@ -89,6 +93,11 @@ export function useStoryGoalSessionCoordinator(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export type StoryGoalSessionCoordinatorResult = ReturnType<
|
||||
typeof useStoryGoalSessionCoordinator
|
||||
export type UseRpgRuntimeStoryStateParams = Parameters<
|
||||
typeof useRpgRuntimeStoryState
|
||||
>[0];
|
||||
export type RpgRuntimeStoryStateResult = ReturnType<
|
||||
typeof useRpgRuntimeStoryState
|
||||
>;
|
||||
|
||||
export const createRpgRuntimeStoryUiResetter = createClearStoryRuntimeUi;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createClearStoryChoiceUi } from './useStoryChoiceCoordinator';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { createStoryChoiceActions } from './choiceActions';
|
||||
import {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createClearStoryGoalOptionUi } from './useStoryGoalOptionCoordinator';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { GameState, StoryMoment } from '../../types';
|
||||
import { useStoryOptions } from '../useStoryOptions';
|
||||
12
src/hooks/rpg-session/index.ts
Normal file
12
src/hooks/rpg-session/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { type RpgSessionBootstrapResult, useRpgSessionBootstrap } from './useRpgSessionBootstrap';
|
||||
export type { BottomTab } from './rpgSessionTypes';
|
||||
export {
|
||||
type RpgRuntimeSessionResult,
|
||||
useRpgRuntimeSession,
|
||||
} from './useRpgRuntimeSession';
|
||||
export {
|
||||
type RpgSessionPersistenceResult,
|
||||
type UseRpgSessionPersistenceParams,
|
||||
useRpgSessionPersistence,
|
||||
} from './useRpgSessionPersistence';
|
||||
export type { BottomTab as RpgBottomTab } from './rpgSessionTypes';
|
||||
3
src/hooks/rpg-session/rpgSessionTypes.ts
Normal file
3
src/hooks/rpg-session/rpgSessionTypes.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { BottomTab } from '../../types/navigation';
|
||||
|
||||
export type { BottomTab };
|
||||
@@ -1,19 +1,23 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { DEFAULT_MUSIC_VOLUME } from '../../packages/shared/src/contracts/runtime';
|
||||
import { useAuthUi } from '../components/auth/AuthUiContext';
|
||||
import type { GameShellProps } from '../components/game-shell/types';
|
||||
import { activateRosterCompanion, benchActiveCompanion } from '../data/companionRoster';
|
||||
import { syncGameStatePlayTime } from '../data/runtimeStats';
|
||||
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
|
||||
import { useBackgroundMusic } from './useBackgroundMusic';
|
||||
import { useCombatFlow } from './useCombatFlow';
|
||||
import { useGameFlow } from './useGameFlow';
|
||||
import { useGamePersistence } from './useGamePersistence';
|
||||
import { useNpcInteractionFlow } from './useNpcInteractionFlow';
|
||||
import { useStoryGeneration } from './useStoryGeneration';
|
||||
import { DEFAULT_MUSIC_VOLUME } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { useAuthUi } from '../../components/auth/AuthUiContext';
|
||||
import type { RpgRuntimeShellProps } from '../../components/rpg-runtime-shell';
|
||||
import { activateRosterCompanion, benchActiveCompanion } from '../../data/companionRoster';
|
||||
import { syncGameStatePlayTime } from '../../data/runtimeStats';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { useBackgroundMusic } from '../useBackgroundMusic';
|
||||
import { useCombatFlow } from '../useCombatFlow';
|
||||
import { useNpcInteractionFlow } from '../useNpcInteractionFlow';
|
||||
import { useRpgRuntimeStory } from '../rpg-runtime-story';
|
||||
import { useRpgSessionBootstrap } from './useRpgSessionBootstrap';
|
||||
import { useRpgSessionPersistence } from './useRpgSessionPersistence';
|
||||
|
||||
export function useGameShellRuntime(): GameShellProps {
|
||||
/**
|
||||
* RPG 主运行态装配器真实实现。
|
||||
* 工作包 C 起主链改为组合 `rpg-session` 下的 bootstrap / persistence 新入口。
|
||||
*/
|
||||
export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
||||
const authUi = useAuthUi();
|
||||
const {
|
||||
gameState,
|
||||
@@ -26,13 +30,13 @@ export function useGameShellRuntime(): GameShellProps {
|
||||
handleCustomWorldSelect: selectCustomWorld,
|
||||
handleBackToWorldSelect: backToWorldSelect,
|
||||
handleCharacterSelect: selectCharacter,
|
||||
} = useGameFlow();
|
||||
} = useRpgSessionBootstrap();
|
||||
|
||||
const combatFlow = useCombatFlow({
|
||||
setGameState,
|
||||
});
|
||||
|
||||
const storyFlow = useStoryGeneration({
|
||||
const storyFlow = useRpgRuntimeStory({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildResolvedChoiceState: combatFlow.buildResolvedChoiceState,
|
||||
@@ -41,7 +45,7 @@ export function useGameShellRuntime(): GameShellProps {
|
||||
|
||||
const { companionRenderStates, buildCompanionRenderStates } =
|
||||
useNpcInteractionFlow(gameState);
|
||||
const persistence = useGamePersistence({
|
||||
const persistence = useRpgSessionPersistence({
|
||||
authenticatedUserId: authUi?.user?.id ?? null,
|
||||
gameState,
|
||||
bottomTab,
|
||||
@@ -183,3 +187,5 @@ export function useGameShellRuntime(): GameShellProps {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type RpgRuntimeSessionResult = ReturnType<typeof useRpgRuntimeSession>;
|
||||
@@ -6,26 +6,26 @@ import {
|
||||
getCharacterMaxHp,
|
||||
getCharacterMaxMana,
|
||||
setRuntimeCharacterOverrides,
|
||||
} from '../data/characterPresets';
|
||||
import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
|
||||
import { getInitialPlayerCurrency } from '../data/economy';
|
||||
} from '../../data/characterPresets';
|
||||
import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime';
|
||||
import { getInitialPlayerCurrency } from '../../data/economy';
|
||||
import {
|
||||
applyEquipmentLoadoutToState,
|
||||
buildInitialEquipmentLoadout,
|
||||
createEmptyEquipmentLoadout,
|
||||
} from '../data/equipmentEffects';
|
||||
} from '../../data/equipmentEffects';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
buildInitialPlayerInventory,
|
||||
} from '../data/npcInteractions';
|
||||
import { createInitialPlayerProgressionState } from '../data/playerProgression';
|
||||
import { createInitialGameRuntimeStats } from '../data/runtimeStats';
|
||||
} from '../../data/npcInteractions';
|
||||
import { createInitialPlayerProgressionState } from '../../data/playerProgression';
|
||||
import { createInitialGameRuntimeStats } from '../../data/runtimeStats';
|
||||
import {
|
||||
ensureSceneEncounterPreview,
|
||||
RESOLVED_ENTITY_X_METERS,
|
||||
} from '../data/sceneEncounterPreviews';
|
||||
import { getScenePreset, getWorldCampScenePreset } from '../data/scenePresets';
|
||||
import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine';
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { getScenePreset, getWorldCampScenePreset } from '../../data/scenePresets';
|
||||
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
@@ -36,13 +36,11 @@ import {
|
||||
InventoryItem,
|
||||
SceneNpc,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import type { BottomTab } from '../types/navigation';
|
||||
} from '../../types';
|
||||
import type { BottomTab } from './rpgSessionTypes';
|
||||
|
||||
const PLAYER_BASE_MAX_HP = 180;
|
||||
|
||||
export type { BottomTab } from '../types/navigation';
|
||||
|
||||
function mergeStarterInventoryItems<
|
||||
T extends { category: string; name: string },
|
||||
>(explicitItems: T[], fallbackItems: T[]) {
|
||||
@@ -213,7 +211,11 @@ function createInitialGameState(): GameState {
|
||||
};
|
||||
}
|
||||
|
||||
export function useGameFlow() {
|
||||
/**
|
||||
* RPG session bootstrap 主实现。
|
||||
* 工作包 C 起由新域 hook 承载世界选择、选角确认与新开局初始化。
|
||||
*/
|
||||
export function useRpgSessionBootstrap() {
|
||||
const [gameState, setGameState] = useState<GameState>(() =>
|
||||
createInitialGameState(),
|
||||
);
|
||||
@@ -335,9 +337,9 @@ export function useGameFlow() {
|
||||
chapterState: null,
|
||||
campaignState: null,
|
||||
activeScenarioPackId:
|
||||
gameState.customWorldProfile?.scenarioPackId ?? null,
|
||||
prev.customWorldProfile?.scenarioPackId ?? null,
|
||||
activeCampaignPackId:
|
||||
gameState.customWorldProfile?.campaignPackId ?? null,
|
||||
prev.customWorldProfile?.campaignPackId ?? null,
|
||||
characterChats: {},
|
||||
currentEncounter: initialEncounter,
|
||||
npcInteractionActive: false,
|
||||
@@ -408,3 +410,7 @@ export function useGameFlow() {
|
||||
handleCharacterSelect,
|
||||
};
|
||||
}
|
||||
|
||||
export type RpgSessionBootstrapResult = ReturnType<
|
||||
typeof useRpgSessionBootstrap
|
||||
>;
|
||||
@@ -1,15 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
|
||||
import { isAbortError } from '../services/apiClient';
|
||||
import {
|
||||
deleteSaveSnapshot,
|
||||
getSaveSnapshot,
|
||||
putSaveSnapshot,
|
||||
} from '../services/storageService';
|
||||
import type { GameState, StoryMoment } from '../types';
|
||||
import { resumeServerRuntimeStory } from './story/runtimeStoryCoordinator';
|
||||
import type { BottomTab } from './useGameFlow';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { isAbortError } from '../../services/apiClient';
|
||||
import { rpgSnapshotClient } from '../../services/rpg-runtime';
|
||||
import type { GameState, StoryMoment } from '../../types';
|
||||
import { resumeServerRuntimeStory } from '../rpg-runtime-story/runtimeStoryCoordinator';
|
||||
import type { BottomTab } from './rpgSessionTypes';
|
||||
|
||||
const AUTO_SAVE_DELAY_MS = 400;
|
||||
|
||||
@@ -38,17 +34,7 @@ function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) {
|
||||
};
|
||||
}
|
||||
|
||||
export function useGamePersistence({
|
||||
authenticatedUserId,
|
||||
gameState,
|
||||
bottomTab,
|
||||
currentStory,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setBottomTab,
|
||||
hydrateStoryState,
|
||||
resetStoryState,
|
||||
}: {
|
||||
export type UseRpgSessionPersistenceParams = {
|
||||
authenticatedUserId: string | null;
|
||||
gameState: GameState;
|
||||
bottomTab: BottomTab;
|
||||
@@ -58,7 +44,23 @@ export function useGamePersistence({
|
||||
setBottomTab: (tab: BottomTab) => void;
|
||||
hydrateStoryState: (story: StoryMoment | null) => void;
|
||||
resetStoryState: () => void;
|
||||
}) {
|
||||
};
|
||||
|
||||
/**
|
||||
* RPG session persistence 主实现。
|
||||
* 工作包 C 起由新域 hook 负责自动存档、继续游戏恢复与运行态 story 恢复刷新。
|
||||
*/
|
||||
export function useRpgSessionPersistence({
|
||||
authenticatedUserId,
|
||||
gameState,
|
||||
bottomTab,
|
||||
currentStory,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setBottomTab,
|
||||
hydrateStoryState,
|
||||
resetStoryState,
|
||||
}: UseRpgSessionPersistenceParams) {
|
||||
const [hasSavedGame, setHasSavedGame] = useState(false);
|
||||
const [savedSnapshot, setSavedSnapshot] =
|
||||
useState<HydratedSavedGameSnapshot | null>(null);
|
||||
@@ -98,7 +100,7 @@ export function useGamePersistence({
|
||||
setPersistenceError(null);
|
||||
|
||||
try {
|
||||
const snapshot = await putSaveSnapshot(
|
||||
const snapshot = await rpgSnapshotClient.putSnapshot(
|
||||
{
|
||||
gameState: params.payload.gameState,
|
||||
bottomTab: params.payload.bottomTab,
|
||||
@@ -124,7 +126,7 @@ export function useGamePersistence({
|
||||
if (saveRequestIdRef.current === requestId) {
|
||||
setPersistenceError(message);
|
||||
}
|
||||
console.warn(`[useGamePersistence] ${params.logLabel}`, error);
|
||||
console.warn(`[useRpgSessionPersistence] ${params.logLabel}`, error);
|
||||
return null;
|
||||
} finally {
|
||||
if (saveControllerRef.current === controller) {
|
||||
@@ -153,7 +155,8 @@ export function useGamePersistence({
|
||||
hydrateControllerRef.current = controller;
|
||||
setIsHydratingSnapshot(true);
|
||||
|
||||
void getSaveSnapshot({ signal: controller.signal })
|
||||
void rpgSnapshotClient
|
||||
.getSnapshot({ signal: controller.signal })
|
||||
.then((snapshot) => {
|
||||
setSavedSnapshot(snapshot);
|
||||
setHasSavedGame(Boolean(snapshot));
|
||||
@@ -167,7 +170,7 @@ export function useGamePersistence({
|
||||
error instanceof Error ? error.message : '读取远端存档失败';
|
||||
setPersistenceError(message);
|
||||
console.warn(
|
||||
'[useGamePersistence] failed to load remote snapshot',
|
||||
'[useRpgSessionPersistence] failed to load remote snapshot',
|
||||
error,
|
||||
);
|
||||
})
|
||||
@@ -254,11 +257,11 @@ export function useGamePersistence({
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteSaveSnapshot();
|
||||
await rpgSnapshotClient.deleteSnapshot();
|
||||
setPersistenceError(null);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[useGamePersistence] failed to delete remote snapshot',
|
||||
'[useRpgSessionPersistence] failed to delete remote snapshot',
|
||||
error,
|
||||
);
|
||||
}
|
||||
@@ -276,10 +279,10 @@ export function useGamePersistence({
|
||||
const snapshot =
|
||||
snapshotOverride ??
|
||||
savedSnapshot ??
|
||||
(await getSaveSnapshot().catch((error) => {
|
||||
(await rpgSnapshotClient.getSnapshot().catch((error) => {
|
||||
if (!isAbortError(error)) {
|
||||
console.warn(
|
||||
'[useGamePersistence] failed to refetch remote snapshot',
|
||||
'[useRpgSessionPersistence] failed to refetch remote snapshot',
|
||||
error,
|
||||
);
|
||||
}
|
||||
@@ -298,7 +301,7 @@ export function useGamePersistence({
|
||||
(error) => {
|
||||
if (!isAbortError(error)) {
|
||||
console.warn(
|
||||
'[useGamePersistence] failed to refresh runtime story state from server',
|
||||
'[useRpgSessionPersistence] failed to refresh runtime story state from server',
|
||||
error,
|
||||
);
|
||||
}
|
||||
@@ -339,3 +342,7 @@ export function useGamePersistence({
|
||||
clearSavedGame,
|
||||
};
|
||||
}
|
||||
|
||||
export type RpgSessionPersistenceResult = ReturnType<
|
||||
typeof useRpgSessionPersistence
|
||||
>;
|
||||
@@ -6,7 +6,7 @@ import { beforeEach, expect, test, vi } from 'vitest';
|
||||
import { readSavedSettings } from '../persistence/gameSettingsStorage';
|
||||
import type { GameState, StoryMoment } from '../types';
|
||||
import type { BottomTab } from '../types/navigation';
|
||||
import { useGamePersistence } from './useGamePersistence';
|
||||
import { useRpgSessionPersistence } from './rpg-session';
|
||||
import { useGameSettings } from './useGameSettings';
|
||||
|
||||
const storageMocks = vi.hoisted(() => ({
|
||||
@@ -17,16 +17,17 @@ const storageMocks = vi.hoisted(() => ({
|
||||
deleteSaveSnapshot: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/storageService', () => ({
|
||||
getSettings: storageMocks.getSettings,
|
||||
putSettings: storageMocks.putSettings,
|
||||
getSaveSnapshot: storageMocks.getSaveSnapshot,
|
||||
putSaveSnapshot: storageMocks.putSaveSnapshot,
|
||||
deleteSaveSnapshot: storageMocks.deleteSaveSnapshot,
|
||||
vi.mock('../services/rpg-entry', () => ({
|
||||
getRpgProfileSettings: storageMocks.getSettings,
|
||||
putRpgProfileSettings: storageMocks.putSettings,
|
||||
}));
|
||||
|
||||
vi.mock('./story/runtimeStoryCoordinator', () => ({
|
||||
resumeServerRuntimeStory: vi.fn(),
|
||||
vi.mock('../services/rpg-runtime', () => ({
|
||||
rpgSnapshotClient: {
|
||||
getSnapshot: storageMocks.getSaveSnapshot,
|
||||
putSnapshot: storageMocks.putSaveSnapshot,
|
||||
deleteSnapshot: storageMocks.deleteSaveSnapshot,
|
||||
},
|
||||
}));
|
||||
|
||||
function SettingsHarness({ authenticatedUserId }: { authenticatedUserId: string | null }) {
|
||||
@@ -52,7 +53,7 @@ function PersistenceHarness({
|
||||
}: {
|
||||
authenticatedUserId: string | null;
|
||||
}) {
|
||||
const persistence = useGamePersistence({
|
||||
const persistence = useRpgSessionPersistence({
|
||||
authenticatedUserId,
|
||||
gameState: {} as GameState,
|
||||
bottomTab: 'adventure' as BottomTab,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
|
||||
import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary';
|
||||
import { WorldType } from '../types';
|
||||
import { useGameFlow } from './useGameFlow';
|
||||
import { useRpgSessionBootstrap } from './rpg-session';
|
||||
|
||||
function buildBackstoryReveal(label: string) {
|
||||
return {
|
||||
@@ -298,7 +298,7 @@ function GameFlowHarness() {
|
||||
);
|
||||
const selectedCharacter = playableCharacters[0] ?? null;
|
||||
const { gameState, handleCustomWorldSelect, handleCharacterSelect } =
|
||||
useGameFlow();
|
||||
useRpgSessionBootstrap();
|
||||
|
||||
const snapshot = {
|
||||
worldType: gameState.worldType,
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
writeSavedSettings,
|
||||
} from '../persistence/gameSettingsStorage';
|
||||
import { isAbortError } from '../services/apiClient';
|
||||
import { getSettings, putSettings } from '../services/storageService';
|
||||
import {
|
||||
getRpgProfileSettings,
|
||||
putRpgProfileSettings,
|
||||
} from '../services/rpg-entry';
|
||||
|
||||
const SETTINGS_SYNC_DELAY_MS = 180;
|
||||
|
||||
@@ -65,7 +68,7 @@ export function useGameSettings(authenticatedUserId: string | null = null) {
|
||||
setHasHydratedSettings(false);
|
||||
setIsHydratingSettings(true);
|
||||
|
||||
void getSettings({ signal: controller.signal })
|
||||
void getRpgProfileSettings({ signal: controller.signal })
|
||||
.then((settings) => {
|
||||
const nextVolume = clampVolume(settings.musicVolume);
|
||||
const nextPlatformTheme = normalizePlatformTheme(settings.platformTheme);
|
||||
@@ -130,7 +133,7 @@ export function useGameSettings(authenticatedUserId: string | null = null) {
|
||||
setIsPersistingSettings(true);
|
||||
setSettingsError(null);
|
||||
|
||||
void putSettings(
|
||||
void putRpgProfileSettings(
|
||||
{
|
||||
musicVolume,
|
||||
platformTheme,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { resolveServerRuntimeChoice } from './story/runtimeStoryCoordinator';
|
||||
import { resolveRpgRuntimeChoice } from './rpg-runtime-story';
|
||||
import { Character, GameState, StoryMoment, StoryOption } from '../types';
|
||||
|
||||
export function useTreasureFlow({
|
||||
@@ -36,7 +36,7 @@ export function useTreasureFlow({
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } =
|
||||
await resolveServerRuntimeChoice({
|
||||
await resolveRpgRuntimeChoice({
|
||||
gameState,
|
||||
currentStory: runtime.currentStory,
|
||||
option,
|
||||
|
||||
Reference in New Issue
Block a user