Files
Genarrative/src/hooks/rpg-runtime-story/inventoryActions.ts

226 lines
6.6 KiB
TypeScript

import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
import type { RuntimeStoryInventoryActionView } from '../../../packages/shared/src/contracts/rpgRuntimeStoryState';
import {
loadRpgRuntimeInventoryView,
type RuntimeStoryChoicePayload,
type RuntimeStoryInventoryView,
} from '../../services/rpg-runtime';
import type { Character, GameState, StoryMoment } from '../../types';
import { resolveRpgRuntimeChoice } from '.';
import type { InventoryFlowUi } from './uiTypes';
type BuildFallbackStoryForState = (
state: GameState,
character: Character,
fallbackText?: string,
) => StoryMoment;
export function useStoryInventoryActions({
gameState,
runtime,
}: {
gameState: GameState;
runtime: {
currentStory: StoryMoment | null;
setGameState: Dispatch<SetStateAction<GameState>>;
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
buildFallbackStoryForState: BuildFallbackStoryForState;
};
}) {
const {
currentStory,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
buildFallbackStoryForState,
} = runtime;
const [serverInventoryView, setServerInventoryView] =
useState<RuntimeStoryInventoryView | null>(null);
const storySessionId = gameState.storySessionId;
const runtimeActionVersion = gameState.runtimeActionVersion;
const currentScene = gameState.currentScene;
const hasPlayerCharacter = Boolean(gameState.playerCharacter);
useEffect(() => {
if (!hasPlayerCharacter || currentScene !== 'Story') {
setServerInventoryView(null);
return;
}
const controller = new AbortController();
void loadRpgRuntimeInventoryView(
{
gameState: {
storySessionId,
runtimeActionVersion,
},
},
{ signal: controller.signal },
)
.then((view) => {
setServerInventoryView(view);
})
.catch((error) => {
if (controller.signal.aborted) {
return;
}
console.error('Failed to load inventory runtime view:', error);
setAiError(error instanceof Error ? error.message : '背包视图同步失败');
});
return () => {
controller.abort();
};
}, [
currentScene,
hasPlayerCharacter,
runtimeActionVersion,
storySessionId,
setAiError,
]);
const rejectInventoryAction = (message: string) => {
setAiError(message);
return false;
};
const findBackpackItemView = (itemId: string) =>
serverInventoryView?.backpackItems.find(
(candidate) => candidate.item.id === itemId,
) ?? null;
const findEquipmentSlotView = (slot: 'weapon' | 'armor' | 'relic') =>
serverInventoryView?.equipmentSlots.find(
(candidate) => candidate.slotId === slot,
) ?? null;
const resolveServerInventoryAction = async (params: {
functionId: string;
actionText: string;
payload?: RuntimeStoryChoicePayload;
}) => {
const character = gameState.playerCharacter;
if (
!character ||
!gameState.worldType ||
gameState.currentScene !== 'Story'
) {
return false;
}
setAiError(null);
setIsLoading(true);
try {
const { response, hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
gameState,
currentStory,
option: {
functionId: params.functionId,
actionText: params.actionText,
},
payload: params.payload,
});
setGameState(hydratedSnapshot.gameState);
setCurrentStory(nextStory);
setServerInventoryView(response.viewModel.inventory);
return true;
} catch (error) {
console.error('Failed to resolve inventory runtime action on the server:', error);
setAiError(error instanceof Error ? error.message : '背包动作执行失败');
if (!currentStory) {
setCurrentStory(buildFallbackStoryForState(gameState, character));
}
return false;
} finally {
setIsLoading(false);
}
};
const submitInventoryAction = async (
action: RuntimeStoryInventoryActionView | undefined,
fallbackReason: string,
) => {
if (!action) {
return rejectInventoryAction(fallbackReason);
}
if (!action.enabled) {
return rejectInventoryAction(action.reason ?? fallbackReason);
}
return resolveServerInventoryAction({
functionId: action.functionId,
actionText: action.actionText,
payload: action.payload as RuntimeStoryChoicePayload | undefined,
});
};
const useInventoryItem = async (itemId: string) =>
submitInventoryAction(
findBackpackItemView(itemId)?.actions.use,
'后端背包视图尚未提供该物品的使用动作。',
);
const equipInventoryItem = async (itemId: string) =>
submitInventoryAction(
findBackpackItemView(itemId)?.actions.equip,
'后端背包视图尚未提供该物品的装备动作。',
);
const unequipItem = async (slot: 'weapon' | 'armor' | 'relic') =>
submitInventoryAction(
findEquipmentSlotView(slot)?.unequip,
'后端装备视图尚未提供该槽位的卸装动作。',
);
const craftRecipe = async (recipeId: string) => {
const recipe = serverInventoryView?.forgeRecipes.find(
(candidate) => candidate.id === recipeId,
);
if (!recipe) {
return rejectInventoryAction('后端锻造视图尚未提供该配方。');
}
if (!recipe.canCraft) {
return rejectInventoryAction(
recipe.disabledReason ?? recipe.action.reason ?? '当前配方不可制作。',
);
}
return submitInventoryAction(recipe.action, '当前配方不可制作。');
};
const dismantleItem = async (itemId: string) =>
submitInventoryAction(
findBackpackItemView(itemId)?.actions.dismantle,
'后端背包视图尚未提供该物品的拆解动作。',
);
const reforgeItem = async (itemId: string) =>
submitInventoryAction(
findBackpackItemView(itemId)?.actions.reforge,
'后端背包视图尚未提供该物品的重铸动作。',
);
return {
inventoryUi: {
useInventoryItem,
equipInventoryItem,
unequipItem,
playerCurrency: serverInventoryView?.playerCurrency ?? null,
currencyText: serverInventoryView?.currencyText ?? null,
backpackItems: serverInventoryView?.backpackItems ?? [],
equipmentSlots: serverInventoryView?.equipmentSlots ?? [],
forgeRecipes: serverInventoryView?.forgeRecipes ?? [],
craftRecipe,
dismantleItem,
reforgeItem,
} satisfies InventoryFlowUi,
};
}