This commit is contained in:
2026-04-21 18:27:46 +08:00
parent 04bff9617d
commit 4372ab5be1
358 changed files with 30788 additions and 14737 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,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
resolveServerRuntimeChoiceMock,
@@ -8,8 +8,8 @@ const {
streamNpcChatTurnMock: vi.fn(),
}));
vi.mock('./runtimeStoryCoordinator', () => ({
resolveServerRuntimeChoice: resolveServerRuntimeChoiceMock,
vi.mock('./rpgRuntimeStoryGateway', () => ({
resolveRpgRuntimeChoice: resolveServerRuntimeChoiceMock,
}));
vi.mock('../../services/aiService', () => ({
@@ -1423,7 +1423,6 @@ describe('npcEncounterActions', () => {
'更换任务',
'放弃任务',
]);
expect(lastStory.text).toContain('断桥夜巡');
});
it('forwards pending quest offer acceptance to the server runtime resolver', async () => {

View File

@@ -0,0 +1,6 @@
export {
createRpgRuntimeNpcEncounterActions as createStoryNpcEncounterActions,
useRpgRuntimeNpcInteraction,
type RpgRuntimeNpcInteractionResult,
type UseRpgRuntimeNpcInteractionParams,
} from './useRpgRuntimeNpcInteraction';

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 {
hasEncounterEntity,

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,
getRpgRuntimeClientVersion,
getRpgRuntimeSessionId,
getRpgRuntimeStoryState,
resolveRpgRuntimeStoryAction,
type RuntimeStorySnapshotRequest,
resolveRuntimeStoryMoment,
resolveRpgRuntimeStoryMoment,
type RuntimeStoryChoicePayload,
type RuntimeStoryResponse,
} from '../../services/runtimeStoryService';
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
import type { GameState, StoryMoment, StoryOption } from '../../types';
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
@@ -21,24 +21,28 @@ function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
function buildRuntimeSnapshotRequest(
gameState: GameState,
currentStory: StoryMoment | null,
) {
): RuntimeStorySnapshotRequest {
return {
gameState,
bottomTab: 'adventure',
currentStory,
} satisfies RuntimeStorySnapshotRequest;
};
}
/**
* 访 runtime story
* option catalog
*/
export async function loadServerRuntimeOptionCatalog(params: {
gameState: GameState;
currentStory: StoryMoment | null;
}) {
const response = await getRuntimeStoryState({
sessionId: getRuntimeSessionId(params.gameState),
clientVersion: getRuntimeClientVersion(params.gameState),
const response = await getRpgRuntimeStoryState({
sessionId: getRpgRuntimeSessionId(params.gameState),
clientVersion: getRpgRuntimeClientVersion(params.gameState),
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
});
const options = resolveRuntimeStoryMoment({
const options = resolveRpgRuntimeStoryMoment({
response,
hydratedSnapshot: response.snapshot,
fallbackGameState: params.gameState,
@@ -64,14 +68,14 @@ export async function resumeServerRuntimeStory(
};
}
const response = await getRuntimeStoryState({
sessionId: getRuntimeSessionId(hydratedSnapshot.gameState),
const response = await getRpgRuntimeStoryState({
sessionId: getRpgRuntimeSessionId(hydratedSnapshot.gameState),
});
const resumedSnapshot = rehydrateSavedSnapshot(response.snapshot);
const runtimeOptions = getRuntimeResponseOptions(response);
const nextStory =
response.presentation.storyText || runtimeOptions.length > 0
? resolveRuntimeStoryMoment({
? resolveRpgRuntimeStoryMoment({
response,
hydratedSnapshot: resumedSnapshot,
fallbackGameState: hydratedSnapshot.gameState,
@@ -96,9 +100,9 @@ export async function resolveServerRuntimeChoice(params: {
Partial<Pick<StoryOption, 'interaction'>>;
payload?: RuntimeStoryChoicePayload;
}) {
const response = await resolveRuntimeStoryAction({
sessionId: getRuntimeSessionId(params.gameState),
clientVersion: getRuntimeClientVersion(params.gameState),
const response = await resolveRpgRuntimeStoryAction({
sessionId: getRpgRuntimeSessionId(params.gameState),
clientVersion: getRpgRuntimeClientVersion(params.gameState),
option: params.option,
targetId:
params.option.interaction?.kind === 'npc'
@@ -112,7 +116,7 @@ export async function resolveServerRuntimeChoice(params: {
return {
response,
hydratedSnapshot,
nextStory: resolveRuntimeStoryMoment({
nextStory: resolveRpgRuntimeStoryMoment({
response,
hydratedSnapshot,
fallbackGameState: params.gameState,
@@ -123,3 +127,14 @@ export async function resolveServerRuntimeChoice(params: {
}),
};
}
export type LoadRpgRuntimeOptionCatalogParams = Parameters<
typeof loadServerRuntimeOptionCatalog
>[0];
export type ResolveRpgRuntimeChoiceParams = Parameters<
typeof resolveServerRuntimeChoice
>[0];
export const loadRpgRuntimeOptionCatalog = loadServerRuntimeOptionCatalog;
export const resumeRpgRuntimeStory = resumeServerRuntimeStory;
export const resolveRpgRuntimeChoice = resolveServerRuntimeChoice;

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
getRuntimeStoryStateMock,
@@ -12,14 +12,20 @@ const {
getRuntimeClientVersionMock: vi.fn(() => 0),
}));
vi.mock('../../services/runtimeStoryService', async () => {
vi.mock('../../services/rpg-runtime/rpgRuntimeStoryClient', async () => {
const actual =
await vi.importActual<typeof import('../../services/runtimeStoryService')>(
'../../services/runtimeStoryService',
await vi.importActual<
typeof import('../../services/rpg-runtime/rpgRuntimeStoryClient')
>(
'../../services/rpg-runtime/rpgRuntimeStoryClient',
);
return {
...actual,
getRpgRuntimeStoryState: getRuntimeStoryStateMock,
resolveRpgRuntimeStoryAction: resolveRuntimeStoryActionMock,
getRpgRuntimeSessionId: getRuntimeSessionIdMock,
getRpgRuntimeClientVersion: getRuntimeClientVersionMock,
getRuntimeStoryState: getRuntimeStoryStateMock,
resolveRuntimeStoryAction: resolveRuntimeStoryActionMock,
getRuntimeSessionId: getRuntimeSessionIdMock,

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 { describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { getWorldCampScenePreset } from '../../data/scenePresets';
import {

View File

@@ -1,4 +1,4 @@
import {
import {
getCharacterAdventureOpening,
getCharacterHomeSceneId,
} from '../../data/characterPresets';

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 type {
import type {
Character,
GameState,
StoryDialogueTurn,

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,

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';
@@ -39,7 +39,7 @@ import type {
} from '../../types';
import { AnimationState } from '../../types';
import type { CommitGeneratedState } from '../generatedState';
import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator';
import { resolveRpgRuntimeChoice } from './rpgRuntimeStoryGateway';
type CommitGeneratedStateWithEncounterEntry = (
entryState: GameState,
@@ -103,6 +103,10 @@ const NPC_CHAT_QUEST_OFFER_FUNCTION_IDS = {
type NpcChatQuestOfferPayloadAction =
keyof typeof NPC_CHAT_QUEST_OFFER_FUNCTION_IDS;
/**
* RPG runtime NPC
* NPC
*/
export function createStoryNpcEncounterActions({
gameState,
currentStory,
@@ -1535,7 +1539,7 @@ export function createStoryNpcEncounterActions({
setIsLoading(true);
try {
const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
gameState,
currentStory,
option: params.option,
@@ -1801,3 +1805,19 @@ export function createStoryNpcEncounterActions({
acceptPendingNpcQuestOffer,
};
}
export type UseRpgRuntimeNpcInteractionParams = Parameters<
typeof createStoryNpcEncounterActions
>[0];
export type RpgRuntimeNpcInteractionResult = ReturnType<
typeof createStoryNpcEncounterActions
>;
export const createRpgRuntimeNpcEncounterActions =
createStoryNpcEncounterActions;
export function useRpgRuntimeNpcInteraction(
params: UseRpgRuntimeNpcInteractionParams,
) {
return createStoryNpcEncounterActions(params);
}

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

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