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:
2026-04-21 20:16:01 +08:00
477 changed files with 38047 additions and 26570 deletions

View File

@@ -1,4 +1,4 @@
import {type Dispatch, type SetStateAction,useState} from 'react';
import {type Dispatch, type SetStateAction,useState} from 'react';
import {
generateCharacterPanelChatSuggestions,

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
buildGoalStackState,

View 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';

View File

@@ -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: {

View File

@@ -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([

View File

@@ -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: {

View File

@@ -1,4 +1,4 @@
import type { Dispatch, SetStateAction } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import {
acceptQuest,

View File

@@ -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;

View File

@@ -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',

View File

@@ -0,0 +1,7 @@
export {
loadRpgRuntimeOptionCatalog as loadServerRuntimeOptionCatalog,
resolveRpgRuntimeChoice as resolveServerRuntimeChoice,
resumeRpgRuntimeStory as resumeServerRuntimeStory,
type LoadRpgRuntimeOptionCatalogParams,
type ResolveRpgRuntimeChoiceParams,
} from './rpgRuntimeStoryGateway';

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import { buildInitialNpcState } from '../../data/npcInteractions';
import {

View File

@@ -1,4 +1,4 @@
import type {
import type {
Dispatch,
SetStateAction,
} from 'react';

View File

@@ -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';

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type {
Character,

View File

@@ -1,4 +1,4 @@
import type { Dispatch, SetStateAction } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import type {
Character,

View File

@@ -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';

View File

@@ -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,

View File

@@ -1,4 +1,4 @@
import { getCharacterById } from '../../data/characterPresets';
import { getCharacterById } from '../../data/characterPresets';
import {
NPC_CHAT_FUNCTION,
STORY_OPENING_CAMP_DIALOGUE_FUNCTION,

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import {
AnimationState,

View File

@@ -1,4 +1,4 @@
import { buildNpcPreviewTalkOption } from '../../data/functionCatalog';
import { buildNpcPreviewTalkOption } from '../../data/functionCatalog';
import {
getDefaultFunctionIdsForContext,
resolveFunctionOption,

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
const { scenes } = vi.hoisted(() => ({
scenes: [

View File

@@ -1,4 +1,4 @@
import {
import {
buildNpcGiftModalState,
buildNpcRecruitModalState,
buildNpcTradeModalState,

View File

@@ -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,

View File

@@ -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,

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
AnimationState,

View File

@@ -1,4 +1,4 @@
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
import type {
Character,
GameState,

View File

@@ -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';

View File

@@ -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,

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type {
Character,

View File

@@ -1,4 +1,4 @@
import type {
import type {
Character,
GameState,
StoryMoment,

View File

@@ -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';

View File

@@ -1,4 +1,4 @@
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
import type { StoryOption } from '../../types';
type ResolveStoryResponseOptionsParams = {

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import type { GameState, InventoryItem } from '../../types';
import {

View File

@@ -1,4 +1,4 @@
import {
import {
buildInitialNpcState,
buildNpcEncounterStoryMoment,
normalizeNpcPersistentState,

View File

@@ -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;
}

View File

@@ -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')),
});

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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>;

View File

@@ -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
>;

View File

@@ -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
>;

View File

@@ -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)}`)),

View File

@@ -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 resethydration 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;

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { createClearStoryChoiceUi } from './useStoryChoiceCoordinator';

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react';
import { useCallback, useState } from 'react';
import { createStoryChoiceActions } from './choiceActions';
import {

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { createClearStoryGoalOptionUi } from './useStoryGoalOptionCoordinator';

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback } from 'react';
import type { GameState, StoryMoment } from '../../types';
import { useStoryOptions } from '../useStoryOptions';

View 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';

View File

@@ -0,0 +1,3 @@
import type { BottomTab } from '../../types/navigation';
export type { BottomTab };

View File

@@ -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>;

View File

@@ -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
>;

View File

@@ -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
>;

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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,
};
}

View File

@@ -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',
]);
});
});

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
};
}

View File

@@ -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,