Merge branch 'codex/dev' into codex/backend-rewrite-spacetimedb
# Conflicts: # docs/technical/README.md # server-node/src/modules/assets/qwenSpriteRoutes.ts # src/components/CustomWorldResultView.test.tsx # src/components/CustomWorldResultView.tsx # src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx # src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx # src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx # src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx # src/components/rpg-entry/RpgEntryCharacterSelectView.tsx # src/components/rpg-entry/RpgEntryHomeView.tsx # src/services/apiClient.ts # src/tools/QwenSpriteSheetTool.tsx
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,27 +1,21 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
resolveServerRuntimeChoiceMock,
|
||||
streamNpcChatTurnMock,
|
||||
generateQuestForNpcEncounterMock,
|
||||
} = vi.hoisted(() => ({
|
||||
resolveServerRuntimeChoiceMock: vi.fn(),
|
||||
streamNpcChatTurnMock: vi.fn(),
|
||||
generateQuestForNpcEncounterMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./runtimeStoryCoordinator', () => ({
|
||||
resolveServerRuntimeChoice: resolveServerRuntimeChoiceMock,
|
||||
vi.mock('./rpgRuntimeStoryGateway', () => ({
|
||||
resolveRpgRuntimeChoice: resolveServerRuntimeChoiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/aiService', () => ({
|
||||
streamNpcChatTurn: streamNpcChatTurnMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/questDirector', () => ({
|
||||
generateQuestForNpcEncounter: generateQuestForNpcEncounterMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
@@ -32,7 +26,7 @@ import {
|
||||
type StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { createStoryNpcEncounterActions } from './npcEncounterActions';
|
||||
import { createStoryNpcEncounterActions } from './useRpgRuntimeNpcInteraction';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
@@ -593,7 +587,6 @@ describe('npcEncounterActions', () => {
|
||||
beforeEach(() => {
|
||||
resolveServerRuntimeChoiceMock.mockReset();
|
||||
streamNpcChatTurnMock.mockReset();
|
||||
generateQuestForNpcEncounterMock.mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -1371,8 +1364,6 @@ describe('npcEncounterActions', () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(generateQuestForNpcEncounterMock).not.toHaveBeenCalled();
|
||||
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(lastStory.npcChatState?.pendingQuestOffer?.quest).toEqual(pendingQuest);
|
||||
expect(lastStory.npcAffinityEffect).toEqual({
|
||||
@@ -1393,18 +1384,38 @@ describe('npcEncounterActions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces a pending quest offer by reusing the existing quest generator', async () => {
|
||||
it('replaces a pending quest offer through the server runtime resolver', async () => {
|
||||
const currentQuest = createQuest('quest-bridge-offer', '断桥口的密信');
|
||||
const nextQuest = createQuest('quest-bridge-replaced', '断桥夜巡');
|
||||
generateQuestForNpcEncounterMock.mockResolvedValueOnce(nextQuest);
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
hydratedSnapshot: {
|
||||
gameState: createState(),
|
||||
},
|
||||
nextStory: createPendingQuestOfferStory(nextQuest),
|
||||
});
|
||||
|
||||
const actions = createNpcEncounterActions({
|
||||
currentStory: createPendingQuestOfferStory(currentQuest),
|
||||
});
|
||||
|
||||
await expect(actions.replacePendingNpcQuestOffer()).resolves.toBe(true);
|
||||
expect(actions.replacePendingNpcQuestOffer()).toBe(true);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(generateQuestForNpcEncounterMock).toHaveBeenCalledTimes(1);
|
||||
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gameState: actions.gameState,
|
||||
currentStory: actions.currentStory,
|
||||
option: expect.objectContaining({
|
||||
functionId: 'npc_chat_quest_offer_replace',
|
||||
actionText: '你请断桥客换一份更合适的委托。',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'quest_offer_replace',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(lastStory.npcChatState?.pendingQuestOffer?.quest).toEqual(nextQuest);
|
||||
expect(lastStory.options.map((option) => option.actionText)).toEqual([
|
||||
@@ -1412,12 +1423,6 @@ describe('npcEncounterActions', () => {
|
||||
'更换任务',
|
||||
'放弃任务',
|
||||
]);
|
||||
expect(lastStory.dialogue?.at(-2)).toEqual(
|
||||
expect.objectContaining({
|
||||
speaker: 'player',
|
||||
text: '能不能换一份更适合眼下局势的委托?',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards pending quest offer acceptance to the server runtime resolver', async () => {
|
||||
@@ -1485,14 +1490,78 @@ describe('npcEncounterActions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('abandons a pending quest offer and returns to free npc chat', () => {
|
||||
it('abandons a pending quest offer through the server runtime resolver', async () => {
|
||||
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
hydratedSnapshot: {
|
||||
gameState: createState(),
|
||||
},
|
||||
nextStory: {
|
||||
...createPendingQuestOfferStory(pendingQuest),
|
||||
options: [
|
||||
createOption('npc_chat', '那先继续聊聊你刚才没说完的部分', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'chat',
|
||||
}),
|
||||
createOption('npc_chat', '除了委托,你对眼前局势还有什么判断', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'chat',
|
||||
}),
|
||||
createOption('npc_chat', '先把这附近真正危险的地方说清楚', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'chat',
|
||||
}),
|
||||
],
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: '断桥客',
|
||||
text: '这件事我只想托给你。',
|
||||
},
|
||||
{
|
||||
speaker: 'player',
|
||||
text: '这件事我先不接,咱们还是先聊别的。',
|
||||
},
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: '断桥客',
|
||||
text: '那就先聊别的。',
|
||||
},
|
||||
],
|
||||
npcChatState: {
|
||||
npcId: 'npc-rival',
|
||||
npcName: '断桥客',
|
||||
turnCount: 2,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
pendingQuestOffer: null,
|
||||
},
|
||||
} satisfies StoryMoment,
|
||||
});
|
||||
const actions = createNpcEncounterActions({
|
||||
currentStory: createPendingQuestOfferStory(pendingQuest),
|
||||
});
|
||||
|
||||
expect(actions.abandonPendingNpcQuestOffer()).toBe(true);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gameState: actions.gameState,
|
||||
currentStory: actions.currentStory,
|
||||
option: expect.objectContaining({
|
||||
functionId: 'npc_chat_quest_offer_abandon',
|
||||
actionText: '你暂时没有接下断桥客提出的委托。',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'quest_offer_abandon',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(lastStory.npcChatState?.pendingQuestOffer).toBeNull();
|
||||
expect(lastStory.options.map((option) => option.actionText)).toEqual([
|
||||
@@ -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 {
|
||||
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,
|
||||
resolveRuntimeStoryMoment,
|
||||
getRpgRuntimeClientVersion,
|
||||
getRpgRuntimeSessionId,
|
||||
getRpgRuntimeStoryState,
|
||||
resolveRpgRuntimeStoryAction,
|
||||
type RuntimeStorySnapshotRequest,
|
||||
resolveRpgRuntimeStoryMoment,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryResponse,
|
||||
} from '../../services/runtimeStoryService';
|
||||
import { putSaveSnapshot } from '../../services/storageService';
|
||||
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
|
||||
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||
@@ -18,27 +18,31 @@ function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||
: response.presentation.options;
|
||||
}
|
||||
|
||||
async function syncRuntimeSnapshot(
|
||||
function buildRuntimeSnapshotRequest(
|
||||
gameState: GameState,
|
||||
currentStory: StoryMoment | null,
|
||||
) {
|
||||
await putSaveSnapshot({
|
||||
): RuntimeStorySnapshotRequest {
|
||||
return {
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端访问服务端 runtime story 的统一网关。
|
||||
* 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。
|
||||
*/
|
||||
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 = resolveRuntimeStoryMoment({
|
||||
const response = await getRpgRuntimeStoryState({
|
||||
sessionId: getRpgRuntimeSessionId(params.gameState),
|
||||
clientVersion: getRpgRuntimeClientVersion(params.gameState),
|
||||
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
|
||||
});
|
||||
const options = resolveRpgRuntimeStoryMoment({
|
||||
response,
|
||||
hydratedSnapshot: response.snapshot,
|
||||
fallbackGameState: params.gameState,
|
||||
@@ -64,14 +68,14 @@ export async function resumeServerRuntimeStory(
|
||||
};
|
||||
}
|
||||
|
||||
const response = await getRuntimeStoryState(
|
||||
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,24 +100,23 @@ export async function resolveServerRuntimeChoice(params: {
|
||||
Partial<Pick<StoryOption, 'interaction'>>;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
}) {
|
||||
await syncRuntimeSnapshot(params.gameState, params.currentStory);
|
||||
|
||||
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'
|
||||
? params.option.interaction.npcId
|
||||
: undefined,
|
||||
payload: params.payload,
|
||||
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
|
||||
});
|
||||
const hydratedSnapshot = rehydrateSavedSnapshot(response.snapshot);
|
||||
|
||||
return {
|
||||
response,
|
||||
hydratedSnapshot,
|
||||
nextStory: resolveRuntimeStoryMoment({
|
||||
nextStory: resolveRpgRuntimeStoryMoment({
|
||||
response,
|
||||
hydratedSnapshot,
|
||||
fallbackGameState: params.gameState,
|
||||
@@ -124,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,31 +1,31 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
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 () => {
|
||||
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,
|
||||
@@ -149,7 +149,6 @@ function createRuntimeNpcBattleSnapshot(
|
||||
|
||||
describe('runtimeStoryCoordinator', () => {
|
||||
beforeEach(() => {
|
||||
putSaveSnapshotMock.mockReset();
|
||||
getRuntimeStoryStateMock.mockReset();
|
||||
resolveRuntimeStoryActionMock.mockReset();
|
||||
getRuntimeSessionIdMock.mockReset();
|
||||
@@ -209,12 +208,15 @@ describe('runtimeStoryCoordinator', () => {
|
||||
currentStory,
|
||||
});
|
||||
|
||||
expect(putSaveSnapshotMock).toHaveBeenCalledWith({
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 7,
|
||||
snapshot: {
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
},
|
||||
});
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith('runtime-main');
|
||||
expect(options).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
@@ -306,11 +308,6 @@ describe('runtimeStoryCoordinator', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(putSaveSnapshotMock).toHaveBeenCalledWith({
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
});
|
||||
expect(resolveRuntimeStoryActionMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 7,
|
||||
@@ -319,6 +316,11 @@ describe('runtimeStoryCoordinator', () => {
|
||||
payload: {
|
||||
note: 'server-runtime-test',
|
||||
},
|
||||
snapshot: {
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
},
|
||||
});
|
||||
expect(result.hydratedSnapshot).toBe(hydratedSnapshot);
|
||||
expect(result.nextStory).toEqual(
|
||||
@@ -414,7 +416,9 @@ describe('runtimeStoryCoordinator', () => {
|
||||
|
||||
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
|
||||
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith('runtime-main');
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
});
|
||||
expect(result.hydratedSnapshot).toBe(serverHydratedSnapshot);
|
||||
expect(result.nextStory).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -614,6 +618,9 @@ describe('runtimeStoryCoordinator', () => {
|
||||
|
||||
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
|
||||
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
});
|
||||
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'npc-bandit',
|
||||
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 { 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 { 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,
|
||||
@@ -82,7 +82,7 @@ export interface QuestFlowUi {
|
||||
}
|
||||
|
||||
export interface NpcChatQuestOfferUi {
|
||||
replacePendingOffer: () => Promise<boolean>;
|
||||
replacePendingOffer: () => boolean;
|
||||
abandonPendingOffer: () => boolean;
|
||||
acceptPendingOffer: () => string | null;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -24,7 +24,6 @@ import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import {
|
||||
resolveLimitedPrimaryNpcChatState,
|
||||
} from '../../services/customWorldSceneActRuntime';
|
||||
import { generateQuestForNpcEncounter } from '../../services/questDirector';
|
||||
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
@@ -40,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,
|
||||
@@ -104,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,
|
||||
@@ -1536,7 +1539,7 @@ export function createStoryNpcEncounterActions({
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({
|
||||
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option: params.option,
|
||||
@@ -1558,81 +1561,36 @@ export function createStoryNpcEncounterActions({
|
||||
}
|
||||
};
|
||||
|
||||
const replacePendingNpcQuestOffer = async () => {
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
const replacePendingNpcQuestOffer = () => {
|
||||
const encounter = gameState.currentEncounter;
|
||||
const pendingQuestOffer = isNpcEncounter(encounter)
|
||||
? getPendingQuestOffer(currentStory, encounter)
|
||||
: null;
|
||||
if (!playerCharacter || !encounter || !pendingQuestOffer) {
|
||||
if (!encounter || !pendingQuestOffer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const encounterKey = getNpcEncounterKey(encounter);
|
||||
const currentNpcChatState =
|
||||
currentStory?.npcChatState?.npcId === encounterKey
|
||||
? currentStory.npcChatState
|
||||
: null;
|
||||
const currentDialogue =
|
||||
currentStory?.dialogue && currentNpcChatState
|
||||
? [...currentStory.dialogue]
|
||||
: [];
|
||||
const turnCount = currentNpcChatState?.turnCount ?? 0;
|
||||
const playerLine = '能不能换一份更适合眼下局势的委托?';
|
||||
const generationState = {
|
||||
...gameState,
|
||||
storyHistory: appendHistory(
|
||||
gameState,
|
||||
`你请${encounter.npcName}换一份更合适的委托。`,
|
||||
`${encounter.npcName}重新斟酌起该交给你的事。`,
|
||||
),
|
||||
};
|
||||
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const nextQuest = await generateQuestForNpcEncounter({
|
||||
state: generationState,
|
||||
encounter,
|
||||
});
|
||||
if (!nextQuest) {
|
||||
setAiError('当前没有更合适的委托可供更换。');
|
||||
return false;
|
||||
}
|
||||
|
||||
setGameState(generationState);
|
||||
setCurrentStory(
|
||||
buildNpcChatStoryMoment({
|
||||
encounter,
|
||||
dialogue: [
|
||||
...currentDialogue,
|
||||
{
|
||||
speaker: 'player',
|
||||
text: playerLine,
|
||||
},
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: encounter.npcName,
|
||||
text: buildQuestOfferDialogueText(encounter, nextQuest),
|
||||
},
|
||||
],
|
||||
options: buildPendingQuestOfferOptions(encounter),
|
||||
streaming: false,
|
||||
turnCount,
|
||||
pendingQuestOffer: {
|
||||
quest: nextQuest,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to replace pending npc quest offer:', error);
|
||||
setAiError(error instanceof Error ? error.message : '更换任务失败');
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
void resolveServerNpcStoryAction({
|
||||
option: {
|
||||
functionId: 'npc_chat_quest_offer_replace',
|
||||
actionText: `你请${encounter.npcName}换一份更合适的委托。`,
|
||||
text: `你请${encounter.npcName}换一份更合适的委托。`,
|
||||
detailText: '',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: encounter.id ?? encounter.npcName,
|
||||
action: 'quest_offer_replace',
|
||||
},
|
||||
},
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const abandonPendingNpcQuestOffer = () => {
|
||||
@@ -1643,49 +1601,27 @@ export function createStoryNpcEncounterActions({
|
||||
if (!encounter || !pendingQuestOffer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const encounterKey = getNpcEncounterKey(encounter);
|
||||
const currentNpcChatState =
|
||||
currentStory?.npcChatState?.npcId === encounterKey
|
||||
? currentStory.npcChatState
|
||||
: null;
|
||||
const currentDialogue =
|
||||
currentStory?.dialogue && currentNpcChatState
|
||||
? [...currentStory.dialogue]
|
||||
: [];
|
||||
const turnCount = currentNpcChatState?.turnCount ?? 0;
|
||||
const playerLine = '这件事我先不接,咱们还是先聊别的。';
|
||||
const npcReply = `${encounter.npcName}点了点头,没有继续强求,只把这份委托暂时收了回去。`;
|
||||
const nextState = {
|
||||
...gameState,
|
||||
storyHistory: appendHistory(
|
||||
gameState,
|
||||
`你暂时没有接下${encounter.npcName}提出的委托。`,
|
||||
npcReply,
|
||||
),
|
||||
};
|
||||
|
||||
setGameState(nextState);
|
||||
setCurrentStory(
|
||||
buildNpcChatStoryMoment({
|
||||
encounter,
|
||||
dialogue: [
|
||||
...currentDialogue,
|
||||
{
|
||||
speaker: 'player',
|
||||
text: playerLine,
|
||||
},
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: encounter.npcName,
|
||||
text: npcReply,
|
||||
},
|
||||
],
|
||||
options: buildPostQuestOfferChatSuggestions(encounter),
|
||||
streaming: false,
|
||||
turnCount,
|
||||
}),
|
||||
);
|
||||
void resolveServerNpcStoryAction({
|
||||
option: {
|
||||
functionId: 'npc_chat_quest_offer_abandon',
|
||||
actionText: `你暂时没有接下${encounter.npcName}提出的委托。`,
|
||||
text: `你暂时没有接下${encounter.npcName}提出的委托。`,
|
||||
detailText: '',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: encounter.id ?? encounter.npcName,
|
||||
action: 'quest_offer_abandon',
|
||||
},
|
||||
},
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1869,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,
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
hasEncounterEntity,
|
||||
interpolateEncounterTransitionState,
|
||||
} from '../../data/encounterTransition';
|
||||
import { STORY_OPENING_CAMP_DIALOGUE_FUNCTION } from '../../data/functionCatalog';
|
||||
import {
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
RESOLVED_ENTITY_X_METERS,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { getWorldCampScenePreset } from '../../data/scenePresets';
|
||||
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
|
||||
import { generateNextStep } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
|
||||
const ENCOUNTER_ENTRY_DURATION_MS = 1800;
|
||||
const ENCOUNTER_ENTRY_TICK_MS = 180;
|
||||
const OPENING_CAMP_DIALOGUE_FUNCTION_ID =
|
||||
STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
|
||||
|
||||
export type PreparedOpeningAdventure = {
|
||||
encounterKey: string;
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
fallbackText: string;
|
||||
openingOptions: StoryOption[];
|
||||
};
|
||||
|
||||
export function buildPreparedOpeningAdventure({
|
||||
state,
|
||||
character,
|
||||
getNpcEncounterKey,
|
||||
appendHistory,
|
||||
buildCampCompanionOpeningOptions,
|
||||
buildCampCompanionOpeningResultText,
|
||||
buildInitialCompanionDialogueText,
|
||||
}: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
getNpcEncounterKey: (encounter: Encounter) => string;
|
||||
appendHistory: (
|
||||
state: GameState,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
) => GameState['storyHistory'];
|
||||
buildCampCompanionOpeningOptions: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
) => StoryOption[];
|
||||
buildCampCompanionOpeningResultText: (
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
worldType: GameState['worldType'],
|
||||
) => string;
|
||||
buildInitialCompanionDialogueText: (
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
worldType: GameState['worldType'],
|
||||
) => string;
|
||||
}): PreparedOpeningAdventure | null {
|
||||
const encounter = state.currentEncounter;
|
||||
if (
|
||||
!encounter ||
|
||||
encounter.kind !== 'npc' ||
|
||||
encounter.specialBehavior !== 'initial_companion'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const campScene = state.worldType
|
||||
? getWorldCampScenePreset(state.worldType)
|
||||
: null;
|
||||
const actionText = '开始冒险';
|
||||
const resultText = buildCampCompanionOpeningResultText(
|
||||
character,
|
||||
encounter,
|
||||
state.worldType,
|
||||
);
|
||||
const dialogueText = buildInitialCompanionDialogueText(
|
||||
character,
|
||||
encounter,
|
||||
state.worldType,
|
||||
);
|
||||
const resolvedEncounter: Encounter = {
|
||||
...encounter,
|
||||
specialBehavior: 'camp_companion',
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
};
|
||||
const resolvedState: GameState = {
|
||||
...state,
|
||||
currentScenePreset: campScene ?? state.currentScenePreset,
|
||||
currentEncounter: resolvedEncounter,
|
||||
npcInteractionActive: false,
|
||||
};
|
||||
const nextHistory = appendHistory(state, actionText, resultText);
|
||||
const stateWithHistory: GameState = {
|
||||
...resolvedState,
|
||||
storyHistory: nextHistory,
|
||||
};
|
||||
|
||||
return {
|
||||
encounterKey: getNpcEncounterKey(encounter),
|
||||
actionText,
|
||||
resultText,
|
||||
fallbackText: dialogueText,
|
||||
openingOptions: buildCampCompanionOpeningOptions(
|
||||
stateWithHistory,
|
||||
character,
|
||||
resolvedEncounter,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function playOpeningAdventureSequence({
|
||||
gameState,
|
||||
encounter,
|
||||
preparedStory,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
character: Character;
|
||||
encounter: Encounter;
|
||||
preparedStory: PreparedOpeningAdventure;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
buildDialogueStoryMoment: (
|
||||
npcName: string,
|
||||
text: string,
|
||||
options: StoryOption[],
|
||||
streaming?: boolean,
|
||||
) => StoryMoment;
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: { lastFunctionId?: string | null },
|
||||
) => StoryGenerationContext;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
hasRenderableDialogueTurns: (text: string, npcName: string) => boolean;
|
||||
inferOpeningCampFollowupOptions: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
baseOptions: StoryOption[],
|
||||
openingBackground: string,
|
||||
openingDialogue: string,
|
||||
) => Promise<StoryOption[]>;
|
||||
getTypewriterDelay: (char: string) => number;
|
||||
}) {
|
||||
const { fallbackText, openingOptions } = preparedStory;
|
||||
const campScene = gameState.worldType
|
||||
? getWorldCampScenePreset(gameState.worldType)
|
||||
: null;
|
||||
const storyEncounter: Encounter = {
|
||||
...encounter,
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
specialBehavior: 'camp_companion',
|
||||
};
|
||||
const resolvedState: GameState = {
|
||||
...gameState,
|
||||
currentScenePreset: campScene ?? gameState.currentScenePreset,
|
||||
currentEncounter: storyEncounter,
|
||||
npcInteractionActive: true,
|
||||
};
|
||||
|
||||
setAiError(null);
|
||||
setIsLoading(false);
|
||||
|
||||
try {
|
||||
setGameState(resolvedState);
|
||||
setCurrentStory({
|
||||
text: fallbackText,
|
||||
options: sortStoryOptionsByPriority(openingOptions),
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: encounter.npcName,
|
||||
text: fallbackText,
|
||||
},
|
||||
],
|
||||
streaming: false,
|
||||
npcChatState: {
|
||||
npcId: storyEncounter.id ?? storyEncounter.npcName,
|
||||
npcName: storyEncounter.npcName,
|
||||
turnCount: 0,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to play opening adventure sequence:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setGameState(resolvedState);
|
||||
setCurrentStory({
|
||||
text: fallbackText,
|
||||
options: sortStoryOptionsByPriority(openingOptions),
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: encounter.npcName,
|
||||
text: fallbackText,
|
||||
},
|
||||
],
|
||||
streaming: false,
|
||||
npcChatState: {
|
||||
npcId: storyEncounter.id ?? storyEncounter.npcName,
|
||||
npcName: storyEncounter.npcName,
|
||||
turnCount: 0,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
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,
|
||||
interaction?: StoryOption['interaction'],
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
interaction,
|
||||
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',
|
||||
characterId: 'sword-princess',
|
||||
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('眼下的风向不对,我们不能直接把底牌亮出来。');
|
||||
expect(text).toContain('我真正要找的东西,还不能让更多人知道。');
|
||||
expect(text).not.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 the opening camp options focused on继续交谈', () => {
|
||||
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']);
|
||||
});
|
||||
|
||||
it('uses AI follow-up options when the camp follow-up request succeeds and falls back on errors', async () => {
|
||||
const baseOptions = [
|
||||
createOption('npc_chat', '继续交谈', {
|
||||
kind: 'npc',
|
||||
npcId: 'camp-companion',
|
||||
action: 'chat',
|
||||
}),
|
||||
createOption('camp_travel_home_scene', '前往旧地点'),
|
||||
];
|
||||
const generateNextStep = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
storyText: '继续营地交谈',
|
||||
options: [
|
||||
createOption('npc_chat', '顺着刚才的话继续问下去'),
|
||||
createOption('camp_travel_home_scene', '先回云河渡'),
|
||||
],
|
||||
})
|
||||
.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).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
actionText: '顺着刚才的话继续问下去',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'camp-companion',
|
||||
action: 'chat',
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'camp_travel_home_scene',
|
||||
actionText: '先回云河渡',
|
||||
}),
|
||||
]);
|
||||
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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,293 +0,0 @@
|
||||
import {
|
||||
getCharacterAdventureOpening,
|
||||
getCharacterHomeSceneId,
|
||||
} from '../../data/characterPresets';
|
||||
import {
|
||||
buildCampTravelHomeOption,
|
||||
NPC_CHAT_FUNCTION,
|
||||
NPC_FIGHT_FUNCTION,
|
||||
NPC_LEAVE_FUNCTION,
|
||||
} from '../../data/functionCatalog';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
buildNpcChatOpeningText,
|
||||
} 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';
|
||||
import { resolveStoryResponseOptions } from './storyResponseOptions';
|
||||
|
||||
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 resolvedEncounter =
|
||||
encounter.characterId === character.id
|
||||
? encounter
|
||||
: {
|
||||
...encounter,
|
||||
characterId: encounter.characterId ?? character.id,
|
||||
};
|
||||
const initialNpcState = buildInitialNpcState(resolvedEncounter, worldType);
|
||||
return buildNpcChatOpeningText(
|
||||
resolvedEncounter,
|
||||
initialNpcState,
|
||||
worldType,
|
||||
character,
|
||||
);
|
||||
}
|
||||
|
||||
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 baseOptions = params.buildNpcStory(
|
||||
state,
|
||||
character,
|
||||
encounter,
|
||||
).options;
|
||||
return baseOptions
|
||||
.filter((option) => option.functionId === NPC_CHAT_FUNCTION.id)
|
||||
.slice(0, 3);
|
||||
};
|
||||
|
||||
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 resolveStoryResponseOptions({
|
||||
responseOptions: response.options,
|
||||
availableOptions: baseOptions,
|
||||
getSanitizedOptions: () => sortStoryOptionsByPriority(baseOptions),
|
||||
});
|
||||
} 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,
|
||||
};
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import {
|
||||
applyEquipmentLoadoutToState,
|
||||
getEquipmentSlotFromItem,
|
||||
getEquipmentSlotLabel,
|
||||
} from '../data/equipmentEffects';
|
||||
import {
|
||||
EQUIPMENT_EQUIP_FUNCTION,
|
||||
EQUIPMENT_UNEQUIP_FUNCTION,
|
||||
} from '../data/functionCatalog';
|
||||
import {
|
||||
addInventoryItems,
|
||||
removeInventoryItem,
|
||||
} from '../data/npcInteractions';
|
||||
import { EquipmentSlotId, GameState, InventoryItem } from '../types';
|
||||
import type { CommitGeneratedState } from './generatedState';
|
||||
|
||||
function normalizeEquippedItem(item: InventoryItem) {
|
||||
return {
|
||||
...item,
|
||||
quantity: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEquipResultText(
|
||||
item: InventoryItem,
|
||||
slot: EquipmentSlotId,
|
||||
replacedItem?: InventoryItem | null,
|
||||
) {
|
||||
return replacedItem
|
||||
? `你将${replacedItem.name}从${getEquipmentSlotLabel(slot)}位上换下,改为装备${item.name}。`
|
||||
: `你将${item.name}装备在${getEquipmentSlotLabel(slot)}位上。`;
|
||||
}
|
||||
|
||||
function buildUnequipResultText(item: InventoryItem) {
|
||||
return `你卸下了${item.name},暂时收回背包。`;
|
||||
}
|
||||
|
||||
export function useEquipmentFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
}) {
|
||||
const handleEquipInventoryItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!gameState.playerCharacter || gameState.inBattle) return false;
|
||||
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (!item || item.quantity <= 0) return false;
|
||||
|
||||
const slot = getEquipmentSlotFromItem(item);
|
||||
if (!slot) return false;
|
||||
|
||||
const replacedItem = gameState.playerEquipment[slot];
|
||||
const nextEquipment = {
|
||||
...gameState.playerEquipment,
|
||||
[slot]: normalizeEquippedItem(item),
|
||||
};
|
||||
|
||||
let nextInventory = removeInventoryItem(
|
||||
gameState.playerInventory,
|
||||
item.id,
|
||||
1,
|
||||
);
|
||||
if (replacedItem) {
|
||||
nextInventory = addInventoryItems(nextInventory, [replacedItem]);
|
||||
}
|
||||
|
||||
const nextState = applyEquipmentLoadoutToState(
|
||||
{
|
||||
...gameState,
|
||||
playerInventory: nextInventory,
|
||||
},
|
||||
nextEquipment,
|
||||
);
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`装备${item.name}`,
|
||||
buildEquipResultText(item, slot, replacedItem),
|
||||
EQUIPMENT_EQUIP_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, gameState],
|
||||
);
|
||||
|
||||
const handleUnequipItem = useCallback(
|
||||
async (slot: EquipmentSlotId) => {
|
||||
if (!gameState.playerCharacter || gameState.inBattle) return false;
|
||||
|
||||
const equippedItem = gameState.playerEquipment[slot];
|
||||
if (!equippedItem) return false;
|
||||
|
||||
const nextEquipment = {
|
||||
...gameState.playerEquipment,
|
||||
[slot]: null,
|
||||
};
|
||||
const nextState = applyEquipmentLoadoutToState(
|
||||
{
|
||||
...gameState,
|
||||
playerInventory: addInventoryItems(gameState.playerInventory, [
|
||||
equippedItem,
|
||||
]),
|
||||
},
|
||||
nextEquipment,
|
||||
);
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`卸下${equippedItem.name}`,
|
||||
buildUnequipResultText(equippedItem),
|
||||
EQUIPMENT_UNEQUIP_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, gameState],
|
||||
);
|
||||
|
||||
return {
|
||||
handleEquipInventoryItem,
|
||||
handleUnequipItem,
|
||||
};
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
buildForgeSuccessText,
|
||||
executeDismantleItem,
|
||||
executeForgeRecipe,
|
||||
executeReforgeItem,
|
||||
getForgeRecipeViews,
|
||||
getReforgeCostView,
|
||||
} from '../data/forgeSystem';
|
||||
import {
|
||||
FORGE_CRAFT_FUNCTION,
|
||||
FORGE_DISMANTLE_FUNCTION,
|
||||
FORGE_REFORGE_FUNCTION,
|
||||
} from '../data/functionCatalog';
|
||||
import type { GameState } from '../types';
|
||||
import type { CommitGeneratedState } from './generatedState';
|
||||
|
||||
export function useForgeFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
}) {
|
||||
const forgeRecipes = useMemo(
|
||||
() =>
|
||||
getForgeRecipeViews(
|
||||
gameState.playerInventory,
|
||||
gameState.playerCurrency,
|
||||
gameState.worldType,
|
||||
),
|
||||
[gameState.playerCurrency, gameState.playerInventory, gameState.worldType],
|
||||
);
|
||||
|
||||
const handleCraftRecipe = useCallback(
|
||||
async (recipeId: string) => {
|
||||
if (!gameState.playerCharacter || gameState.inBattle) return false;
|
||||
|
||||
const result = executeForgeRecipe(
|
||||
gameState.playerInventory,
|
||||
recipeId,
|
||||
gameState.worldType,
|
||||
gameState.playerCurrency,
|
||||
);
|
||||
if (!result) return false;
|
||||
|
||||
const recipe = forgeRecipes.find(
|
||||
(candidate) => candidate.id === recipeId,
|
||||
);
|
||||
const nextState: GameState = {
|
||||
...gameState,
|
||||
playerCurrency: result.currency,
|
||||
playerInventory: result.inventory,
|
||||
};
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`制作${result.createdItem.name}`,
|
||||
buildForgeSuccessText('craft', {
|
||||
recipeName: recipe?.name ?? recipeId,
|
||||
createdItemName: result.createdItem.name,
|
||||
currencyText: recipe?.currencyText,
|
||||
}),
|
||||
FORGE_CRAFT_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, forgeRecipes, gameState],
|
||||
);
|
||||
|
||||
const handleDismantleItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!gameState.playerCharacter || gameState.inBattle) return false;
|
||||
|
||||
const sourceItem = gameState.playerInventory.find(
|
||||
(item) => item.id === itemId,
|
||||
);
|
||||
if (!sourceItem) return false;
|
||||
|
||||
const result = executeDismantleItem(gameState.playerInventory, itemId);
|
||||
if (!result) return false;
|
||||
|
||||
const nextState: GameState = {
|
||||
...gameState,
|
||||
playerInventory: result.inventory,
|
||||
};
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`拆解${sourceItem.name}`,
|
||||
buildForgeSuccessText('dismantle', {
|
||||
sourceItemName: sourceItem.name,
|
||||
outputNames: result.outputs.map((item) => item.name),
|
||||
}),
|
||||
FORGE_DISMANTLE_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, gameState],
|
||||
);
|
||||
|
||||
const handleReforgeItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!gameState.playerCharacter || gameState.inBattle) return false;
|
||||
|
||||
const sourceItem = gameState.playerInventory.find(
|
||||
(item) => item.id === itemId,
|
||||
);
|
||||
if (!sourceItem) return false;
|
||||
|
||||
const result = executeReforgeItem(
|
||||
gameState.playerInventory,
|
||||
itemId,
|
||||
gameState.playerCurrency,
|
||||
);
|
||||
if (!result) return false;
|
||||
|
||||
const nextState: GameState = {
|
||||
...gameState,
|
||||
playerCurrency: Math.max(
|
||||
0,
|
||||
gameState.playerCurrency - result.currencyCost,
|
||||
),
|
||||
playerInventory: result.inventory,
|
||||
};
|
||||
|
||||
const reforgeCost = getReforgeCostView(sourceItem, gameState.worldType);
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`重铸${sourceItem.name}`,
|
||||
buildForgeSuccessText('reforge', {
|
||||
sourceItemName: sourceItem.name,
|
||||
createdItemName: result.reforgedItem.name,
|
||||
currencyText: reforgeCost.currencyText,
|
||||
}),
|
||||
FORGE_REFORGE_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, gameState],
|
||||
);
|
||||
|
||||
return {
|
||||
forgeRecipes,
|
||||
handleCraftRecipe,
|
||||
handleDismantleItem,
|
||||
handleReforgeItem,
|
||||
getReforgeCostView,
|
||||
};
|
||||
}
|
||||
@@ -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,99 +0,0 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { appendBuildBuffs } from '../data/buildDamage';
|
||||
import { INVENTORY_USE_FUNCTION } from '../data/functionCatalog';
|
||||
import {
|
||||
buildInventoryUseResultText,
|
||||
isInventoryItemUsable,
|
||||
resolveInventoryItemUseEffect,
|
||||
} from '../data/inventoryEffects';
|
||||
import { removeInventoryItem } from '../data/npcInteractions';
|
||||
import { incrementGameRuntimeStats } from '../data/runtimeStats';
|
||||
import { GameState } from '../types';
|
||||
import type { CommitGeneratedState } from './generatedState';
|
||||
|
||||
type TickCooldowns = (
|
||||
cooldowns: Record<string, number>,
|
||||
) => Record<string, number>;
|
||||
|
||||
export function useInventoryFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
tickCooldowns,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
tickCooldowns: TickCooldowns;
|
||||
}) {
|
||||
const handleUseInventoryItem = useCallback(
|
||||
async (itemId: string) => {
|
||||
if (!gameState.playerCharacter) return false;
|
||||
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (!item || !isInventoryItemUsable(item) || item.quantity <= 0)
|
||||
return false;
|
||||
|
||||
const effect = resolveInventoryItemUseEffect(
|
||||
item,
|
||||
gameState.playerCharacter,
|
||||
);
|
||||
if (!effect) return false;
|
||||
|
||||
if (
|
||||
effect.hpRestore <= 0 &&
|
||||
effect.manaRestore <= 0 &&
|
||||
effect.cooldownReduction <= 0 &&
|
||||
effect.buildBuffs.length <= 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let cooldowns = gameState.playerSkillCooldowns;
|
||||
for (let index = 0; index < effect.cooldownReduction; index += 1) {
|
||||
cooldowns = tickCooldowns(cooldowns);
|
||||
}
|
||||
|
||||
const nextState: GameState = {
|
||||
...gameState,
|
||||
playerHp: Math.min(
|
||||
gameState.playerMaxHp,
|
||||
gameState.playerHp + effect.hpRestore,
|
||||
),
|
||||
playerMana: Math.min(
|
||||
gameState.playerMaxMana,
|
||||
gameState.playerMana + effect.manaRestore,
|
||||
),
|
||||
playerSkillCooldowns: cooldowns,
|
||||
activeBuildBuffs: appendBuildBuffs(
|
||||
gameState.activeBuildBuffs,
|
||||
effect.buildBuffs,
|
||||
),
|
||||
playerInventory: removeInventoryItem(
|
||||
gameState.playerInventory,
|
||||
item.id,
|
||||
1,
|
||||
),
|
||||
runtimeStats: incrementGameRuntimeStats(gameState.runtimeStats, {
|
||||
itemsUsed: 1,
|
||||
}),
|
||||
};
|
||||
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
`使用${item.name}`,
|
||||
buildInventoryUseResultText(item, effect),
|
||||
INVENTORY_USE_FUNCTION.id,
|
||||
);
|
||||
|
||||
return true;
|
||||
},
|
||||
[commitGeneratedState, gameState, tickCooldowns],
|
||||
);
|
||||
|
||||
return {
|
||||
handleUseInventoryItem,
|
||||
};
|
||||
}
|
||||
@@ -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