1
This commit is contained in:
178
src/App.tsx
178
src/App.tsx
@@ -1,180 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { GameShellRuntime } from './components/game-shell/GameShellRuntime.tsx';
|
||||
import { activateRosterCompanion, benchActiveCompanion } from './data/companionRoster';
|
||||
import { syncGameStatePlayTime } from './data/runtimeStats';
|
||||
import { useBackgroundMusic } from './hooks/useBackgroundMusic';
|
||||
import { useCombatFlow } from './hooks/useCombatFlow';
|
||||
import { useGameFlow } from './hooks/useGameFlow';
|
||||
import { useGamePersistence } from './hooks/useGamePersistence';
|
||||
import { useGameSettings } from './hooks/useGameSettings';
|
||||
import { useNpcInteractionFlow } from './hooks/useNpcInteractionFlow';
|
||||
import { useStoryGeneration } from './hooks/useStoryGeneration';
|
||||
import { useGameShellRuntime } from './hooks/useGameShellRuntime';
|
||||
|
||||
export default function App() {
|
||||
const {
|
||||
gameState,
|
||||
setGameState,
|
||||
bottomTab,
|
||||
setBottomTab,
|
||||
isMapOpen,
|
||||
setIsMapOpen,
|
||||
resetGame,
|
||||
handleCustomWorldSelect: selectCustomWorld,
|
||||
handleBackToWorldSelect: backToWorldSelect,
|
||||
handleCharacterSelect: selectCharacter,
|
||||
} = useGameFlow();
|
||||
const gameShellProps = useGameShellRuntime();
|
||||
|
||||
const combatFlow = useCombatFlow({
|
||||
setGameState,
|
||||
});
|
||||
|
||||
const storyFlow = useStoryGeneration({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildResolvedChoiceState: combatFlow.buildResolvedChoiceState,
|
||||
playResolvedChoice: combatFlow.playResolvedChoice,
|
||||
});
|
||||
|
||||
const { companionRenderStates, buildCompanionRenderStates } = useNpcInteractionFlow(gameState);
|
||||
const settings = useGameSettings();
|
||||
|
||||
const persistence = useGamePersistence({
|
||||
gameState,
|
||||
bottomTab,
|
||||
currentStory: storyFlow.currentStory,
|
||||
isLoading: storyFlow.isLoading,
|
||||
setGameState,
|
||||
setBottomTab,
|
||||
hydrateStoryState: storyFlow.hydrateStoryState,
|
||||
resetStoryState: storyFlow.resetStoryState,
|
||||
});
|
||||
|
||||
useBackgroundMusic({
|
||||
active: Boolean(gameState.playerCharacter && gameState.currentScene === 'Story'),
|
||||
volume: settings.musicVolume,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
setGameState(currentState => {
|
||||
if (!currentState.playerCharacter || currentState.currentScene !== 'Story') {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return syncGameStatePlayTime(currentState);
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [gameState.currentScene, gameState.playerCharacter, setGameState]);
|
||||
|
||||
const handleCustomWorldSelect = (
|
||||
customWorldProfile: Parameters<typeof selectCustomWorld>[0],
|
||||
) => {
|
||||
storyFlow.resetStoryState();
|
||||
selectCustomWorld(customWorldProfile);
|
||||
};
|
||||
|
||||
const handleCharacterSelect = (
|
||||
character: Parameters<typeof selectCharacter>[0],
|
||||
) => {
|
||||
storyFlow.resetStoryState();
|
||||
selectCharacter(character);
|
||||
};
|
||||
|
||||
const handleBackToWorldSelect = () => {
|
||||
storyFlow.resetStoryState();
|
||||
backToWorldSelect();
|
||||
};
|
||||
|
||||
const handleContinueGame = () => {
|
||||
void persistence.continueSavedGame();
|
||||
};
|
||||
|
||||
const handleStartNewGame = () => {
|
||||
void persistence.clearSavedGame();
|
||||
storyFlow.resetStoryState();
|
||||
resetGame();
|
||||
};
|
||||
|
||||
const handleSaveAndExit = () => {
|
||||
const syncedGameState = syncGameStatePlayTime(gameState);
|
||||
void persistence.saveCurrentGame({
|
||||
gameState: syncedGameState,
|
||||
bottomTab,
|
||||
currentStory: storyFlow.currentStory,
|
||||
});
|
||||
storyFlow.resetStoryState();
|
||||
resetGame();
|
||||
};
|
||||
|
||||
const handleBenchCompanion = (npcId: string) => {
|
||||
setGameState(currentState => benchActiveCompanion(currentState, npcId));
|
||||
};
|
||||
|
||||
const handleActivateRosterCompanion = (npcId: string, swapNpcId?: string | null) => {
|
||||
setGameState(currentState => activateRosterCompanion(currentState, npcId, swapNpcId));
|
||||
};
|
||||
|
||||
const gameShellSession = {
|
||||
gameState,
|
||||
currentStory: storyFlow.currentStory,
|
||||
isLoading: storyFlow.isLoading,
|
||||
aiError: storyFlow.aiError,
|
||||
bottomTab,
|
||||
setBottomTab,
|
||||
isMapOpen,
|
||||
setIsMapOpen,
|
||||
};
|
||||
|
||||
const gameShellStory = {
|
||||
displayedOptions: storyFlow.displayedOptions,
|
||||
canRefreshOptions: storyFlow.canRefreshOptions,
|
||||
handleRefreshOptions: storyFlow.handleRefreshOptions,
|
||||
handleChoice: storyFlow.handleChoice,
|
||||
handleMapTravelToScene: storyFlow.travelToSceneFromMap,
|
||||
npcUi: storyFlow.npcUi,
|
||||
characterChatUi: storyFlow.characterChatUi,
|
||||
inventoryUi: storyFlow.inventoryUi,
|
||||
battleRewardUi: storyFlow.battleRewardUi,
|
||||
questUi: storyFlow.questUi,
|
||||
goalUi: storyFlow.goalUi,
|
||||
};
|
||||
|
||||
const gameShellEntry = {
|
||||
hasSavedGame: persistence.hasSavedGame,
|
||||
handleContinueGame,
|
||||
handleStartNewGame,
|
||||
handleSaveAndExit,
|
||||
handleCustomWorldSelect,
|
||||
handleBackToWorldSelect,
|
||||
handleCharacterSelect,
|
||||
};
|
||||
|
||||
const gameShellCompanions = {
|
||||
companionRenderStates,
|
||||
buildCompanionRenderStates,
|
||||
onBenchCompanion: handleBenchCompanion,
|
||||
onActivateRosterCompanion: handleActivateRosterCompanion,
|
||||
};
|
||||
|
||||
const gameShellAudio = {
|
||||
musicVolume: settings.musicVolume,
|
||||
onMusicVolumeChange: settings.setMusicVolume,
|
||||
};
|
||||
|
||||
return (
|
||||
<GameShellRuntime
|
||||
session={gameShellSession}
|
||||
story={gameShellStory}
|
||||
entry={gameShellEntry}
|
||||
companions={gameShellCompanions}
|
||||
audio={gameShellAudio}
|
||||
/>
|
||||
);
|
||||
return <GameShellRuntime {...gameShellProps} />;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { motion } from 'motion/react';
|
||||
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
} from '../services/aiService';
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
|
||||
interface CustomWorldGenerationViewProps {
|
||||
|
||||
@@ -11,9 +11,13 @@ import {
|
||||
createInventoryItemFromCatalogEntry,
|
||||
ITEM_CATALOG_API_PATH,
|
||||
ITEM_CATEGORY_OPTIONS,
|
||||
ITEM_OVERRIDES_API_PATH,
|
||||
} from '../data/itemCatalog';
|
||||
import { fetchJson, saveJsonObject } from '../editor/shared/jsonClient';
|
||||
import {
|
||||
EDITOR_JSON_RESOURCE_IDS,
|
||||
fetchEditorJsonResource,
|
||||
saveEditorJsonResource,
|
||||
} from '../editor/shared/editorApiClient';
|
||||
import { fetchJson } from '../editor/shared/jsonClient';
|
||||
import { SectionCard as Section } from '../editor/shared/SectionCard';
|
||||
import { type ItemCatalogOverride, type ItemRarity, type TimedBuildBuff,WorldType } from '../types';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
@@ -176,7 +180,9 @@ export function ItemCatalogEditor() {
|
||||
try {
|
||||
const [catalogResponse, overridesResponse] = await Promise.all([
|
||||
fetchJson<ItemCatalogAssetResponse>(ITEM_CATALOG_API_PATH),
|
||||
fetchJson<Record<string, ItemCatalogOverride>>(ITEM_OVERRIDES_API_PATH),
|
||||
fetchEditorJsonResource<Record<string, ItemCatalogOverride>>(
|
||||
EDITOR_JSON_RESOURCE_IDS.itemOverrides,
|
||||
),
|
||||
]);
|
||||
|
||||
if (disposed) return;
|
||||
@@ -416,7 +422,11 @@ export function ItemCatalogEditor() {
|
||||
setSaveMessage(null);
|
||||
|
||||
try {
|
||||
await saveJsonObject(ITEM_OVERRIDES_API_PATH, overrideMap as Record<string, unknown>);
|
||||
await saveEditorJsonResource(
|
||||
EDITOR_JSON_RESOURCE_IDS.itemOverrides,
|
||||
overrideMap as Record<string, unknown>,
|
||||
'保存失败',
|
||||
);
|
||||
setSaveMessage('物品覆盖已保存到 src/data/itemOverrides.json。');
|
||||
setTimeout(() => setSaveMessage(null), 5000);
|
||||
} catch (error) {
|
||||
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
type ScenePreset,
|
||||
} from '../data/scenePresets';
|
||||
import stateFunctionOverridesJson from '../data/stateFunctionOverrides.json';
|
||||
import {
|
||||
EDITOR_JSON_RESOURCE_IDS,
|
||||
saveEditorJsonResource,
|
||||
} from '../editor/shared/editorApiClient';
|
||||
import {
|
||||
buildStateFunctionDefinitions,
|
||||
type FunctionAvailabilityContext,
|
||||
@@ -30,7 +34,6 @@ import {
|
||||
type StateFunctionOverrideMap,
|
||||
} from '../data/stateFunctions';
|
||||
import {cloneValue} from '../editor/shared/cloneValue';
|
||||
import {saveJsonObject} from '../editor/shared/jsonClient';
|
||||
import {SectionCard} from '../editor/shared/SectionCard';
|
||||
import {type ResolvedChoiceState,useCombatFlow} from '../hooks/useCombatFlow';
|
||||
import {
|
||||
@@ -1134,8 +1137,8 @@ export function StateFunctionEditor() {
|
||||
setIsSaving(true);
|
||||
setSaveMessage(null);
|
||||
try {
|
||||
await saveJsonObject(
|
||||
'/api/state-function-overrides',
|
||||
await saveEditorJsonResource(
|
||||
EDITOR_JSON_RESOURCE_IDS.stateFunctionOverrides,
|
||||
overrideMap as Record<string, unknown>,
|
||||
'保存选项行为覆盖失败',
|
||||
);
|
||||
|
||||
478
src/components/auth/AccountModal.tsx
Normal file
478
src/components/auth/AccountModal.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type {
|
||||
AuthAuditLogEntry,
|
||||
AuthCaptchaChallenge,
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionSummary,
|
||||
AuthUser,
|
||||
} from '../../services/authService';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type AccountModalProps = {
|
||||
user: AuthUser;
|
||||
isOpen: boolean;
|
||||
riskBlocks: AuthRiskBlockSummary[];
|
||||
sessions: AuthSessionSummary[];
|
||||
auditLogs: AuthAuditLogEntry[];
|
||||
loadingRiskBlocks: boolean;
|
||||
loadingSessions: boolean;
|
||||
loadingAuditLogs: boolean;
|
||||
onClose: () => void;
|
||||
onLogout: () => Promise<void>;
|
||||
onRefreshRiskBlocks: () => Promise<void>;
|
||||
onLiftRiskBlock: (scopeType: 'phone' | 'ip') => Promise<void>;
|
||||
onRefreshSessions: () => Promise<void>;
|
||||
onLogoutAll: () => Promise<void>;
|
||||
onRefreshAuditLogs: () => Promise<void>;
|
||||
onRevokeSession: (sessionId: string) => Promise<void>;
|
||||
changePhoneCaptchaChallenge: AuthCaptchaChallenge | null;
|
||||
onSendChangePhoneCode: (
|
||||
phone: string,
|
||||
captcha?: {
|
||||
challengeId?: string;
|
||||
answer?: string;
|
||||
},
|
||||
) => Promise<{
|
||||
cooldownSeconds: number;
|
||||
expiresInSeconds: number;
|
||||
}>;
|
||||
onChangePhone: (phone: string, code: string) => Promise<void>;
|
||||
};
|
||||
|
||||
function resolveLoginMethodLabel(loginMethod: AuthUser['loginMethod']) {
|
||||
switch (loginMethod) {
|
||||
case 'wechat':
|
||||
return '微信登录';
|
||||
case 'phone':
|
||||
return '手机号登录';
|
||||
default:
|
||||
return '账号登录';
|
||||
}
|
||||
}
|
||||
|
||||
function formatSessionTime(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function AccountModal({
|
||||
user,
|
||||
isOpen,
|
||||
riskBlocks,
|
||||
sessions,
|
||||
auditLogs,
|
||||
loadingRiskBlocks,
|
||||
loadingSessions,
|
||||
loadingAuditLogs,
|
||||
onClose,
|
||||
onLogout,
|
||||
onRefreshRiskBlocks,
|
||||
onLiftRiskBlock,
|
||||
onRefreshSessions,
|
||||
onLogoutAll,
|
||||
onRefreshAuditLogs,
|
||||
onRevokeSession,
|
||||
changePhoneCaptchaChallenge,
|
||||
onSendChangePhoneCode,
|
||||
onChangePhone,
|
||||
}: AccountModalProps) {
|
||||
const [editingPhone, setEditingPhone] = useState(false);
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||
const [changePhoneError, setChangePhoneError] = useState('');
|
||||
const [changePhoneHint, setChangePhoneHint] = useState('');
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [changingPhone, setChangingPhone] = useState(false);
|
||||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldownSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setCooldownSeconds((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [cooldownSeconds]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-end justify-center bg-black/62 px-4 py-4 sm:items-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,_rgba(20,23,31,0.96),_rgba(10,12,18,0.98))] p-5 shadow-[0_24px_80px_rgba(0,0,0,0.58)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.28em] text-amber-200/70">
|
||||
账号信息
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">
|
||||
{user.displayName}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 text-sm text-zinc-200">
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
登录方式:{resolveLoginMethodLabel(user.loginMethod)}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
手机号:{user.phoneNumberMasked || '未绑定'}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
微信绑定:{user.wechatBound ? '已绑定' : '未绑定'}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
账号状态:
|
||||
{user.bindingStatus === 'pending_bind_phone'
|
||||
? ' 待绑定手机号'
|
||||
: ' 已激活'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-zinc-400">
|
||||
当前安全状态
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||
onClick={() => {
|
||||
void onRefreshRiskBlocks();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3">
|
||||
{loadingRiskBlocks ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
|
||||
正在读取安全状态...
|
||||
</div>
|
||||
) : riskBlocks.length > 0 ? (
|
||||
riskBlocks.map((block) => (
|
||||
<div
|
||||
key={`${block.scopeType}:${block.expiresAt}`}
|
||||
className="rounded-2xl border border-amber-300/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-50"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{block.title}</span>
|
||||
<span className="text-xs text-amber-100/75">
|
||||
剩余约 {Math.max(1, Math.ceil(block.remainingSeconds / 60))} 分钟
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-amber-100/85">
|
||||
{block.detail}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 h-9 rounded-2xl border border-emerald-300/20 px-3 text-xs text-emerald-50 transition hover:border-emerald-300/45 hover:bg-emerald-400/10"
|
||||
onClick={() => {
|
||||
void onLiftRiskBlock(block.scopeType);
|
||||
}}
|
||||
>
|
||||
解除保护
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
|
||||
当前没有生效中的安全限制。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-zinc-400">
|
||||
登录设备
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||
onClick={() => {
|
||||
void onRefreshSessions();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3">
|
||||
{loadingSessions ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
|
||||
正在读取当前登录设备...
|
||||
</div>
|
||||
) : sessions.length > 0 ? (
|
||||
sessions.map((session) => (
|
||||
<div
|
||||
key={session.sessionId}
|
||||
className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-200"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{session.clientLabel}</span>
|
||||
<span className="text-xs text-emerald-200/85">
|
||||
{session.isCurrent ? '当前设备' : '已登录'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-zinc-400">
|
||||
最近活跃:{formatSessionTime(session.lastSeenAt)}
|
||||
</div>
|
||||
<div className="text-xs leading-5 text-zinc-500">
|
||||
到期时间:{formatSessionTime(session.expiresAt)}
|
||||
</div>
|
||||
{session.ipMasked ? (
|
||||
<div className="text-xs leading-5 text-zinc-500">
|
||||
IP:{session.ipMasked}
|
||||
</div>
|
||||
) : null}
|
||||
{!session.isCurrent ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 h-9 rounded-2xl border border-rose-400/20 px-3 text-xs text-rose-100 transition hover:border-rose-400/45 hover:bg-rose-500/10"
|
||||
onClick={() => {
|
||||
void onRevokeSession(session.sessionId);
|
||||
}}
|
||||
>
|
||||
踢下线
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
|
||||
暂无可展示的登录设备。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-zinc-400">
|
||||
更换手机号
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||
onClick={() => {
|
||||
setEditingPhone((current) => !current);
|
||||
setChangePhoneError('');
|
||||
setChangePhoneHint('');
|
||||
}}
|
||||
>
|
||||
{editingPhone ? '收起' : '修改'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editingPhone ? (
|
||||
<div className="mt-3 grid gap-3 rounded-2xl border border-white/8 bg-white/5 px-4 py-4">
|
||||
<label className="grid gap-2 text-sm text-zinc-200">
|
||||
<span>新手机号</span>
|
||||
<input
|
||||
className="h-11 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/40 focus:bg-black/40"
|
||||
value={phone}
|
||||
inputMode="numeric"
|
||||
placeholder="13800000000"
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-zinc-200">
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="h-11 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/40 focus:bg-black/40"
|
||||
value={code}
|
||||
inputMode="numeric"
|
||||
placeholder="输入验证码"
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="h-11 shrink-0 rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setSendingCode(true);
|
||||
setChangePhoneError('');
|
||||
try {
|
||||
const result = await onSendChangePhoneCode(phone, {
|
||||
challengeId: changePhoneCaptchaChallenge?.challengeId,
|
||||
answer: captchaAnswer,
|
||||
});
|
||||
setCooldownSeconds(result.cooldownSeconds);
|
||||
setChangePhoneHint(
|
||||
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
|
||||
);
|
||||
setCaptchaAnswer('');
|
||||
} catch (error) {
|
||||
setChangePhoneError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: '发送验证码失败,请稍后再试。',
|
||||
);
|
||||
setChangePhoneHint('');
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中...'
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
{changePhoneHint ? (
|
||||
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
|
||||
{changePhoneHint}
|
||||
</div>
|
||||
) : null}
|
||||
<CaptchaChallengeField
|
||||
challenge={changePhoneCaptchaChallenge}
|
||||
answer={captchaAnswer}
|
||||
onAnswerChange={setCaptchaAnswer}
|
||||
/>
|
||||
{changePhoneError ? (
|
||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{changePhoneError}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={changingPhone || !phone.trim() || !code.trim()}
|
||||
className="h-11 rounded-2xl border border-sky-300/20 px-4 text-sm font-medium text-sky-100 transition hover:border-sky-300/45 hover:bg-sky-500/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
setChangingPhone(true);
|
||||
setChangePhoneError('');
|
||||
try {
|
||||
await onChangePhone(phone, code);
|
||||
setChangePhoneHint('手机号已更新。');
|
||||
setPhone('');
|
||||
setCode('');
|
||||
} catch (error) {
|
||||
setChangePhoneError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: '更换手机号失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setChangingPhone(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{changingPhone ? '提交中...' : '确认更换手机号'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-zinc-400">
|
||||
最近账号操作
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||
onClick={() => {
|
||||
void onRefreshAuditLogs();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3">
|
||||
{loadingAuditLogs ? (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
|
||||
正在读取账号操作记录...
|
||||
</div>
|
||||
) : auditLogs.length > 0 ? (
|
||||
auditLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-200"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{log.title}</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{formatSessionTime(log.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-zinc-400">
|
||||
{log.detail}
|
||||
</div>
|
||||
{log.ipMasked ? (
|
||||
<div className="text-xs leading-5 text-zinc-500">
|
||||
IP:{log.ipMasked}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
|
||||
暂无账号操作记录。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-5 h-11 w-full rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10"
|
||||
onClick={() => {
|
||||
void onLogout();
|
||||
}}
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 h-11 w-full rounded-2xl border border-rose-400/20 px-4 text-sm font-medium text-rose-100 transition hover:border-rose-400/45 hover:bg-rose-500/10"
|
||||
onClick={() => {
|
||||
void onLogoutAll();
|
||||
}}
|
||||
>
|
||||
退出全部设备
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,22 +5,69 @@ import {
|
||||
getStoredAccessToken,
|
||||
} from '../../services/apiClient';
|
||||
import {
|
||||
type AuthAuditLogEntry,
|
||||
type AuthCaptchaChallenge,
|
||||
type AuthRiskBlockSummary,
|
||||
type AuthSessionSummary,
|
||||
type AuthUser,
|
||||
bindWechatPhone,
|
||||
changePhoneNumber,
|
||||
consumeAuthCallbackResult,
|
||||
ensureAutoAuthUser,
|
||||
getAuthAuditLogs,
|
||||
getAuthRiskBlocks,
|
||||
getAuthSessions,
|
||||
getCaptchaChallengeFromError,
|
||||
getCurrentAuthUser,
|
||||
liftAuthRiskBlock,
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
logoutAuthUser,
|
||||
revokeAuthSession,
|
||||
sendPhoneLoginCode,
|
||||
startWechatLogin,
|
||||
} from '../../services/authService';
|
||||
import { AccountModal } from './AccountModal';
|
||||
import { BindPhoneScreen } from './BindPhoneScreen';
|
||||
import { LoginScreen } from './LoginScreen';
|
||||
|
||||
type AuthGateProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type AuthStatus = 'checking' | 'recovering' | 'ready' | 'error';
|
||||
type AuthStatus =
|
||||
| 'checking'
|
||||
| 'recovering'
|
||||
| 'unauthenticated'
|
||||
| 'pending_bind_phone'
|
||||
| 'ready'
|
||||
| 'error';
|
||||
|
||||
const allowDevGuestAutoAuth =
|
||||
import.meta.env.DEV &&
|
||||
import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST === 'true';
|
||||
|
||||
export function AuthGate({ children }: AuthGateProps) {
|
||||
const [status, setStatus] = useState<AuthStatus>('checking');
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
const [bindingPhone, setBindingPhone] = useState(false);
|
||||
const [wechatLoading, setWechatLoading] = useState(false);
|
||||
const [showAccountModal, setShowAccountModal] = useState(false);
|
||||
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
const [auditLogs, setAuditLogs] = useState<AuthAuditLogEntry[]>([]);
|
||||
const [loadingAuditLogs, setLoadingAuditLogs] = useState(false);
|
||||
const [riskBlocks, setRiskBlocks] = useState<AuthRiskBlockSummary[]>([]);
|
||||
const [loadingRiskBlocks, setLoadingRiskBlocks] = useState(false);
|
||||
const [loginCaptchaChallenge, setLoginCaptchaChallenge] =
|
||||
useState<AuthCaptchaChallenge | null>(null);
|
||||
const [bindCaptchaChallenge, setBindCaptchaChallenge] =
|
||||
useState<AuthCaptchaChallenge | null>(null);
|
||||
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
|
||||
useState<AuthCaptchaChallenge | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
@@ -57,31 +104,58 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
};
|
||||
|
||||
const hydrate = async () => {
|
||||
const callbackResult = consumeAuthCallbackResult();
|
||||
if (callbackResult?.error && isActive) {
|
||||
setError(callbackResult.error);
|
||||
}
|
||||
|
||||
const token = getStoredAccessToken();
|
||||
if (!token) {
|
||||
await ensureAutoUser();
|
||||
if (allowDevGuestAutoAuth) {
|
||||
await ensureAutoUser();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextUser = await getCurrentAuthUser();
|
||||
const nextSession = await getCurrentAuthUser();
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextUser) {
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
setError('');
|
||||
if (!nextSession.user) {
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureAutoUser();
|
||||
setUser(nextSession.user);
|
||||
setStatus(
|
||||
nextSession.user.bindingStatus === 'pending_bind_phone'
|
||||
? 'pending_bind_phone'
|
||||
: 'ready',
|
||||
);
|
||||
setError(callbackResult?.error ?? '');
|
||||
} catch {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
await ensureAutoUser();
|
||||
|
||||
if (allowDevGuestAutoAuth) {
|
||||
await ensureAutoUser();
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -100,6 +174,91 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAccountModal || status !== 'ready') {
|
||||
return;
|
||||
}
|
||||
|
||||
let isActive = true;
|
||||
setLoadingRiskBlocks(true);
|
||||
setLoadingSessions(true);
|
||||
setLoadingAuditLogs(true);
|
||||
void getAuthRiskBlocks()
|
||||
.then((nextBlocks) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setRiskBlocks(nextBlocks);
|
||||
})
|
||||
.catch((blockError) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
blockError instanceof Error
|
||||
? blockError.message
|
||||
: '读取安全状态失败,请稍后再试。',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setLoadingRiskBlocks(false);
|
||||
});
|
||||
void getAuthSessions()
|
||||
.then((nextSessions) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setSessions(nextSessions);
|
||||
})
|
||||
.catch((sessionError) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
sessionError instanceof Error
|
||||
? sessionError.message
|
||||
: '读取登录设备失败,请稍后再试。',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setLoadingSessions(false);
|
||||
});
|
||||
|
||||
void getAuthAuditLogs()
|
||||
.then((nextLogs) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setAuditLogs(nextLogs);
|
||||
})
|
||||
.catch((auditError) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
auditError instanceof Error
|
||||
? auditError.message
|
||||
: '读取账号操作记录失败,请稍后再试。',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
setLoadingAuditLogs(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [showAccountModal, status]);
|
||||
|
||||
if (status === 'checking') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
|
||||
@@ -116,11 +275,135 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<LoginScreen
|
||||
sendingCode={sendingCode}
|
||||
loggingIn={loggingIn}
|
||||
wechatLoading={wechatLoading}
|
||||
error={error}
|
||||
captchaChallenge={loginCaptchaChallenge}
|
||||
onSendCode={async (phone, captcha) => {
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(phone, 'login', captcha);
|
||||
setLoginCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setLoginCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
setError(
|
||||
sendError instanceof Error
|
||||
? sendError.message
|
||||
: '发送验证码失败,请稍后再试。',
|
||||
);
|
||||
throw sendError;
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}}
|
||||
onSubmit={async (phone, code) => {
|
||||
setLoggingIn(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await loginWithPhoneCode(phone, code);
|
||||
setLoginCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
} catch (loginError) {
|
||||
setError(
|
||||
loginError instanceof Error
|
||||
? loginError.message
|
||||
: '登录失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
}}
|
||||
onStartWechatLogin={async () => {
|
||||
setWechatLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await startWechatLogin();
|
||||
} catch (wechatError) {
|
||||
setError(
|
||||
wechatError instanceof Error
|
||||
? wechatError.message
|
||||
: '微信登录暂不可用,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setWechatLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'pending_bind_phone' && user) {
|
||||
return (
|
||||
<BindPhoneScreen
|
||||
user={user}
|
||||
sendingCode={sendingCode}
|
||||
binding={bindingPhone}
|
||||
error={error}
|
||||
captchaChallenge={bindCaptchaChallenge}
|
||||
onSendCode={async (phone, captcha) => {
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(phone, 'bind_phone', captcha);
|
||||
setBindCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setBindCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
setError(
|
||||
sendError instanceof Error
|
||||
? sendError.message
|
||||
: '发送验证码失败,请稍后再试。',
|
||||
);
|
||||
throw sendError;
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}}
|
||||
onSubmit={async (phone, code) => {
|
||||
setBindingPhone(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await bindWechatPhone(phone, code);
|
||||
setBindCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
} catch (bindError) {
|
||||
setError(
|
||||
bindError instanceof Error
|
||||
? bindError.message
|
||||
: '绑定手机号失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setBindingPhone(false);
|
||||
}
|
||||
}}
|
||||
onLogout={async () => {
|
||||
await logoutAuthUser();
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== 'ready' || !user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#090b11] px-6 text-zinc-200">
|
||||
<div className="max-w-md rounded-3xl border border-white/10 bg-black/40 px-6 py-7 text-center shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
|
||||
<div className="text-base font-medium text-zinc-50">自动登录失败</div>
|
||||
<div className="text-base font-medium text-zinc-50">登录状态异常</div>
|
||||
<div className="mt-3 text-sm leading-6 text-zinc-300">
|
||||
{error || '账号恢复失败,请刷新页面后重试。'}
|
||||
</div>
|
||||
@@ -142,7 +425,13 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 py-2 text-xs text-zinc-200 backdrop-blur">
|
||||
<span>{user.username}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-white/25 hover:text-white"
|
||||
onClick={() => setShowAccountModal(true)}
|
||||
>
|
||||
{user.displayName}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-amber-300/40 hover:text-amber-100"
|
||||
@@ -154,6 +443,118 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<AccountModal
|
||||
user={user}
|
||||
isOpen={showAccountModal}
|
||||
riskBlocks={riskBlocks}
|
||||
sessions={sessions}
|
||||
auditLogs={auditLogs}
|
||||
loadingRiskBlocks={loadingRiskBlocks}
|
||||
loadingSessions={loadingSessions}
|
||||
loadingAuditLogs={loadingAuditLogs}
|
||||
onClose={() => setShowAccountModal(false)}
|
||||
onLogout={async () => {
|
||||
await logoutAuthUser();
|
||||
setShowAccountModal(false);
|
||||
}}
|
||||
onRefreshRiskBlocks={async () => {
|
||||
setLoadingRiskBlocks(true);
|
||||
try {
|
||||
setRiskBlocks(await getAuthRiskBlocks());
|
||||
} catch (blockError) {
|
||||
setError(
|
||||
blockError instanceof Error
|
||||
? blockError.message
|
||||
: '读取安全状态失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingRiskBlocks(false);
|
||||
}
|
||||
}}
|
||||
onLiftRiskBlock={async (scopeType) => {
|
||||
try {
|
||||
await liftAuthRiskBlock(scopeType);
|
||||
setRiskBlocks(await getAuthRiskBlocks());
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (liftError) {
|
||||
setError(
|
||||
liftError instanceof Error
|
||||
? liftError.message
|
||||
: '解除保护失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onRefreshSessions={async () => {
|
||||
setLoadingSessions(true);
|
||||
try {
|
||||
setSessions(await getAuthSessions());
|
||||
} catch (sessionError) {
|
||||
setError(
|
||||
sessionError instanceof Error
|
||||
? sessionError.message
|
||||
: '读取登录设备失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
}}
|
||||
onRefreshAuditLogs={async () => {
|
||||
setLoadingAuditLogs(true);
|
||||
try {
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (auditError) {
|
||||
setError(
|
||||
auditError instanceof Error
|
||||
? auditError.message
|
||||
: '读取账号操作记录失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingAuditLogs(false);
|
||||
}
|
||||
}}
|
||||
onRevokeSession={async (sessionId) => {
|
||||
try {
|
||||
await revokeAuthSession(sessionId);
|
||||
setSessions((current) =>
|
||||
current.filter((session) => session.sessionId !== sessionId),
|
||||
);
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (revokeError) {
|
||||
setError(
|
||||
revokeError instanceof Error
|
||||
? revokeError.message
|
||||
: '移除登录设备失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onLogoutAll={async () => {
|
||||
await logoutAllAuthSessions();
|
||||
setShowAccountModal(false);
|
||||
}}
|
||||
changePhoneCaptchaChallenge={changePhoneCaptchaChallenge}
|
||||
onSendChangePhoneCode={async (phone, captcha) => {
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(
|
||||
phone,
|
||||
'change_phone',
|
||||
captcha,
|
||||
);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setChangePhoneCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
throw sendError;
|
||||
}
|
||||
}}
|
||||
onChangePhone={async (phone, code) => {
|
||||
const nextUser = await changePhoneNumber(phone, code);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
179
src/components/auth/BindPhoneScreen.tsx
Normal file
179
src/components/auth/BindPhoneScreen.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type BindPhoneScreenProps = {
|
||||
user: AuthUser;
|
||||
sendingCode: boolean;
|
||||
binding: boolean;
|
||||
error: string;
|
||||
captchaChallenge: AuthCaptchaChallenge | null;
|
||||
onSendCode: (
|
||||
phone: string,
|
||||
captcha?: {
|
||||
challengeId?: string;
|
||||
answer?: string;
|
||||
},
|
||||
) => Promise<{
|
||||
cooldownSeconds: number;
|
||||
expiresInSeconds: number;
|
||||
}>;
|
||||
onSubmit: (phone: string, code: string) => Promise<void>;
|
||||
onLogout: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function BindPhoneScreen({
|
||||
user,
|
||||
sendingCode,
|
||||
binding,
|
||||
error,
|
||||
captchaChallenge,
|
||||
onSendCode,
|
||||
onSubmit,
|
||||
onLogout,
|
||||
}: BindPhoneScreenProps) {
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||||
const [hint, setHint] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldownSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setCooldownSeconds((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [cooldownSeconds]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(16,185,129,0.14),_transparent_42%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-6 text-zinc-100 sm:py-8">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
|
||||
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-emerald-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="border-b border-emerald-200/10 bg-[linear-gradient(135deg,_rgba(16,185,129,0.14),_rgba(59,130,246,0.08))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
|
||||
<div className="selection-hero-brand selection-hero-brand--left">
|
||||
<div className="selection-hero-brand__title">叙世</div>
|
||||
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
|
||||
</div>
|
||||
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-emerald-200/70">
|
||||
账号激活
|
||||
</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
绑定手机号
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300">
|
||||
微信身份已建立,还差最后一步。绑定手机号后,你的账号才会正式激活,并同步到后端存档体系。
|
||||
</p>
|
||||
<div className="mt-8 rounded-2xl border border-white/8 bg-white/5 px-4 py-4 text-sm text-zinc-300">
|
||||
当前登录身份:{user.displayName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit(phone, code);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-emerald-300/50 focus:bg-black/40"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-emerald-300/50 focus:bg-black/40"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
placeholder="输入验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="h-12 shrink-0 rounded-2xl border border-emerald-300/25 px-4 text-sm font-medium text-emerald-100 transition hover:border-emerald-300/55 hover:bg-emerald-300/10 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await onSendCode(phone, {
|
||||
challengeId: captchaChallenge?.challengeId,
|
||||
answer: captchaAnswer,
|
||||
});
|
||||
setCooldownSeconds(result.cooldownSeconds);
|
||||
setHint(
|
||||
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
|
||||
);
|
||||
setCaptchaAnswer('');
|
||||
} catch {
|
||||
setHint('');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中...'
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{hint ? (
|
||||
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
|
||||
{hint}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CaptchaChallengeField
|
||||
challenge={captchaChallenge}
|
||||
answer={captchaAnswer}
|
||||
onAnswerChange={setCaptchaAnswer}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={binding || !phone.trim() || !code.trim()}
|
||||
className="h-12 rounded-2xl bg-[linear-gradient(135deg,_#10b981,_#22c55e)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{binding ? '正在绑定...' : '绑定手机号并进入游戏'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="h-11 rounded-2xl border border-white/10 px-4 text-sm text-zinc-300 transition hover:border-white/25 hover:text-white"
|
||||
onClick={() => {
|
||||
void onLogout();
|
||||
}}
|
||||
>
|
||||
返回其他登录方式
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
src/components/auth/CaptchaChallengeField.tsx
Normal file
34
src/components/auth/CaptchaChallengeField.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { AuthCaptchaChallenge } from '../../services/authService';
|
||||
|
||||
type CaptchaChallengeFieldProps = {
|
||||
challenge: AuthCaptchaChallenge | null;
|
||||
answer: string;
|
||||
onAnswerChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function CaptchaChallengeField({
|
||||
challenge,
|
||||
answer,
|
||||
onAnswerChange,
|
||||
}: CaptchaChallengeFieldProps) {
|
||||
if (!challenge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 rounded-2xl border border-sky-300/20 bg-sky-500/10 px-4 py-4">
|
||||
<div className="text-sm leading-6 text-sky-100">{challenge.promptText}</div>
|
||||
<img
|
||||
src={challenge.imageDataUrl}
|
||||
alt="图形验证码"
|
||||
className="h-14 w-40 rounded-2xl border border-white/10 bg-black/20 object-cover"
|
||||
/>
|
||||
<input
|
||||
className="h-11 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-sky-300/45 focus:bg-black/40"
|
||||
value={answer}
|
||||
placeholder="输入图形验证码"
|
||||
onChange={(event) => onAnswerChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,82 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { AuthCaptchaChallenge } from '../../services/authService';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type LoginScreenProps = {
|
||||
loading: boolean;
|
||||
sendingCode: boolean;
|
||||
loggingIn: boolean;
|
||||
wechatLoading: boolean;
|
||||
error: string;
|
||||
onSubmit: (username: string, password: string) => Promise<void>;
|
||||
captchaChallenge: AuthCaptchaChallenge | null;
|
||||
onSendCode: (
|
||||
phone: string,
|
||||
captcha?: {
|
||||
challengeId?: string;
|
||||
answer?: string;
|
||||
},
|
||||
) => Promise<{
|
||||
cooldownSeconds: number;
|
||||
expiresInSeconds: number;
|
||||
}>;
|
||||
onSubmit: (phone: string, code: string) => Promise<void>;
|
||||
onStartWechatLogin: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function LoginScreen({
|
||||
loading,
|
||||
sendingCode,
|
||||
loggingIn,
|
||||
wechatLoading,
|
||||
error,
|
||||
captchaChallenge,
|
||||
onSendCode,
|
||||
onSubmit,
|
||||
onStartWechatLogin,
|
||||
}: LoginScreenProps) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||||
const [hint, setHint] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldownSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setCooldownSeconds((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [cooldownSeconds]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(245,158,11,0.18),_transparent_38%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-8 text-zinc-100">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-4rem)] w-full max-w-5xl items-center justify-center">
|
||||
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-amber-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.15fr_0.85fr]">
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(245,158,11,0.18),_transparent_38%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-6 text-zinc-100 sm:py-8">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
|
||||
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-amber-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.08fr_0.92fr]">
|
||||
<div className="border-b border-amber-200/10 bg-[linear-gradient(135deg,_rgba(245,158,11,0.16),_rgba(20,184,166,0.08))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-amber-200/70">
|
||||
Genarrative
|
||||
<div className="selection-hero-brand selection-hero-brand--left">
|
||||
<div className="selection-hero-brand__title">叙世</div>
|
||||
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
|
||||
</div>
|
||||
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-amber-200/70">
|
||||
账号系统
|
||||
</p>
|
||||
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
登录后进入冒险
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
|
||||
账号登录
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300">
|
||||
当前版本已切到后端账号模式。输入用户名和密码即可直接进入,用户名不存在时会自动创建账号。
|
||||
先登录账号,再同步你的冒险进度。本轮先开放手机号验证码登录,微信登录将在下一阶段接入。
|
||||
</p>
|
||||
<div className="mt-8 grid gap-3 text-sm text-zinc-300">
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
用户名:3 到 24 位字母、数字、下划线
|
||||
手机号登录后可在不同设备继续同一份存档
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
|
||||
第一次提交会自动注册,后续同名即登录
|
||||
验证码登录优先适配移动端,网页端也可直接使用
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,32 +85,78 @@ export function LoginScreen({
|
||||
className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit(username, password);
|
||||
void onSubmit(phone, code);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<span>用户名</span>
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
placeholder="hero_name"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<span>密码</span>
|
||||
<input
|
||||
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="至少 6 位"
|
||||
/>
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
placeholder="输入验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="h-12 shrink-0 rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await onSendCode(phone, {
|
||||
challengeId: captchaChallenge?.challengeId,
|
||||
answer: captchaAnswer,
|
||||
});
|
||||
setCooldownSeconds(result.cooldownSeconds);
|
||||
setHint(
|
||||
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
|
||||
);
|
||||
setCaptchaAnswer('');
|
||||
} catch {
|
||||
setHint('');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中...'
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{hint ? (
|
||||
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
|
||||
{hint}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CaptchaChallengeField
|
||||
challenge={captchaChallenge}
|
||||
answer={captchaAnswer}
|
||||
onAnswerChange={setCaptchaAnswer}
|
||||
/>
|
||||
|
||||
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-300">
|
||||
手机验证码适合直接登录;如果你更习惯微信,也可以先走微信登录再绑定手机号。
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{error}
|
||||
@@ -76,10 +165,21 @@ export function LoginScreen({
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
disabled={loggingIn || !phone.trim() || !code.trim()}
|
||||
className="mt-2 h-12 rounded-2xl bg-[linear-gradient(135deg,_#f59e0b,_#f97316)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? '正在进入...' : '进入游戏'}
|
||||
{loggingIn ? '正在进入...' : '登录并进入游戏'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={wechatLoading || sendingCode || loggingIn}
|
||||
className="h-12 rounded-2xl border border-white/12 bg-white/5 px-4 text-base font-medium text-zinc-100 transition hover:border-emerald-300/35 hover:bg-emerald-400/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => {
|
||||
void onStartWechatLogin();
|
||||
}}
|
||||
>
|
||||
{wechatLoading ? '正在跳转微信...' : '微信登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
GameState,
|
||||
} from '../../types';
|
||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import type { GameShellDialogueIndicator } from './types';
|
||||
import { GameCanvas } from '../GameCanvas';
|
||||
|
||||
export function GameShellCanvasStage({
|
||||
@@ -21,11 +22,7 @@ export function GameShellCanvasStage({
|
||||
visibleGameState: GameState;
|
||||
hideSelectionHero: boolean;
|
||||
canvasCompanionRenderStates: CompanionRenderState[];
|
||||
dialogueIndicator: {
|
||||
showPlayer: boolean;
|
||||
showEncounter: boolean;
|
||||
activeSpeaker?: 'player' | 'npc' | null;
|
||||
} | null;
|
||||
dialogueIndicator: GameShellDialogueIndicator | null;
|
||||
sceneTransitionPhase: 'idle' | 'exiting' | 'entering';
|
||||
sceneTransitionToken: number;
|
||||
setSelectedSceneEntity: (selection: GameCanvasEntitySelection | null) => void;
|
||||
@@ -36,9 +33,9 @@ export function GameShellCanvasStage({
|
||||
<div className={`relative ${hideSelectionHero ? 'h-0 border-b-0' : 'h-[36%] border-b border-white/5'}`}>
|
||||
{gameState.currentScene === 'Selection' && !hideSelectionHero ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.14),transparent_40%),linear-gradient(180deg,rgba(8,10,14,0.2),rgba(8,10,14,0.82))]">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl font-black tracking-[0.14em] text-white sm:text-6xl">叙世</div>
|
||||
<div className="mt-3 text-sm tracking-[0.44em] text-zinc-300 sm:text-base">GENARRATIVE</div>
|
||||
<div className="selection-hero-brand px-6 text-center">
|
||||
<div className="selection-hero-brand__title">叙世</div>
|
||||
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -20,22 +20,7 @@ import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import { CharacterSelectionFlow } from './CharacterSelectionFlow';
|
||||
import { GameShellStoryPanels } from './GameShellStoryPanels';
|
||||
import { PreGameSelectionFlow, type SelectionStage } from './PreGameSelectionFlow';
|
||||
|
||||
type AdventureStatistics = {
|
||||
playTimeMs: number;
|
||||
hostileNpcsDefeated: number;
|
||||
questsAccepted: number;
|
||||
questsCompleted: number;
|
||||
questsTurnedIn: number;
|
||||
itemsUsed: number;
|
||||
scenesTraveled: number;
|
||||
currentSceneName: string;
|
||||
playerCurrency: number;
|
||||
inventoryItemCount: number;
|
||||
inventoryStackCount: number;
|
||||
activeCompanionCount: number;
|
||||
rosterCompanionCount: number;
|
||||
};
|
||||
import type { GameShellAdventureStatistics } from './types';
|
||||
|
||||
export function GameShellMainContent({
|
||||
gameState,
|
||||
@@ -106,7 +91,7 @@ export function GameShellMainContent({
|
||||
openOverlayPanel: (panel: 'character' | 'inventory') => void;
|
||||
openCampModal: () => void;
|
||||
openPartyMemberDetails: (selection: GameCanvasEntitySelection) => void;
|
||||
adventureStatistics: AdventureStatistics;
|
||||
adventureStatistics: GameShellAdventureStatistics;
|
||||
musicVolume: number;
|
||||
onMusicVolumeChange: (value: number) => void;
|
||||
resetForSaveAndExit: () => void;
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
|
||||
import {getLiveGamePlayTimeMs} from '../../data/runtimeStats';
|
||||
import {getWorldCampScenePreset} from '../../data/scenePresets';
|
||||
import type {StoryOption} from '../../types';
|
||||
import {UI_CHROME} from '../../uiAssets';
|
||||
import {GameShellCanvasStage} from './GameShellCanvasStage';
|
||||
import {GameShellMainContent} from './GameShellMainContent';
|
||||
import {GameShellOverlays} from './GameShellOverlays';
|
||||
import {type GameShellProps} from './types';
|
||||
import {useGameShellViewModel} from './useGameShellViewModel';
|
||||
import {SCENE_TRANSITION_FUNCTION_MODES, useSceneTransitionModel} from './useSceneTransitionModel';
|
||||
import { UI_CHROME } from '../../uiAssets';
|
||||
import { GameShellCanvasStage } from './GameShellCanvasStage';
|
||||
import { GameShellMainContent } from './GameShellMainContent';
|
||||
import { GameShellOverlays } from './GameShellOverlays';
|
||||
import type { GameShellProps } from './types';
|
||||
import { useGameShellRuntimeViewModel } from './useGameShellRuntimeViewModel';
|
||||
|
||||
export function GameShellRuntime({session, story, entry, companions, audio}: GameShellProps) {
|
||||
const {
|
||||
gameState,
|
||||
currentStory,
|
||||
isLoading,
|
||||
aiError,
|
||||
bottomTab,
|
||||
@@ -26,7 +19,6 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
handleChoice,
|
||||
handleMapTravelToScene,
|
||||
npcUi,
|
||||
characterChatUi,
|
||||
@@ -46,18 +38,10 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
} = entry;
|
||||
const {
|
||||
companionRenderStates,
|
||||
buildCompanionRenderStates,
|
||||
onBenchCompanion,
|
||||
onActivateRosterCompanion,
|
||||
} = companions;
|
||||
const {musicVolume, onMusicVolumeChange} = audio;
|
||||
|
||||
const [clockNow, setClockNow] = useState(() => Date.now());
|
||||
const openingCampSceneId = useMemo(
|
||||
() => (gameState.worldType ? getWorldCampScenePreset(gameState.worldType)?.id ?? null : null),
|
||||
[gameState.worldType],
|
||||
);
|
||||
const hasNpcModalOpen = Boolean(npcUi.tradeModal || npcUi.giftModal || npcUi.recruitModal);
|
||||
const {
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
@@ -77,120 +61,24 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
|
||||
shouldMountMapModal,
|
||||
shouldMountCharacterChatModal,
|
||||
shouldMountNpcModals,
|
||||
} = useGameShellViewModel({
|
||||
gameState,
|
||||
isMapOpen,
|
||||
characterChatModalOpen: Boolean(characterChatUi.modal),
|
||||
hasNpcModalOpen,
|
||||
});
|
||||
const {
|
||||
visibleGameState,
|
||||
visibleCurrentStory,
|
||||
sceneTransitionPhase,
|
||||
sceneTransitionToken,
|
||||
setSceneTransitionDurations,
|
||||
beginSceneTransition,
|
||||
} = useSceneTransitionModel({
|
||||
gameState,
|
||||
currentStory,
|
||||
openingCampSceneId,
|
||||
isCharacterSelectionStage,
|
||||
shouldHideStoryOptions,
|
||||
hideSelectionHero,
|
||||
dialogueIndicator,
|
||||
characterChatSummaries,
|
||||
canvasCompanionRenderStates,
|
||||
adventureStatistics,
|
||||
handleSceneTransitionChoice,
|
||||
} = useGameShellRuntimeViewModel({
|
||||
session,
|
||||
story,
|
||||
companions,
|
||||
});
|
||||
const isCharacterSelectionStage =
|
||||
gameState.currentScene === 'Selection' &&
|
||||
Boolean(gameState.worldType) &&
|
||||
!gameState.playerCharacter;
|
||||
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
|
||||
const hideSelectionHero =
|
||||
gameState.currentScene === 'Selection' &&
|
||||
selectionStage !== 'start';
|
||||
|
||||
const dialogueIndicator = useMemo(() => {
|
||||
if (!isLoading || visibleCurrentStory?.displayMode !== 'dialogue' || visibleGameState.currentEncounter?.kind !== 'npc') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastSpeaker = visibleCurrentStory.dialogue?.[visibleCurrentStory.dialogue.length - 1]?.speaker ?? null;
|
||||
return {
|
||||
showPlayer: true,
|
||||
showEncounter: true,
|
||||
activeSpeaker: lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null,
|
||||
} as const;
|
||||
}, [visibleCurrentStory?.dialogue, visibleCurrentStory?.displayMode, visibleGameState.currentEncounter?.kind, isLoading]);
|
||||
|
||||
const characterChatSummaries = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [characterId, record.summary]),
|
||||
),
|
||||
[gameState.characterChats],
|
||||
);
|
||||
|
||||
const visibleCompanionRenderStates = useMemo(
|
||||
() => buildCompanionRenderStates(visibleGameState),
|
||||
[buildCompanionRenderStates, visibleGameState],
|
||||
);
|
||||
|
||||
const canvasCompanionRenderStates = useMemo(() => {
|
||||
const activeEncounterNpcId = visibleGameState.currentEncounter?.kind === 'npc'
|
||||
? visibleGameState.currentEncounter.id ?? null
|
||||
: null;
|
||||
if (!activeEncounterNpcId) return visibleCompanionRenderStates;
|
||||
return visibleCompanionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId);
|
||||
}, [visibleCompanionRenderStates, visibleGameState.currentEncounter]);
|
||||
|
||||
const livePlayTimeMs = useMemo(
|
||||
() => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow),
|
||||
[clockNow, gameState.runtimeStats],
|
||||
);
|
||||
|
||||
const adventureStatistics = useMemo(
|
||||
() => ({
|
||||
playTimeMs: livePlayTimeMs,
|
||||
hostileNpcsDefeated: gameState.runtimeStats.hostileNpcsDefeated,
|
||||
questsAccepted: gameState.runtimeStats.questsAccepted,
|
||||
questsCompleted: visibleGameState.quests.filter(quest => quest.status === 'completed' || quest.status === 'turned_in').length,
|
||||
questsTurnedIn: visibleGameState.quests.filter(quest => quest.status === 'turned_in').length,
|
||||
itemsUsed: gameState.runtimeStats.itemsUsed,
|
||||
scenesTraveled: gameState.runtimeStats.scenesTraveled,
|
||||
currentSceneName: visibleGameState.currentScenePreset?.name ?? '当前区域',
|
||||
playerCurrency: visibleGameState.playerCurrency,
|
||||
inventoryItemCount: visibleGameState.playerInventory.reduce((sum, item) => sum + item.quantity, 0),
|
||||
inventoryStackCount: visibleGameState.playerInventory.length,
|
||||
activeCompanionCount: visibleGameState.companions.length,
|
||||
rosterCompanionCount: visibleGameState.roster.length,
|
||||
}),
|
||||
[
|
||||
gameState.runtimeStats.itemsUsed,
|
||||
gameState.runtimeStats.hostileNpcsDefeated,
|
||||
gameState.runtimeStats.questsAccepted,
|
||||
gameState.runtimeStats.scenesTraveled,
|
||||
livePlayTimeMs,
|
||||
visibleGameState.companions.length,
|
||||
visibleGameState.currentScenePreset?.name,
|
||||
visibleGameState.playerCurrency,
|
||||
visibleGameState.playerInventory,
|
||||
visibleGameState.quests,
|
||||
visibleGameState.roster.length,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
|
||||
return;
|
||||
}
|
||||
|
||||
setClockNow(Date.now());
|
||||
const intervalId = window.setInterval(() => setClockNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [gameState.currentScene, gameState.playerCharacter]);
|
||||
|
||||
const handleSceneTransitionChoice = useCallback((option: StoryOption) => {
|
||||
const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId];
|
||||
if (transitionMode) {
|
||||
beginSceneTransition(transitionMode);
|
||||
}
|
||||
handleChoice(option);
|
||||
}, [beginSceneTransition, handleChoice]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getNineSliceStyle,TAB_ICONS, UI_CHROME } from '../../uiAssets';
|
||||
import type { GameCanvasEntitySelection } from '../GameCanvas';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import { PanelLoadingFallback } from './GameShellLoaders';
|
||||
import type { GameShellAdventureStatistics } from './types';
|
||||
|
||||
const AdventurePanel = lazy(async () => {
|
||||
const module = await import('../AdventurePanel');
|
||||
@@ -38,22 +39,6 @@ const InventoryPanel = lazy(async () => {
|
||||
};
|
||||
});
|
||||
|
||||
type AdventureStatistics = {
|
||||
playTimeMs: number;
|
||||
hostileNpcsDefeated: number;
|
||||
questsAccepted: number;
|
||||
questsCompleted: number;
|
||||
questsTurnedIn: number;
|
||||
itemsUsed: number;
|
||||
scenesTraveled: number;
|
||||
currentSceneName: string;
|
||||
playerCurrency: number;
|
||||
inventoryItemCount: number;
|
||||
inventoryStackCount: number;
|
||||
activeCompanionCount: number;
|
||||
rosterCompanionCount: number;
|
||||
};
|
||||
|
||||
export function GameShellStoryPanels({
|
||||
visibleGameState,
|
||||
visibleCurrentStory,
|
||||
@@ -102,7 +87,7 @@ export function GameShellStoryPanels({
|
||||
openOverlayPanel: (panel: 'character' | 'inventory') => void;
|
||||
openCampModal: () => void;
|
||||
openPartyMemberDetails: (selection: GameCanvasEntitySelection) => void;
|
||||
adventureStatistics: AdventureStatistics;
|
||||
adventureStatistics: GameShellAdventureStatistics;
|
||||
musicVolume: number;
|
||||
onMusicVolumeChange: (value: number) => void;
|
||||
onSaveAndExit: () => void;
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
} from '../../data/characterPresets';
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { JsonObject } from '../../../packages/shared/src/contracts/common';
|
||||
import {
|
||||
readSavedCustomWorldProfiles,
|
||||
upsertSavedCustomWorldProfile,
|
||||
@@ -11,7 +15,6 @@ import {
|
||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||
import { getScenePreset } from '../../data/scenePresets';
|
||||
import {
|
||||
type CustomWorldGenerationProgress,
|
||||
generateCustomWorldProfile,
|
||||
} from '../../services/aiService';
|
||||
import {
|
||||
@@ -365,7 +368,9 @@ export function PreGameSelectionFlow({
|
||||
settingText:
|
||||
generatedCustomWorldProfile.settingText.trim() ||
|
||||
customWorldSettingPreview,
|
||||
creatorIntent: generatedCustomWorldProfile.creatorIntent,
|
||||
creatorIntent:
|
||||
(generatedCustomWorldProfile.creatorIntent as JsonObject | null) ??
|
||||
null,
|
||||
generationMode:
|
||||
options.generationMode ??
|
||||
generatedCustomWorldProfile.generationMode ??
|
||||
@@ -453,7 +458,7 @@ export function PreGameSelectionFlow({
|
||||
const profile = await generateCustomWorldProfile(
|
||||
{
|
||||
settingText,
|
||||
creatorIntent: customWorldCreatorIntent,
|
||||
creatorIntent: customWorldCreatorIntent as unknown as JsonObject,
|
||||
generationMode: customWorldGenerationMode,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -63,6 +63,28 @@ export interface GameShellAudioProps {
|
||||
onMusicVolumeChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export interface GameShellDialogueIndicator {
|
||||
showPlayer: boolean;
|
||||
showEncounter: boolean;
|
||||
activeSpeaker?: 'player' | 'npc' | null;
|
||||
}
|
||||
|
||||
export interface GameShellAdventureStatistics {
|
||||
playTimeMs: number;
|
||||
hostileNpcsDefeated: number;
|
||||
questsAccepted: number;
|
||||
questsCompleted: number;
|
||||
questsTurnedIn: number;
|
||||
itemsUsed: number;
|
||||
scenesTraveled: number;
|
||||
currentSceneName: string;
|
||||
playerCurrency: number;
|
||||
inventoryItemCount: number;
|
||||
inventoryStackCount: number;
|
||||
activeCompanionCount: number;
|
||||
rosterCompanionCount: number;
|
||||
}
|
||||
|
||||
export interface GameShellProps {
|
||||
session: GameShellSessionProps;
|
||||
story: GameShellStoryProps;
|
||||
|
||||
289
src/components/game-shell/useGameShellRuntimeViewModel.test.ts
Normal file
289
src/components/game-shell/useGameShellRuntimeViewModel.test.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { CharacterChatRecord, CompanionRenderState, GameState, StoryMoment } from '../../types';
|
||||
import { AnimationState, WorldType } from '../../types';
|
||||
import {
|
||||
buildAdventureStatistics,
|
||||
buildCanvasCompanionRenderStates,
|
||||
buildCharacterChatSummaries,
|
||||
buildGameShellDialogueIndicator,
|
||||
} from './useGameShellRuntimeViewModel';
|
||||
|
||||
function createBaseGameState(): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: null,
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 3,
|
||||
questsAccepted: 2,
|
||||
itemsUsed: 4,
|
||||
scenesTraveled: 5,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: {
|
||||
id: 'scene-1',
|
||||
name: '断桥旧哨',
|
||||
description: '测试场景',
|
||||
imageSrc: '/scene.png',
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 18,
|
||||
playerInventory: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '药草',
|
||||
description: '恢复道具',
|
||||
quantity: 2,
|
||||
category: 'consumable',
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
id: 'item-2',
|
||||
name: '布料',
|
||||
description: '材料',
|
||||
quantity: 3,
|
||||
category: 'material',
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
value: 1,
|
||||
},
|
||||
],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [
|
||||
{
|
||||
id: 'quest-1',
|
||||
issuerNpcId: 'npc-1',
|
||||
issuerNpcName: '老周',
|
||||
sceneId: 'scene-1',
|
||||
title: '寻回包裹',
|
||||
description: '找回丢失的包裹',
|
||||
summary: '一项测试任务',
|
||||
objective: {
|
||||
kind: 'deliver_item',
|
||||
targetItemId: 'item-1',
|
||||
requiredCount: 1,
|
||||
},
|
||||
progress: 1,
|
||||
status: 'completed',
|
||||
reward: {
|
||||
affinityBonus: 2,
|
||||
currency: 10,
|
||||
items: [],
|
||||
},
|
||||
rewardText: '测试奖励',
|
||||
},
|
||||
{
|
||||
id: 'quest-2',
|
||||
issuerNpcId: 'npc-2',
|
||||
issuerNpcName: '阿青',
|
||||
sceneId: 'scene-1',
|
||||
title: '护送商队',
|
||||
description: '保护商队通行',
|
||||
summary: '另一项测试任务',
|
||||
objective: {
|
||||
kind: 'reach_scene',
|
||||
targetSceneId: 'scene-2',
|
||||
requiredCount: 1,
|
||||
},
|
||||
progress: 1,
|
||||
status: 'turned_in',
|
||||
reward: {
|
||||
affinityBonus: 3,
|
||||
currency: 20,
|
||||
items: [],
|
||||
},
|
||||
rewardText: '测试奖励',
|
||||
},
|
||||
],
|
||||
roster: [
|
||||
{
|
||||
npcId: 'npc-roster',
|
||||
characterId: 'char-roster',
|
||||
joinedAtAffinity: 10,
|
||||
hp: 90,
|
||||
maxHp: 90,
|
||||
mana: 12,
|
||||
maxMana: 12,
|
||||
skillCooldowns: {},
|
||||
},
|
||||
],
|
||||
companions: [
|
||||
{
|
||||
npcId: 'npc-active',
|
||||
characterId: 'char-active',
|
||||
joinedAtAffinity: 18,
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 16,
|
||||
maxMana: 16,
|
||||
skillCooldowns: {},
|
||||
},
|
||||
{
|
||||
npcId: 'npc-encounter',
|
||||
characterId: 'char-encounter',
|
||||
joinedAtAffinity: 22,
|
||||
hp: 88,
|
||||
maxHp: 88,
|
||||
mana: 14,
|
||||
maxMana: 14,
|
||||
skillCooldowns: {},
|
||||
},
|
||||
],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useGameShellRuntimeViewModel helpers', () => {
|
||||
it('builds a dialogue indicator only for active npc dialogue playback', () => {
|
||||
const state = {
|
||||
...createBaseGameState(),
|
||||
currentEncounter: {
|
||||
id: 'npc-encounter',
|
||||
kind: 'npc' as const,
|
||||
npcName: '山道客',
|
||||
npcDescription: '拦路人',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道相遇',
|
||||
},
|
||||
};
|
||||
const story = {
|
||||
text: '继续对话',
|
||||
displayMode: 'dialogue' as const,
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'npc' as const,
|
||||
text: '先别急着出手。',
|
||||
},
|
||||
{
|
||||
speaker: 'player' as const,
|
||||
text: '那你想说什么?',
|
||||
},
|
||||
],
|
||||
options: [],
|
||||
} satisfies StoryMoment;
|
||||
|
||||
expect(
|
||||
buildGameShellDialogueIndicator({
|
||||
isLoading: true,
|
||||
visibleGameState: state,
|
||||
visibleCurrentStory: story,
|
||||
}),
|
||||
).toEqual({
|
||||
showPlayer: true,
|
||||
showEncounter: true,
|
||||
activeSpeaker: 'player',
|
||||
});
|
||||
|
||||
expect(
|
||||
buildGameShellDialogueIndicator({
|
||||
isLoading: false,
|
||||
visibleGameState: state,
|
||||
visibleCurrentStory: story,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('derives compact chat summaries and hides the active encounter companion from canvas renders', () => {
|
||||
const chatSummaries = buildCharacterChatSummaries({
|
||||
'char-active': {
|
||||
history: [],
|
||||
summary: '已经建立起稳定默契。',
|
||||
updatedAt: null,
|
||||
},
|
||||
'char-roster': {
|
||||
history: [],
|
||||
summary: '仍在营地观望局势。',
|
||||
updatedAt: null,
|
||||
},
|
||||
} satisfies Record<string, CharacterChatRecord>);
|
||||
|
||||
expect(chatSummaries).toEqual({
|
||||
'char-active': '已经建立起稳定默契。',
|
||||
'char-roster': '仍在营地观望局势。',
|
||||
});
|
||||
|
||||
const visibleCompanionRenderStates = [
|
||||
{ npcId: 'npc-active' },
|
||||
{ npcId: 'npc-encounter' },
|
||||
] as CompanionRenderState[];
|
||||
const visibleGameState = {
|
||||
...createBaseGameState(),
|
||||
currentEncounter: {
|
||||
id: 'npc-encounter',
|
||||
kind: 'npc' as const,
|
||||
npcName: '山道客',
|
||||
npcDescription: '拦路人',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道相遇',
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
buildCanvasCompanionRenderStates({
|
||||
visibleCompanionRenderStates,
|
||||
visibleGameState,
|
||||
}),
|
||||
).toEqual([{ npcId: 'npc-active' }]);
|
||||
});
|
||||
|
||||
it('aggregates adventure statistics from runtime and visible state slices', () => {
|
||||
const gameState = createBaseGameState();
|
||||
const statistics = buildAdventureStatistics({
|
||||
gameState,
|
||||
visibleGameState: gameState,
|
||||
livePlayTimeMs: 3210,
|
||||
});
|
||||
|
||||
expect(statistics).toEqual({
|
||||
playTimeMs: 3210,
|
||||
hostileNpcsDefeated: 3,
|
||||
questsAccepted: 2,
|
||||
questsCompleted: 2,
|
||||
questsTurnedIn: 1,
|
||||
itemsUsed: 4,
|
||||
scenesTraveled: 5,
|
||||
currentSceneName: '断桥旧哨',
|
||||
playerCurrency: 18,
|
||||
inventoryItemCount: 5,
|
||||
inventoryStackCount: 2,
|
||||
activeCompanionCount: 2,
|
||||
rosterCompanionCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
235
src/components/game-shell/useGameShellRuntimeViewModel.ts
Normal file
235
src/components/game-shell/useGameShellRuntimeViewModel.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { getLiveGamePlayTimeMs } from '../../data/runtimeStats';
|
||||
import { getWorldCampScenePreset } from '../../data/scenePresets';
|
||||
import type {
|
||||
CharacterChatRecord,
|
||||
CompanionRenderState,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type {
|
||||
GameShellAdventureStatistics,
|
||||
GameShellDialogueIndicator,
|
||||
GameShellProps,
|
||||
} from './types';
|
||||
import { useGameShellViewModel } from './useGameShellViewModel';
|
||||
import {
|
||||
SCENE_TRANSITION_FUNCTION_MODES,
|
||||
useSceneTransitionModel,
|
||||
} from './useSceneTransitionModel';
|
||||
|
||||
export function buildGameShellDialogueIndicator(params: {
|
||||
isLoading: boolean;
|
||||
visibleGameState: GameState;
|
||||
visibleCurrentStory: StoryMoment | null;
|
||||
}): GameShellDialogueIndicator | null {
|
||||
const { isLoading, visibleGameState, visibleCurrentStory } = params;
|
||||
if (
|
||||
!isLoading ||
|
||||
visibleCurrentStory?.displayMode !== 'dialogue' ||
|
||||
visibleGameState.currentEncounter?.kind !== 'npc'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastSpeaker =
|
||||
visibleCurrentStory.dialogue?.[visibleCurrentStory.dialogue.length - 1]
|
||||
?.speaker ?? null;
|
||||
|
||||
return {
|
||||
showPlayer: true,
|
||||
showEncounter: true,
|
||||
activeSpeaker: lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCharacterChatSummaries(
|
||||
characterChats: Record<string, CharacterChatRecord> | undefined,
|
||||
) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(characterChats ?? {}).map(([characterId, record]) => [
|
||||
characterId,
|
||||
record.summary,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCanvasCompanionRenderStates(params: {
|
||||
visibleCompanionRenderStates: CompanionRenderState[];
|
||||
visibleGameState: GameState;
|
||||
}) {
|
||||
const activeEncounterNpcId =
|
||||
params.visibleGameState.currentEncounter?.kind === 'npc'
|
||||
? params.visibleGameState.currentEncounter.id ?? null
|
||||
: null;
|
||||
if (!activeEncounterNpcId) {
|
||||
return params.visibleCompanionRenderStates;
|
||||
}
|
||||
|
||||
return params.visibleCompanionRenderStates.filter(
|
||||
(companion) => companion.npcId !== activeEncounterNpcId,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAdventureStatistics(params: {
|
||||
gameState: GameState;
|
||||
visibleGameState: GameState;
|
||||
livePlayTimeMs: number;
|
||||
}): GameShellAdventureStatistics {
|
||||
const { gameState, visibleGameState, livePlayTimeMs } = params;
|
||||
|
||||
return {
|
||||
playTimeMs: livePlayTimeMs,
|
||||
hostileNpcsDefeated: gameState.runtimeStats.hostileNpcsDefeated,
|
||||
questsAccepted: gameState.runtimeStats.questsAccepted,
|
||||
questsCompleted: visibleGameState.quests.filter(
|
||||
(quest) => quest.status === 'completed' || quest.status === 'turned_in',
|
||||
).length,
|
||||
questsTurnedIn: visibleGameState.quests.filter(
|
||||
(quest) => quest.status === 'turned_in',
|
||||
).length,
|
||||
itemsUsed: gameState.runtimeStats.itemsUsed,
|
||||
scenesTraveled: gameState.runtimeStats.scenesTraveled,
|
||||
currentSceneName: visibleGameState.currentScenePreset?.name ?? '当前区域',
|
||||
playerCurrency: visibleGameState.playerCurrency,
|
||||
inventoryItemCount: visibleGameState.playerInventory.reduce(
|
||||
(sum, item) => sum + item.quantity,
|
||||
0,
|
||||
),
|
||||
inventoryStackCount: visibleGameState.playerInventory.length,
|
||||
activeCompanionCount: visibleGameState.companions.length,
|
||||
rosterCompanionCount: visibleGameState.roster.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function useGameShellRuntimeViewModel(params: Pick<
|
||||
GameShellProps,
|
||||
'session' | 'story' | 'companions'
|
||||
>) {
|
||||
const { session, story, companions } = params;
|
||||
const {
|
||||
gameState,
|
||||
currentStory,
|
||||
isLoading,
|
||||
isMapOpen,
|
||||
} = session;
|
||||
const { npcUi, characterChatUi, handleChoice } = story;
|
||||
const { buildCompanionRenderStates } = companions;
|
||||
|
||||
const [clockNow, setClockNow] = useState(() => Date.now());
|
||||
const openingCampSceneId = useMemo(
|
||||
() =>
|
||||
gameState.worldType
|
||||
? getWorldCampScenePreset(gameState.worldType)?.id ?? null
|
||||
: null,
|
||||
[gameState.worldType],
|
||||
);
|
||||
const hasNpcModalOpen = Boolean(
|
||||
npcUi.tradeModal || npcUi.giftModal || npcUi.recruitModal,
|
||||
);
|
||||
const shellViewModel = useGameShellViewModel({
|
||||
gameState,
|
||||
isMapOpen,
|
||||
characterChatModalOpen: Boolean(characterChatUi.modal),
|
||||
hasNpcModalOpen,
|
||||
});
|
||||
const sceneTransitionModel = useSceneTransitionModel({
|
||||
gameState,
|
||||
currentStory,
|
||||
openingCampSceneId,
|
||||
});
|
||||
const {
|
||||
visibleGameState,
|
||||
visibleCurrentStory,
|
||||
sceneTransitionPhase,
|
||||
beginSceneTransition,
|
||||
} = sceneTransitionModel;
|
||||
const isCharacterSelectionStage =
|
||||
gameState.currentScene === 'Selection' &&
|
||||
Boolean(gameState.worldType) &&
|
||||
!gameState.playerCharacter;
|
||||
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
|
||||
const hideSelectionHero =
|
||||
gameState.currentScene === 'Selection' &&
|
||||
shellViewModel.selectionStage !== 'start';
|
||||
|
||||
const dialogueIndicator = useMemo(
|
||||
() =>
|
||||
buildGameShellDialogueIndicator({
|
||||
isLoading,
|
||||
visibleGameState,
|
||||
visibleCurrentStory,
|
||||
}),
|
||||
[isLoading, visibleCurrentStory, visibleGameState],
|
||||
);
|
||||
|
||||
const characterChatSummaries = useMemo(
|
||||
() => buildCharacterChatSummaries(gameState.characterChats),
|
||||
[gameState.characterChats],
|
||||
);
|
||||
|
||||
const visibleCompanionRenderStates = useMemo(
|
||||
() => buildCompanionRenderStates(visibleGameState),
|
||||
[buildCompanionRenderStates, visibleGameState],
|
||||
);
|
||||
|
||||
const canvasCompanionRenderStates = useMemo(
|
||||
() =>
|
||||
buildCanvasCompanionRenderStates({
|
||||
visibleCompanionRenderStates,
|
||||
visibleGameState,
|
||||
}),
|
||||
[visibleCompanionRenderStates, visibleGameState],
|
||||
);
|
||||
|
||||
const livePlayTimeMs = useMemo(
|
||||
() => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow),
|
||||
[clockNow, gameState.runtimeStats],
|
||||
);
|
||||
|
||||
const adventureStatistics = useMemo(
|
||||
() =>
|
||||
buildAdventureStatistics({
|
||||
gameState,
|
||||
visibleGameState,
|
||||
livePlayTimeMs,
|
||||
}),
|
||||
[gameState, livePlayTimeMs, visibleGameState],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
|
||||
return;
|
||||
}
|
||||
|
||||
setClockNow(Date.now());
|
||||
const intervalId = window.setInterval(() => setClockNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [gameState.currentScene, gameState.playerCharacter]);
|
||||
|
||||
const handleSceneTransitionChoice = useCallback(
|
||||
(option: StoryOption) => {
|
||||
const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId];
|
||||
if (transitionMode) {
|
||||
beginSceneTransition(transitionMode);
|
||||
}
|
||||
handleChoice(option);
|
||||
},
|
||||
[beginSceneTransition, handleChoice],
|
||||
);
|
||||
|
||||
return {
|
||||
...shellViewModel,
|
||||
...sceneTransitionModel,
|
||||
isCharacterSelectionStage,
|
||||
shouldHideStoryOptions,
|
||||
hideSelectionHero,
|
||||
dialogueIndicator,
|
||||
characterChatSummaries,
|
||||
canvasCompanionRenderStates,
|
||||
adventureStatistics,
|
||||
handleSceneTransitionChoice,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { MedievalNpcVisualOverride } from '../data/medievalNpcVisuals';
|
||||
import {
|
||||
buildEditorJsonApiPath,
|
||||
EDITOR_JSON_RESOURCE_IDS,
|
||||
} from '../editor/shared/editorApiClient';
|
||||
import { saveJsonObject } from '../editor/shared/jsonClient';
|
||||
import {
|
||||
buildNpcVisualSavePayload,
|
||||
@@ -6,18 +10,27 @@ import {
|
||||
} from './npcVisualEditorModel';
|
||||
import { cloneNpcLayoutConfig, type NpcLayoutConfig } from './npcVisualShared';
|
||||
|
||||
export const NPC_VISUAL_OVERRIDES_API_PATH = '/api/npc-visual-overrides';
|
||||
export const NPC_LAYOUT_CONFIG_API_PATH = '/api/npc-layout-config';
|
||||
export const NPC_VISUAL_OVERRIDES_API_PATH = buildEditorJsonApiPath(
|
||||
EDITOR_JSON_RESOURCE_IDS.npcVisualOverrides,
|
||||
);
|
||||
export const NPC_LAYOUT_CONFIG_API_PATH = buildEditorJsonApiPath(
|
||||
EDITOR_JSON_RESOURCE_IDS.npcLayoutConfig,
|
||||
);
|
||||
|
||||
type SaveJsonObjectFn = typeof saveJsonObject;
|
||||
type SaveEditorJsonFn = typeof saveJsonObject;
|
||||
|
||||
export async function persistNpcVisualOverrides(params: {
|
||||
overrideMap: Record<string, MedievalNpcVisualOverride>;
|
||||
npcId: string;
|
||||
editorState: EditableNpcVisualState;
|
||||
saveJson?: SaveJsonObjectFn;
|
||||
saveJson?: SaveEditorJsonFn;
|
||||
}) {
|
||||
const { overrideMap, npcId, editorState, saveJson = saveJsonObject } = params;
|
||||
const {
|
||||
overrideMap,
|
||||
npcId,
|
||||
editorState,
|
||||
saveJson = saveJsonObject,
|
||||
} = params;
|
||||
const nextOverrideMap = buildNpcVisualSavePayload(overrideMap, npcId, editorState);
|
||||
|
||||
await saveJson(
|
||||
@@ -34,7 +47,7 @@ export async function persistNpcVisualOverrides(params: {
|
||||
|
||||
export async function persistNpcLayoutConfig(params: {
|
||||
layoutDraft: NpcLayoutConfig;
|
||||
saveJson?: SaveJsonObjectFn;
|
||||
saveJson?: SaveEditorJsonFn;
|
||||
}) {
|
||||
const { layoutDraft, saveJson = saveJsonObject } = params;
|
||||
const nextLayout = cloneNpcLayoutConfig(layoutDraft);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { validateCharacterOverrides } from '../../data/editorValidation';
|
||||
import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets';
|
||||
import { getScenePresetsByWorld } from '../../data/scenePresets';
|
||||
import { cloneValue } from '../../editor/shared/cloneValue';
|
||||
import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient';
|
||||
import { EditorEmptyState } from '../../editor/shared/EditorEmptyState';
|
||||
import { EditorSelectionCard } from '../../editor/shared/EditorSelectionCard';
|
||||
import {
|
||||
@@ -83,7 +84,7 @@ export function CharacterPresetPanel() {
|
||||
)
|
||||
: null;
|
||||
const { isSaving, saveMessage, save } = useJsonSave({
|
||||
endpoint: '/api/character-overrides',
|
||||
resourceId: EDITOR_JSON_RESOURCE_IDS.characterOverrides,
|
||||
payload: overrideMap as Record<string, unknown>,
|
||||
validate: () =>
|
||||
validateCharacterOverrides(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type MonsterPresetOverride,
|
||||
} from '../../data/hostileNpcPresets';
|
||||
import monsterOverridesJson from '../../data/monsterOverrides.json';
|
||||
import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient';
|
||||
import { EditorSelectionCard } from '../../editor/shared/EditorSelectionCard';
|
||||
import {
|
||||
NumberField,
|
||||
@@ -43,7 +44,7 @@ export function MonsterPresetPanel() {
|
||||
const [previewAnimation, setPreviewAnimation] =
|
||||
useState<(typeof MONSTER_ANIMATION_OPTIONS)[number]>('idle');
|
||||
const { isSaving, saveMessage, save } = useJsonSave({
|
||||
endpoint: '/api/monster-overrides',
|
||||
resourceId: EDITOR_JSON_RESOURCE_IDS.monsterOverrides,
|
||||
payload: overrideMap as Record<string, unknown>,
|
||||
validate: () => validateMonsterOverrides(overrideMap, allMonsters),
|
||||
successMessage: '敌人预设覆盖已保存到 src/data/monsterOverrides.json。',
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { validateSceneNpcOverrides } from '../../data/editorValidation';
|
||||
import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets';
|
||||
import sceneNpcOverridesJson from '../../data/sceneNpcOverrides.json';
|
||||
import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient';
|
||||
import {
|
||||
getScenePresetsByWorld,
|
||||
type SceneNpcPresetOverride,
|
||||
@@ -116,7 +117,7 @@ export function SceneNpcPresetPanel() {
|
||||
(effectiveNpc?.initialAffinity ?? 0) < 0,
|
||||
);
|
||||
const { isSaving, saveMessage, save } = useJsonSave({
|
||||
endpoint: '/api/scene-npc-overrides',
|
||||
resourceId: EDITOR_JSON_RESOURCE_IDS.sceneNpcOverrides,
|
||||
payload: overrideMap as Record<string, unknown>,
|
||||
validate: () =>
|
||||
validateSceneNpcOverrides(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { validateSceneOverrides } from '../../data/editorValidation';
|
||||
import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets';
|
||||
import { createSceneHostileNpcsFromIds } from '../../data/hostileNpcs';
|
||||
import sceneOverridesJson from '../../data/sceneOverrides.json';
|
||||
import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient';
|
||||
import {
|
||||
getSceneHostileNpcPresetIds,
|
||||
getSceneHostileNpcs,
|
||||
@@ -46,7 +47,7 @@ export function ScenePresetPanel() {
|
||||
);
|
||||
const [previewMode, setPreviewMode] = useState<PreviewMode>('monster');
|
||||
const { isSaving, saveMessage, save } = useJsonSave({
|
||||
endpoint: '/api/scene-overrides',
|
||||
resourceId: EDITOR_JSON_RESOURCE_IDS.sceneOverrides,
|
||||
payload: overrideMap as Record<string, unknown>,
|
||||
validate: () =>
|
||||
validateSceneOverrides(overrideMap, allScenes, MONSTER_PRESETS_BY_WORLD),
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import {
|
||||
fetchJson,
|
||||
parseApiErrorMessage,
|
||||
} from '../../editor/shared/jsonClient';
|
||||
ASSET_API_PATHS,
|
||||
postApiJson,
|
||||
} from '../../editor/shared/editorApiClient';
|
||||
import { fetchJson } from '../../editor/shared/jsonClient';
|
||||
|
||||
export const CHARACTER_VISUAL_GENERATE_API_PATH =
|
||||
'/api/character-visual/generate';
|
||||
ASSET_API_PATHS.characterVisualGenerate;
|
||||
export const CHARACTER_VISUAL_PUBLISH_API_PATH =
|
||||
'/api/character-visual/publish';
|
||||
export const CHARACTER_VISUAL_JOB_API_PATH = '/api/character-visual/jobs';
|
||||
export const CHARACTER_ANIMATION_GENERATE_API_PATH = '/api/animation/generate';
|
||||
export const CHARACTER_ANIMATION_PUBLISH_API_PATH = '/api/animation/publish';
|
||||
export const CHARACTER_ANIMATION_JOB_API_PATH = '/api/animation/jobs';
|
||||
ASSET_API_PATHS.characterVisualPublish;
|
||||
export const CHARACTER_VISUAL_JOB_API_PATH = ASSET_API_PATHS.characterVisualJobs;
|
||||
export const CHARACTER_ANIMATION_GENERATE_API_PATH =
|
||||
ASSET_API_PATHS.characterAnimationGenerate;
|
||||
export const CHARACTER_ANIMATION_PUBLISH_API_PATH =
|
||||
ASSET_API_PATHS.characterAnimationPublish;
|
||||
export const CHARACTER_ANIMATION_JOB_API_PATH =
|
||||
ASSET_API_PATHS.characterAnimationJobs;
|
||||
export const CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH =
|
||||
'/api/animation/import-video';
|
||||
ASSET_API_PATHS.characterAnimationImportVideo;
|
||||
export const CHARACTER_ANIMATION_TEMPLATES_API_PATH =
|
||||
'/api/animation/templates';
|
||||
ASSET_API_PATHS.characterAnimationTemplates;
|
||||
|
||||
export type CharacterVisualSourceMode =
|
||||
| 'text-to-image'
|
||||
@@ -119,26 +123,13 @@ export type CharacterAssetJobStatus = {
|
||||
export async function generateCharacterVisualCandidates(
|
||||
payload: CharacterVisualGenerationPayload,
|
||||
) {
|
||||
const response = await fetch(CHARACTER_VISUAL_GENERATE_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
parseApiErrorMessage(responseText, '生成角色主形象候选失败'),
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.parse(responseText) as {
|
||||
return postApiJson<{
|
||||
ok: true;
|
||||
taskId: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
drafts: CharacterVisualDraft[];
|
||||
};
|
||||
}>(CHARACTER_VISUAL_GENERATE_API_PATH, payload, '生成角色主形象候选失败');
|
||||
}
|
||||
|
||||
export async function fetchCharacterVisualJobStatus(taskId: string) {
|
||||
@@ -151,41 +142,19 @@ export async function fetchCharacterVisualJobStatus(taskId: string) {
|
||||
export async function publishCharacterVisualAsset(
|
||||
payload: CharacterVisualPublishPayload,
|
||||
) {
|
||||
const response = await fetch(CHARACTER_VISUAL_PUBLISH_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(parseApiErrorMessage(responseText, '发布角色主形象失败'));
|
||||
}
|
||||
|
||||
return JSON.parse(responseText) as {
|
||||
return postApiJson<{
|
||||
ok: true;
|
||||
assetId: string;
|
||||
portraitPath: string;
|
||||
overrideMap: Record<string, unknown>;
|
||||
saveMessage: string;
|
||||
};
|
||||
}>(CHARACTER_VISUAL_PUBLISH_API_PATH, payload, '发布角色主形象失败');
|
||||
}
|
||||
|
||||
export async function generateCharacterAnimationDraft(
|
||||
payload: CharacterAnimationGenerationPayload,
|
||||
) {
|
||||
const response = await fetch(CHARACTER_ANIMATION_GENERATE_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(parseApiErrorMessage(responseText, '生成角色动作草稿失败'));
|
||||
}
|
||||
|
||||
return JSON.parse(responseText) as
|
||||
return postApiJson<
|
||||
| {
|
||||
ok: true;
|
||||
taskId: string;
|
||||
@@ -201,7 +170,8 @@ export async function generateCharacterAnimationDraft(
|
||||
model: string;
|
||||
prompt: string;
|
||||
previewVideoPath: string;
|
||||
};
|
||||
}
|
||||
>(CHARACTER_ANIMATION_GENERATE_API_PATH, payload, '生成角色动作草稿失败');
|
||||
}
|
||||
|
||||
export async function fetchCharacterAnimationJobStatus(taskId: string) {
|
||||
@@ -224,23 +194,12 @@ export async function importCharacterAnimationVideo(payload: {
|
||||
videoSource: string;
|
||||
sourceLabel?: string;
|
||||
}) {
|
||||
const response = await fetch(CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(parseApiErrorMessage(responseText, '导入动作视频失败'));
|
||||
}
|
||||
|
||||
return JSON.parse(responseText) as {
|
||||
return postApiJson<{
|
||||
ok: true;
|
||||
importedVideoPath: string;
|
||||
draftId: string;
|
||||
saveMessage: string;
|
||||
};
|
||||
}>(CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH, payload, '导入动作视频失败');
|
||||
}
|
||||
|
||||
export async function publishCharacterAnimationAssets(payload: {
|
||||
@@ -249,22 +208,11 @@ export async function publishCharacterAnimationAssets(payload: {
|
||||
animations: Record<string, CharacterAnimationDraftPayload>;
|
||||
updateCharacterOverride?: boolean;
|
||||
}) {
|
||||
const response = await fetch(CHARACTER_ANIMATION_PUBLISH_API_PATH, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(parseApiErrorMessage(responseText, '发布角色基础动作失败'));
|
||||
}
|
||||
|
||||
return JSON.parse(responseText) as {
|
||||
return postApiJson<{
|
||||
ok: true;
|
||||
animationSetId: string;
|
||||
overrideMap: Record<string, unknown>;
|
||||
animationMap: Record<string, unknown>;
|
||||
saveMessage: string;
|
||||
};
|
||||
}>(CHARACTER_ANIMATION_PUBLISH_API_PATH, payload, '发布角色基础动作失败');
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import type {
|
||||
ItemRarity,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
EDITOR_ITEM_CATALOG_API_PATH,
|
||||
} from '../editor/shared/editorApiClient';
|
||||
import { buildDesignedItemMetadata } from './itemDesign';
|
||||
|
||||
export const ITEM_CATALOG_API_PATH = '/api/item-catalog';
|
||||
export const ITEM_OVERRIDES_API_PATH = '/api/item-overrides';
|
||||
export { EDITOR_ITEM_CATALOG_API_PATH as ITEM_CATALOG_API_PATH };
|
||||
|
||||
export const ITEM_CATEGORY_OPTIONS = [
|
||||
'武器',
|
||||
|
||||
78
src/editor/shared/editorApiClient.ts
Normal file
78
src/editor/shared/editorApiClient.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { fetchJson, parseApiErrorMessage, saveJsonObject } from './jsonClient';
|
||||
|
||||
export const EDITOR_API_BASE_PATH = '/api/editor';
|
||||
export const ASSETS_API_BASE_PATH = '/api/assets';
|
||||
|
||||
export const EDITOR_JSON_RESOURCE_IDS = {
|
||||
itemOverrides: 'item-overrides',
|
||||
npcVisualOverrides: 'npc-visual-overrides',
|
||||
npcLayoutConfig: 'npc-layout-config',
|
||||
characterOverrides: 'character-overrides',
|
||||
monsterOverrides: 'monster-overrides',
|
||||
sceneOverrides: 'scene-overrides',
|
||||
sceneNpcOverrides: 'scene-npc-overrides',
|
||||
stateFunctionOverrides: 'state-function-overrides',
|
||||
} as const;
|
||||
|
||||
export type EditorJsonResourceId =
|
||||
(typeof EDITOR_JSON_RESOURCE_IDS)[keyof typeof EDITOR_JSON_RESOURCE_IDS];
|
||||
|
||||
export const ASSET_API_PATHS = {
|
||||
characterVisualGenerate: `${ASSETS_API_BASE_PATH}/character-visual/generate`,
|
||||
characterVisualPublish: `${ASSETS_API_BASE_PATH}/character-visual/publish`,
|
||||
characterVisualJobs: `${ASSETS_API_BASE_PATH}/character-visual/jobs`,
|
||||
characterAnimationGenerate: `${ASSETS_API_BASE_PATH}/character-animation/generate`,
|
||||
characterAnimationPublish: `${ASSETS_API_BASE_PATH}/character-animation/publish`,
|
||||
characterAnimationJobs: `${ASSETS_API_BASE_PATH}/character-animation/jobs`,
|
||||
characterAnimationImportVideo: `${ASSETS_API_BASE_PATH}/character-animation/import-video`,
|
||||
characterAnimationTemplates: `${ASSETS_API_BASE_PATH}/character-animation/templates`,
|
||||
qwenSpriteMaster: `${ASSETS_API_BASE_PATH}/qwen-sprite/master`,
|
||||
qwenSpriteSheet: `${ASSETS_API_BASE_PATH}/qwen-sprite/sheet`,
|
||||
qwenSpriteFrameRepair: `${ASSETS_API_BASE_PATH}/qwen-sprite/frame-repair`,
|
||||
qwenSpriteSave: `${ASSETS_API_BASE_PATH}/qwen-sprite/save`,
|
||||
} as const;
|
||||
|
||||
export const EDITOR_ITEM_CATALOG_API_PATH =
|
||||
`${EDITOR_API_BASE_PATH}/catalog/items`;
|
||||
|
||||
export function buildEditorJsonApiPath(resourceId: EditorJsonResourceId) {
|
||||
return `${EDITOR_API_BASE_PATH}/json/${resourceId}`;
|
||||
}
|
||||
|
||||
export function fetchEditorJsonResource<T>(
|
||||
resourceId: EditorJsonResourceId,
|
||||
fallbackMessage = '读取失败',
|
||||
) {
|
||||
return fetchJson<T>(buildEditorJsonApiPath(resourceId), fallbackMessage);
|
||||
}
|
||||
|
||||
export function saveEditorJsonResource(
|
||||
resourceId: EditorJsonResourceId,
|
||||
payload: Record<string, unknown>,
|
||||
fallbackMessage = '保存失败',
|
||||
) {
|
||||
return saveJsonObject(
|
||||
buildEditorJsonApiPath(resourceId),
|
||||
payload,
|
||||
fallbackMessage,
|
||||
);
|
||||
}
|
||||
|
||||
export async function postApiJson<T>(
|
||||
url: string,
|
||||
payload: Record<string, unknown>,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
|
||||
}
|
||||
|
||||
return responseText ? (JSON.parse(responseText) as T) : ({} as T);
|
||||
}
|
||||
@@ -1,43 +1,28 @@
|
||||
type ApiErrorPayload = {
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export function parseApiErrorMessage(responseText: string, fallbackMessage: string) {
|
||||
if (!responseText) {
|
||||
return fallbackMessage;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(responseText) as ApiErrorPayload;
|
||||
if (parsed.error?.message) {
|
||||
return parsed.error.message;
|
||||
}
|
||||
|
||||
if (typeof parsed.message === 'string' && parsed.message.trim()) {
|
||||
return parsed.message;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the raw response text below.
|
||||
}
|
||||
|
||||
return responseText;
|
||||
}
|
||||
import {
|
||||
API_RESPONSE_ENVELOPE_HEADER,
|
||||
API_RESPONSE_ENVELOPE_VERSION,
|
||||
parseApiErrorMessage,
|
||||
unwrapApiResponse,
|
||||
} from '../../../packages/shared/src/http';
|
||||
|
||||
export async function fetchJson<T>(
|
||||
url: string,
|
||||
fallbackMessage = '请求失败',
|
||||
): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
[API_RESPONSE_ENVELOPE_HEADER]: API_RESPONSE_ENVELOPE_VERSION,
|
||||
},
|
||||
});
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(parseApiErrorMessage(responseText, `${fallbackMessage}: ${response.status}`));
|
||||
}
|
||||
|
||||
return responseText ? (JSON.parse(responseText) as T) : ({} as T);
|
||||
return responseText
|
||||
? unwrapApiResponse<T>(JSON.parse(responseText) as T)
|
||||
: ({} as T);
|
||||
}
|
||||
|
||||
export async function saveJsonObject(
|
||||
@@ -47,7 +32,10 @@ export async function saveJsonObject(
|
||||
) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
[API_RESPONSE_ENVELOPE_HEADER]: API_RESPONSE_ENVELOPE_VERSION,
|
||||
},
|
||||
body: JSON.stringify(payload, null, 2),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
@@ -56,3 +44,5 @@ export async function saveJsonObject(
|
||||
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
|
||||
}
|
||||
}
|
||||
|
||||
export { parseApiErrorMessage };
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { saveJsonObject } from './jsonClient';
|
||||
import {
|
||||
saveEditorJsonResource,
|
||||
type EditorJsonResourceId,
|
||||
} from './editorApiClient';
|
||||
|
||||
type UseJsonSaveOptions = {
|
||||
endpoint: string;
|
||||
resourceId: EditorJsonResourceId;
|
||||
payload: Record<string, unknown>;
|
||||
validate?: () => string[];
|
||||
successMessage: string;
|
||||
@@ -11,7 +14,7 @@ type UseJsonSaveOptions = {
|
||||
};
|
||||
|
||||
export function useJsonSave({
|
||||
endpoint,
|
||||
resourceId,
|
||||
payload,
|
||||
validate,
|
||||
successMessage,
|
||||
@@ -32,7 +35,7 @@ export function useJsonSave({
|
||||
}
|
||||
|
||||
try {
|
||||
await saveJsonObject(endpoint, payload);
|
||||
await saveEditorJsonResource(resourceId, payload, errorMessage);
|
||||
setSaveMessage(successMessage);
|
||||
} catch (error) {
|
||||
setSaveMessage(error instanceof Error ? error.message : errorMessage);
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../services/ai', () => ({
|
||||
vi.mock('../../services/aiService', () => ({
|
||||
generateNextStep: vi.fn(),
|
||||
}));
|
||||
|
||||
import { generateNextStep } from '../../services/ai';
|
||||
const {
|
||||
isServerRuntimeFunctionIdMock,
|
||||
resolveServerRuntimeChoiceMock,
|
||||
} = vi.hoisted(() => ({
|
||||
isServerRuntimeFunctionIdMock: vi.fn(() => false),
|
||||
resolveServerRuntimeChoiceMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./runtimeStoryCoordinator', () => ({
|
||||
resolveServerRuntimeChoice: resolveServerRuntimeChoiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/runtimeStoryService', () => ({
|
||||
isServerRuntimeFunctionId: isServerRuntimeFunctionIdMock,
|
||||
}));
|
||||
|
||||
import { generateNextStep } from '../../services/aiService';
|
||||
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
|
||||
import { createStoryChoiceActions } from './choiceActions';
|
||||
|
||||
@@ -133,6 +149,228 @@ const neverNpcEncounter = (
|
||||
): encounter is Encounter => false;
|
||||
|
||||
describe('createStoryChoiceActions', () => {
|
||||
beforeEach(() => {
|
||||
isServerRuntimeFunctionIdMock.mockReset();
|
||||
isServerRuntimeFunctionIdMock.mockReturnValue(false);
|
||||
resolveServerRuntimeChoiceMock.mockReset();
|
||||
});
|
||||
|
||||
it('routes task5 story choices through the server runtime action endpoint', async () => {
|
||||
const state = createBaseState();
|
||||
const option = createBattleOption('npc_chat');
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
|
||||
isServerRuntimeFunctionIdMock.mockReturnValue(true);
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValue({
|
||||
hydratedSnapshot: {
|
||||
gameState: {
|
||||
...state,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 1,
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
'npc-opponent': {
|
||||
...state.npcStates['npc-opponent'],
|
||||
affinity: 6,
|
||||
chattedCount: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
currentStory: {
|
||||
text: '后端已结算关系变化',
|
||||
options: [],
|
||||
},
|
||||
bottomTab: 'adventure',
|
||||
},
|
||||
nextStory: {
|
||||
text: '后端已结算关系变化',
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
text: '请求援手',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: {
|
||||
...state,
|
||||
currentEncounter: {
|
||||
id: 'npc-opponent',
|
||||
kind: 'npc',
|
||||
npcName: '山道客',
|
||||
npcDescription: '拦路的陌生人',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道相遇',
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
},
|
||||
currentStory: createFallbackStory('当前故事'),
|
||||
isLoading: false,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(),
|
||||
playResolvedChoice: vi.fn(),
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(() => null),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
startOpeningAdventure: vi.fn(),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(() => false),
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isInitialCompanionEncounter: neverNpcEncounter,
|
||||
isRegularNpcEncounter: (encounter): encounter is Encounter =>
|
||||
Boolean(encounter?.kind === 'npc'),
|
||||
isNpcEncounter: (encounter): encounter is Encounter =>
|
||||
Boolean(encounter?.kind === 'npc'),
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith({
|
||||
gameState: expect.objectContaining({
|
||||
currentEncounter: expect.objectContaining({
|
||||
id: 'npc-opponent',
|
||||
}),
|
||||
}),
|
||||
currentStory: createFallbackStory('当前故事'),
|
||||
option,
|
||||
});
|
||||
expect(setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtimeActionVersion: 1,
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '后端已结算关系变化',
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps npc trade choices on the local UI path so the trade modal can collect payload', async () => {
|
||||
const state: GameState = {
|
||||
...createBaseState(),
|
||||
currentEncounter: {
|
||||
id: 'npc-merchant',
|
||||
kind: 'npc' as const,
|
||||
npcName: '梁伯',
|
||||
npcDescription: '沿街商贩',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '沿街商贩',
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
};
|
||||
const option: StoryOption = {
|
||||
functionId: 'npc_trade',
|
||||
actionText: '交易',
|
||||
text: '交易',
|
||||
interaction: {
|
||||
kind: 'npc' as const,
|
||||
npcId: 'npc-merchant',
|
||||
action: 'trade' as const,
|
||||
},
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right' as const,
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
const handleNpcInteraction = vi.fn(() => true);
|
||||
|
||||
isServerRuntimeFunctionIdMock.mockReturnValue(true);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory: createFallbackStory('当前故事'),
|
||||
isLoading: false,
|
||||
setGameState: vi.fn(),
|
||||
setCurrentStory: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(),
|
||||
playResolvedChoice: vi.fn(),
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(() => null),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
startOpeningAdventure: vi.fn(),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isInitialCompanionEncounter: neverNpcEncounter,
|
||||
isRegularNpcEncounter: (encounter): encounter is Encounter =>
|
||||
Boolean(encounter?.kind === 'npc'),
|
||||
isNpcEncounter: (encounter): encounter is Encounter =>
|
||||
Boolean(encounter?.kind === 'npc'),
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(handleNpcInteraction).toHaveBeenCalledWith(option);
|
||||
expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the finishing action in history before npc victory follow-up generation', async () => {
|
||||
const state = createBaseState();
|
||||
const option = createBattleOption();
|
||||
|
||||
@@ -3,25 +3,9 @@ import type {
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
buildEncounterEntryState,
|
||||
hasEncounterEntity,
|
||||
} from '../../data/encounterTransition';
|
||||
import { rollHostileNpcLoot } from '../../data/hostileNpcPresets';
|
||||
import { addInventoryItems } from '../../data/npcInteractions';
|
||||
import { applyQuestProgressFromHostileNpcDefeat } from '../../data/questFlow';
|
||||
import {
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
createSceneEncounterPreview,
|
||||
resolveSceneEncounterPreview,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import { generateNextStep } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import { isServerRuntimeFunctionId } from '../../services/runtimeStoryService';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
@@ -34,6 +18,12 @@ import type {
|
||||
CommitGeneratedStateWithEncounterEntry,
|
||||
GenerateStoryForState,
|
||||
} from './progressionActions';
|
||||
import { runLocalStoryChoiceContinuation } from './storyChoiceContinuation';
|
||||
import {
|
||||
runCampTravelHomeChoice,
|
||||
runServerRuntimeChoiceAction,
|
||||
shouldOpenLocalRuntimeNpcModal,
|
||||
} from './storyChoiceRuntime';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
|
||||
type RuntimeStatsIncrements = Partial<Pick<GameState['runtimeStats'], 'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'>>;
|
||||
@@ -78,112 +68,6 @@ type IncrementRuntimeStats = (
|
||||
increments: RuntimeStatsIncrements,
|
||||
) => GameState;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function buildReasonedOptionCatalog(options: StoryOption[]) {
|
||||
const seenFunctionIds = new Set<string>();
|
||||
|
||||
return options.filter(option => {
|
||||
if (seenFunctionIds.has(option.functionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenFunctionIds.add(option.functionId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function buildCombatResolutionContextText(params: {
|
||||
baseState: GameState;
|
||||
afterSequence: GameState;
|
||||
optionKind: ResolvedChoiceState['optionKind'];
|
||||
projectedBattleReward: BattleRewardSummary | null;
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
}) {
|
||||
const {
|
||||
baseState,
|
||||
afterSequence,
|
||||
optionKind,
|
||||
projectedBattleReward,
|
||||
getResolvedSceneHostileNpcs,
|
||||
} = params;
|
||||
|
||||
if (optionKind === 'escape') {
|
||||
const hostileNames = getResolvedSceneHostileNpcs(baseState)
|
||||
.map((hostileNpc) => hostileNpc.name)
|
||||
.join('、');
|
||||
return hostileNames
|
||||
? `你已成功逃脱,与${hostileNames}的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。`
|
||||
: '你已成功逃脱刚才的交战,当前不再处于战斗状态。';
|
||||
}
|
||||
|
||||
if (
|
||||
!baseState.inBattle ||
|
||||
afterSequence.inBattle ||
|
||||
Boolean(baseState.currentBattleNpcId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostileNames = getResolvedSceneHostileNpcs(baseState)
|
||||
.map((hostileNpc) => hostileNpc.name)
|
||||
.join('、');
|
||||
const lootText =
|
||||
projectedBattleReward?.items.length
|
||||
? `战利品:${projectedBattleReward.items.map((item) => item.name).join('、')}。`
|
||||
: '';
|
||||
|
||||
return hostileNames
|
||||
? `你已经击败${hostileNames},眼前这一轮交战已经结束。${lootText}`
|
||||
: `眼前这一轮交战已经结束,当前不再处于战斗状态。${lootText}`;
|
||||
}
|
||||
|
||||
async function buildHostileNpcBattleReward(
|
||||
state: GameState,
|
||||
afterSequence: GameState,
|
||||
optionKind: ResolvedChoiceState['optionKind'],
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'],
|
||||
): Promise<BattleRewardSummary | null> {
|
||||
if (
|
||||
optionKind === 'escape'
|
||||
|| !state.worldType
|
||||
|| state.currentBattleNpcId
|
||||
|| !state.inBattle
|
||||
|| afterSequence.inBattle
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeHostileNpcs = getResolvedSceneHostileNpcs(state);
|
||||
const nextHostileNpcs = getResolvedSceneHostileNpcs(afterSequence);
|
||||
const defeatedHostileNpcs = activeHostileNpcs.filter(hostileNpc =>
|
||||
!nextHostileNpcs.some(nextHostileNpc => nextHostileNpc.id === hostileNpc.id),
|
||||
);
|
||||
|
||||
if (defeatedHostileNpcs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rolledItems = await rollHostileNpcLoot(
|
||||
state,
|
||||
defeatedHostileNpcs.map(hostileNpc => ({
|
||||
id: hostileNpc.id,
|
||||
name: hostileNpc.name,
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
id: `battle-reward-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
defeatedHostileNpcs: defeatedHostileNpcs.map(hostileNpc => ({
|
||||
id: hostileNpc.id,
|
||||
name: hostileNpc.name,
|
||||
})),
|
||||
items: addInventoryItems([], rolledItems),
|
||||
};
|
||||
}
|
||||
|
||||
export function createStoryChoiceActions({
|
||||
gameState,
|
||||
currentStory,
|
||||
@@ -250,8 +134,10 @@ export function createStoryChoiceActions({
|
||||
getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
|
||||
startOpeningAdventure: () => Promise<void>;
|
||||
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
|
||||
handleNpcInteraction: (option: StoryOption) => boolean;
|
||||
handleTreasureInteraction: (option: StoryOption) => void | Promise<void> | boolean;
|
||||
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
|
||||
handleTreasureInteraction: (
|
||||
option: StoryOption,
|
||||
) => void | Promise<void> | boolean | Promise<boolean>;
|
||||
commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry;
|
||||
finalizeNpcBattleResult: (
|
||||
state: GameState,
|
||||
@@ -268,91 +154,6 @@ export function createStoryChoiceActions({
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
}) {
|
||||
const handleCampTravelHome = async (option: StoryOption, character: Character) => {
|
||||
const targetScene = getCampCompanionTravelScene(gameState, character);
|
||||
if (!targetScene) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBattleReward(null);
|
||||
setAiError(null);
|
||||
|
||||
const companionName = isNpcEncounter(gameState.currentEncounter)
|
||||
? gameState.currentEncounter.npcName
|
||||
: fallbackCompanionName;
|
||||
const travelRunState: GameState = {
|
||||
...gameState,
|
||||
ambientIdleMode: undefined,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.RUN,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: true,
|
||||
inBattle: false,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
const travelBaseState: GameState = incrementRuntimeStats({
|
||||
...gameState,
|
||||
ambientIdleMode: undefined,
|
||||
currentScenePreset: targetScene,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
}, {
|
||||
scenesTraveled: 1,
|
||||
});
|
||||
const travelPreviewState: GameState = {
|
||||
...travelBaseState,
|
||||
...createSceneEncounterPreview(travelBaseState),
|
||||
};
|
||||
const resolvedState = hasEncounterEntity(travelPreviewState)
|
||||
? resolveSceneEncounterPreview(travelPreviewState)
|
||||
: travelBaseState;
|
||||
const entryState = buildEncounterEntryState(resolvedState, CALL_OUT_ENTRY_X_METERS);
|
||||
|
||||
setIsLoading(true);
|
||||
setGameState(travelRunState);
|
||||
await sleep(turnVisualMs);
|
||||
|
||||
await commitGeneratedStateWithEncounterEntry(
|
||||
entryState,
|
||||
resolvedState,
|
||||
character,
|
||||
option.actionText,
|
||||
`You and ${companionName} leave camp and formally step into ${targetScene.name} to begin the adventure.`,
|
||||
option.functionId,
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
const handleChoice = async (option: StoryOption) => {
|
||||
const character = gameState.playerCharacter;
|
||||
if (!gameState.worldType || !character || isLoading) return;
|
||||
@@ -367,7 +168,43 @@ export function createStoryChoiceActions({
|
||||
}
|
||||
|
||||
if (isCampTravelHomeOption(option)) {
|
||||
await handleCampTravelHome(option, character);
|
||||
await runCampTravelHomeChoice({
|
||||
gameState,
|
||||
option,
|
||||
character,
|
||||
setBattleReward,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setGameState,
|
||||
incrementRuntimeStats,
|
||||
getCampCompanionTravelScene,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
isNpcEncounter,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldOpenLocalRuntimeNpcModal(option)) {
|
||||
setAiError(null);
|
||||
await handleNpcInteraction(option);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isServerRuntimeFunctionId(option.functionId)) {
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
character,
|
||||
setBattleReward,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setGameState,
|
||||
setCurrentStory: (story) => setCurrentStory(story),
|
||||
buildFallbackStoryForState,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -393,238 +230,41 @@ export function createStoryChoiceActions({
|
||||
|
||||
if (option.interaction?.kind === 'npc') {
|
||||
setAiError(null);
|
||||
handleNpcInteraction(option);
|
||||
await handleNpcInteraction(option);
|
||||
return;
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'treasure') {
|
||||
setAiError(null);
|
||||
handleTreasureInteraction(option);
|
||||
await handleTreasureInteraction(option);
|
||||
return;
|
||||
}
|
||||
|
||||
setBattleReward(null);
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
const baseChoiceState = (
|
||||
isRegularNpcEncounter(gameState.currentEncounter)
|
||||
&& !gameState.npcInteractionActive
|
||||
&& !option.interaction
|
||||
)
|
||||
? {
|
||||
...gameState,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
}
|
||||
: gameState;
|
||||
|
||||
let fallbackState = baseChoiceState;
|
||||
|
||||
try {
|
||||
const history = baseChoiceState.storyHistory;
|
||||
const resolvedChoice = buildResolvedChoiceState(baseChoiceState, option, character);
|
||||
const projectedState = resolvedChoice.afterSequence;
|
||||
const shouldUseLocalNpcVictory = Boolean(
|
||||
baseChoiceState.currentBattleNpcId &&
|
||||
resolvedChoice.optionKind === 'battle' &&
|
||||
(
|
||||
projectedState.currentNpcBattleOutcome ||
|
||||
(baseChoiceState.currentNpcBattleMode === 'fight' && !projectedState.inBattle)
|
||||
),
|
||||
);
|
||||
const projectedBattleReward = shouldUseLocalNpcVictory
|
||||
? null
|
||||
: await buildHostileNpcBattleReward(
|
||||
baseChoiceState,
|
||||
projectedState,
|
||||
resolvedChoice.optionKind,
|
||||
getResolvedSceneHostileNpcs,
|
||||
);
|
||||
const projectedStateWithBattleReward = projectedBattleReward
|
||||
? appendStoryEngineCarrierMemory({
|
||||
...projectedState,
|
||||
playerInventory: addInventoryItems(projectedState.playerInventory, projectedBattleReward.items),
|
||||
} as GameState, projectedBattleReward.items)
|
||||
: projectedState;
|
||||
fallbackState = projectedStateWithBattleReward;
|
||||
const projectedAvailableOptions = getAvailableOptionsForState(
|
||||
projectedStateWithBattleReward,
|
||||
character,
|
||||
);
|
||||
const combatResolutionContextText = buildCombatResolutionContextText({
|
||||
baseState: baseChoiceState,
|
||||
afterSequence: projectedStateWithBattleReward,
|
||||
optionKind: resolvedChoice.optionKind,
|
||||
projectedBattleReward,
|
||||
getResolvedSceneHostileNpcs,
|
||||
});
|
||||
const historyForStoryGeneration = combatResolutionContextText
|
||||
? [
|
||||
...history,
|
||||
createHistoryMoment(option.actionText, 'action'),
|
||||
createHistoryMoment(combatResolutionContextText, 'result'),
|
||||
]
|
||||
: history;
|
||||
|
||||
const responsePromise = shouldUseLocalNpcVictory
|
||||
? Promise.resolve(null)
|
||||
: generateNextStep(
|
||||
gameState.worldType,
|
||||
character,
|
||||
getStoryGenerationHostileNpcs(projectedStateWithBattleReward),
|
||||
historyForStoryGeneration,
|
||||
option.actionText,
|
||||
buildStoryContextFromState(projectedStateWithBattleReward, {
|
||||
lastFunctionId: option.functionId,
|
||||
observeSignsRequested: option.functionId === 'idle_observe_signs',
|
||||
recentActionResult: combatResolutionContextText,
|
||||
}),
|
||||
projectedAvailableOptions ? { availableOptions: projectedAvailableOptions } : undefined,
|
||||
);
|
||||
const responseSettledPromise = responsePromise.then(() => undefined, () => undefined);
|
||||
const playbackSync: EscapePlaybackSync | undefined = resolvedChoice.optionKind === 'escape'
|
||||
? { waitForStoryResponse: responseSettledPromise }
|
||||
: undefined;
|
||||
const actionPromise = playResolvedChoice(
|
||||
baseChoiceState,
|
||||
option,
|
||||
character,
|
||||
resolvedChoice,
|
||||
playbackSync,
|
||||
);
|
||||
const [actionResult, responseResult] = await Promise.allSettled([actionPromise, responsePromise]);
|
||||
|
||||
if (actionResult.status === 'rejected') {
|
||||
throw actionResult.reason;
|
||||
}
|
||||
|
||||
let afterSequence = shouldUseLocalNpcVictory ? resolvedChoice.afterSequence : actionResult.value;
|
||||
if (projectedBattleReward) {
|
||||
afterSequence = appendStoryEngineCarrierMemory({
|
||||
...afterSequence,
|
||||
playerInventory: addInventoryItems(afterSequence.playerInventory, projectedBattleReward.items),
|
||||
} as GameState, projectedBattleReward.items);
|
||||
}
|
||||
fallbackState = afterSequence;
|
||||
|
||||
if (shouldUseLocalNpcVictory) {
|
||||
const victory = finalizeNpcBattleResult(
|
||||
afterSequence,
|
||||
character,
|
||||
baseChoiceState.currentNpcBattleMode!,
|
||||
afterSequence.currentNpcBattleOutcome,
|
||||
);
|
||||
if (victory) {
|
||||
const historyBase = baseChoiceState.currentNpcBattleMode === 'spar'
|
||||
? (afterSequence.sparStoryHistoryBefore ?? [])
|
||||
: baseChoiceState.storyHistory;
|
||||
const nextHistory = [
|
||||
...historyBase,
|
||||
createHistoryMoment(option.actionText, 'action'),
|
||||
createHistoryMoment(victory.resultText, 'result'),
|
||||
];
|
||||
const nextState = {
|
||||
...victory.nextState,
|
||||
storyHistory: nextHistory,
|
||||
};
|
||||
const postBattleOptionCatalog = baseChoiceState.currentNpcBattleMode === 'spar' && nextState.currentEncounter
|
||||
? buildReasonedOptionCatalog(
|
||||
buildNpcStory(
|
||||
nextState,
|
||||
character,
|
||||
nextState.currentEncounter,
|
||||
).options,
|
||||
)
|
||||
: null;
|
||||
fallbackState = nextState;
|
||||
setGameState(nextState);
|
||||
try {
|
||||
const nextStory = await generateStoryForState({
|
||||
state: nextState,
|
||||
character,
|
||||
history: nextHistory,
|
||||
choice: option.actionText,
|
||||
lastFunctionId: option.functionId,
|
||||
optionCatalog: postBattleOptionCatalog,
|
||||
});
|
||||
const recoveredState = applyStoryReasoningRecovery(nextState);
|
||||
setGameState(recoveredState);
|
||||
setCurrentStory(nextStory);
|
||||
} catch (storyError) {
|
||||
console.error('Failed to continue npc battle resolution story:', storyError);
|
||||
setAiError(storyError instanceof Error ? storyError.message : '未知智能生成错误');
|
||||
setCurrentStory(buildFallbackStoryForState(nextState, character, victory.resultText));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (responseResult.status === 'rejected') {
|
||||
throw responseResult.reason;
|
||||
}
|
||||
|
||||
const response = responseResult.value!;
|
||||
const defeatedHostileNpcIds = baseChoiceState.currentBattleNpcId || resolvedChoice.optionKind === 'escape'
|
||||
? []
|
||||
: getResolvedSceneHostileNpcs(baseChoiceState)
|
||||
.map(hostileNpc => hostileNpc.id)
|
||||
.filter(hostileNpcId => !getResolvedSceneHostileNpcs(afterSequence).some(hostileNpc => hostileNpc.id === hostileNpcId));
|
||||
const nextHistory = combatResolutionContextText
|
||||
? [
|
||||
...historyForStoryGeneration,
|
||||
createHistoryMoment(response.storyText, 'result', response.options),
|
||||
]
|
||||
: [
|
||||
...baseChoiceState.storyHistory,
|
||||
createHistoryMoment(option.actionText, 'action'),
|
||||
createHistoryMoment(response.storyText, 'result', response.options),
|
||||
];
|
||||
|
||||
const nextState = incrementRuntimeStats({
|
||||
...updateQuestLog(
|
||||
afterSequence,
|
||||
quests => applyQuestProgressFromHostileNpcDefeat(
|
||||
quests,
|
||||
baseChoiceState.currentScenePreset?.id ?? null,
|
||||
defeatedHostileNpcIds,
|
||||
),
|
||||
),
|
||||
lastObserveSignsSceneId: option.functionId === 'idle_observe_signs'
|
||||
? (afterSequence.currentScenePreset?.id ?? null)
|
||||
: afterSequence.lastObserveSignsSceneId ?? null,
|
||||
lastObserveSignsReport: option.functionId === 'idle_observe_signs'
|
||||
? response.storyText
|
||||
: afterSequence.lastObserveSignsReport ?? null,
|
||||
storyHistory: nextHistory,
|
||||
}, {
|
||||
hostileNpcsDefeated: defeatedHostileNpcIds.length,
|
||||
});
|
||||
|
||||
const recoveredState = applyStoryReasoningRecovery(nextState);
|
||||
setGameState(recoveredState);
|
||||
if (projectedBattleReward) {
|
||||
setBattleReward(projectedBattleReward);
|
||||
}
|
||||
|
||||
setCurrentStory(
|
||||
buildStoryFromResponse(
|
||||
recoveredState,
|
||||
character,
|
||||
{
|
||||
text: response.storyText,
|
||||
options: response.options,
|
||||
},
|
||||
projectedAvailableOptions,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to get next step:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setCurrentStory(buildFallbackStoryForState(fallbackState, character));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
await runLocalStoryChoiceContinuation({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
character,
|
||||
setGameState,
|
||||
setCurrentStory: (story) => setCurrentStory(story),
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setBattleReward,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
buildFallbackStoryForState,
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs,
|
||||
buildNpcStory,
|
||||
updateQuestLog,
|
||||
incrementRuntimeStats,
|
||||
finalizeNpcBattleResult,
|
||||
isRegularNpcEncounter,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
88
src/hooks/story/goalFlow.ts
Normal file
88
src/hooks/story/goalFlow.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildGoalStackState,
|
||||
createGoalPulseSnapshot,
|
||||
deriveGoalPulseEvent,
|
||||
} from '../../services/storyEngine/goalDirector';
|
||||
import type { GameState } from '../../types';
|
||||
import type { GoalFlowUi } from './uiTypes';
|
||||
|
||||
export function useStoryGoalFlow(gameState: GameState) {
|
||||
const [goalPulse, setGoalPulse] = useState<GoalFlowUi['pulse']>(null);
|
||||
const previousGoalPulseSnapshotRef =
|
||||
useRef<ReturnType<typeof createGoalPulseSnapshot> | null>(null);
|
||||
|
||||
const runtimeGoalStack = useMemo(
|
||||
() =>
|
||||
buildGoalStackState({
|
||||
quests: gameState.quests,
|
||||
worldType: gameState.worldType,
|
||||
currentSceneId: gameState.currentScenePreset?.id ?? null,
|
||||
chapterState:
|
||||
gameState.chapterState ??
|
||||
gameState.storyEngineMemory?.currentChapter ??
|
||||
null,
|
||||
journeyBeat: gameState.storyEngineMemory?.currentJourneyBeat ?? null,
|
||||
setpieceDirective:
|
||||
gameState.storyEngineMemory?.currentSetpieceDirective ?? null,
|
||||
currentCampEvent:
|
||||
gameState.storyEngineMemory?.currentCampEvent ?? null,
|
||||
currentSceneName: gameState.currentScenePreset?.name ?? null,
|
||||
}),
|
||||
[
|
||||
gameState.chapterState,
|
||||
gameState.currentScenePreset?.id,
|
||||
gameState.currentScenePreset?.name,
|
||||
gameState.quests,
|
||||
gameState.storyEngineMemory?.currentCampEvent,
|
||||
gameState.storyEngineMemory?.currentChapter,
|
||||
gameState.storyEngineMemory?.currentJourneyBeat,
|
||||
gameState.storyEngineMemory?.currentSetpieceDirective,
|
||||
gameState.worldType,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentSnapshot = createGoalPulseSnapshot(
|
||||
gameState.quests,
|
||||
runtimeGoalStack,
|
||||
);
|
||||
const previousSnapshot = previousGoalPulseSnapshotRef.current;
|
||||
|
||||
if (!previousSnapshot) {
|
||||
previousGoalPulseSnapshotRef.current = currentSnapshot;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPulse = deriveGoalPulseEvent({
|
||||
previous: previousSnapshot,
|
||||
quests: gameState.quests,
|
||||
goalStack: runtimeGoalStack,
|
||||
});
|
||||
if (nextPulse) {
|
||||
setGoalPulse(nextPulse);
|
||||
}
|
||||
|
||||
previousGoalPulseSnapshotRef.current = currentSnapshot;
|
||||
}, [gameState.quests, runtimeGoalStack]);
|
||||
|
||||
const dismissGoalPulse = useCallback(() => {
|
||||
setGoalPulse(null);
|
||||
}, []);
|
||||
|
||||
const resetGoalPulseTracking = useCallback(() => {
|
||||
previousGoalPulseSnapshotRef.current = null;
|
||||
setGoalPulse(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
runtimeGoalStack,
|
||||
goalUi: {
|
||||
goalStack: runtimeGoalStack,
|
||||
pulse: goalPulse,
|
||||
dismissPulse: dismissGoalPulse,
|
||||
} satisfies GoalFlowUi,
|
||||
resetGoalPulseTracking,
|
||||
};
|
||||
}
|
||||
@@ -1,44 +1,196 @@
|
||||
import type { GameState } from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
import { useEquipmentFlow } from '../useEquipmentFlow';
|
||||
import { useForgeFlow } from '../useForgeFlow';
|
||||
import { useInventoryFlow } from '../useInventoryFlow';
|
||||
import { useMemo, type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
EQUIPMENT_EQUIP_FUNCTION,
|
||||
EQUIPMENT_UNEQUIP_FUNCTION,
|
||||
FORGE_CRAFT_FUNCTION,
|
||||
FORGE_DISMANTLE_FUNCTION,
|
||||
FORGE_REFORGE_FUNCTION,
|
||||
INVENTORY_USE_FUNCTION,
|
||||
} from '../../data/functionCatalog';
|
||||
import { getForgeRecipeViews } from '../../data/forgeSystem';
|
||||
import type { Character, GameState, StoryMoment } from '../../types';
|
||||
import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator';
|
||||
import type { InventoryFlowUi } from './uiTypes';
|
||||
|
||||
type TickCooldowns = (cooldowns: Record<string, number>) => Record<string, number>;
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
export function useStoryInventoryActions({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
tickCooldowns,
|
||||
runtime,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
tickCooldowns: TickCooldowns;
|
||||
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 inventoryFlow = useInventoryFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
tickCooldowns,
|
||||
});
|
||||
const equipmentFlow = useEquipmentFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
});
|
||||
const forgeFlow = useForgeFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
});
|
||||
const {
|
||||
currentStory,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
buildFallbackStoryForState,
|
||||
} = runtime;
|
||||
const forgeRecipes = useMemo(
|
||||
() =>
|
||||
getForgeRecipeViews(
|
||||
gameState.playerInventory,
|
||||
gameState.playerCurrency,
|
||||
gameState.worldType,
|
||||
),
|
||||
[gameState.playerCurrency, gameState.playerInventory, gameState.worldType],
|
||||
);
|
||||
|
||||
const resolveServerInventoryAction = async (params: {
|
||||
functionId: string;
|
||||
actionText: string;
|
||||
payload: Record<string, unknown>;
|
||||
}) => {
|
||||
const character = gameState.playerCharacter;
|
||||
if (
|
||||
!character ||
|
||||
!gameState.worldType ||
|
||||
gameState.currentScene !== 'Story'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option: {
|
||||
functionId: params.functionId,
|
||||
actionText: params.actionText,
|
||||
},
|
||||
payload: params.payload,
|
||||
});
|
||||
|
||||
setGameState(hydratedSnapshot.gameState);
|
||||
setCurrentStory(nextStory);
|
||||
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 useInventoryItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: INVENTORY_USE_FUNCTION.id,
|
||||
actionText: `使用${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
const equipInventoryItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: EQUIPMENT_EQUIP_FUNCTION.id,
|
||||
actionText: `装备${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
const unequipItem = async (slot: 'weapon' | 'armor' | 'relic') => {
|
||||
const equippedItem = gameState.playerEquipment[slot];
|
||||
if (!equippedItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: EQUIPMENT_UNEQUIP_FUNCTION.id,
|
||||
actionText: `卸下${equippedItem.name}`,
|
||||
payload: { slotId: slot },
|
||||
});
|
||||
};
|
||||
|
||||
const craftRecipe = async (recipeId: string) => {
|
||||
const recipe = forgeRecipes.find(
|
||||
(candidate) => candidate.id === recipeId,
|
||||
);
|
||||
if (!recipe) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: FORGE_CRAFT_FUNCTION.id,
|
||||
actionText: `制作${recipe.resultLabel}`,
|
||||
payload: { recipeId },
|
||||
});
|
||||
};
|
||||
|
||||
const dismantleItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: FORGE_DISMANTLE_FUNCTION.id,
|
||||
actionText: `拆解${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
const reforgeItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: FORGE_REFORGE_FUNCTION.id,
|
||||
actionText: `重铸${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
inventoryUi: {
|
||||
useInventoryItem: inventoryFlow.handleUseInventoryItem,
|
||||
equipInventoryItem: equipmentFlow.handleEquipInventoryItem,
|
||||
unequipItem: equipmentFlow.handleUnequipItem,
|
||||
forgeRecipes: forgeFlow.forgeRecipes,
|
||||
craftRecipe: forgeFlow.handleCraftRecipe,
|
||||
dismantleItem: forgeFlow.handleDismantleItem,
|
||||
reforgeItem: forgeFlow.handleReforgeItem,
|
||||
useInventoryItem,
|
||||
equipInventoryItem,
|
||||
unequipItem,
|
||||
forgeRecipes,
|
||||
craftRecipe,
|
||||
dismantleItem,
|
||||
reforgeItem,
|
||||
} satisfies InventoryFlowUi,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ import type {
|
||||
} from '../../types';
|
||||
import { AnimationState } from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator';
|
||||
|
||||
type CommitGeneratedStateWithEncounterEntry = (
|
||||
entryState: GameState,
|
||||
@@ -116,6 +117,7 @@ function isNpcEncounter(
|
||||
|
||||
export function createStoryNpcEncounterActions({
|
||||
gameState,
|
||||
currentStory,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
@@ -142,6 +144,7 @@ export function createStoryNpcEncounterActions({
|
||||
npcInteractionFlow,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
@@ -588,6 +591,46 @@ export function createStoryNpcEncounterActions({
|
||||
return true;
|
||||
};
|
||||
|
||||
const resolveServerNpcStoryAction = async (params: {
|
||||
option: StoryOption;
|
||||
encounter: Encounter;
|
||||
payload?: Record<string, unknown>;
|
||||
}) => {
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
if (
|
||||
!playerCharacter ||
|
||||
!gameState.worldType ||
|
||||
gameState.currentScene !== 'Story'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option: params.option,
|
||||
payload: params.payload,
|
||||
});
|
||||
|
||||
setGameState(hydratedSnapshot.gameState);
|
||||
setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve npc story action on the server:', error);
|
||||
setAiError(error instanceof Error ? error.message : 'NPC 动作执行失败');
|
||||
if (!currentStory) {
|
||||
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNpcInteraction = (option: StoryOption) => {
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
if (!playerCharacter || !option.interaction || !isNpcEncounter(gameState.currentEncounter)) {
|
||||
@@ -835,141 +878,23 @@ export function createStoryNpcEncounterActions({
|
||||
return true;
|
||||
}
|
||||
case 'quest_accept': {
|
||||
const existingQuest = getQuestForIssuer(
|
||||
gameState.quests,
|
||||
getNpcEncounterKey(encounter),
|
||||
);
|
||||
if (existingQuest) return true;
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
void (async () => {
|
||||
let committed = false;
|
||||
|
||||
try {
|
||||
const quest =
|
||||
(await generateQuestForNpcEncounter({
|
||||
state: gameState,
|
||||
encounter,
|
||||
})) ??
|
||||
buildQuestForEncounter({
|
||||
issuerNpcId: getNpcEncounterKey(encounter),
|
||||
issuerNpcName: encounter.npcName,
|
||||
roleText: encounter.context,
|
||||
scene: gameState.currentScenePreset,
|
||||
worldType: gameState.worldType,
|
||||
});
|
||||
if (!quest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextState = incrementRuntimeStats(
|
||||
updateNpcState(
|
||||
updateQuestLog(gameState, (quests) => acceptQuest(quests, quest)),
|
||||
encounter,
|
||||
(currentNpcState) => ({
|
||||
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
stanceProfile: applyStoryChoiceToStanceProfile(
|
||||
currentNpcState.stanceProfile,
|
||||
'npc_quest_accept',
|
||||
),
|
||||
}),
|
||||
),
|
||||
{questsAccepted: 1},
|
||||
);
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
playerCharacter,
|
||||
option.actionText,
|
||||
buildQuestAcceptResultText(quest),
|
||||
option.functionId,
|
||||
);
|
||||
committed = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to accept npc quest:', error);
|
||||
const fallbackQuest = buildQuestForEncounter({
|
||||
issuerNpcId: getNpcEncounterKey(encounter),
|
||||
issuerNpcName: encounter.npcName,
|
||||
roleText: encounter.context,
|
||||
scene: gameState.currentScenePreset,
|
||||
worldType: gameState.worldType,
|
||||
});
|
||||
if (!fallbackQuest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextState = incrementRuntimeStats(
|
||||
updateNpcState(
|
||||
updateQuestLog(gameState, (quests) =>
|
||||
acceptQuest(quests, fallbackQuest),
|
||||
),
|
||||
encounter,
|
||||
(currentNpcState) => ({
|
||||
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
stanceProfile: applyStoryChoiceToStanceProfile(
|
||||
currentNpcState.stanceProfile,
|
||||
'npc_quest_accept',
|
||||
),
|
||||
}),
|
||||
),
|
||||
{questsAccepted: 1},
|
||||
);
|
||||
await commitGeneratedState(
|
||||
nextState,
|
||||
playerCharacter,
|
||||
option.actionText,
|
||||
buildQuestAcceptResultText(fallbackQuest),
|
||||
option.functionId,
|
||||
);
|
||||
committed = true;
|
||||
} finally {
|
||||
if (!committed) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
void resolveServerNpcStoryAction({
|
||||
option,
|
||||
encounter,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case 'quest_turn_in': {
|
||||
const questId = option.interaction.questId;
|
||||
const quest = questId ? findQuestById(gameState.quests, questId) : null;
|
||||
if (!quest || quest.status !== 'completed') return true;
|
||||
|
||||
const nextState = appendStoryEngineCarrierMemory({
|
||||
...updateQuestLog(gameState, (quests) =>
|
||||
markQuestTurnedIn(quests, quest.id),
|
||||
),
|
||||
npcStates: {
|
||||
...gameState.npcStates,
|
||||
[getNpcEncounterKey(encounter)]: {
|
||||
...syncNpcNarrativeState({
|
||||
encounter,
|
||||
npcState: {
|
||||
...npcState,
|
||||
...markNpcFirstMeaningfulContactResolved(npcState),
|
||||
affinity: npcState.affinity + quest.reward.affinityBonus,
|
||||
relationState: buildRelationState(
|
||||
npcState.affinity + quest.reward.affinityBonus,
|
||||
),
|
||||
},
|
||||
customWorldProfile: gameState.customWorldProfile,
|
||||
storyEngineMemory: gameState.storyEngineMemory,
|
||||
}),
|
||||
},
|
||||
},
|
||||
playerCurrency: gameState.playerCurrency + quest.reward.currency,
|
||||
playerInventory: addInventoryItems(
|
||||
gameState.playerInventory,
|
||||
quest.reward.items,
|
||||
),
|
||||
} as GameState, quest.reward.items);
|
||||
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
playerCharacter,
|
||||
option.actionText,
|
||||
buildQuestTurnInResultText(quest),
|
||||
option.functionId,
|
||||
);
|
||||
void resolveServerNpcStoryAction({
|
||||
option,
|
||||
encounter,
|
||||
payload: questId
|
||||
? {
|
||||
questId,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case 'leave': {
|
||||
|
||||
@@ -50,6 +50,7 @@ import type {
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator';
|
||||
import type {
|
||||
GiftModalState,
|
||||
RecruitModalState,
|
||||
@@ -67,6 +68,7 @@ type GenerateStoryForState = (params: {
|
||||
}) => Promise<StoryMoment>;
|
||||
|
||||
type StoryNpcInteractionRuntime = {
|
||||
currentStory: StoryMoment | null;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -656,6 +658,60 @@ export function useStoryNpcInteractionFlow({
|
||||
setRecruitModal(null);
|
||||
};
|
||||
|
||||
const resolveServerNpcAction = async (params: {
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
functionId: string;
|
||||
action: 'trade' | 'gift' | 'quest_accept' | 'quest_turn_in';
|
||||
payload?: Record<string, unknown>;
|
||||
}) => {
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
if (
|
||||
!playerCharacter ||
|
||||
!gameState.worldType ||
|
||||
gameState.currentScene !== 'Story'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
runtime.setAiError(null);
|
||||
runtime.setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory: runtime.currentStory,
|
||||
option: {
|
||||
functionId: params.functionId,
|
||||
actionText: params.actionText,
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: params.encounter.id ?? getNpcEncounterKey(params.encounter),
|
||||
action: params.action,
|
||||
},
|
||||
},
|
||||
payload: params.payload,
|
||||
});
|
||||
|
||||
setGameState(hydratedSnapshot.gameState);
|
||||
runtime.setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve npc runtime 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 confirmTrade = () => {
|
||||
if (!tradeModal || !gameState.playerCharacter) return;
|
||||
|
||||
@@ -669,27 +725,8 @@ export function useStoryNpcInteractionFlow({
|
||||
if (!npcItem || quantity <= 0) return;
|
||||
if (npcItem.quantity < quantity || gameState.playerCurrency < totalPrice) return;
|
||||
|
||||
let nextState = updateNpcState(
|
||||
gameState,
|
||||
encounter,
|
||||
currentNpcState => ({
|
||||
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
inventory: removeInventoryItem(currentNpcState.inventory, npcItem.id, quantity),
|
||||
}),
|
||||
);
|
||||
|
||||
nextState = appendStoryEngineCarrierMemory({
|
||||
...nextState,
|
||||
playerCurrency: nextState.playerCurrency - totalPrice,
|
||||
playerInventory: addInventoryItems(
|
||||
nextState.playerInventory,
|
||||
[cloneInventoryItemForOwner(npcItem, 'player', quantity)],
|
||||
),
|
||||
} as GameState, [cloneInventoryItemForOwner(npcItem, 'player', quantity)]);
|
||||
|
||||
setTradeModal(null);
|
||||
void commitNpcReactionAndGenerate({
|
||||
nextState,
|
||||
void resolveServerNpcAction({
|
||||
encounter,
|
||||
actionText: buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
@@ -697,16 +734,13 @@ export function useStoryNpcInteractionFlow({
|
||||
item: npcItem,
|
||||
quantity,
|
||||
}),
|
||||
resultText: buildNpcTradeTransactionResultText({
|
||||
encounter,
|
||||
functionId: 'npc_trade',
|
||||
action: 'trade',
|
||||
payload: {
|
||||
mode: 'buy',
|
||||
item: npcItem,
|
||||
itemId: npcItem.id,
|
||||
quantity,
|
||||
totalPrice,
|
||||
worldType: gameState.worldType,
|
||||
}),
|
||||
lastFunctionId: 'npc_trade',
|
||||
contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -715,27 +749,8 @@ export function useStoryNpcInteractionFlow({
|
||||
if (!playerItem || quantity <= 0) return;
|
||||
if (playerItem.quantity < quantity) return;
|
||||
|
||||
let nextState = updateNpcState(
|
||||
gameState,
|
||||
encounter,
|
||||
currentNpcState => ({
|
||||
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
inventory: addInventoryItems(
|
||||
currentNpcState.inventory,
|
||||
[cloneInventoryItemForOwner(playerItem, 'npc', quantity)],
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
nextState = {
|
||||
...nextState,
|
||||
playerCurrency: nextState.playerCurrency + totalPrice,
|
||||
playerInventory: removeInventoryItem(nextState.playerInventory, playerItem.id, quantity),
|
||||
};
|
||||
|
||||
setTradeModal(null);
|
||||
void commitNpcReactionAndGenerate({
|
||||
nextState,
|
||||
void resolveServerNpcAction({
|
||||
encounter,
|
||||
actionText: buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
@@ -743,16 +758,13 @@ export function useStoryNpcInteractionFlow({
|
||||
item: playerItem,
|
||||
quantity,
|
||||
}),
|
||||
resultText: buildNpcTradeTransactionResultText({
|
||||
encounter,
|
||||
functionId: 'npc_trade',
|
||||
action: 'trade',
|
||||
payload: {
|
||||
mode: 'sell',
|
||||
item: playerItem,
|
||||
itemId: playerItem.id,
|
||||
quantity,
|
||||
totalPrice,
|
||||
worldType: gameState.worldType,
|
||||
}),
|
||||
lastFunctionId: 'npc_trade',
|
||||
contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -763,57 +775,15 @@ export function useStoryNpcInteractionFlow({
|
||||
const giftItem = gameState.playerInventory.find(item => item.id === giftModal.selectedItemId);
|
||||
if (!giftItem) return;
|
||||
|
||||
const giftCandidate = getGiftCandidates(gameState.playerInventory, encounter, {
|
||||
worldType: gameState.worldType,
|
||||
customWorldProfile: gameState.customWorldProfile,
|
||||
})
|
||||
.find(candidate => candidate.item.id === giftItem.id);
|
||||
const affinityGain = giftCandidate?.affinityGain ?? 0;
|
||||
const attributeSummary = giftCandidate?.attributeInsight?.reasonText ?? null;
|
||||
let nextAffinity = 0;
|
||||
|
||||
let nextState = updateNpcState(
|
||||
gameState,
|
||||
encounter,
|
||||
currentNpcState => {
|
||||
nextAffinity = currentNpcState.affinity + affinityGain;
|
||||
return {
|
||||
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
||||
affinity: nextAffinity,
|
||||
relationState: buildRelationState(nextAffinity),
|
||||
giftsGiven: currentNpcState.giftsGiven + 1,
|
||||
stanceProfile: applyStoryChoiceToStanceProfile(
|
||||
currentNpcState.stanceProfile,
|
||||
'npc_gift',
|
||||
{ affinityGain },
|
||||
),
|
||||
inventory: addInventoryItems(
|
||||
currentNpcState.inventory,
|
||||
[cloneInventoryItemForOwner(giftItem, 'npc')],
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
nextState = {
|
||||
...nextState,
|
||||
playerInventory: removeInventoryItem(nextState.playerInventory, giftItem.id, 1),
|
||||
};
|
||||
|
||||
setGiftModal(null);
|
||||
void commitNpcReactionAndGenerate({
|
||||
nextState,
|
||||
void resolveServerNpcAction({
|
||||
encounter,
|
||||
actionText: buildNpcGiftCommitActionText(encounter, giftItem),
|
||||
resultText: buildNpcGiftResultText(
|
||||
encounter,
|
||||
giftItem,
|
||||
affinityGain,
|
||||
nextAffinity,
|
||||
attributeSummary ?? undefined,
|
||||
),
|
||||
lastFunctionId: 'npc_gift',
|
||||
contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
|
||||
functionId: 'npc_gift',
|
||||
action: 'gift',
|
||||
payload: {
|
||||
itemId: giftItem.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
366
src/hooks/story/runtimeStoryCoordinator.test.ts
Normal file
366
src/hooks/story/runtimeStoryCoordinator.test.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
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 () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../../services/runtimeStoryService')>(
|
||||
'../../services/runtimeStoryService',
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getRuntimeStoryState: getRuntimeStoryStateMock,
|
||||
resolveRuntimeStoryAction: resolveRuntimeStoryActionMock,
|
||||
getRuntimeSessionId: getRuntimeSessionIdMock,
|
||||
getRuntimeClientVersion: getRuntimeClientVersionMock,
|
||||
};
|
||||
});
|
||||
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
loadServerRuntimeOptionCatalog,
|
||||
resumeServerRuntimeStory,
|
||||
resolveServerRuntimeChoice,
|
||||
} from './runtimeStoryCoordinator';
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createGameState(): GameState {
|
||||
return {
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 7,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('runtimeStoryCoordinator', () => {
|
||||
beforeEach(() => {
|
||||
putSaveSnapshotMock.mockReset();
|
||||
getRuntimeStoryStateMock.mockReset();
|
||||
resolveRuntimeStoryActionMock.mockReset();
|
||||
getRuntimeSessionIdMock.mockReset();
|
||||
getRuntimeSessionIdMock.mockReturnValue('runtime-main');
|
||||
getRuntimeClientVersionMock.mockReset();
|
||||
getRuntimeClientVersionMock.mockReturnValue(7);
|
||||
});
|
||||
|
||||
it('loads runtime option catalogs through the persisted server snapshot flow', async () => {
|
||||
const gameState = createGameState();
|
||||
const currentStory = createStory('当前故事');
|
||||
|
||||
getRuntimeStoryStateMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 3,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
scope: 'npc',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: true,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '服务端故事',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {},
|
||||
currentStory: null,
|
||||
},
|
||||
});
|
||||
|
||||
const options = await loadServerRuntimeOptionCatalog({
|
||||
gameState,
|
||||
currentStory,
|
||||
});
|
||||
|
||||
expect(putSaveSnapshotMock).toHaveBeenCalledWith({
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
});
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith('runtime-main');
|
||||
expect(options).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('hydrates runtime choices into snapshot state and presentation-safe story data', async () => {
|
||||
const gameState = createGameState();
|
||||
const currentStory = createStory('当前故事');
|
||||
const option = {
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
text: '继续交谈',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-opponent',
|
||||
action: 'chat',
|
||||
},
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
const hydratedSnapshot = {
|
||||
version: 8,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
gameState: {
|
||||
...gameState,
|
||||
runtimeActionVersion: 8,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
} as GameState,
|
||||
currentStory: createStory('快照中的故事'),
|
||||
bottomTab: 'adventure',
|
||||
} as HydratedSavedGameSnapshot;
|
||||
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 96,
|
||||
maxHp: 100,
|
||||
mana: 18,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
scope: 'npc',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: true,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '继续交谈',
|
||||
resultText: '关系已有变化',
|
||||
storyText: '',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: hydratedSnapshot,
|
||||
});
|
||||
|
||||
const result = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
payload: {
|
||||
note: 'server-runtime-test',
|
||||
},
|
||||
});
|
||||
|
||||
expect(putSaveSnapshotMock).toHaveBeenCalledWith({
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
});
|
||||
expect(resolveRuntimeStoryActionMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 7,
|
||||
option,
|
||||
targetId: 'npc-opponent',
|
||||
payload: {
|
||||
note: 'server-runtime-test',
|
||||
},
|
||||
});
|
||||
expect(result.hydratedSnapshot).toBe(hydratedSnapshot);
|
||||
expect(result.nextStory).toEqual(
|
||||
expect.objectContaining({
|
||||
text: '快照中的故事',
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshes resumable runtime stories from the server before hydrating the main flow', async () => {
|
||||
const localHydratedSnapshot = {
|
||||
version: 7,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
gameState: {
|
||||
currentScene: 'Story',
|
||||
worldType: 'wuxia',
|
||||
playerCharacter: {
|
||||
id: 'hero',
|
||||
},
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
runtimeActionVersion: 7,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
} as unknown as GameState,
|
||||
currentStory: createStory('本地快照故事'),
|
||||
bottomTab: 'inventory' as const,
|
||||
} as HydratedSavedGameSnapshot;
|
||||
const serverHydratedSnapshot = {
|
||||
version: 8,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
gameState: {
|
||||
currentScene: 'Story',
|
||||
worldType: 'wuxia',
|
||||
playerCharacter: {
|
||||
id: 'hero',
|
||||
},
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
runtimeActionVersion: 8,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
} as unknown as GameState,
|
||||
currentStory: createStory('服务端快照故事'),
|
||||
bottomTab: 'character' as const,
|
||||
} as HydratedSavedGameSnapshot;
|
||||
|
||||
getRuntimeStoryStateMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 90,
|
||||
maxHp: 100,
|
||||
mana: 16,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
scope: 'npc',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '服务端恢复后的故事',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: serverHydratedSnapshot,
|
||||
});
|
||||
|
||||
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
|
||||
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith('runtime-main');
|
||||
expect(result.hydratedSnapshot).toBe(serverHydratedSnapshot);
|
||||
expect(result.nextStory).toEqual(
|
||||
expect.objectContaining({
|
||||
text: '服务端恢复后的故事',
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps local snapshot hydration when the saved state is not an active runtime story', async () => {
|
||||
const localHydratedSnapshot = {
|
||||
version: 7,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
gameState: {
|
||||
currentScene: 'Home',
|
||||
worldType: null,
|
||||
playerCharacter: null,
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
runtimeActionVersion: 7,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
} as unknown as GameState,
|
||||
currentStory: createStory('本地快照故事'),
|
||||
bottomTab: 'adventure' as const,
|
||||
} as HydratedSavedGameSnapshot;
|
||||
|
||||
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
|
||||
|
||||
expect(getRuntimeStoryStateMock).not.toHaveBeenCalled();
|
||||
expect(result.hydratedSnapshot).toBe(localHydratedSnapshot);
|
||||
expect(result.nextStory).toBe(localHydratedSnapshot.currentStory);
|
||||
});
|
||||
});
|
||||
122
src/hooks/story/runtimeStoryCoordinator.ts
Normal file
122
src/hooks/story/runtimeStoryCoordinator.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
buildStoryMomentFromRuntimeOptions,
|
||||
getRuntimeClientVersion,
|
||||
getRuntimeSessionId,
|
||||
getRuntimeStoryState,
|
||||
resolveRuntimeStoryAction,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryResponse,
|
||||
} from '../../services/runtimeStoryService';
|
||||
import { putSaveSnapshot } from '../../services/storageService';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
|
||||
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||
return response.viewModel.availableOptions.length > 0
|
||||
? response.viewModel.availableOptions
|
||||
: response.presentation.options;
|
||||
}
|
||||
|
||||
async function syncRuntimeSnapshot(
|
||||
gameState: GameState,
|
||||
currentStory: StoryMoment | null,
|
||||
) {
|
||||
await putSaveSnapshot({
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
});
|
||||
}
|
||||
|
||||
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 = buildStoryMomentFromRuntimeOptions({
|
||||
storyText: response.presentation.storyText,
|
||||
options: getRuntimeResponseOptions(response),
|
||||
gameState: params.gameState,
|
||||
}).options;
|
||||
|
||||
return options.length > 0 ? options : null;
|
||||
}
|
||||
|
||||
export async function resumeServerRuntimeStory(
|
||||
snapshot: HydratedSavedGameSnapshot,
|
||||
) {
|
||||
const hydratedSnapshot = snapshot;
|
||||
const shouldRefreshFromServer =
|
||||
hydratedSnapshot.gameState.currentScene === 'Story' &&
|
||||
Boolean(hydratedSnapshot.gameState.worldType) &&
|
||||
Boolean(hydratedSnapshot.gameState.playerCharacter);
|
||||
|
||||
if (!shouldRefreshFromServer) {
|
||||
return {
|
||||
hydratedSnapshot,
|
||||
nextStory: hydratedSnapshot.currentStory,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await getRuntimeStoryState(
|
||||
getRuntimeSessionId(hydratedSnapshot.gameState),
|
||||
);
|
||||
const resumedSnapshot = response.snapshot;
|
||||
const runtimeOptions = getRuntimeResponseOptions(response);
|
||||
const nextStory =
|
||||
response.presentation.storyText || runtimeOptions.length > 0
|
||||
? buildStoryMomentFromRuntimeOptions({
|
||||
storyText:
|
||||
response.presentation.storyText ||
|
||||
resumedSnapshot.currentStory?.text ||
|
||||
hydratedSnapshot.currentStory?.text ||
|
||||
'',
|
||||
options: runtimeOptions,
|
||||
gameState: resumedSnapshot.gameState,
|
||||
})
|
||||
: resumedSnapshot.currentStory;
|
||||
|
||||
return {
|
||||
hydratedSnapshot: resumedSnapshot,
|
||||
nextStory,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveServerRuntimeChoice(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
option: Pick<StoryOption, 'functionId' | 'actionText'> &
|
||||
Partial<Pick<StoryOption, 'interaction'>>;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
}) {
|
||||
await syncRuntimeSnapshot(params.gameState, params.currentStory);
|
||||
|
||||
const response = await resolveRuntimeStoryAction({
|
||||
sessionId: getRuntimeSessionId(params.gameState),
|
||||
clientVersion: getRuntimeClientVersion(params.gameState),
|
||||
option: params.option,
|
||||
targetId:
|
||||
params.option.interaction?.kind === 'npc'
|
||||
? params.option.interaction.npcId
|
||||
: undefined,
|
||||
payload: params.payload,
|
||||
});
|
||||
const hydratedSnapshot = response.snapshot;
|
||||
|
||||
return {
|
||||
response,
|
||||
hydratedSnapshot,
|
||||
nextStory: buildStoryMomentFromRuntimeOptions({
|
||||
storyText:
|
||||
response.presentation.storyText ||
|
||||
hydratedSnapshot.currentStory?.text ||
|
||||
params.option.actionText,
|
||||
options: getRuntimeResponseOptions(response),
|
||||
gameState: hydratedSnapshot.gameState,
|
||||
}),
|
||||
};
|
||||
}
|
||||
249
src/hooks/story/storyBootstrap.ts
Normal file
249
src/hooks/story/storyBootstrap.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
337
src/hooks/story/storyCampCompanion.test.ts
Normal file
337
src/hooks/story/storyCampCompanion.test.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
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,
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
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',
|
||||
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('我真正要找的东西,还不能让更多人知道。');
|
||||
});
|
||||
|
||||
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 chat and recruit options while appending the travel action for camp openings', () => {
|
||||
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',
|
||||
'npc_recruit',
|
||||
'camp_travel_home_scene',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses AI follow-up options when the camp follow-up request succeeds and falls back on errors', async () => {
|
||||
const baseOptions = [createOption('npc_chat', '继续交谈')];
|
||||
const generateNextStep = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
storyText: '继续营地交谈',
|
||||
options: [
|
||||
createOption('npc_trade', '先看对方带来的东西'),
|
||||
createOption('npc_chat', '继续交谈'),
|
||||
],
|
||||
})
|
||||
.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.map((option) => option.functionId)).toEqual([
|
||||
'npc_trade',
|
||||
'npc_chat',
|
||||
]);
|
||||
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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
300
src/hooks/story/storyCampCompanion.ts
Normal file
300
src/hooks/story/storyCampCompanion.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import {
|
||||
getCharacterAdventureOpening,
|
||||
getCharacterHomeSceneId,
|
||||
} from '../../data/characterPresets';
|
||||
import {
|
||||
buildCampTravelHomeOption,
|
||||
NPC_CHAT_FUNCTION,
|
||||
NPC_FIGHT_FUNCTION,
|
||||
NPC_LEAVE_FUNCTION,
|
||||
NPC_RECRUIT_FUNCTION,
|
||||
} from '../../data/functionCatalog';
|
||||
import { buildInitialNpcState } 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';
|
||||
|
||||
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 opening = getCharacterAdventureOpening(character, worldType);
|
||||
const surfaceHook =
|
||||
opening?.surfaceHook ?? '这个地方与我来此的目的息息相关。';
|
||||
const immediateConcern =
|
||||
opening?.immediateConcern ?? '前方似乎有些不对劲,我们不能贸然前进。';
|
||||
const guardedMotive =
|
||||
opening?.guardedMotive ?? '我并非偶然来到这里,但我还不准备全盘托出。';
|
||||
|
||||
return [
|
||||
`你:${surfaceHook}`,
|
||||
`${encounter.npcName}:那就不要说得太快太多。前方的道路并不稳定,贸然冲进去只会最先遇到最糟糕的情况。`,
|
||||
`你:${immediateConcern}`,
|
||||
`${encounter.npcName}:我能看出你不是误闯此地的。${guardedMotive} 剩下的我们可以在路上再梳理清楚。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
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 targetScene = getCampCompanionTravelScene(state, character);
|
||||
const baseOptions = params.buildNpcStory(
|
||||
state,
|
||||
character,
|
||||
encounter,
|
||||
).options;
|
||||
const chatOptions = baseOptions
|
||||
.filter((option) => option.functionId === NPC_CHAT_FUNCTION.id)
|
||||
.slice(0, 1);
|
||||
const recruitOption =
|
||||
baseOptions.find(
|
||||
(option) => option.functionId === NPC_RECRUIT_FUNCTION.id,
|
||||
) ?? null;
|
||||
const openingOptions = recruitOption
|
||||
? [...chatOptions, recruitOption]
|
||||
: chatOptions;
|
||||
|
||||
if (!targetScene) {
|
||||
return openingOptions;
|
||||
}
|
||||
|
||||
return [...openingOptions, buildCampTravelHomeOption(targetScene.name)];
|
||||
};
|
||||
|
||||
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 sortStoryOptionsByPriority(response.options);
|
||||
} 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,
|
||||
};
|
||||
}
|
||||
407
src/hooks/story/storyChoiceContinuation.ts
Normal file
407
src/hooks/story/storyChoiceContinuation.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import { addInventoryItems } from '../../data/npcInteractions';
|
||||
import { applyQuestProgressFromHostileNpcDefeat } from '../../data/questFlow';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import { generateNextStep } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { EscapePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import {
|
||||
buildCombatResolutionContextText,
|
||||
buildHostileNpcBattleReward,
|
||||
buildReasonedOptionCatalog,
|
||||
} from './storyChoiceRuntime';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
|
||||
type RuntimeStatsIncrements = Partial<
|
||||
Pick<
|
||||
GameState['runtimeStats'],
|
||||
'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'
|
||||
>
|
||||
>;
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type BuildStoryFromResponse = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog?: StoryOption[] | null,
|
||||
) => StoryMoment;
|
||||
|
||||
type BuildNpcStory = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
type UpdateQuestLog = (
|
||||
state: GameState,
|
||||
updater: (quests: GameState['quests']) => GameState['quests'],
|
||||
) => GameState;
|
||||
|
||||
type IncrementRuntimeStats = (
|
||||
state: GameState,
|
||||
increments: RuntimeStatsIncrements,
|
||||
) => GameState;
|
||||
|
||||
export async function runLocalStoryChoiceContinuation(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
setGameState: (state: GameState) => void;
|
||||
setCurrentStory: (story: StoryMoment) => void;
|
||||
setAiError: (message: string | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setBattleReward: (reward: BattleRewardSummary | null) => void;
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: EscapePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
buildStoryFromResponse: BuildStoryFromResponse;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
generateStoryForState: (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
getAvailableOptionsForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
buildNpcStory: BuildNpcStory;
|
||||
updateQuestLog: UpdateQuestLog;
|
||||
incrementRuntimeStats: IncrementRuntimeStats;
|
||||
finalizeNpcBattleResult: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
battleMode: NonNullable<GameState['currentNpcBattleMode']>,
|
||||
battleOutcome: GameState['currentNpcBattleOutcome'],
|
||||
) => { nextState: GameState; resultText: string } | null;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
}) {
|
||||
params.setBattleReward(null);
|
||||
params.setAiError(null);
|
||||
params.setIsLoading(true);
|
||||
|
||||
const baseChoiceState =
|
||||
params.isRegularNpcEncounter(params.gameState.currentEncounter) &&
|
||||
!params.gameState.npcInteractionActive &&
|
||||
!params.option.interaction
|
||||
? {
|
||||
...params.gameState,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
}
|
||||
: params.gameState;
|
||||
|
||||
let fallbackState = baseChoiceState;
|
||||
|
||||
try {
|
||||
const history = baseChoiceState.storyHistory;
|
||||
const resolvedChoice = params.buildResolvedChoiceState(
|
||||
baseChoiceState,
|
||||
params.option,
|
||||
params.character,
|
||||
);
|
||||
const projectedState = resolvedChoice.afterSequence;
|
||||
const shouldUseLocalNpcVictory = Boolean(
|
||||
baseChoiceState.currentBattleNpcId &&
|
||||
resolvedChoice.optionKind === 'battle' &&
|
||||
(projectedState.currentNpcBattleOutcome ||
|
||||
(baseChoiceState.currentNpcBattleMode === 'fight' &&
|
||||
!projectedState.inBattle)),
|
||||
);
|
||||
const projectedBattleReward = shouldUseLocalNpcVictory
|
||||
? null
|
||||
: await buildHostileNpcBattleReward(
|
||||
baseChoiceState,
|
||||
projectedState,
|
||||
resolvedChoice.optionKind,
|
||||
params.getResolvedSceneHostileNpcs,
|
||||
);
|
||||
const projectedStateWithBattleReward = projectedBattleReward
|
||||
? appendStoryEngineCarrierMemory(
|
||||
{
|
||||
...projectedState,
|
||||
playerInventory: addInventoryItems(
|
||||
projectedState.playerInventory,
|
||||
projectedBattleReward.items,
|
||||
),
|
||||
} as GameState,
|
||||
projectedBattleReward.items,
|
||||
)
|
||||
: projectedState;
|
||||
fallbackState = projectedStateWithBattleReward;
|
||||
const projectedAvailableOptions = params.getAvailableOptionsForState(
|
||||
projectedStateWithBattleReward,
|
||||
params.character,
|
||||
);
|
||||
const combatResolutionContextText = buildCombatResolutionContextText({
|
||||
baseState: baseChoiceState,
|
||||
afterSequence: projectedStateWithBattleReward,
|
||||
optionKind: resolvedChoice.optionKind,
|
||||
projectedBattleReward,
|
||||
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
|
||||
});
|
||||
const historyForStoryGeneration = combatResolutionContextText
|
||||
? [
|
||||
...history,
|
||||
createHistoryMoment(params.option.actionText, 'action'),
|
||||
createHistoryMoment(combatResolutionContextText, 'result'),
|
||||
]
|
||||
: history;
|
||||
|
||||
const responsePromise = shouldUseLocalNpcVictory
|
||||
? Promise.resolve(null)
|
||||
: generateNextStep(
|
||||
params.gameState.worldType!,
|
||||
params.character,
|
||||
params.getStoryGenerationHostileNpcs(projectedStateWithBattleReward),
|
||||
historyForStoryGeneration,
|
||||
params.option.actionText,
|
||||
params.buildStoryContextFromState(projectedStateWithBattleReward, {
|
||||
lastFunctionId: params.option.functionId,
|
||||
observeSignsRequested:
|
||||
params.option.functionId === 'idle_observe_signs',
|
||||
recentActionResult: combatResolutionContextText,
|
||||
}),
|
||||
projectedAvailableOptions
|
||||
? { availableOptions: projectedAvailableOptions }
|
||||
: undefined,
|
||||
);
|
||||
const responseSettledPromise = responsePromise.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
const playbackSync: EscapePlaybackSync | undefined =
|
||||
resolvedChoice.optionKind === 'escape'
|
||||
? { waitForStoryResponse: responseSettledPromise }
|
||||
: undefined;
|
||||
const actionPromise = params.playResolvedChoice(
|
||||
baseChoiceState,
|
||||
params.option,
|
||||
params.character,
|
||||
resolvedChoice,
|
||||
playbackSync,
|
||||
);
|
||||
const [actionResult, responseResult] = await Promise.allSettled([
|
||||
actionPromise,
|
||||
responsePromise,
|
||||
]);
|
||||
|
||||
if (actionResult.status === 'rejected') {
|
||||
throw actionResult.reason;
|
||||
}
|
||||
|
||||
let afterSequence = shouldUseLocalNpcVictory
|
||||
? resolvedChoice.afterSequence
|
||||
: actionResult.value;
|
||||
if (projectedBattleReward) {
|
||||
afterSequence = appendStoryEngineCarrierMemory(
|
||||
{
|
||||
...afterSequence,
|
||||
playerInventory: addInventoryItems(
|
||||
afterSequence.playerInventory,
|
||||
projectedBattleReward.items,
|
||||
),
|
||||
} as GameState,
|
||||
projectedBattleReward.items,
|
||||
);
|
||||
}
|
||||
fallbackState = afterSequence;
|
||||
|
||||
if (shouldUseLocalNpcVictory) {
|
||||
const victory = params.finalizeNpcBattleResult(
|
||||
afterSequence,
|
||||
params.character,
|
||||
baseChoiceState.currentNpcBattleMode!,
|
||||
afterSequence.currentNpcBattleOutcome,
|
||||
);
|
||||
if (victory) {
|
||||
const historyBase =
|
||||
baseChoiceState.currentNpcBattleMode === 'spar'
|
||||
? (afterSequence.sparStoryHistoryBefore ?? [])
|
||||
: baseChoiceState.storyHistory;
|
||||
const nextHistory = [
|
||||
...historyBase,
|
||||
createHistoryMoment(params.option.actionText, 'action'),
|
||||
createHistoryMoment(victory.resultText, 'result'),
|
||||
];
|
||||
const nextState = {
|
||||
...victory.nextState,
|
||||
storyHistory: nextHistory,
|
||||
};
|
||||
const postBattleOptionCatalog =
|
||||
baseChoiceState.currentNpcBattleMode === 'spar' &&
|
||||
nextState.currentEncounter
|
||||
? buildReasonedOptionCatalog(
|
||||
params.buildNpcStory(
|
||||
nextState,
|
||||
params.character,
|
||||
nextState.currentEncounter,
|
||||
).options,
|
||||
)
|
||||
: null;
|
||||
fallbackState = nextState;
|
||||
params.setGameState(nextState);
|
||||
try {
|
||||
const nextStory = await params.generateStoryForState({
|
||||
state: nextState,
|
||||
character: params.character,
|
||||
history: nextHistory,
|
||||
choice: params.option.actionText,
|
||||
lastFunctionId: params.option.functionId,
|
||||
optionCatalog: postBattleOptionCatalog,
|
||||
});
|
||||
const recoveredState = applyStoryReasoningRecovery(nextState);
|
||||
params.setGameState(recoveredState);
|
||||
params.setCurrentStory(nextStory);
|
||||
} catch (storyError) {
|
||||
console.error(
|
||||
'Failed to continue npc battle resolution story:',
|
||||
storyError,
|
||||
);
|
||||
params.setAiError(
|
||||
storyError instanceof Error
|
||||
? storyError.message
|
||||
: '未知智能生成错误',
|
||||
);
|
||||
params.setCurrentStory(
|
||||
params.buildFallbackStoryForState(
|
||||
nextState,
|
||||
params.character,
|
||||
victory.resultText,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (responseResult.status === 'rejected') {
|
||||
throw responseResult.reason;
|
||||
}
|
||||
|
||||
const response = responseResult.value!;
|
||||
const defeatedHostileNpcIds =
|
||||
baseChoiceState.currentBattleNpcId ||
|
||||
resolvedChoice.optionKind === 'escape'
|
||||
? []
|
||||
: params
|
||||
.getResolvedSceneHostileNpcs(baseChoiceState)
|
||||
.map((hostileNpc) => hostileNpc.id)
|
||||
.filter(
|
||||
(hostileNpcId) =>
|
||||
!params
|
||||
.getResolvedSceneHostileNpcs(afterSequence)
|
||||
.some((hostileNpc) => hostileNpc.id === hostileNpcId),
|
||||
);
|
||||
const nextHistory = combatResolutionContextText
|
||||
? [
|
||||
...historyForStoryGeneration,
|
||||
createHistoryMoment(response.storyText, 'result', response.options),
|
||||
]
|
||||
: [
|
||||
...baseChoiceState.storyHistory,
|
||||
createHistoryMoment(params.option.actionText, 'action'),
|
||||
createHistoryMoment(response.storyText, 'result', response.options),
|
||||
];
|
||||
|
||||
const nextState = params.incrementRuntimeStats(
|
||||
{
|
||||
...params.updateQuestLog(afterSequence, (quests) =>
|
||||
applyQuestProgressFromHostileNpcDefeat(
|
||||
quests,
|
||||
baseChoiceState.currentScenePreset?.id ?? null,
|
||||
defeatedHostileNpcIds,
|
||||
),
|
||||
),
|
||||
lastObserveSignsSceneId:
|
||||
params.option.functionId === 'idle_observe_signs'
|
||||
? (afterSequence.currentScenePreset?.id ?? null)
|
||||
: afterSequence.lastObserveSignsSceneId ?? null,
|
||||
lastObserveSignsReport:
|
||||
params.option.functionId === 'idle_observe_signs'
|
||||
? response.storyText
|
||||
: afterSequence.lastObserveSignsReport ?? null,
|
||||
storyHistory: nextHistory,
|
||||
},
|
||||
{
|
||||
hostileNpcsDefeated: defeatedHostileNpcIds.length,
|
||||
},
|
||||
);
|
||||
|
||||
const recoveredState = applyStoryReasoningRecovery(nextState);
|
||||
params.setGameState(recoveredState);
|
||||
if (projectedBattleReward) {
|
||||
params.setBattleReward(projectedBattleReward);
|
||||
}
|
||||
|
||||
params.setCurrentStory(
|
||||
params.buildStoryFromResponse(
|
||||
recoveredState,
|
||||
params.character,
|
||||
{
|
||||
text: response.storyText,
|
||||
options: response.options,
|
||||
},
|
||||
projectedAvailableOptions,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to get next step:', error);
|
||||
params.setAiError(
|
||||
error instanceof Error ? error.message : '未知智能生成错误',
|
||||
);
|
||||
params.setCurrentStory(
|
||||
params.buildFallbackStoryForState(fallbackState, params.character),
|
||||
);
|
||||
} finally {
|
||||
params.setIsLoading(false);
|
||||
}
|
||||
}
|
||||
137
src/hooks/story/storyChoiceCoordinator.test.ts
Normal file
137
src/hooks/story/storyChoiceCoordinator.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { createStoryChoiceCoordinatorConfig } from './storyChoiceCoordinator';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试角色',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: '谨慎',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createOption(functionId = 'npc_chat', actionText = '继续交谈'): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createState(): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
currentScene: 'Story',
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
const neverNpcEncounter = (
|
||||
_encounter: GameState['currentEncounter'],
|
||||
): _encounter is Encounter => false;
|
||||
|
||||
describe('storyChoiceCoordinator', () => {
|
||||
it('builds one config object for createStoryChoiceActions from runtime controller and support', () => {
|
||||
const runtimeController = {
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
buildStoryFromResponse: vi.fn(),
|
||||
buildFallbackStoryForState: vi.fn(),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(),
|
||||
getCampCompanionTravelScene: vi.fn(),
|
||||
startOpeningAdventure: vi.fn(),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
};
|
||||
const runtimeSupport = {
|
||||
buildNpcStory: vi.fn(),
|
||||
updateQuestLog: vi.fn(),
|
||||
updateRuntimeStats: vi.fn(),
|
||||
};
|
||||
|
||||
const config = createStoryChoiceCoordinatorConfig({
|
||||
gameState: createState(),
|
||||
currentStory: createStory('当前故事'),
|
||||
isLoading: false,
|
||||
setGameState: vi.fn(),
|
||||
setCurrentStory: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(),
|
||||
playResolvedChoice: vi.fn(),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn(() => []),
|
||||
runtimeController: runtimeController as never,
|
||||
runtimeSupport: runtimeSupport as never,
|
||||
enterNpcInteraction: vi.fn(),
|
||||
handleNpcInteraction: vi.fn(),
|
||||
handleTreasureInteraction: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(),
|
||||
sortOptions: vi.fn((options: StoryOption[]) => options),
|
||||
buildContinueAdventureOption: vi.fn(() => createOption('continue')),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isInitialCompanionEncounter: neverNpcEncounter,
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
expect(config).toEqual(
|
||||
expect.objectContaining({
|
||||
buildStoryContextFromState: runtimeController.buildStoryContextFromState,
|
||||
buildStoryFromResponse: runtimeController.buildStoryFromResponse,
|
||||
buildFallbackStoryForState: runtimeController.buildFallbackStoryForState,
|
||||
generateStoryForState: runtimeController.generateStoryForState,
|
||||
getAvailableOptionsForState: runtimeController.getAvailableOptionsForState,
|
||||
buildNpcStory: runtimeSupport.buildNpcStory,
|
||||
updateQuestLog: runtimeSupport.updateQuestLog,
|
||||
incrementRuntimeStats: runtimeSupport.updateRuntimeStats,
|
||||
getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene,
|
||||
startOpeningAdventure: runtimeController.startOpeningAdventure,
|
||||
commitGeneratedStateWithEncounterEntry:
|
||||
runtimeController.commitGeneratedStateWithEncounterEntry,
|
||||
}),
|
||||
);
|
||||
|
||||
void createCharacter();
|
||||
});
|
||||
});
|
||||
175
src/hooks/story/storyChoiceCoordinator.ts
Normal file
175
src/hooks/story/storyChoiceCoordinator.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
|
||||
export type ChoiceRuntimeController = {
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
buildStoryFromResponse: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog?: StoryOption[] | null,
|
||||
) => StoryMoment;
|
||||
buildFallbackStoryForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
generateStoryForState: (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) => Promise<StoryMoment>;
|
||||
getAvailableOptionsForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
getCampCompanionTravelScene: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => GameState['currentScenePreset'] | null;
|
||||
startOpeningAdventure: () => Promise<void>;
|
||||
commitGeneratedStateWithEncounterEntry: (
|
||||
entryState: GameState,
|
||||
resolvedState: GameState,
|
||||
character: Character,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
lastFunctionId?: string,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export type ChoiceRuntimeSupport = Pick<
|
||||
StoryRuntimeSupport,
|
||||
'buildNpcStory' | 'updateQuestLog' | 'updateRuntimeStats'
|
||||
>;
|
||||
|
||||
export type StoryChoiceCoordinatorParams = {
|
||||
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>>;
|
||||
setBattleReward: Dispatch<SetStateAction<BattleRewardSummary | null>>;
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: ResolvedChoicePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
runtimeController: ChoiceRuntimeController;
|
||||
runtimeSupport: ChoiceRuntimeSupport;
|
||||
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
|
||||
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
|
||||
handleTreasureInteraction: (
|
||||
option: StoryOption,
|
||||
) => void | Promise<void> | boolean | Promise<boolean>;
|
||||
finalizeNpcBattleResult: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
battleMode: NonNullable<GameState['currentNpcBattleMode']>,
|
||||
battleOutcome: GameState['currentNpcBattleOutcome'],
|
||||
) => { nextState: GameState; resultText: string } | null;
|
||||
sortOptions: (options: StoryOption[]) => StoryOption[];
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isInitialCompanionEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
npcPreviewTalkFunctionId: string;
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
};
|
||||
|
||||
export function createStoryChoiceCoordinatorConfig(
|
||||
params: StoryChoiceCoordinatorParams,
|
||||
) {
|
||||
return {
|
||||
gameState: params.gameState,
|
||||
currentStory: params.currentStory,
|
||||
isLoading: params.isLoading,
|
||||
setGameState: params.setGameState,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
setBattleReward: params.setBattleReward,
|
||||
buildResolvedChoiceState: params.buildResolvedChoiceState,
|
||||
playResolvedChoice: params.playResolvedChoice,
|
||||
buildStoryContextFromState:
|
||||
params.runtimeController.buildStoryContextFromState,
|
||||
buildStoryFromResponse: params.runtimeController.buildStoryFromResponse,
|
||||
buildFallbackStoryForState:
|
||||
params.runtimeController.buildFallbackStoryForState,
|
||||
generateStoryForState: params.runtimeController.generateStoryForState,
|
||||
getAvailableOptionsForState:
|
||||
params.runtimeController.getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
|
||||
buildNpcStory: params.runtimeSupport.buildNpcStory,
|
||||
updateQuestLog: params.runtimeSupport.updateQuestLog,
|
||||
incrementRuntimeStats: params.runtimeSupport.updateRuntimeStats,
|
||||
getCampCompanionTravelScene:
|
||||
params.runtimeController.getCampCompanionTravelScene,
|
||||
startOpeningAdventure: params.runtimeController.startOpeningAdventure,
|
||||
enterNpcInteraction: params.enterNpcInteraction,
|
||||
handleNpcInteraction: params.handleNpcInteraction,
|
||||
handleTreasureInteraction: params.handleTreasureInteraction,
|
||||
commitGeneratedStateWithEncounterEntry:
|
||||
params.runtimeController.commitGeneratedStateWithEncounterEntry,
|
||||
finalizeNpcBattleResult: params.finalizeNpcBattleResult,
|
||||
isContinueAdventureOption: params.isContinueAdventureOption,
|
||||
isCampTravelHomeOption: params.isCampTravelHomeOption,
|
||||
isInitialCompanionEncounter: params.isInitialCompanionEncounter,
|
||||
isRegularNpcEncounter: params.isRegularNpcEncounter,
|
||||
isNpcEncounter: params.isNpcEncounter,
|
||||
npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName: params.fallbackCompanionName,
|
||||
turnVisualMs: params.turnVisualMs,
|
||||
};
|
||||
}
|
||||
338
src/hooks/story/storyChoiceRuntime.test.ts
Normal file
338
src/hooks/story/storyChoiceRuntime.test.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
rollHostileNpcLootMock,
|
||||
resolveServerRuntimeChoiceMock,
|
||||
} = vi.hoisted(() => ({
|
||||
rollHostileNpcLootMock: vi.fn(),
|
||||
resolveServerRuntimeChoiceMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../data/hostileNpcPresets', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../../data/hostileNpcPresets')>(
|
||||
'../../data/hostileNpcPresets',
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
rollHostileNpcLoot: rollHostileNpcLootMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./runtimeStoryCoordinator', () => ({
|
||||
resolveServerRuntimeChoice: resolveServerRuntimeChoiceMock,
|
||||
}));
|
||||
|
||||
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import {
|
||||
buildCombatResolutionContextText,
|
||||
buildHostileNpcBattleReward,
|
||||
buildReasonedOptionCatalog,
|
||||
runServerRuntimeChoiceAction,
|
||||
shouldOpenLocalRuntimeNpcModal,
|
||||
} from './storyChoiceRuntime';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试角色',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 10,
|
||||
spirit: 10,
|
||||
},
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(
|
||||
functionId: string,
|
||||
interaction?: StoryOption['interaction'],
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText: functionId,
|
||||
text: functionId,
|
||||
interaction,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
function createState(overrides: Partial<GameState> = {}): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: 'idle',
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
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('storyChoiceRuntime', () => {
|
||||
beforeEach(() => {
|
||||
rollHostileNpcLootMock.mockReset();
|
||||
resolveServerRuntimeChoiceMock.mockReset();
|
||||
});
|
||||
|
||||
it('deduplicates option catalogs by function id for post-battle recovery', () => {
|
||||
const options = buildReasonedOptionCatalog([
|
||||
createOption('npc_chat'),
|
||||
createOption('npc_chat'),
|
||||
createOption('npc_help'),
|
||||
]);
|
||||
|
||||
expect(options.map((option) => option.functionId)).toEqual([
|
||||
'npc_chat',
|
||||
'npc_help',
|
||||
]);
|
||||
});
|
||||
|
||||
it('identifies npc trade and gift as local runtime modal actions', () => {
|
||||
expect(
|
||||
shouldOpenLocalRuntimeNpcModal(
|
||||
createOption('npc_trade', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-merchant',
|
||||
action: 'trade',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldOpenLocalRuntimeNpcModal(
|
||||
createOption('npc_gift', {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-friend',
|
||||
action: 'gift',
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldOpenLocalRuntimeNpcModal(createOption('npc_chat')),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('builds escape and victory context text for local battle resolution', () => {
|
||||
const baseState = createState({
|
||||
inBattle: true,
|
||||
sceneHostileNpcs: [
|
||||
{ id: 'wolf', name: '山狼' },
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
});
|
||||
|
||||
expect(
|
||||
buildCombatResolutionContextText({
|
||||
baseState,
|
||||
afterSequence: {
|
||||
...baseState,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
},
|
||||
optionKind: 'escape',
|
||||
projectedBattleReward: null,
|
||||
getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs,
|
||||
}),
|
||||
).toContain('你已成功逃脱');
|
||||
|
||||
expect(
|
||||
buildCombatResolutionContextText({
|
||||
baseState: {
|
||||
...baseState,
|
||||
currentBattleNpcId: null,
|
||||
},
|
||||
afterSequence: {
|
||||
...baseState,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
},
|
||||
optionKind: 'battle',
|
||||
projectedBattleReward: {
|
||||
id: 'reward-1',
|
||||
defeatedHostileNpcs: [{ id: 'wolf', name: '山狼' }],
|
||||
items: [
|
||||
{ id: 'loot-1', category: '材料', name: '狼牙', quantity: 1, rarity: 'common', tags: [] },
|
||||
],
|
||||
},
|
||||
getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs,
|
||||
}),
|
||||
).toContain('战利品:狼牙。');
|
||||
});
|
||||
|
||||
it('builds defeated hostile rewards from locally resolved battle states', async () => {
|
||||
rollHostileNpcLootMock.mockResolvedValue([
|
||||
{
|
||||
id: 'loot-1',
|
||||
category: '材料',
|
||||
name: '狼牙',
|
||||
quantity: 1,
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const reward = await buildHostileNpcBattleReward(
|
||||
createState({
|
||||
inBattle: true,
|
||||
sceneHostileNpcs: [
|
||||
{ id: 'wolf', name: '山狼' },
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
currentBattleNpcId: null,
|
||||
}),
|
||||
createState({
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
}),
|
||||
'battle',
|
||||
(state) => state.sceneHostileNpcs,
|
||||
);
|
||||
|
||||
expect(rollHostileNpcLootMock).toHaveBeenCalledTimes(1);
|
||||
expect(reward?.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
name: '狼牙',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('applies server runtime responses and falls back locally when the request fails', async () => {
|
||||
const gameState = createState();
|
||||
const currentStory = createStory('当前故事');
|
||||
const setBattleReward = vi.fn();
|
||||
const setAiError = vi.fn();
|
||||
const setIsLoading = vi.fn();
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
hydratedSnapshot: {
|
||||
gameState: {
|
||||
...gameState,
|
||||
runtimeActionVersion: 3,
|
||||
},
|
||||
},
|
||||
nextStory: createStory('服务端故事'),
|
||||
});
|
||||
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory,
|
||||
option: createOption('npc_chat'),
|
||||
character: createCharacter(),
|
||||
setBattleReward,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setGameState,
|
||||
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
|
||||
buildFallbackStoryForState: () => createStory('fallback'),
|
||||
});
|
||||
|
||||
expect(setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtimeActionVersion: 3,
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '服务端故事',
|
||||
}),
|
||||
);
|
||||
|
||||
resolveServerRuntimeChoiceMock.mockRejectedValueOnce(new Error('boom'));
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory: null,
|
||||
option: createOption('npc_chat'),
|
||||
character: createCharacter(),
|
||||
setBattleReward,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setGameState,
|
||||
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
|
||||
buildFallbackStoryForState: () => createStory('fallback'),
|
||||
});
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(setAiError).toHaveBeenCalledWith('boom');
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: 'fallback',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
322
src/hooks/story/storyChoiceRuntime.ts
Normal file
322
src/hooks/story/storyChoiceRuntime.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
buildEncounterEntryState,
|
||||
hasEncounterEntity,
|
||||
} from '../../data/encounterTransition';
|
||||
import { rollHostileNpcLoot } from '../../data/hostileNpcPresets';
|
||||
import { addInventoryItems } from '../../data/npcInteractions';
|
||||
import {
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
createSceneEncounterPreview,
|
||||
resolveSceneEncounterPreview,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
|
||||
type RuntimeStatsIncrements = Partial<
|
||||
Pick<
|
||||
GameState['runtimeStats'],
|
||||
'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'
|
||||
>
|
||||
>;
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type IncrementRuntimeStats = (
|
||||
state: GameState,
|
||||
increments: RuntimeStatsIncrements,
|
||||
) => GameState;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function buildReasonedOptionCatalog(options: StoryOption[]) {
|
||||
const seenFunctionIds = new Set<string>();
|
||||
|
||||
return options.filter((option) => {
|
||||
if (seenFunctionIds.has(option.functionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenFunctionIds.add(option.functionId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCombatResolutionContextText(params: {
|
||||
baseState: GameState;
|
||||
afterSequence: GameState;
|
||||
optionKind: 'battle' | 'escape' | 'idle';
|
||||
projectedBattleReward: BattleRewardSummary | null;
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
}) {
|
||||
const {
|
||||
baseState,
|
||||
afterSequence,
|
||||
optionKind,
|
||||
projectedBattleReward,
|
||||
getResolvedSceneHostileNpcs,
|
||||
} = params;
|
||||
|
||||
if (optionKind === 'escape') {
|
||||
const hostileNames = getResolvedSceneHostileNpcs(baseState)
|
||||
.map((hostileNpc) => hostileNpc.name)
|
||||
.join('、');
|
||||
return hostileNames
|
||||
? `你已成功逃脱,与${hostileNames}的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。`
|
||||
: '你已成功逃脱刚才的交战,当前不再处于战斗状态。';
|
||||
}
|
||||
|
||||
if (
|
||||
!baseState.inBattle ||
|
||||
afterSequence.inBattle ||
|
||||
Boolean(baseState.currentBattleNpcId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostileNames = getResolvedSceneHostileNpcs(baseState)
|
||||
.map((hostileNpc) => hostileNpc.name)
|
||||
.join('、');
|
||||
const lootText =
|
||||
projectedBattleReward?.items.length
|
||||
? `战利品:${projectedBattleReward.items
|
||||
.map((item) => item.name)
|
||||
.join('、')}。`
|
||||
: '';
|
||||
|
||||
return hostileNames
|
||||
? `你已经击败${hostileNames},眼前这一轮交战已经结束。${lootText}`
|
||||
: `眼前这一轮交战已经结束,当前不再处于战斗状态。${lootText}`;
|
||||
}
|
||||
|
||||
export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) {
|
||||
return (
|
||||
option.interaction?.kind === 'npc' &&
|
||||
(option.functionId === 'npc_trade' || option.functionId === 'npc_gift')
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildHostileNpcBattleReward(
|
||||
state: GameState,
|
||||
afterSequence: GameState,
|
||||
optionKind: 'battle' | 'escape' | 'idle',
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'],
|
||||
): Promise<BattleRewardSummary | null> {
|
||||
if (
|
||||
optionKind === 'escape' ||
|
||||
!state.worldType ||
|
||||
state.currentBattleNpcId ||
|
||||
!state.inBattle ||
|
||||
afterSequence.inBattle
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeHostileNpcs = getResolvedSceneHostileNpcs(state);
|
||||
const nextHostileNpcs = getResolvedSceneHostileNpcs(afterSequence);
|
||||
const defeatedHostileNpcs = activeHostileNpcs.filter(
|
||||
(hostileNpc) =>
|
||||
!nextHostileNpcs.some(
|
||||
(nextHostileNpc) => nextHostileNpc.id === hostileNpc.id,
|
||||
),
|
||||
);
|
||||
|
||||
if (defeatedHostileNpcs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rolledItems = await rollHostileNpcLoot(
|
||||
state,
|
||||
defeatedHostileNpcs.map((hostileNpc) => ({
|
||||
id: hostileNpc.id,
|
||||
name: hostileNpc.name,
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
id: `battle-reward-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`,
|
||||
defeatedHostileNpcs: defeatedHostileNpcs.map((hostileNpc) => ({
|
||||
id: hostileNpc.id,
|
||||
name: hostileNpc.name,
|
||||
})),
|
||||
items: addInventoryItems([], rolledItems),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCampTravelHomeChoice(params: {
|
||||
gameState: GameState;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
setBattleReward: (reward: BattleRewardSummary | null) => void;
|
||||
setAiError: (message: string | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setGameState: (state: GameState) => void;
|
||||
incrementRuntimeStats: IncrementRuntimeStats;
|
||||
getCampCompanionTravelScene: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => GameState['currentScenePreset'] | null;
|
||||
commitGeneratedStateWithEncounterEntry: (
|
||||
entryState: GameState,
|
||||
resolvedState: GameState,
|
||||
character: Character,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
lastFunctionId?: string,
|
||||
) => Promise<void> | void;
|
||||
isNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
}) {
|
||||
const targetScene = params.getCampCompanionTravelScene(
|
||||
params.gameState,
|
||||
params.character,
|
||||
);
|
||||
if (!targetScene) {
|
||||
return false;
|
||||
}
|
||||
|
||||
params.setBattleReward(null);
|
||||
params.setAiError(null);
|
||||
|
||||
const companionName = params.isNpcEncounter(params.gameState.currentEncounter)
|
||||
? params.gameState.currentEncounter.npcName
|
||||
: params.fallbackCompanionName;
|
||||
const travelRunState: GameState = {
|
||||
...params.gameState,
|
||||
ambientIdleMode: undefined,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.RUN,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: true,
|
||||
inBattle: false,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
const travelBaseState: GameState = params.incrementRuntimeStats(
|
||||
{
|
||||
...params.gameState,
|
||||
ambientIdleMode: undefined,
|
||||
currentScenePreset: targetScene,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
},
|
||||
{
|
||||
scenesTraveled: 1,
|
||||
},
|
||||
);
|
||||
const travelPreviewState: GameState = {
|
||||
...travelBaseState,
|
||||
...createSceneEncounterPreview(travelBaseState),
|
||||
};
|
||||
const resolvedState = hasEncounterEntity(travelPreviewState)
|
||||
? resolveSceneEncounterPreview(travelPreviewState)
|
||||
: travelBaseState;
|
||||
const entryState = buildEncounterEntryState(
|
||||
resolvedState,
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
);
|
||||
|
||||
params.setIsLoading(true);
|
||||
params.setGameState(travelRunState);
|
||||
await sleep(params.turnVisualMs);
|
||||
|
||||
await params.commitGeneratedStateWithEncounterEntry(
|
||||
entryState,
|
||||
resolvedState,
|
||||
params.character,
|
||||
params.option.actionText,
|
||||
`You and ${companionName} leave camp and formally step into ${targetScene.name} to begin the adventure.`,
|
||||
params.option.functionId,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function runServerRuntimeChoiceAction(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
setBattleReward: (reward: BattleRewardSummary | null) => void;
|
||||
setAiError: (message: string | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setGameState: (state: GameState) => void;
|
||||
setCurrentStory: (story: StoryMoment) => void;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
}) {
|
||||
params.setBattleReward(null);
|
||||
params.setAiError(null);
|
||||
params.setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({
|
||||
gameState: params.gameState,
|
||||
currentStory: params.currentStory,
|
||||
option: params.option,
|
||||
});
|
||||
|
||||
params.setGameState(hydratedSnapshot.gameState);
|
||||
params.setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve runtime action on the server:', error);
|
||||
params.setAiError(
|
||||
error instanceof Error ? error.message : '运行时动作执行失败',
|
||||
);
|
||||
if (!params.currentStory) {
|
||||
params.setCurrentStory(
|
||||
params.buildFallbackStoryForState(
|
||||
params.gameState,
|
||||
params.character,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
params.setIsLoading(false);
|
||||
}
|
||||
}
|
||||
625
src/hooks/story/storyContextBuilder.ts
Normal file
625
src/hooks/story/storyContextBuilder.ts
Normal file
@@ -0,0 +1,625 @@
|
||||
import { getCharacterById } from '../../data/characterPresets';
|
||||
import {
|
||||
NPC_CHAT_FUNCTION,
|
||||
STORY_OPENING_CAMP_DIALOGUE_FUNCTION,
|
||||
} from '../../data/functionCatalog';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
describeNpcAffinityInWords,
|
||||
getNpcConversationDirective,
|
||||
isNpcFirstMeaningfulContact,
|
||||
} from '../../data/npcInteractions';
|
||||
import { buildSceneEntityCatalogText } from '../../data/scenePresets';
|
||||
import { hasMixedNarrativeLanguage } from '../../services/narrativeLanguage';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
} from '../../services/storyEngine/actorNarrativeProfile';
|
||||
import { applyAdaptiveTuningToPromptContext } from '../../services/storyEngine/adaptiveNarrativeTuner';
|
||||
import { compileCampaignFromWorldProfile } from '../../services/storyEngine/campaignPackCompiler';
|
||||
import {
|
||||
buildCampEvent,
|
||||
evaluateCampEventOpportunity,
|
||||
} from '../../services/storyEngine/campEventDirector';
|
||||
import {
|
||||
advanceChapterState,
|
||||
resolveCurrentChapterState,
|
||||
} from '../../services/storyEngine/chapterDirector';
|
||||
import {
|
||||
advanceCompanionArc,
|
||||
buildCompanionArcStates,
|
||||
} from '../../services/storyEngine/companionArcDirector';
|
||||
import { buildGoalStackState } from '../../services/storyEngine/goalDirector';
|
||||
import { resolveCurrentJourneyBeat } from '../../services/storyEngine/journeyBeatPlanner';
|
||||
import { buildVisibilitySliceFromFacts } from '../../services/storyEngine/knowledgeContract';
|
||||
import { buildKnowledgeGraph } from '../../services/storyEngine/knowledgeGraph';
|
||||
import { buildRecentCarrierEchoes } from '../../services/storyEngine/narrativeCarrierCatalog';
|
||||
import { buildChapterRecap } from '../../services/storyEngine/recapDigest';
|
||||
import { resolveScenarioPack } from '../../services/storyEngine/scenarioPackRegistry';
|
||||
import { buildSceneNarrativeDirective } from '../../services/storyEngine/sceneNarrativeDirector';
|
||||
import {
|
||||
buildSetpieceDirective,
|
||||
evaluateSetpieceOpportunity,
|
||||
} from '../../services/storyEngine/setpieceDirector';
|
||||
import { buildChronicleSummary } from '../../services/storyEngine/storyChronicle';
|
||||
import { buildThemePackFromWorldProfile } from '../../services/storyEngine/themePack';
|
||||
import {
|
||||
buildEncounterVisibilitySlice,
|
||||
createEmptyStoryEngineMemoryState,
|
||||
} from '../../services/storyEngine/visibilityEngine';
|
||||
import { buildFallbackWorldStoryGraph } from '../../services/storyEngine/worldStoryGraph';
|
||||
import type { GameState } from '../../types';
|
||||
import { getCharacterChatRecord } from './characterChat';
|
||||
import { getNpcEncounterKey } from './storyGenerationState';
|
||||
|
||||
const OPENING_CAMP_DIALOGUE_FUNCTION_ID =
|
||||
STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
|
||||
|
||||
export type StoryContextBuilderExtras = {
|
||||
pendingSceneEncounter?: boolean;
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
};
|
||||
|
||||
function buildPartyRelationshipNotes(state: GameState) {
|
||||
const lines: string[] = [];
|
||||
const seenCharacterIds = new Set<string>();
|
||||
|
||||
const appendNote = (characterId: string, roleLabel: string) => {
|
||||
if (seenCharacterIds.has(characterId)) return;
|
||||
const character = getCharacterById(characterId);
|
||||
const summary = getCharacterChatRecord(state, characterId).summary.trim();
|
||||
if (hasMixedNarrativeLanguage(summary)) return;
|
||||
if (!character || !summary) return;
|
||||
|
||||
seenCharacterIds.add(characterId);
|
||||
lines.push(
|
||||
`- ${character.name} (${character.title} / ${roleLabel}): ${summary}`,
|
||||
);
|
||||
};
|
||||
|
||||
state.companions.forEach((companion) =>
|
||||
appendNote(companion.characterId, '当前同行'),
|
||||
);
|
||||
state.roster.forEach((companion) =>
|
||||
appendNote(companion.characterId, '营地待命'),
|
||||
);
|
||||
|
||||
return lines.length > 0 ? lines.join('\n') : null;
|
||||
}
|
||||
|
||||
function describeScenePressureLevel(
|
||||
pressureLevel: 'low' | 'medium' | 'high' | 'extreme' | null | undefined,
|
||||
) {
|
||||
switch (pressureLevel) {
|
||||
case 'low':
|
||||
return '低';
|
||||
case 'medium':
|
||||
return '中';
|
||||
case 'high':
|
||||
return '高';
|
||||
case 'extreme':
|
||||
return '极高';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRecentConversationEventText(state: GameState) {
|
||||
const recentText = state.storyHistory
|
||||
.slice(-6)
|
||||
.map((item) => item.text)
|
||||
.join('\n');
|
||||
if (
|
||||
/击败|怪物|战斗|切磋|交手|脱身/u.test(recentText)
|
||||
) {
|
||||
return '你们刚经历过一场交锋或切磋,空气里的紧张感还没有完全散去。';
|
||||
}
|
||||
if (/携手|相助|帮你|并肩/u.test(recentText)) {
|
||||
return '你们刚并肩配合过一次,彼此之间的距离感稍微淡了一些。';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferConversationSituation(
|
||||
state: GameState,
|
||||
extras: Pick<
|
||||
StoryContextBuilderExtras,
|
||||
'lastFunctionId' | 'openingCampDialogue'
|
||||
>,
|
||||
) {
|
||||
if (state.inBattle) return 'shared_danger_coordination' as const;
|
||||
if (extras.lastFunctionId === OPENING_CAMP_DIALOGUE_FUNCTION_ID)
|
||||
return 'camp_first_contact' as const;
|
||||
if (
|
||||
state.currentEncounter?.specialBehavior === 'camp_companion' &&
|
||||
extras.openingCampDialogue?.trim()
|
||||
) {
|
||||
return 'camp_followup' as const;
|
||||
}
|
||||
const recentText = state.storyHistory
|
||||
.slice(-6)
|
||||
.map((item) => item.text)
|
||||
.join('\n');
|
||||
if (
|
||||
/击败|怪物|战斗|切磋|交手|脱身/u.test(recentText)
|
||||
) {
|
||||
return 'post_battle_breath' as const;
|
||||
}
|
||||
if (extras.lastFunctionId === NPC_CHAT_FUNCTION.id)
|
||||
return 'private_followup' as const;
|
||||
return 'first_contact_cautious' as const;
|
||||
}
|
||||
|
||||
function inferConversationPressure(
|
||||
state: GameState,
|
||||
situation: ReturnType<typeof inferConversationSituation>,
|
||||
) {
|
||||
const hpRatio = state.playerHp / Math.max(state.playerMaxHp, 1);
|
||||
if (state.inBattle || hpRatio < 0.35) return 'high' as const;
|
||||
if (
|
||||
situation === 'post_battle_breath' ||
|
||||
situation === 'shared_danger_coordination'
|
||||
)
|
||||
return 'medium' as const;
|
||||
if (situation === 'camp_first_contact' || situation === 'camp_followup')
|
||||
return 'low' as const;
|
||||
return 'medium' as const;
|
||||
}
|
||||
|
||||
function describeConversationSituation(
|
||||
situation: ReturnType<typeof inferConversationSituation>,
|
||||
) {
|
||||
switch (situation) {
|
||||
case 'camp_first_contact':
|
||||
return '这是营地里第一次真正静下来对话的时刻,语气要保持谨慎、观察和轻微试探。';
|
||||
case 'camp_followup':
|
||||
return '营地里的第一轮试探已经发生过了,这一轮应当顺着刚才的话头稍微往深处接。';
|
||||
case 'post_battle_breath':
|
||||
return '一场交锋刚结束,眼前危险稍缓,但双方都还带着余悸和紧绷。';
|
||||
case 'shared_danger_coordination':
|
||||
return '危险还没过去,对话应当短、准、直接,优先服务眼前判断。';
|
||||
case 'private_followup':
|
||||
return '这已经不是严格意义上的初见,更适合作为刚才未说完那句话的延续。';
|
||||
default:
|
||||
return '双方才刚真正对上话,此刻仍在判断彼此能信到什么程度。';
|
||||
}
|
||||
}
|
||||
|
||||
function describeConversationTalkPriority(
|
||||
situation: ReturnType<typeof inferConversationSituation>,
|
||||
) {
|
||||
switch (situation) {
|
||||
case 'camp_first_contact':
|
||||
return '优先写眼前印象、彼此态度和营地气氛,不要一上来就把动机讲透。';
|
||||
case 'camp_followup':
|
||||
return '先接住上一轮还没说透的话头,再决定要不要继续往下追问。';
|
||||
case 'post_battle_breath':
|
||||
return '先谈刚刚那次交锋以及彼此的判断,再视情况往更深处推进。';
|
||||
case 'shared_danger_coordination':
|
||||
return '先说最有用的判断、危险和下一步,不要扩成大段背景说明。';
|
||||
case 'private_followup':
|
||||
return '承接当前话头和关系变化,不要把对话又写回刚见面时的节奏。';
|
||||
default:
|
||||
return '先试探态度和现场判断,不要急着把来意和秘密一次摊开。';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEncounterNarrativeProfile(state: GameState) {
|
||||
const encounter = state.currentEncounter;
|
||||
if (!encounter || encounter.kind !== 'npc') {
|
||||
return null;
|
||||
}
|
||||
if (encounter.narrativeProfile) {
|
||||
return encounter.narrativeProfile;
|
||||
}
|
||||
if (!state.customWorldProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const role =
|
||||
state.customWorldProfile.storyNpcs.find((npc) =>
|
||||
npc.id === encounter.id || npc.name === encounter.npcName,
|
||||
)
|
||||
?? state.customWorldProfile.playableNpcs.find((npc) =>
|
||||
npc.id === encounter.id || npc.name === encounter.npcName,
|
||||
);
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const themePack =
|
||||
state.customWorldProfile.themePack
|
||||
?? buildThemePackFromWorldProfile(state.customWorldProfile);
|
||||
const storyGraph =
|
||||
state.customWorldProfile.storyGraph
|
||||
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
|
||||
|
||||
return normalizeActorNarrativeProfile(
|
||||
role.narrativeProfile,
|
||||
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveActiveThreadIds(
|
||||
state: GameState,
|
||||
encounterNarrativeProfile: ReturnType<typeof resolveEncounterNarrativeProfile>,
|
||||
) {
|
||||
if (state.storyEngineMemory?.activeThreadIds?.length) {
|
||||
return state.storyEngineMemory.activeThreadIds.slice(0, 4);
|
||||
}
|
||||
if (encounterNarrativeProfile?.relatedThreadIds.length) {
|
||||
return encounterNarrativeProfile.relatedThreadIds.slice(0, 4);
|
||||
}
|
||||
if (!state.customWorldProfile) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const themePack =
|
||||
state.customWorldProfile.themePack
|
||||
?? buildThemePackFromWorldProfile(state.customWorldProfile);
|
||||
const storyGraph =
|
||||
state.customWorldProfile.storyGraph
|
||||
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
|
||||
|
||||
return storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id);
|
||||
}
|
||||
|
||||
export function buildStoryContextFromState(
|
||||
state: GameState,
|
||||
extras: StoryContextBuilderExtras = {},
|
||||
): StoryGenerationContext {
|
||||
const conversationSituation = inferConversationSituation(state, extras);
|
||||
const conversationPressure = inferConversationPressure(
|
||||
state,
|
||||
conversationSituation,
|
||||
);
|
||||
const recentSharedEvent = buildRecentConversationEventText(state);
|
||||
const encounterNpcState =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const encounter = state.currentEncounter;
|
||||
return extras.encounterNpcStateOverride
|
||||
?? state.npcStates[getNpcEncounterKey(encounter)]
|
||||
?? buildInitialNpcState(encounter, state.worldType, state);
|
||||
})()
|
||||
: null;
|
||||
const encounterDirective =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const encounter = state.currentEncounter;
|
||||
return encounterNpcState
|
||||
? getNpcConversationDirective(encounter, encounterNpcState)
|
||||
: null;
|
||||
})()
|
||||
: null;
|
||||
const isFirstMeaningfulContact =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const encounter = state.currentEncounter;
|
||||
return encounterNpcState
|
||||
? isNpcFirstMeaningfulContact(encounter, encounterNpcState)
|
||||
: false;
|
||||
})()
|
||||
: false;
|
||||
const firstContactRelationStance = (() => {
|
||||
if (
|
||||
!isFirstMeaningfulContact ||
|
||||
!state.currentEncounter ||
|
||||
state.currentEncounter.kind !== 'npc'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stance = encounterNpcState?.relationState?.stance ?? null;
|
||||
if (
|
||||
stance === 'guarded' ||
|
||||
stance === 'neutral' ||
|
||||
stance === 'cooperative' ||
|
||||
stance === 'bonded'
|
||||
) {
|
||||
return stance;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const encounterAffinityText =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const encounter = state.currentEncounter;
|
||||
return encounterNpcState
|
||||
? describeNpcAffinityInWords(encounter, encounterNpcState.affinity, {
|
||||
recruited: encounterNpcState.recruited,
|
||||
})
|
||||
: null;
|
||||
})()
|
||||
: null;
|
||||
const baseSceneDescription = state.currentScenePreset?.description ?? null;
|
||||
const sceneMutationDescription = [
|
||||
state.currentScenePreset?.mutationStateText
|
||||
? `最新世界变化:${state.currentScenePreset.mutationStateText}`
|
||||
: null,
|
||||
describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)
|
||||
? `当前区域压力等级:${describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
const observeSignsSceneDescription =
|
||||
extras.observeSignsRequested && state.worldType
|
||||
? [
|
||||
baseSceneDescription,
|
||||
sceneMutationDescription,
|
||||
'当前可观察实体池:',
|
||||
buildSceneEntityCatalogText(
|
||||
state.worldType,
|
||||
state.currentScenePreset?.id ?? null,
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: [baseSceneDescription, sceneMutationDescription].filter(Boolean).join('\n');
|
||||
const storyEngineMemory =
|
||||
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
const knowledgeFacts =
|
||||
state.customWorldProfile?.knowledgeFacts
|
||||
?? (state.customWorldProfile ? buildKnowledgeGraph(state.customWorldProfile) : []);
|
||||
const encounterNarrativeProfile = resolveEncounterNarrativeProfile(state);
|
||||
const activeThreadIds = resolveActiveThreadIds(
|
||||
{
|
||||
...state,
|
||||
storyEngineMemory,
|
||||
} as GameState,
|
||||
encounterNarrativeProfile,
|
||||
);
|
||||
const visibilitySlice =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const relevantFacts = knowledgeFacts.filter((fact) =>
|
||||
fact.ownerActorIds.includes(state.currentEncounter?.id ?? '')
|
||||
|| fact.ownerActorIds.includes(state.currentEncounter?.npcName ?? '')
|
||||
|| fact.relatedThreadIds.some((threadId) => activeThreadIds.includes(threadId)),
|
||||
);
|
||||
return relevantFacts.length > 0
|
||||
? buildVisibilitySliceFromFacts({
|
||||
facts: relevantFacts,
|
||||
discoveredFactIds: [
|
||||
...storyEngineMemory.discoveredFactIds,
|
||||
...(encounterNpcState?.revealedFacts ?? []),
|
||||
...(encounterNpcState?.seenBackstoryChapterIds ?? []).map(
|
||||
(chapterId) =>
|
||||
relevantFacts.find((fact) =>
|
||||
fact.aliases?.includes(chapterId) || fact.id.includes(chapterId),
|
||||
)?.id ?? '',
|
||||
),
|
||||
],
|
||||
activeThreadIds,
|
||||
disclosureStage: encounterDirective?.disclosureStage ?? null,
|
||||
isFirstMeaningfulContact,
|
||||
})
|
||||
: buildEncounterVisibilitySlice({
|
||||
narrativeProfile: encounterNarrativeProfile,
|
||||
backstoryReveal: state.currentEncounter.backstoryReveal ?? null,
|
||||
disclosureStage: encounterDirective?.disclosureStage ?? null,
|
||||
isFirstMeaningfulContact,
|
||||
seenBackstoryChapterIds: encounterNpcState?.seenBackstoryChapterIds ?? [],
|
||||
storyEngineMemory,
|
||||
activeThreadIds,
|
||||
});
|
||||
})()
|
||||
: null;
|
||||
const sceneNarrativeDirective = buildSceneNarrativeDirective({
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
sceneName: state.currentScenePreset?.name ?? null,
|
||||
encounterId: state.currentEncounter?.id ?? null,
|
||||
encounterName: state.currentEncounter?.npcName ?? null,
|
||||
recentActions: state.storyHistory.slice(-3).map((moment) => moment.text),
|
||||
activeThreadIds,
|
||||
visibilitySlice,
|
||||
encounterNarrativeProfile,
|
||||
disclosureStage: encounterDirective?.disclosureStage ?? null,
|
||||
isFirstMeaningfulContact,
|
||||
affinity: encounterNpcState?.affinity ?? null,
|
||||
});
|
||||
const chapterState = advanceChapterState({
|
||||
previousChapter: state.chapterState ?? storyEngineMemory.currentChapter ?? null,
|
||||
nextChapter: resolveCurrentChapterState({
|
||||
state: {
|
||||
...state,
|
||||
storyEngineMemory,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const journeyBeat = resolveCurrentJourneyBeat({
|
||||
state: {
|
||||
...state,
|
||||
chapterState,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
currentChapter: chapterState,
|
||||
},
|
||||
} as GameState,
|
||||
chapterState,
|
||||
});
|
||||
const companionArcStates = advanceCompanionArc({
|
||||
previous: storyEngineMemory.companionArcStates,
|
||||
next: buildCompanionArcStates({
|
||||
state,
|
||||
reactions: storyEngineMemory.recentCompanionReactions,
|
||||
}),
|
||||
});
|
||||
const currentCampEvent = evaluateCampEventOpportunity({
|
||||
state,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
companionArcStates,
|
||||
})
|
||||
? buildCampEvent({
|
||||
state,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
companionArcStates,
|
||||
})
|
||||
: null;
|
||||
const setpieceDirective = evaluateSetpieceOpportunity({
|
||||
state,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
})
|
||||
? buildSetpieceDirective({
|
||||
state,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
})
|
||||
: null;
|
||||
const recentWorldMutations = storyEngineMemory.worldMutations ?? [];
|
||||
const recentChronicleSummary = buildChronicleSummary({
|
||||
...state,
|
||||
chapterState,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
currentChapter: chapterState,
|
||||
companionArcStates,
|
||||
},
|
||||
} as GameState);
|
||||
const compiledPacks = state.customWorldProfile
|
||||
? compileCampaignFromWorldProfile({ profile: state.customWorldProfile })
|
||||
: null;
|
||||
const goalStack = buildGoalStackState({
|
||||
quests: state.quests,
|
||||
worldType: state.worldType,
|
||||
currentSceneId: state.currentScenePreset?.id ?? null,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
setpieceDirective,
|
||||
currentCampEvent,
|
||||
currentSceneName: state.currentScenePreset?.name ?? null,
|
||||
});
|
||||
const activeScenarioPack =
|
||||
resolveScenarioPack(state.activeScenarioPackId)
|
||||
?? compiledPacks?.scenarioPack
|
||||
?? null;
|
||||
const activeCampaignPack = compiledPacks?.campaignPack ?? null;
|
||||
|
||||
const fallbackChapterRecap = buildChapterRecap({
|
||||
state: { ...state, chapterState } as GameState,
|
||||
});
|
||||
const safeEncounterRelationshipSummary =
|
||||
state.currentEncounter?.characterId
|
||||
? getCharacterChatRecord(state, state.currentEncounter.characterId)
|
||||
.summary
|
||||
.trim()
|
||||
: '';
|
||||
|
||||
return applyAdaptiveTuningToPromptContext({
|
||||
context: {
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
playerMana: state.playerMana,
|
||||
playerMaxMana: state.playerMaxMana,
|
||||
inBattle: state.inBattle,
|
||||
playerX: state.playerX,
|
||||
playerFacing: state.playerFacing,
|
||||
playerAnimation: state.animationState,
|
||||
skillCooldowns: state.playerSkillCooldowns,
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
sceneName: state.currentScenePreset?.name ?? null,
|
||||
sceneDescription: observeSignsSceneDescription,
|
||||
pendingSceneEncounter: extras.pendingSceneEncounter ?? false,
|
||||
lastFunctionId: extras.lastFunctionId ?? null,
|
||||
observeSignsRequested: extras.observeSignsRequested ?? false,
|
||||
recentActionResult: extras.recentActionResult ?? null,
|
||||
lastObserveSignsReport:
|
||||
state.lastObserveSignsSceneId === (state.currentScenePreset?.id ?? null)
|
||||
? (state.lastObserveSignsReport ?? null)
|
||||
: null,
|
||||
encounterKind: state.currentEncounter?.kind ?? null,
|
||||
encounterName: state.currentEncounter?.npcName ?? null,
|
||||
encounterDescription: state.currentEncounter?.npcDescription ?? null,
|
||||
encounterContext: state.currentEncounter?.context ?? null,
|
||||
encounterId: state.currentEncounter?.id ?? null,
|
||||
encounterCharacterId: state.currentEncounter?.characterId ?? null,
|
||||
encounterGender: state.currentEncounter?.gender ?? null,
|
||||
encounterCustomProfile: state.currentEncounter
|
||||
? {
|
||||
title: state.currentEncounter.title ?? '',
|
||||
description: state.currentEncounter.npcDescription ?? '',
|
||||
backstory: state.currentEncounter.backstory ?? '',
|
||||
personality: state.currentEncounter.personality ?? '',
|
||||
motivation: state.currentEncounter.motivation ?? '',
|
||||
combatStyle: state.currentEncounter.combatStyle ?? '',
|
||||
relationshipHooks: [...(state.currentEncounter.relationshipHooks ?? [])],
|
||||
tags: [...(state.currentEncounter.tags ?? [])],
|
||||
backstoryReveal: state.currentEncounter.backstoryReveal,
|
||||
skills: [...(state.currentEncounter.skills ?? [])],
|
||||
initialItems: [...(state.currentEncounter.initialItems ?? [])],
|
||||
imageSrc: state.currentEncounter.imageSrc,
|
||||
visual: state.currentEncounter.visual,
|
||||
narrativeProfile: state.currentEncounter.narrativeProfile,
|
||||
}
|
||||
: null,
|
||||
encounterAffinity: encounterDirective?.affinity ?? null,
|
||||
encounterAffinityText,
|
||||
encounterStanceProfile: encounterNpcState?.stanceProfile ?? null,
|
||||
encounterConversationStyle: encounterDirective?.style ?? null,
|
||||
encounterDisclosureStage: encounterDirective?.disclosureStage ?? null,
|
||||
encounterWarmthStage: encounterDirective?.warmthStage ?? null,
|
||||
encounterAnswerMode: encounterDirective?.answerMode ?? null,
|
||||
encounterAllowedTopics: encounterDirective?.allowTopics ?? null,
|
||||
encounterBlockedTopics: encounterDirective?.blockedTopics ?? null,
|
||||
isFirstMeaningfulContact,
|
||||
firstContactRelationStance,
|
||||
conversationSituation,
|
||||
conversationPressure,
|
||||
recentSharedEvent:
|
||||
recentSharedEvent ?? describeConversationSituation(conversationSituation),
|
||||
talkPriority: describeConversationTalkPriority(conversationSituation),
|
||||
visibilitySlice,
|
||||
sceneNarrativeDirective,
|
||||
campaignState: state.campaignState ?? storyEngineMemory.campaignState ?? null,
|
||||
actState: storyEngineMemory.actState ?? null,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
goalStack,
|
||||
currentCampEvent,
|
||||
setpieceDirective,
|
||||
activeScenarioPack,
|
||||
activeCampaignPack,
|
||||
encounterNarrativeProfile,
|
||||
knowledgeFacts,
|
||||
activeThreadIds,
|
||||
companionArcStates,
|
||||
companionResolutions: storyEngineMemory.companionResolutions ?? [],
|
||||
consequenceLedger: storyEngineMemory.consequenceLedger ?? [],
|
||||
authorialConstraintPack: storyEngineMemory.authorialConstraintPack ?? null,
|
||||
playerStyleProfile: storyEngineMemory.playerStyleProfile ?? null,
|
||||
recentCompanionReactions: storyEngineMemory.recentCompanionReactions ?? [],
|
||||
recentCarrierEchoes: buildRecentCarrierEchoes(state),
|
||||
recentWorldMutations,
|
||||
recentFactionTensionStates: storyEngineMemory.factionTensionStates ?? [],
|
||||
recentChronicleSummary:
|
||||
recentChronicleSummary.trim() &&
|
||||
!hasMixedNarrativeLanguage(recentChronicleSummary)
|
||||
? recentChronicleSummary
|
||||
: fallbackChapterRecap,
|
||||
narrativeQaReport: storyEngineMemory.narrativeQaReport ?? null,
|
||||
releaseGateReport: storyEngineMemory.releaseGateReport ?? null,
|
||||
simulationRunResults: storyEngineMemory.simulationRunResults ?? [],
|
||||
branchBudgetPressure: storyEngineMemory.branchBudgetStatus?.pressure ?? null,
|
||||
encounterRelationshipSummary: state.currentEncounter?.characterId
|
||||
? !hasMixedNarrativeLanguage(safeEncounterRelationshipSummary)
|
||||
? safeEncounterRelationshipSummary || null
|
||||
: null
|
||||
: null,
|
||||
partyRelationshipNotes: buildPartyRelationshipNotes(state),
|
||||
customWorldProfile: state.customWorldProfile ?? null,
|
||||
openingCampBackground: extras.openingCampBackground ?? null,
|
||||
openingCampDialogue: extras.openingCampDialogue ?? null,
|
||||
},
|
||||
profile: storyEngineMemory.playerStyleProfile ?? null,
|
||||
});
|
||||
}
|
||||
172
src/hooks/story/storyEncounterState.test.ts
Normal file
172
src/hooks/story/storyEncounterState.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
} from '../../types';
|
||||
import { createStoryStateResolvers } from './storyEncounterState';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试主角',
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createGameState(overrides: Partial<GameState> = {}): GameState {
|
||||
return {
|
||||
worldType: null,
|
||||
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: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
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;
|
||||
}
|
||||
|
||||
function createNpcEncounter(
|
||||
overrides: Partial<Encounter> = {},
|
||||
): Encounter {
|
||||
return {
|
||||
id: 'npc-guard',
|
||||
kind: 'npc',
|
||||
npcName: '山道客',
|
||||
npcDescription: '守在路口的陌生人',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道相遇',
|
||||
...overrides,
|
||||
} as Encounter;
|
||||
}
|
||||
|
||||
describe('storyEncounterState', () => {
|
||||
it('delegates camp companion option pools to the dedicated builder', () => {
|
||||
const character = createCharacter();
|
||||
const state = createGameState({
|
||||
currentEncounter: createNpcEncounter({
|
||||
specialBehavior: 'camp_companion',
|
||||
}),
|
||||
});
|
||||
const campStory: StoryMoment = {
|
||||
text: '营地同伴剧情',
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
text: '继续交谈',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const buildCampCompanionIdleOptions = vi.fn(() => campStory);
|
||||
const buildNpcStory = vi.fn();
|
||||
|
||||
const { getAvailableOptionsForState } = createStoryStateResolvers({
|
||||
buildCampCompanionIdleOptions,
|
||||
buildNpcStory,
|
||||
});
|
||||
|
||||
expect(getAvailableOptionsForState(state, character)).toEqual(
|
||||
campStory.options,
|
||||
);
|
||||
expect(buildCampCompanionIdleOptions).toHaveBeenCalledWith(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
undefined,
|
||||
);
|
||||
expect(buildNpcStory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses preview talk options for initial companion encounters before formal interaction starts', () => {
|
||||
const character = createCharacter();
|
||||
const state = createGameState({
|
||||
currentEncounter: createNpcEncounter({
|
||||
specialBehavior: 'initial_companion',
|
||||
}),
|
||||
});
|
||||
const { getAvailableOptionsForState } = createStoryStateResolvers({
|
||||
buildCampCompanionIdleOptions: vi.fn(),
|
||||
buildNpcStory: vi.fn(),
|
||||
});
|
||||
|
||||
const options = getAvailableOptionsForState(state, character);
|
||||
|
||||
expect(options).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_preview_talk',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves explicit fallback text when the state falls back to the generic story moment', () => {
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const { buildFallbackStoryForState } = createStoryStateResolvers({
|
||||
buildCampCompanionIdleOptions: vi.fn(),
|
||||
buildNpcStory: vi.fn(),
|
||||
});
|
||||
|
||||
const story = buildFallbackStoryForState(state, character, '手动兜底文本');
|
||||
|
||||
expect(story.text).toBe('手动兜底文本');
|
||||
expect(story.options.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
238
src/hooks/story/storyEncounterState.ts
Normal file
238
src/hooks/story/storyEncounterState.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { buildNpcPreviewTalkOption } from '../../data/functionCatalog';
|
||||
import {
|
||||
getDefaultFunctionIdsForContext,
|
||||
resolveFunctionOption,
|
||||
} from '../../data/stateFunctions';
|
||||
import { buildTreasureEncounterStoryMoment } from '../../data/treasureInteractions';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { buildFallbackStoryMoment } from '../combatStoryUtils';
|
||||
|
||||
type CampCompanionEncounter = Encounter & {
|
||||
specialBehavior: 'camp_companion';
|
||||
};
|
||||
|
||||
type EncounterStoryBuilder = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
export function buildNpcPreviewStory(
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
): StoryMoment {
|
||||
if (!state.worldType) {
|
||||
return {
|
||||
text:
|
||||
overrideText ??
|
||||
`${encounter.npcName}正停在前方,像是在等你先决定要不要真正把注意力落到他身上。`,
|
||||
options: [buildNpcPreviewTalkOption(encounter)],
|
||||
};
|
||||
}
|
||||
|
||||
const functionContext = {
|
||||
worldType: state.worldType,
|
||||
playerCharacter: character,
|
||||
inBattle: false,
|
||||
currentSceneId: state.currentScenePreset?.id ?? null,
|
||||
currentSceneName: state.currentScenePreset?.name ?? null,
|
||||
monsters: [],
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
playerMana: state.playerMana,
|
||||
playerMaxMana: state.playerMaxMana,
|
||||
};
|
||||
|
||||
const locationOptions = getDefaultFunctionIdsForContext(functionContext)
|
||||
.filter((functionId) => functionId !== 'idle_call_out')
|
||||
.map((functionId) => resolveFunctionOption(functionId, functionContext))
|
||||
.filter((option): option is StoryOption => Boolean(option));
|
||||
|
||||
return {
|
||||
text:
|
||||
overrideText ??
|
||||
`${encounter.npcName}出现在${state.currentScenePreset?.name ?? '前方道路'}附近,但你还没有真正把全部注意力落到对方身上。`,
|
||||
options: [buildNpcPreviewTalkOption(encounter), ...locationOptions],
|
||||
};
|
||||
}
|
||||
|
||||
export function getResolvedSceneHostileNpcs(state: GameState) {
|
||||
return state.sceneHostileNpcs;
|
||||
}
|
||||
|
||||
export function getStoryGenerationHostileNpcs(state: GameState) {
|
||||
return state.inBattle ? getResolvedSceneHostileNpcs(state) : [];
|
||||
}
|
||||
|
||||
export function isCampCompanionEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is CampCompanionEncounter {
|
||||
return Boolean(
|
||||
encounter?.kind === 'npc' && encounter.specialBehavior === 'camp_companion',
|
||||
);
|
||||
}
|
||||
|
||||
export function isInitialCompanionEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return Boolean(
|
||||
encounter?.kind === 'npc' &&
|
||||
encounter.specialBehavior === 'initial_companion',
|
||||
);
|
||||
}
|
||||
|
||||
export function isNpcEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return Boolean(encounter?.kind === 'npc');
|
||||
}
|
||||
|
||||
export function isRegularNpcEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return Boolean(encounter?.kind === 'npc' && !encounter.specialBehavior);
|
||||
}
|
||||
|
||||
export function isTreasureEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
return Boolean(encounter?.kind === 'treasure');
|
||||
}
|
||||
|
||||
export function buildTreasureStory(
|
||||
state: GameState,
|
||||
_character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
): StoryMoment {
|
||||
return buildTreasureEncounterStoryMoment({
|
||||
state,
|
||||
encounter,
|
||||
overrideText,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEncounterStory(params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
buildCampCompanionIdleOptions: EncounterStoryBuilder;
|
||||
buildNpcStory: EncounterStoryBuilder;
|
||||
fallbackText?: string;
|
||||
}) {
|
||||
const { state, character, fallbackText } = params;
|
||||
|
||||
if (isCampCompanionEncounter(state.currentEncounter) && !state.inBattle) {
|
||||
return params.buildCampCompanionIdleOptions(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
isInitialCompanionEncounter(state.currentEncounter) &&
|
||||
!state.inBattle &&
|
||||
!state.npcInteractionActive
|
||||
) {
|
||||
return buildNpcPreviewStory(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
if (isRegularNpcEncounter(state.currentEncounter) && !state.inBattle) {
|
||||
if (!state.npcInteractionActive) {
|
||||
return buildNpcPreviewStory(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
return params.buildNpcStory(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
if (isNpcEncounter(state.currentEncounter) && !state.inBattle) {
|
||||
return params.buildNpcStory(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
if (isTreasureEncounter(state.currentEncounter) && !state.inBattle) {
|
||||
return buildTreasureStory(
|
||||
state,
|
||||
character,
|
||||
state.currentEncounter,
|
||||
fallbackText,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createStoryStateResolvers(params: {
|
||||
buildCampCompanionIdleOptions: EncounterStoryBuilder;
|
||||
buildNpcStory: EncounterStoryBuilder;
|
||||
}) {
|
||||
const getAvailableOptionsForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) =>
|
||||
resolveEncounterStory({
|
||||
state,
|
||||
character,
|
||||
buildCampCompanionIdleOptions: params.buildCampCompanionIdleOptions,
|
||||
buildNpcStory: params.buildNpcStory,
|
||||
})?.options ?? null;
|
||||
|
||||
const buildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => {
|
||||
const resolvedStory = resolveEncounterStory({
|
||||
state,
|
||||
character,
|
||||
fallbackText,
|
||||
buildCampCompanionIdleOptions: params.buildCampCompanionIdleOptions,
|
||||
buildNpcStory: params.buildNpcStory,
|
||||
});
|
||||
if (resolvedStory) {
|
||||
return resolvedStory;
|
||||
}
|
||||
|
||||
const fallback = buildFallbackStoryMoment(state, character);
|
||||
return fallbackText
|
||||
? {
|
||||
...fallback,
|
||||
text: fallbackText,
|
||||
}
|
||||
: fallback;
|
||||
};
|
||||
|
||||
return {
|
||||
getAvailableOptionsForState,
|
||||
buildFallbackStoryForState,
|
||||
};
|
||||
}
|
||||
155
src/hooks/story/storyInteractionCoordinator.test.ts
Normal file
155
src/hooks/story/storyInteractionCoordinator.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import { createStoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
|
||||
|
||||
function createOption(
|
||||
functionId = 'npc_chat',
|
||||
actionText = '继续交谈',
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createState(): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
currentScene: 'Story',
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('storyInteractionCoordinator', () => {
|
||||
it('builds shared interaction configs for treasure, inventory and npc flows', () => {
|
||||
const gameState = createState();
|
||||
const currentStory = createStory('当前故事');
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
const setAiError = vi.fn();
|
||||
const setIsLoading = vi.fn();
|
||||
const buildFallbackStoryForState = vi.fn();
|
||||
const buildStoryContextFromState = vi.fn();
|
||||
const buildDialogueStoryMoment = vi.fn();
|
||||
const generateStoryForState = vi.fn();
|
||||
const getStoryGenerationHostileNpcs = vi.fn(() => []);
|
||||
const getAvailableOptionsForState = vi.fn(() => [createOption()]);
|
||||
const getTypewriterDelay = (_char: string) => 90 as const;
|
||||
const commitGeneratedState = vi.fn();
|
||||
const commitGeneratedStateWithEncounterEntry = vi.fn();
|
||||
const appendHistory = vi.fn();
|
||||
const buildOpeningCampChatContext = vi.fn();
|
||||
const sortOptions = vi.fn((options: StoryOption[]) => options);
|
||||
const buildContinueAdventureOption = vi.fn(() => createOption('continue'));
|
||||
const sanitizeOptions = vi.fn((options: StoryOption[]) => options);
|
||||
const resolveNpcInteractionDecision = vi.fn(() => ({ kind: 'chat' }));
|
||||
const runtimeSupport = {
|
||||
buildNpcStory: vi.fn(),
|
||||
cloneInventoryItemForOwner: vi.fn(),
|
||||
getNpcEncounterKey: vi.fn(),
|
||||
getResolvedNpcState: vi.fn(),
|
||||
updateNpcState: vi.fn(),
|
||||
updateQuestLog: vi.fn(),
|
||||
updateRuntimeStats: vi.fn(),
|
||||
};
|
||||
|
||||
const config = createStoryInteractionCoordinatorConfig({
|
||||
gameState,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
currentStory,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
runtimeSupport,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
buildOpeningCampChatContext,
|
||||
sortOptions,
|
||||
buildContinueAdventureOption,
|
||||
sanitizeOptions,
|
||||
resolveNpcInteractionDecision,
|
||||
});
|
||||
|
||||
expect(config.treasureFlow.runtime).toBe(config.inventoryFlow.runtime);
|
||||
expect(config.treasureFlow).toEqual({
|
||||
gameState,
|
||||
runtime: config.inventoryFlow.runtime,
|
||||
});
|
||||
expect(config.npcInteractionFlow).toEqual(
|
||||
expect.objectContaining({
|
||||
gameState,
|
||||
setGameState,
|
||||
commitGeneratedState,
|
||||
getNpcEncounterKey: runtimeSupport.getNpcEncounterKey,
|
||||
getResolvedNpcState: runtimeSupport.getResolvedNpcState,
|
||||
updateNpcState: runtimeSupport.updateNpcState,
|
||||
cloneInventoryItemForOwner: runtimeSupport.cloneInventoryItemForOwner,
|
||||
runtime: expect.objectContaining({
|
||||
currentStory,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(config.npcEncounterActions).toEqual(
|
||||
expect.objectContaining({
|
||||
gameState,
|
||||
currentStory,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
buildOpeningCampChatContext,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
getAvailableOptionsForState,
|
||||
sanitizeOptions,
|
||||
sortOptions,
|
||||
buildContinueAdventureOption,
|
||||
getNpcEncounterKey: runtimeSupport.getNpcEncounterKey,
|
||||
getResolvedNpcState: runtimeSupport.getResolvedNpcState,
|
||||
updateNpcState: runtimeSupport.updateNpcState,
|
||||
cloneInventoryItemForOwner: runtimeSupport.cloneInventoryItemForOwner,
|
||||
resolveNpcInteractionDecision,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
137
src/hooks/story/storyInteractionCoordinator.ts
Normal file
137
src/hooks/story/storyInteractionCoordinator.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
|
||||
import type { StoryRuntimeControllerResult } from './useStoryRuntimeController';
|
||||
|
||||
type StoryInteractionCoordinatorParams = {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
currentStory: StoryMoment | null;
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
buildFallbackStoryForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
buildDialogueStoryMoment: StoryRuntimeControllerResult['buildDialogueStoryMoment'];
|
||||
generateStoryForState: StoryRuntimeControllerResult['generateStoryForState'];
|
||||
getAvailableOptionsForState: StoryRuntimeControllerResult['getAvailableOptionsForState'];
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getTypewriterDelay: StoryRuntimeControllerResult['getTypewriterDelay'];
|
||||
runtimeSupport: StoryRuntimeSupport;
|
||||
commitGeneratedState: StoryRuntimeControllerResult['commitGeneratedState'];
|
||||
commitGeneratedStateWithEncounterEntry: StoryRuntimeControllerResult['commitGeneratedStateWithEncounterEntry'];
|
||||
appendHistory: StoryRuntimeControllerResult['appendHistory'];
|
||||
buildOpeningCampChatContext: StoryRuntimeControllerResult['buildOpeningCampChatContext'];
|
||||
sortOptions: (options: StoryOption[]) => StoryOption[];
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
sanitizeOptions: (
|
||||
options: StoryOption[],
|
||||
character: Character,
|
||||
state: GameState,
|
||||
) => StoryOption[];
|
||||
resolveNpcInteractionDecision: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
) => { kind: string };
|
||||
};
|
||||
|
||||
export function createStoryInteractionCoordinatorConfig(
|
||||
params: StoryInteractionCoordinatorParams,
|
||||
) {
|
||||
const sharedRuntime = {
|
||||
currentStory: params.currentStory,
|
||||
setGameState: params.setGameState,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
buildFallbackStoryForState: params.buildFallbackStoryForState,
|
||||
};
|
||||
|
||||
return {
|
||||
treasureFlow: {
|
||||
gameState: params.gameState,
|
||||
runtime: sharedRuntime,
|
||||
},
|
||||
inventoryFlow: {
|
||||
gameState: params.gameState,
|
||||
runtime: sharedRuntime,
|
||||
},
|
||||
npcInteractionFlow: {
|
||||
gameState: params.gameState,
|
||||
setGameState: params.setGameState,
|
||||
commitGeneratedState: params.commitGeneratedState,
|
||||
getNpcEncounterKey: params.runtimeSupport.getNpcEncounterKey,
|
||||
getResolvedNpcState: params.runtimeSupport.getResolvedNpcState,
|
||||
updateNpcState: params.runtimeSupport.updateNpcState,
|
||||
cloneInventoryItemForOwner:
|
||||
params.runtimeSupport.cloneInventoryItemForOwner,
|
||||
runtime: {
|
||||
currentStory: params.currentStory,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
buildStoryContextFromState: params.buildStoryContextFromState,
|
||||
buildFallbackStoryForState: params.buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment: params.buildDialogueStoryMoment,
|
||||
generateStoryForState: params.generateStoryForState,
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay: params.getTypewriterDelay,
|
||||
},
|
||||
},
|
||||
npcEncounterActions: {
|
||||
gameState: params.gameState,
|
||||
currentStory: params.currentStory,
|
||||
setGameState: params.setGameState,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
commitGeneratedState: params.commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry:
|
||||
params.commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory: params.appendHistory,
|
||||
buildOpeningCampChatContext: params.buildOpeningCampChatContext,
|
||||
buildStoryContextFromState: params.buildStoryContextFromState,
|
||||
buildFallbackStoryForState: params.buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment: params.buildDialogueStoryMoment,
|
||||
generateStoryForState: params.generateStoryForState,
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay: params.getTypewriterDelay,
|
||||
getAvailableOptionsForState: params.getAvailableOptionsForState,
|
||||
sanitizeOptions: params.sanitizeOptions,
|
||||
sortOptions: params.sortOptions,
|
||||
buildContinueAdventureOption: params.buildContinueAdventureOption,
|
||||
getNpcEncounterKey: params.runtimeSupport.getNpcEncounterKey,
|
||||
getResolvedNpcState: params.runtimeSupport.getResolvedNpcState,
|
||||
updateNpcState: params.runtimeSupport.updateNpcState,
|
||||
cloneInventoryItemForOwner:
|
||||
params.runtimeSupport.cloneInventoryItemForOwner,
|
||||
resolveNpcInteractionDecision: params.resolveNpcInteractionDecision,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type StoryInteractionCoordinatorConfig = ReturnType<
|
||||
typeof createStoryInteractionCoordinatorConfig
|
||||
>;
|
||||
149
src/hooks/story/storyPresentation.test.ts
Normal file
149
src/hooks/story/storyPresentation.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
} from '../../types';
|
||||
import { buildStoryFromResponse } from './storyPresentation';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试主角',
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createGameState(overrides: Partial<GameState> = {}): GameState {
|
||||
return {
|
||||
worldType: null,
|
||||
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: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
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;
|
||||
}
|
||||
|
||||
function createOption(
|
||||
functionId = 'npc_chat',
|
||||
actionText = '继续交谈',
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
function createStory(
|
||||
text: string,
|
||||
options: StoryOption[] = [],
|
||||
): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
describe('storyPresentation', () => {
|
||||
it('keeps provided available options when the AI response omits them', () => {
|
||||
const availableOptions = [createOption('npc_help', '请求援手')];
|
||||
|
||||
const story = buildStoryFromResponse({
|
||||
state: createGameState(),
|
||||
character: createCharacter(),
|
||||
response: createStory('服务端返回正文'),
|
||||
availableOptions,
|
||||
});
|
||||
|
||||
expect(story.text).toBe('服务端返回正文');
|
||||
expect(story.options).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates repeated response options before padding local fallbacks', () => {
|
||||
const duplicatedOptions = [
|
||||
createOption('npc_chat', '继续交谈'),
|
||||
createOption('npc_chat', '继续交谈'),
|
||||
];
|
||||
|
||||
const story = buildStoryFromResponse({
|
||||
state: createGameState(),
|
||||
character: createCharacter(),
|
||||
response: createStory('需要本地归一化', duplicatedOptions),
|
||||
availableOptions: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
story.options.filter(
|
||||
(option) =>
|
||||
option.functionId === 'npc_chat' &&
|
||||
option.actionText === '继续交谈',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(story.options.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
245
src/hooks/story/storyPresentation.ts
Normal file
245
src/hooks/story/storyPresentation.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryDialogueTurn,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import {
|
||||
buildFallbackStoryMoment,
|
||||
normalizeSkillProbabilities,
|
||||
} from '../combatStoryUtils';
|
||||
import { resolveStoryResponseOptions } from './storyResponseOptions';
|
||||
|
||||
const MIN_OPTION_POOL_SIZE = 6;
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
export function sanitizeStoryOptions(
|
||||
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 sortStoryOptionsByPriority(
|
||||
dedupeStoryOptions([
|
||||
...normalizedOptions,
|
||||
...buildFallbackStoryMoment(state, character).options,
|
||||
]).slice(0, MIN_OPTION_POOL_SIZE),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildStoryFromResponse(params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
response: StoryMoment;
|
||||
availableOptions: StoryOption[] | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
}) {
|
||||
return {
|
||||
text: params.response.text,
|
||||
options: resolveStoryResponseOptions({
|
||||
responseOptions: params.response.options,
|
||||
availableOptions: params.availableOptions,
|
||||
optionCatalog: params.optionCatalog ?? null,
|
||||
getSanitizedOptions: () =>
|
||||
sanitizeStoryOptions(
|
||||
params.response.options,
|
||||
params.character,
|
||||
params.state,
|
||||
),
|
||||
}),
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
241
src/hooks/story/storyRenderingHelpers.ts
Normal file
241
src/hooks/story/storyRenderingHelpers.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
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;
|
||||
}
|
||||
192
src/hooks/story/storyRequestCoordinator.test.ts
Normal file
192
src/hooks/story/storyRequestCoordinator.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiService';
|
||||
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import {
|
||||
generateStoryForStateWithCoordinator,
|
||||
resolveStoryRequestOptions,
|
||||
} from './storyRequestCoordinator';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '在风声里辨认危险的旅人。',
|
||||
personality: '谨慎而果断',
|
||||
skills: [],
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createGameState(): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 3,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(functionId = 'npc_chat', actionText = '继续交谈'): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
describe('storyRequestCoordinator', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('switches to server runtime option catalogs when the local option pool is fully server-backed', async () => {
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const currentStory = createStory('当前故事');
|
||||
const getAvailableOptionsForState = vi.fn(() => [createOption('npc_chat')]);
|
||||
const loadRuntimeOptionCatalog = vi
|
||||
.fn()
|
||||
.mockResolvedValue([createOption('npc_help', '请求援手')]);
|
||||
|
||||
const result = await resolveStoryRequestOptions({
|
||||
state,
|
||||
character,
|
||||
currentStory,
|
||||
getAvailableOptionsForState,
|
||||
loadRuntimeOptionCatalog,
|
||||
});
|
||||
|
||||
expect(loadRuntimeOptionCatalog).toHaveBeenCalledWith({
|
||||
gameState: state,
|
||||
currentStory,
|
||||
});
|
||||
expect(result.availableOptions).toBeNull();
|
||||
expect(result.optionCatalog).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps explicit option catalogs without reloading server runtime options', async () => {
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const currentStory = createStory('当前故事');
|
||||
const optionCatalog = [createOption('npc_help', '请求援手')];
|
||||
const getAvailableOptionsForState = vi.fn(() => [createOption('npc_chat')]);
|
||||
const loadRuntimeOptionCatalog = vi.fn();
|
||||
|
||||
const result = await resolveStoryRequestOptions({
|
||||
state,
|
||||
character,
|
||||
currentStory,
|
||||
optionCatalog,
|
||||
getAvailableOptionsForState,
|
||||
loadRuntimeOptionCatalog,
|
||||
});
|
||||
|
||||
expect(loadRuntimeOptionCatalog).not.toHaveBeenCalled();
|
||||
expect(getAvailableOptionsForState).not.toHaveBeenCalled();
|
||||
expect(result.optionCatalog).toBe(optionCatalog);
|
||||
expect(result.availableOptions).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to local available options when server runtime catalog refresh fails during续写', async () => {
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const currentStory = createStory('当前故事');
|
||||
const history = [createStory('上一轮剧情')];
|
||||
const localOptions = [createOption('npc_chat', '继续交谈')];
|
||||
const getAvailableOptionsForState = vi.fn(() => localOptions);
|
||||
const getStoryGenerationHostileNpcs = vi.fn(() => []);
|
||||
const buildStoryContextFromState = vi.fn(
|
||||
(_state, extras) =>
|
||||
({
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 30,
|
||||
playerMaxMana: 30,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: 'idle',
|
||||
skillCooldowns: {},
|
||||
sceneId: 'inn_room',
|
||||
sceneName: '客栈内室',
|
||||
sceneDescription: '屋里安静得只剩风声。',
|
||||
pendingSceneEncounter: false,
|
||||
lastFunctionId: extras?.lastFunctionId ?? null,
|
||||
}) as StoryGenerationContext,
|
||||
);
|
||||
const buildStoryFromResponse = vi.fn(
|
||||
(
|
||||
_state: GameState,
|
||||
_character: Character,
|
||||
response: StoryMoment,
|
||||
) => response,
|
||||
);
|
||||
const requestInitialStory = vi.fn();
|
||||
const requestNextStep = vi.fn().mockResolvedValue({
|
||||
storyText: '服务端续写完成',
|
||||
options: [createOption('npc_help', '顺势追问')],
|
||||
});
|
||||
const loadRuntimeOptionCatalog = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('server option catalog failed'));
|
||||
const onServerOptionCatalogLoadError = vi.fn();
|
||||
|
||||
const result = await generateStoryForStateWithCoordinator({
|
||||
state,
|
||||
character,
|
||||
history,
|
||||
currentStory,
|
||||
choice: '继续交谈',
|
||||
lastFunctionId: 'npc_chat',
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
requestInitialStory,
|
||||
requestNextStep,
|
||||
loadRuntimeOptionCatalog,
|
||||
onServerOptionCatalogLoadError,
|
||||
});
|
||||
|
||||
expect(onServerOptionCatalogLoadError).toHaveBeenCalledTimes(1);
|
||||
expect(requestInitialStory).not.toHaveBeenCalled();
|
||||
expect(requestNextStep).toHaveBeenCalledWith(
|
||||
'WUXIA',
|
||||
character,
|
||||
[],
|
||||
history,
|
||||
'继续交谈',
|
||||
expect.objectContaining({
|
||||
sceneId: 'inn_room',
|
||||
lastFunctionId: 'npc_chat',
|
||||
}),
|
||||
{
|
||||
availableOptions: localOptions,
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({
|
||||
text: '服务端续写完成',
|
||||
options: [createOption('npc_help', '顺势追问')],
|
||||
});
|
||||
});
|
||||
});
|
||||
196
src/hooks/story/storyRequestCoordinator.ts
Normal file
196
src/hooks/story/storyRequestCoordinator.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type {
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
} from '../../services/aiService';
|
||||
import { shouldUseServerRuntimeOptions } from '../../services/runtimeStoryService';
|
||||
import type {
|
||||
AIResponse,
|
||||
Character,
|
||||
GameState,
|
||||
SceneHostileNpc,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { loadServerRuntimeOptionCatalog } from './runtimeStoryCoordinator';
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
type BuildStoryFromResponse = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog?: StoryOption[] | null,
|
||||
) => StoryMoment;
|
||||
|
||||
type GetAvailableOptionsForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
|
||||
type GetStoryGenerationHostileNpcs = (state: GameState) => SceneHostileNpc[];
|
||||
|
||||
type RequestInitialStory = (
|
||||
worldType: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
requestOptions?: StoryRequestOptions,
|
||||
) => Promise<AIResponse>;
|
||||
|
||||
type RequestNextStep = (
|
||||
worldType: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
choice: string,
|
||||
context: StoryGenerationContext,
|
||||
requestOptions?: StoryRequestOptions,
|
||||
) => Promise<AIResponse>;
|
||||
|
||||
type LoadRuntimeOptionCatalog = typeof loadServerRuntimeOptionCatalog;
|
||||
|
||||
export type ResolvedStoryRequestOptions = {
|
||||
availableOptions: StoryOption[] | null;
|
||||
optionCatalog: StoryOption[] | null;
|
||||
};
|
||||
|
||||
export async function resolveStoryRequestOptions(params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
currentStory: StoryMoment | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
getAvailableOptionsForState: GetAvailableOptionsForState;
|
||||
loadRuntimeOptionCatalog?: LoadRuntimeOptionCatalog;
|
||||
onServerOptionCatalogLoadError?: (error: unknown) => void;
|
||||
}) {
|
||||
let optionCatalog =
|
||||
params.optionCatalog && params.optionCatalog.length > 0
|
||||
? params.optionCatalog
|
||||
: null;
|
||||
let availableOptions = optionCatalog
|
||||
? null
|
||||
: params.getAvailableOptionsForState(params.state, params.character);
|
||||
|
||||
if (optionCatalog || !shouldUseServerRuntimeOptions(availableOptions)) {
|
||||
return {
|
||||
availableOptions,
|
||||
optionCatalog,
|
||||
} satisfies ResolvedStoryRequestOptions;
|
||||
}
|
||||
|
||||
try {
|
||||
const serverOptionCatalog = await (
|
||||
params.loadRuntimeOptionCatalog ?? loadServerRuntimeOptionCatalog
|
||||
)({
|
||||
gameState: params.state,
|
||||
currentStory: params.currentStory,
|
||||
});
|
||||
|
||||
if (serverOptionCatalog && serverOptionCatalog.length > 0) {
|
||||
optionCatalog = serverOptionCatalog;
|
||||
availableOptions = null;
|
||||
}
|
||||
} catch (error) {
|
||||
params.onServerOptionCatalogLoadError?.(error);
|
||||
}
|
||||
|
||||
return {
|
||||
availableOptions,
|
||||
optionCatalog,
|
||||
} satisfies ResolvedStoryRequestOptions;
|
||||
}
|
||||
|
||||
export function buildAiStoryRequestOptions(
|
||||
options: ResolvedStoryRequestOptions,
|
||||
) {
|
||||
if (options.availableOptions) {
|
||||
return {
|
||||
availableOptions: options.availableOptions,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.optionCatalog) {
|
||||
return {
|
||||
optionCatalog: options.optionCatalog,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function generateStoryForStateWithCoordinator(params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
history: StoryMoment[];
|
||||
currentStory: StoryMoment | null;
|
||||
choice?: string;
|
||||
lastFunctionId?: string | null;
|
||||
optionCatalog?: StoryOption[] | null;
|
||||
getAvailableOptionsForState: GetAvailableOptionsForState;
|
||||
getStoryGenerationHostileNpcs: GetStoryGenerationHostileNpcs;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
buildStoryFromResponse: BuildStoryFromResponse;
|
||||
requestInitialStory: RequestInitialStory;
|
||||
requestNextStep: RequestNextStep;
|
||||
loadRuntimeOptionCatalog?: LoadRuntimeOptionCatalog;
|
||||
onServerOptionCatalogLoadError?: (error: unknown) => void;
|
||||
}) {
|
||||
if (!params.state.worldType) {
|
||||
throw new Error(
|
||||
'The current world is not initialized, so story generation cannot continue.',
|
||||
);
|
||||
}
|
||||
const worldType = params.state.worldType;
|
||||
|
||||
const resolvedOptions = await resolveStoryRequestOptions({
|
||||
state: params.state,
|
||||
character: params.character,
|
||||
currentStory: params.currentStory,
|
||||
optionCatalog: params.optionCatalog,
|
||||
getAvailableOptionsForState: params.getAvailableOptionsForState,
|
||||
loadRuntimeOptionCatalog: params.loadRuntimeOptionCatalog,
|
||||
onServerOptionCatalogLoadError: params.onServerOptionCatalogLoadError,
|
||||
});
|
||||
const requestOptions = buildAiStoryRequestOptions(resolvedOptions);
|
||||
const monsters = params.getStoryGenerationHostileNpcs(params.state);
|
||||
const context = params.choice
|
||||
? params.buildStoryContextFromState(params.state, {
|
||||
lastFunctionId: params.lastFunctionId,
|
||||
})
|
||||
: params.buildStoryContextFromState(params.state);
|
||||
const response = params.choice
|
||||
? await params.requestNextStep(
|
||||
worldType,
|
||||
params.character,
|
||||
monsters,
|
||||
params.history,
|
||||
params.choice,
|
||||
context,
|
||||
requestOptions,
|
||||
)
|
||||
: await params.requestInitialStory(
|
||||
worldType,
|
||||
params.character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
|
||||
return params.buildStoryFromResponse(
|
||||
params.state,
|
||||
params.character,
|
||||
{
|
||||
text: response.storyText,
|
||||
options: response.options,
|
||||
},
|
||||
resolvedOptions.availableOptions,
|
||||
resolvedOptions.optionCatalog,
|
||||
);
|
||||
}
|
||||
136
src/hooks/story/storyRequestRuntime.test.ts
Normal file
136
src/hooks/story/storyRequestRuntime.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { createGenerateStoryForState } from './storyRequestRuntime';
|
||||
|
||||
const { generateStoryForStateWithCoordinatorMock } = vi.hoisted(() => ({
|
||||
generateStoryForStateWithCoordinatorMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./storyRequestCoordinator', () => ({
|
||||
generateStoryForStateWithCoordinator:
|
||||
generateStoryForStateWithCoordinatorMock,
|
||||
}));
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '在风声里辨认危险的旅人。',
|
||||
backstory: '长年行走江湖。',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: '谨慎而果断',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createGameState(): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
currentScene: 'Story',
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(
|
||||
functionId = 'npc_chat',
|
||||
actionText = '继续交谈',
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
}
|
||||
|
||||
describe('storyRequestRuntime', () => {
|
||||
it('forwards runtime request dependencies and currentStory into the coordinator', async () => {
|
||||
const currentStory = createStory('当前故事');
|
||||
const getAvailableOptionsForState = vi.fn(() => [createOption()]);
|
||||
const getStoryGenerationHostileNpcs = vi.fn(() => []);
|
||||
const buildStoryContextFromState = vi.fn();
|
||||
const buildStoryFromResponse = vi.fn();
|
||||
const requestInitialStory = vi.fn();
|
||||
const requestNextStep = vi.fn();
|
||||
const onServerOptionCatalogLoadError = vi.fn();
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const history = [createStory('上一轮剧情')];
|
||||
|
||||
generateStoryForStateWithCoordinatorMock.mockResolvedValue({
|
||||
text: '生成完成',
|
||||
options: [],
|
||||
});
|
||||
|
||||
const generateStoryForState = createGenerateStoryForState({
|
||||
currentStory,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
requestInitialStory,
|
||||
requestNextStep,
|
||||
onServerOptionCatalogLoadError,
|
||||
});
|
||||
|
||||
const result = await generateStoryForState({
|
||||
state,
|
||||
character,
|
||||
history,
|
||||
choice: '继续交谈',
|
||||
lastFunctionId: 'npc_chat',
|
||||
optionCatalog: [createOption('npc_help', '请求援手')],
|
||||
});
|
||||
|
||||
expect(generateStoryForStateWithCoordinatorMock).toHaveBeenCalledWith({
|
||||
state,
|
||||
character,
|
||||
history,
|
||||
currentStory,
|
||||
choice: '继续交谈',
|
||||
lastFunctionId: 'npc_chat',
|
||||
optionCatalog: [createOption('npc_help', '请求援手')],
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
requestInitialStory,
|
||||
requestNextStep,
|
||||
onServerOptionCatalogLoadError,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
text: '生成完成',
|
||||
options: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
69
src/hooks/story/storyRequestRuntime.ts
Normal file
69
src/hooks/story/storyRequestRuntime.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { GenerateStoryForState } from './progressionActions';
|
||||
import { generateStoryForStateWithCoordinator } from './storyRequestCoordinator';
|
||||
|
||||
type GetAvailableOptionsForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
|
||||
type BuildStoryContextFromState = Parameters<
|
||||
typeof generateStoryForStateWithCoordinator
|
||||
>[0]['buildStoryContextFromState'];
|
||||
|
||||
type BuildStoryFromResponse = Parameters<
|
||||
typeof generateStoryForStateWithCoordinator
|
||||
>[0]['buildStoryFromResponse'];
|
||||
|
||||
type GetStoryGenerationHostileNpcs = Parameters<
|
||||
typeof generateStoryForStateWithCoordinator
|
||||
>[0]['getStoryGenerationHostileNpcs'];
|
||||
|
||||
type RequestInitialStory = Parameters<
|
||||
typeof generateStoryForStateWithCoordinator
|
||||
>[0]['requestInitialStory'];
|
||||
|
||||
type RequestNextStep = Parameters<
|
||||
typeof generateStoryForStateWithCoordinator
|
||||
>[0]['requestNextStep'];
|
||||
|
||||
export function createGenerateStoryForState(params: {
|
||||
currentStory: StoryMoment | null;
|
||||
getAvailableOptionsForState: GetAvailableOptionsForState;
|
||||
getStoryGenerationHostileNpcs: GetStoryGenerationHostileNpcs;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
buildStoryFromResponse: BuildStoryFromResponse;
|
||||
requestInitialStory: RequestInitialStory;
|
||||
requestNextStep: RequestNextStep;
|
||||
onServerOptionCatalogLoadError?: (error: unknown) => void;
|
||||
}): GenerateStoryForState {
|
||||
return async ({
|
||||
state,
|
||||
character,
|
||||
history,
|
||||
choice,
|
||||
lastFunctionId,
|
||||
optionCatalog,
|
||||
}) =>
|
||||
generateStoryForStateWithCoordinator({
|
||||
state,
|
||||
character,
|
||||
history,
|
||||
currentStory: params.currentStory,
|
||||
choice,
|
||||
lastFunctionId,
|
||||
optionCatalog,
|
||||
getAvailableOptionsForState: params.getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
buildStoryContextFromState: params.buildStoryContextFromState,
|
||||
buildStoryFromResponse: params.buildStoryFromResponse,
|
||||
requestInitialStory: params.requestInitialStory,
|
||||
requestNextStep: params.requestNextStep,
|
||||
onServerOptionCatalogLoadError: params.onServerOptionCatalogLoadError,
|
||||
});
|
||||
}
|
||||
123
src/hooks/story/storyRuntimeSupport.test.ts
Normal file
123
src/hooks/story/storyRuntimeSupport.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { GameState, InventoryItem } from '../../types';
|
||||
import {
|
||||
cloneInventoryItemForOwner,
|
||||
updateQuestLog,
|
||||
updateRuntimeStats,
|
||||
} from './storyRuntimeSupport';
|
||||
|
||||
function createGameState(): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
customWorldProfile: null,
|
||||
playerCharacter: null,
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: 'idle',
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 1,
|
||||
playerMaxHp: 1,
|
||||
playerMana: 1,
|
||||
playerMaxMana: 1,
|
||||
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,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('storyRuntimeSupport', () => {
|
||||
it('preserves identity-sensitive inventory items when cloning for another owner', () => {
|
||||
const item = {
|
||||
id: 'artifact-1',
|
||||
category: '饰品',
|
||||
name: '旧日秘匣',
|
||||
quantity: 1,
|
||||
rarity: 'epic',
|
||||
tags: ['relic'],
|
||||
runtimeMetadata: {
|
||||
seedKey: 'artifact-seed',
|
||||
},
|
||||
} as InventoryItem;
|
||||
|
||||
expect(cloneInventoryItemForOwner(item, 'npc', 2)).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'npc:artifact-1:2',
|
||||
quantity: 2,
|
||||
runtimeMetadata: expect.objectContaining({
|
||||
seedKey: 'artifact-seed:npc',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses synthetic ids for ordinary stackable items when cloning for another owner', () => {
|
||||
const item = {
|
||||
id: 'potion-1',
|
||||
category: '消耗品',
|
||||
name: '回气散',
|
||||
quantity: 3,
|
||||
rarity: 'common',
|
||||
tags: ['healing'],
|
||||
} as InventoryItem;
|
||||
|
||||
expect(cloneInventoryItemForOwner(item, 'player')).toEqual(
|
||||
expect.objectContaining({
|
||||
id: `player:${encodeURIComponent('消耗品-回气散')}`,
|
||||
quantity: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('updates quest logs and runtime stats without keeping that logic in the main hook', () => {
|
||||
const initialState = createGameState();
|
||||
const withQuest = updateQuestLog(initialState, () => [
|
||||
{
|
||||
id: 'quest-1',
|
||||
},
|
||||
] as GameState['quests']);
|
||||
const withStats = updateRuntimeStats(withQuest, {
|
||||
itemsUsed: 2,
|
||||
scenesTraveled: 1,
|
||||
});
|
||||
|
||||
expect(withStats.quests).toEqual([{ id: 'quest-1' }]);
|
||||
expect(withStats.runtimeStats.itemsUsed).toBe(2);
|
||||
expect(withStats.runtimeStats.scenesTraveled).toBe(1);
|
||||
});
|
||||
});
|
||||
127
src/hooks/story/storyRuntimeSupport.ts
Normal file
127
src/hooks/story/storyRuntimeSupport.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
buildNpcEncounterStoryMoment,
|
||||
normalizeNpcPersistentState,
|
||||
} from '../../data/npcInteractions';
|
||||
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
|
||||
import { syncNpcNarrativeState } from '../../services/storyEngine/echoMemory';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
} from '../../types';
|
||||
import { getNpcEncounterKey } from './storyGenerationState';
|
||||
|
||||
export function cloneInventoryItemForOwner(
|
||||
item: InventoryItem,
|
||||
owner: 'player' | 'npc',
|
||||
quantity = 1,
|
||||
) {
|
||||
const preserveIdentity = Boolean(
|
||||
item.runtimeMetadata ||
|
||||
item.buildProfile ||
|
||||
item.equipmentSlotId ||
|
||||
item.statProfile ||
|
||||
item.attributeResonance,
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
id: preserveIdentity
|
||||
? `${owner}:${item.id}:${quantity}`
|
||||
: `${owner}:${encodeURIComponent(`${item.category}-${item.name}`)}`,
|
||||
quantity,
|
||||
runtimeMetadata: item.runtimeMetadata
|
||||
? {
|
||||
...item.runtimeMetadata,
|
||||
seedKey: `${item.runtimeMetadata.seedKey}:${owner}`,
|
||||
}
|
||||
: item.runtimeMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
export function getResolvedNpcState(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
) {
|
||||
return (
|
||||
state.npcStates[getNpcEncounterKey(encounter)] ??
|
||||
buildInitialNpcState(encounter, state.worldType, state)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildNpcStory(
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
) {
|
||||
return buildNpcEncounterStoryMoment({
|
||||
state,
|
||||
encounter,
|
||||
npcState: getResolvedNpcState(state, encounter),
|
||||
playerCharacter: character,
|
||||
playerInventory: state.playerInventory,
|
||||
activeQuests: state.quests,
|
||||
scene: state.currentScenePreset,
|
||||
partySize: state.companions.length,
|
||||
overrideText,
|
||||
worldType: state.worldType,
|
||||
});
|
||||
}
|
||||
|
||||
export function updateNpcState(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
updater: (
|
||||
npcState: ReturnType<typeof getResolvedNpcState>,
|
||||
) => ReturnType<typeof getResolvedNpcState>,
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
[getNpcEncounterKey(encounter)]: normalizeNpcPersistentState(
|
||||
syncNpcNarrativeState({
|
||||
encounter,
|
||||
npcState: updater(getResolvedNpcState(state, encounter)),
|
||||
customWorldProfile: state.customWorldProfile,
|
||||
storyEngineMemory: state.storyEngineMemory,
|
||||
}),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function updateQuestLog(
|
||||
state: GameState,
|
||||
updater: (quests: GameState['quests']) => GameState['quests'],
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
quests: updater(state.quests),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateRuntimeStats(
|
||||
state: GameState,
|
||||
increments: Parameters<typeof incrementGameRuntimeStats>[1],
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, increments),
|
||||
};
|
||||
}
|
||||
|
||||
export const storyRuntimeSupport = {
|
||||
cloneInventoryItemForOwner,
|
||||
getNpcEncounterKey,
|
||||
getResolvedNpcState,
|
||||
buildNpcStory,
|
||||
updateNpcState,
|
||||
updateQuestLog,
|
||||
updateRuntimeStats,
|
||||
};
|
||||
|
||||
export type StoryRuntimeSupport = typeof storyRuntimeSupport;
|
||||
16
src/hooks/story/useStoryChoiceCoordinator.test.ts
Normal file
16
src/hooks/story/useStoryChoiceCoordinator.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createClearStoryChoiceUi } from './useStoryChoiceCoordinator';
|
||||
|
||||
describe('useStoryChoiceCoordinator helpers', () => {
|
||||
it('clears choice ui by dismissing battle reward', () => {
|
||||
const calls: string[] = [];
|
||||
const clearStoryChoiceUi = createClearStoryChoiceUi({
|
||||
clearBattleReward: vi.fn(() => calls.push('battle')),
|
||||
});
|
||||
|
||||
clearStoryChoiceUi();
|
||||
|
||||
expect(calls).toEqual(['battle']);
|
||||
});
|
||||
});
|
||||
140
src/hooks/story/useStoryChoiceCoordinator.ts
Normal file
140
src/hooks/story/useStoryChoiceCoordinator.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { createStoryChoiceActions } from './choiceActions';
|
||||
import {
|
||||
createStoryChoiceCoordinatorConfig,
|
||||
type ChoiceRuntimeController,
|
||||
type ChoiceRuntimeSupport,
|
||||
} from './storyChoiceCoordinator';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
|
||||
|
||||
type StoryChoiceCoordinatorParams = {
|
||||
gameState: GameState;
|
||||
isLoading: boolean;
|
||||
setGameState: Parameters<typeof createStoryChoiceActions>[0]['setGameState'];
|
||||
setCurrentStory: Parameters<
|
||||
typeof createStoryChoiceActions
|
||||
>[0]['setCurrentStory'];
|
||||
setAiError: Parameters<typeof createStoryChoiceActions>[0]['setAiError'];
|
||||
setIsLoading: Parameters<typeof createStoryChoiceActions>[0]['setIsLoading'];
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: ResolvedChoicePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
runtimeController: ChoiceRuntimeController & {
|
||||
currentStory: StoryMoment | null;
|
||||
};
|
||||
runtimeSupport: ChoiceRuntimeSupport;
|
||||
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
|
||||
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
|
||||
handleTreasureInteraction: (
|
||||
option: StoryOption,
|
||||
) => void | Promise<void> | boolean | Promise<boolean>;
|
||||
finalizeNpcBattleResult: Parameters<
|
||||
typeof createStoryChoiceCoordinatorConfig
|
||||
>[0]['finalizeNpcBattleResult'];
|
||||
sortOptions: (options: StoryOption[]) => StoryOption[];
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isInitialCompanionEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
npcPreviewTalkFunctionId: string;
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
};
|
||||
|
||||
export function createClearStoryChoiceUi(params: {
|
||||
clearBattleReward: () => void;
|
||||
}) {
|
||||
return () => {
|
||||
params.clearBattleReward();
|
||||
};
|
||||
}
|
||||
|
||||
export function useStoryChoiceCoordinator(
|
||||
params: StoryChoiceCoordinatorParams,
|
||||
) {
|
||||
const [battleReward, setBattleReward] = useState<BattleRewardSummary | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions(
|
||||
createStoryChoiceCoordinatorConfig({
|
||||
gameState: params.gameState,
|
||||
currentStory: params.runtimeController.currentStory,
|
||||
isLoading: params.isLoading,
|
||||
setGameState: params.setGameState,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
setBattleReward,
|
||||
buildResolvedChoiceState: params.buildResolvedChoiceState,
|
||||
playResolvedChoice: params.playResolvedChoice,
|
||||
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
|
||||
runtimeController: params.runtimeController,
|
||||
runtimeSupport: params.runtimeSupport,
|
||||
enterNpcInteraction: params.enterNpcInteraction,
|
||||
handleNpcInteraction: params.handleNpcInteraction,
|
||||
handleTreasureInteraction: params.handleTreasureInteraction,
|
||||
finalizeNpcBattleResult: params.finalizeNpcBattleResult,
|
||||
sortOptions: params.sortOptions,
|
||||
buildContinueAdventureOption: params.buildContinueAdventureOption,
|
||||
isContinueAdventureOption: params.isContinueAdventureOption,
|
||||
isCampTravelHomeOption: params.isCampTravelHomeOption,
|
||||
isInitialCompanionEncounter: params.isInitialCompanionEncounter,
|
||||
isRegularNpcEncounter: params.isRegularNpcEncounter,
|
||||
isNpcEncounter: params.isNpcEncounter,
|
||||
npcPreviewTalkFunctionId: params.npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName: params.fallbackCompanionName,
|
||||
turnVisualMs: params.turnVisualMs,
|
||||
}),
|
||||
);
|
||||
|
||||
const clearStoryChoiceUi = useCallback(
|
||||
createClearStoryChoiceUi({
|
||||
clearBattleReward: () => setBattleReward(null),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
handleChoice,
|
||||
battleRewardUi: {
|
||||
reward: battleReward,
|
||||
dismiss: () => setBattleReward(null),
|
||||
},
|
||||
clearStoryChoiceUi,
|
||||
};
|
||||
}
|
||||
190
src/hooks/story/useStoryFlowCoordinator.ts
Normal file
190
src/hooks/story/useStoryFlowCoordinator.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { Character, Encounter, GameState, StoryOption } from '../../types';
|
||||
import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import { createStoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
|
||||
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';
|
||||
|
||||
type StoryFlowCoordinatorParams = {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: ResolvedChoicePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
runtimeController: StoryRuntimeControllerResult;
|
||||
runtimeSupport: StoryRuntimeSupport;
|
||||
sortOptions: (options: StoryOption[]) => StoryOption[];
|
||||
buildContinueAdventureOption: () => StoryOption;
|
||||
resolveNpcInteractionDecision: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
) => { kind: string };
|
||||
clearCharacterChatModal: () => void;
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isInitialCompanionEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
npcPreviewTalkFunctionId: string;
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
};
|
||||
|
||||
export function useStoryFlowCoordinator({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs,
|
||||
runtimeController,
|
||||
runtimeSupport,
|
||||
sortOptions,
|
||||
buildContinueAdventureOption,
|
||||
resolveNpcInteractionDecision,
|
||||
clearCharacterChatModal,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isInitialCompanionEncounter,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
}: StoryFlowCoordinatorParams) {
|
||||
const {
|
||||
currentStory,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
isLoading,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState,
|
||||
getTypewriterDelay,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
buildOpeningCampChatContext,
|
||||
resetPreparedOpeningAdventure,
|
||||
} = runtimeController;
|
||||
const interactionConfig = createStoryInteractionCoordinatorConfig({
|
||||
gameState,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
currentStory,
|
||||
buildStoryContextFromState,
|
||||
buildFallbackStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getTypewriterDelay,
|
||||
runtimeSupport,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
buildOpeningCampChatContext,
|
||||
sortOptions,
|
||||
buildContinueAdventureOption,
|
||||
sanitizeOptions: sanitizeStoryOptions,
|
||||
resolveNpcInteractionDecision,
|
||||
});
|
||||
const {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
goalUi,
|
||||
clearStoryGoalOptionUi,
|
||||
} = useStoryGoalOptionCoordinator({
|
||||
gameState,
|
||||
currentStory,
|
||||
});
|
||||
const {
|
||||
handleChoice,
|
||||
battleRewardUi,
|
||||
npcUi,
|
||||
inventoryUi,
|
||||
clearStoryInteractionUi,
|
||||
} = useStoryInteractionCoordinator({
|
||||
gameState,
|
||||
isLoading,
|
||||
interactionConfig,
|
||||
runtimeSupport,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryFromResponse: runtimeController.buildStoryFromResponse,
|
||||
getResolvedSceneHostileNpcs,
|
||||
getCampCompanionTravelScene: runtimeController.getCampCompanionTravelScene,
|
||||
startOpeningAdventure: runtimeController.startOpeningAdventure,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isInitialCompanionEncounter,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
});
|
||||
const { questUi, resetStoryState, hydrateStoryState, travelToSceneFromMap } =
|
||||
useStoryGoalSessionCoordinator({
|
||||
gameState,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
commitGeneratedState,
|
||||
buildFallbackStoryForState,
|
||||
resetPreparedOpeningAdventure,
|
||||
clearStoryGoalOptionUi,
|
||||
clearStoryInteractionUi,
|
||||
clearCharacterChatModal,
|
||||
});
|
||||
|
||||
return {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
handleChoice,
|
||||
resetStoryState,
|
||||
hydrateStoryState,
|
||||
travelToSceneFromMap,
|
||||
battleRewardUi,
|
||||
questUi,
|
||||
goalUi,
|
||||
npcUi,
|
||||
inventoryUi,
|
||||
};
|
||||
}
|
||||
17
src/hooks/story/useStoryGoalOptionCoordinator.test.ts
Normal file
17
src/hooks/story/useStoryGoalOptionCoordinator.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createClearStoryGoalOptionUi } from './useStoryGoalOptionCoordinator';
|
||||
|
||||
describe('useStoryGoalOptionCoordinator helpers', () => {
|
||||
it('clears story goal and option ui together', () => {
|
||||
const calls: string[] = [];
|
||||
const clearStoryGoalOptionUi = createClearStoryGoalOptionUi({
|
||||
resetStoryOptions: vi.fn(() => calls.push('options')),
|
||||
resetGoalPulseTracking: vi.fn(() => calls.push('goal')),
|
||||
});
|
||||
|
||||
clearStoryGoalOptionUi();
|
||||
|
||||
expect(calls).toEqual(['options', 'goal']);
|
||||
});
|
||||
});
|
||||
50
src/hooks/story/useStoryGoalOptionCoordinator.ts
Normal file
50
src/hooks/story/useStoryGoalOptionCoordinator.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { GameState, StoryMoment } from '../../types';
|
||||
import { useStoryOptions } from '../useStoryOptions';
|
||||
import { useStoryGoalFlow } from './goalFlow';
|
||||
|
||||
export function createClearStoryGoalOptionUi(params: {
|
||||
resetStoryOptions: () => void;
|
||||
resetGoalPulseTracking: () => void;
|
||||
}) {
|
||||
return () => {
|
||||
params.resetStoryOptions();
|
||||
params.resetGoalPulseTracking();
|
||||
};
|
||||
}
|
||||
|
||||
export function useStoryGoalOptionCoordinator(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
}) {
|
||||
const { runtimeGoalStack, goalUi, resetGoalPulseTracking } = useStoryGoalFlow(
|
||||
params.gameState,
|
||||
);
|
||||
const {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
resetStoryOptions,
|
||||
} = useStoryOptions(params.currentStory, runtimeGoalStack);
|
||||
|
||||
const clearStoryGoalOptionUi = useCallback(
|
||||
createClearStoryGoalOptionUi({
|
||||
resetStoryOptions,
|
||||
resetGoalPulseTracking,
|
||||
}),
|
||||
[resetGoalPulseTracking, resetStoryOptions],
|
||||
);
|
||||
|
||||
return {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
handleRefreshOptions,
|
||||
goalUi,
|
||||
clearStoryGoalOptionUi,
|
||||
};
|
||||
}
|
||||
|
||||
export type StoryGoalOptionCoordinatorResult = ReturnType<
|
||||
typeof useStoryGoalOptionCoordinator
|
||||
>;
|
||||
28
src/hooks/story/useStoryGoalSessionCoordinator.test.ts
Normal file
28
src/hooks/story/useStoryGoalSessionCoordinator.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createClearStoryRuntimeUi } from './useStoryGoalSessionCoordinator';
|
||||
|
||||
describe('useStoryGoalSessionCoordinator helpers', () => {
|
||||
it('clears story runtime ui in the expected order', () => {
|
||||
const calls: string[] = [];
|
||||
const clearStoryRuntimeUi = createClearStoryRuntimeUi({
|
||||
clearStoryGoalOptionUi: vi.fn(() => calls.push('goal-option')),
|
||||
clearStoryInteractionUi: vi.fn(() => calls.push('interaction')),
|
||||
setAiError: vi.fn((value) => calls.push(`ai:${String(value)}`)),
|
||||
setIsLoading: vi.fn((value) => calls.push(`loading:${String(value)}`)),
|
||||
resetPreparedOpeningAdventure: vi.fn(() => calls.push('opening')),
|
||||
clearCharacterChatModal: vi.fn(() => calls.push('chat')),
|
||||
});
|
||||
|
||||
clearStoryRuntimeUi();
|
||||
|
||||
expect(calls).toEqual([
|
||||
'goal-option',
|
||||
'interaction',
|
||||
'ai:null',
|
||||
'loading:false',
|
||||
'opening',
|
||||
'chat',
|
||||
]);
|
||||
});
|
||||
});
|
||||
94
src/hooks/story/useStoryGoalSessionCoordinator.ts
Normal file
94
src/hooks/story/useStoryGoalSessionCoordinator.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useCallback, type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
import type { StoryMoment, GameState, Character } from '../../types';
|
||||
import type { CommitGeneratedState } from '../generatedState';
|
||||
import { createStorySessionActions } from './sessionActions';
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
export function createClearStoryRuntimeUi(params: {
|
||||
clearStoryGoalOptionUi: () => void;
|
||||
clearStoryInteractionUi: () => void;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
resetPreparedOpeningAdventure: () => void;
|
||||
clearCharacterChatModal: () => void;
|
||||
}) {
|
||||
return () => {
|
||||
params.clearStoryGoalOptionUi();
|
||||
params.clearStoryInteractionUi();
|
||||
params.setAiError(null);
|
||||
params.setIsLoading(false);
|
||||
params.resetPreparedOpeningAdventure();
|
||||
params.clearCharacterChatModal();
|
||||
};
|
||||
}
|
||||
|
||||
export function useStoryGoalSessionCoordinator(params: {
|
||||
gameState: GameState;
|
||||
isLoading: boolean;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
resetPreparedOpeningAdventure: () => void;
|
||||
clearStoryGoalOptionUi: () => void;
|
||||
clearStoryInteractionUi: () => void;
|
||||
clearCharacterChatModal: () => void;
|
||||
}) {
|
||||
const clearStoryRuntimeUi = useCallback(
|
||||
createClearStoryRuntimeUi({
|
||||
clearStoryGoalOptionUi: params.clearStoryGoalOptionUi,
|
||||
clearStoryInteractionUi: params.clearStoryInteractionUi,
|
||||
setAiError: params.setAiError,
|
||||
setIsLoading: params.setIsLoading,
|
||||
resetPreparedOpeningAdventure: params.resetPreparedOpeningAdventure,
|
||||
clearCharacterChatModal: params.clearCharacterChatModal,
|
||||
}),
|
||||
[
|
||||
params.clearCharacterChatModal,
|
||||
params.clearStoryGoalOptionUi,
|
||||
params.clearStoryInteractionUi,
|
||||
params.resetPreparedOpeningAdventure,
|
||||
params.setAiError,
|
||||
params.setIsLoading,
|
||||
],
|
||||
);
|
||||
|
||||
const {
|
||||
acknowledgeQuestCompletion,
|
||||
claimQuestReward,
|
||||
resetStoryState,
|
||||
hydrateStoryState,
|
||||
travelToSceneFromMap,
|
||||
} = createStorySessionActions({
|
||||
gameState: params.gameState,
|
||||
isLoading: params.isLoading,
|
||||
setGameState: params.setGameState,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
clearStoryRuntimeUi,
|
||||
commitGeneratedState: params.commitGeneratedState,
|
||||
buildFallbackStoryForState: params.buildFallbackStoryForState,
|
||||
});
|
||||
|
||||
return {
|
||||
questUi: {
|
||||
acknowledgeQuestCompletion,
|
||||
claimQuestReward,
|
||||
},
|
||||
resetStoryState,
|
||||
hydrateStoryState,
|
||||
travelToSceneFromMap,
|
||||
clearStoryRuntimeUi,
|
||||
};
|
||||
}
|
||||
|
||||
export type StoryGoalSessionCoordinatorResult = ReturnType<
|
||||
typeof useStoryGoalSessionCoordinator
|
||||
>;
|
||||
17
src/hooks/story/useStoryInteractionCoordinator.test.ts
Normal file
17
src/hooks/story/useStoryInteractionCoordinator.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createClearStoryInteractionUi } from './useStoryInteractionCoordinator';
|
||||
|
||||
describe('useStoryInteractionCoordinator helpers', () => {
|
||||
it('clears interaction ui in the expected order', () => {
|
||||
const calls: string[] = [];
|
||||
const clearStoryInteractionUi = createClearStoryInteractionUi({
|
||||
clearStoryChoiceUi: vi.fn(() => calls.push('choice')),
|
||||
clearNpcInteractionUi: vi.fn(() => calls.push('npc')),
|
||||
});
|
||||
|
||||
clearStoryInteractionUi();
|
||||
|
||||
expect(calls).toEqual(['choice', 'npc']);
|
||||
});
|
||||
});
|
||||
203
src/hooks/story/useStoryInteractionCoordinator.ts
Normal file
203
src/hooks/story/useStoryInteractionCoordinator.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
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 { useStoryNpcInteractionFlow } from './npcInteraction';
|
||||
import type { StoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
|
||||
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
|
||||
import type {
|
||||
ChoiceRuntimeController,
|
||||
StoryChoiceCoordinatorParams,
|
||||
} from './storyChoiceCoordinator';
|
||||
import { useStoryChoiceCoordinator } from './useStoryChoiceCoordinator';
|
||||
|
||||
type StoryInteractionCoordinatorParams = {
|
||||
gameState: GameState;
|
||||
isLoading: boolean;
|
||||
interactionConfig: StoryInteractionCoordinatorConfig;
|
||||
runtimeSupport: StoryRuntimeSupport;
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: ResolvedChoicePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
buildStoryFromResponse: ChoiceRuntimeController['buildStoryFromResponse'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getCampCompanionTravelScene: StoryChoiceCoordinatorParams['runtimeController']['getCampCompanionTravelScene'];
|
||||
startOpeningAdventure: StoryChoiceCoordinatorParams['runtimeController']['startOpeningAdventure'];
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isInitialCompanionEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
npcPreviewTalkFunctionId: string;
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
};
|
||||
|
||||
export function createClearStoryInteractionUi(params: {
|
||||
clearStoryChoiceUi: () => void;
|
||||
clearNpcInteractionUi: () => void;
|
||||
}) {
|
||||
return () => {
|
||||
params.clearStoryChoiceUi();
|
||||
params.clearNpcInteractionUi();
|
||||
};
|
||||
}
|
||||
|
||||
export function useStoryInteractionCoordinator({
|
||||
gameState,
|
||||
isLoading,
|
||||
interactionConfig,
|
||||
runtimeSupport,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryFromResponse,
|
||||
getResolvedSceneHostileNpcs,
|
||||
getCampCompanionTravelScene,
|
||||
startOpeningAdventure,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isInitialCompanionEncounter,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
}: StoryInteractionCoordinatorParams) {
|
||||
const { buildNpcStory } = runtimeSupport;
|
||||
|
||||
const { handleTreasureInteraction } = useTreasureFlow(
|
||||
interactionConfig.treasureFlow,
|
||||
);
|
||||
const { inventoryUi } = useStoryInventoryActions(
|
||||
interactionConfig.inventoryFlow,
|
||||
);
|
||||
const npcInteractionFlow = useStoryNpcInteractionFlow(
|
||||
interactionConfig.npcInteractionFlow,
|
||||
);
|
||||
const { enterNpcInteraction, handleNpcInteraction, finalizeNpcBattleResult } =
|
||||
createStoryNpcEncounterActions({
|
||||
...interactionConfig.npcEncounterActions,
|
||||
npcInteractionFlow,
|
||||
});
|
||||
const choiceRuntimeController: Parameters<
|
||||
typeof useStoryChoiceCoordinator
|
||||
>[0]['runtimeController'] = {
|
||||
currentStory: interactionConfig.npcEncounterActions.currentStory,
|
||||
buildStoryContextFromState:
|
||||
interactionConfig.npcEncounterActions.buildStoryContextFromState,
|
||||
buildStoryFromResponse: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog?: StoryOption[] | null,
|
||||
) =>
|
||||
buildStoryFromResponse(
|
||||
state,
|
||||
character,
|
||||
response,
|
||||
availableOptions,
|
||||
optionCatalog,
|
||||
),
|
||||
buildFallbackStoryForState:
|
||||
interactionConfig.npcEncounterActions.buildFallbackStoryForState,
|
||||
generateStoryForState: async (params) =>
|
||||
interactionConfig.npcEncounterActions.generateStoryForState(params),
|
||||
getAvailableOptionsForState:
|
||||
interactionConfig.npcEncounterActions.getAvailableOptionsForState,
|
||||
getCampCompanionTravelScene: (state, character) =>
|
||||
getCampCompanionTravelScene(state, character),
|
||||
startOpeningAdventure: () => startOpeningAdventure(),
|
||||
commitGeneratedStateWithEncounterEntry: async (
|
||||
entryState,
|
||||
resolvedState,
|
||||
character,
|
||||
actionText,
|
||||
resultText,
|
||||
lastFunctionId,
|
||||
) => {
|
||||
await interactionConfig.npcEncounterActions.commitGeneratedStateWithEncounterEntry(
|
||||
entryState,
|
||||
resolvedState,
|
||||
character,
|
||||
actionText,
|
||||
resultText,
|
||||
lastFunctionId,
|
||||
);
|
||||
},
|
||||
};
|
||||
const { handleChoice, battleRewardUi, clearStoryChoiceUi } =
|
||||
useStoryChoiceCoordinator({
|
||||
gameState,
|
||||
isLoading,
|
||||
setGameState: interactionConfig.npcEncounterActions.setGameState,
|
||||
setCurrentStory: interactionConfig.npcEncounterActions.setCurrentStory,
|
||||
setAiError: interactionConfig.npcEncounterActions.setAiError,
|
||||
setIsLoading: interactionConfig.npcEncounterActions.setIsLoading,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
getStoryGenerationHostileNpcs:
|
||||
interactionConfig.npcEncounterActions.getStoryGenerationHostileNpcs,
|
||||
getResolvedSceneHostileNpcs,
|
||||
runtimeController: choiceRuntimeController,
|
||||
runtimeSupport,
|
||||
enterNpcInteraction,
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction,
|
||||
finalizeNpcBattleResult,
|
||||
sortOptions: interactionConfig.npcEncounterActions.sortOptions,
|
||||
buildContinueAdventureOption:
|
||||
interactionConfig.npcEncounterActions.buildContinueAdventureOption,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isInitialCompanionEncounter,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
});
|
||||
|
||||
const clearStoryInteractionUi = useCallback(
|
||||
createClearStoryInteractionUi({
|
||||
clearStoryChoiceUi,
|
||||
clearNpcInteractionUi: npcInteractionFlow.clearNpcInteractionUi,
|
||||
}),
|
||||
[clearStoryChoiceUi, npcInteractionFlow.clearNpcInteractionUi],
|
||||
);
|
||||
|
||||
return {
|
||||
handleChoice,
|
||||
battleRewardUi,
|
||||
npcUi: npcInteractionFlow.npcUi,
|
||||
inventoryUi,
|
||||
clearStoryInteractionUi,
|
||||
};
|
||||
}
|
||||
200
src/hooks/story/useStoryRuntimeController.ts
Normal file
200
src/hooks/story/useStoryRuntimeController.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
import { generateInitialStory, generateNextStep } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import { buildPreparedOpeningAdventure as buildPreparedOpeningAdventureState } from './openingAdventure';
|
||||
import {
|
||||
appendStoryHistory,
|
||||
createStoryProgressionActions,
|
||||
} from './progressionActions';
|
||||
import {
|
||||
buildCampCompanionOpeningResultText,
|
||||
buildInitialCompanionDialogueText,
|
||||
createCampCompanionStoryHelpers,
|
||||
} from './storyCampCompanion';
|
||||
import { useStoryBootstrap } from './storyBootstrap';
|
||||
import {
|
||||
createStoryStateResolvers,
|
||||
getStoryGenerationHostileNpcs,
|
||||
isInitialCompanionEncounter,
|
||||
isNpcEncounter,
|
||||
} from './storyEncounterState';
|
||||
import { getNpcEncounterKey } from './storyGenerationState';
|
||||
import {
|
||||
buildDialogueStoryMoment,
|
||||
buildStoryFromResponse as buildStoryFromResponseFromPresentation,
|
||||
getTypewriterDelay,
|
||||
hasRenderableDialogueTurns,
|
||||
} from './storyPresentation';
|
||||
import { buildNpcStory } from './storyRuntimeSupport';
|
||||
import { createGenerateStoryForState } from './storyRequestRuntime';
|
||||
import type { StoryContextBuilderExtras } from './storyContextBuilder';
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: StoryContextBuilderExtras,
|
||||
) => StoryGenerationContext;
|
||||
|
||||
export function useStoryRuntimeController(params: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
}) {
|
||||
const { gameState, setGameState, buildStoryContextFromState } = params;
|
||||
|
||||
const [currentStory, setCurrentStory] = useState<StoryMoment | null>(null);
|
||||
const [aiError, setAiError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const {
|
||||
getCampCompanionTravelScene,
|
||||
buildCampCompanionOpeningOptions,
|
||||
inferOpeningCampFollowupOptions,
|
||||
buildOpeningCampChatContext,
|
||||
buildCampCompanionIdleStory,
|
||||
} = useMemo(
|
||||
() =>
|
||||
createCampCompanionStoryHelpers({
|
||||
buildNpcStory,
|
||||
buildStoryContextFromState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getNpcEncounterKey,
|
||||
generateNextStep,
|
||||
}),
|
||||
[buildStoryContextFromState],
|
||||
);
|
||||
|
||||
const { getAvailableOptionsForState, buildFallbackStoryForState } = useMemo(
|
||||
() =>
|
||||
createStoryStateResolvers({
|
||||
buildCampCompanionIdleOptions: buildCampCompanionIdleStory,
|
||||
buildNpcStory,
|
||||
}),
|
||||
[buildCampCompanionIdleStory],
|
||||
);
|
||||
|
||||
const buildStoryFromResponse = useCallback(
|
||||
(
|
||||
state: GameState,
|
||||
character: Character,
|
||||
response: StoryMoment,
|
||||
availableOptions: StoryOption[] | null,
|
||||
optionCatalog: StoryOption[] | null = null,
|
||||
) =>
|
||||
buildStoryFromResponseFromPresentation({
|
||||
state,
|
||||
character,
|
||||
response,
|
||||
availableOptions,
|
||||
optionCatalog,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const generateStoryForState = useMemo(
|
||||
() =>
|
||||
createGenerateStoryForState({
|
||||
currentStory,
|
||||
getAvailableOptionsForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
requestInitialStory: generateInitialStory,
|
||||
requestNextStep: generateNextStep,
|
||||
onServerOptionCatalogLoadError: (error) => {
|
||||
console.warn(
|
||||
'[useStoryGeneration] failed to load server runtime option catalog',
|
||||
error,
|
||||
);
|
||||
},
|
||||
}),
|
||||
[
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse,
|
||||
currentStory,
|
||||
getAvailableOptionsForState,
|
||||
],
|
||||
);
|
||||
|
||||
const appendHistory = useCallback(appendStoryHistory, []);
|
||||
|
||||
const prepareOpeningAdventure = useCallback(
|
||||
(state: GameState, character: Character) =>
|
||||
buildPreparedOpeningAdventureState({
|
||||
state,
|
||||
character,
|
||||
getNpcEncounterKey,
|
||||
appendHistory,
|
||||
buildCampCompanionOpeningOptions,
|
||||
buildCampCompanionOpeningResultText,
|
||||
buildInitialCompanionDialogueText,
|
||||
}),
|
||||
[appendHistory, buildCampCompanionOpeningOptions],
|
||||
);
|
||||
|
||||
const { commitGeneratedState, commitGeneratedStateWithEncounterEntry } =
|
||||
createStoryProgressionActions({
|
||||
gameState,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
generateStoryForState,
|
||||
buildFallbackStoryForState,
|
||||
});
|
||||
|
||||
const {
|
||||
preparedOpeningAdventure,
|
||||
startOpeningAdventure,
|
||||
resetPreparedOpeningAdventure,
|
||||
} = useStoryBootstrap({
|
||||
gameState,
|
||||
currentStory,
|
||||
isLoading,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
prepareOpeningAdventure,
|
||||
getNpcEncounterKey,
|
||||
buildFallbackStoryForState,
|
||||
generateStoryForState,
|
||||
buildDialogueStoryMoment,
|
||||
buildStoryContextFromState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
hasRenderableDialogueTurns,
|
||||
inferOpeningCampFollowupOptions,
|
||||
getTypewriterDelay,
|
||||
isNpcEncounter,
|
||||
isInitialCompanionEncounter,
|
||||
});
|
||||
|
||||
return {
|
||||
currentStory,
|
||||
setCurrentStory,
|
||||
aiError,
|
||||
setAiError,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
preparedOpeningAdventure,
|
||||
startOpeningAdventure,
|
||||
resetPreparedOpeningAdventure,
|
||||
buildStoryContextFromState,
|
||||
buildDialogueStoryMoment,
|
||||
getTypewriterDelay,
|
||||
getCampCompanionTravelScene,
|
||||
buildOpeningCampChatContext,
|
||||
getAvailableOptionsForState,
|
||||
buildFallbackStoryForState,
|
||||
buildStoryFromResponse,
|
||||
generateStoryForState,
|
||||
commitGeneratedState,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
appendHistory,
|
||||
};
|
||||
}
|
||||
|
||||
export type StoryRuntimeControllerResult = ReturnType<
|
||||
typeof useStoryRuntimeController
|
||||
>;
|
||||
@@ -1,150 +1,18 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getCharacterMaxHp, getCharacterMaxMana } from '../data/characterPresets';
|
||||
import { normalizeRoster } from '../data/companionRoster';
|
||||
import { getInitialPlayerCurrency } from '../data/economy';
|
||||
import {
|
||||
applyEquipmentLoadoutToState,
|
||||
buildInitialEquipmentLoadout,
|
||||
createEmptyEquipmentLoadout,
|
||||
} from '../data/equipmentEffects';
|
||||
import { normalizeNpcPersistentState } from '../data/npcInteractions';
|
||||
import { normalizeQuestLogEntries } from '../data/questFlow';
|
||||
import { normalizeGameRuntimeStats } from '../data/runtimeStats';
|
||||
import { ensureSceneEncounterPreview, TREASURE_ENCOUNTERS_ENABLED } from '../data/sceneEncounterPreviews';
|
||||
import type { SavedGameSnapshot } from '../persistence/gameSaveStorage';
|
||||
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
|
||||
import { isAbortError } from '../services/apiClient';
|
||||
import {
|
||||
deleteSaveSnapshot,
|
||||
getSaveSnapshot,
|
||||
putSaveSnapshot,
|
||||
} from '../services/storageService';
|
||||
import {
|
||||
applyStoryEngineMigration,
|
||||
buildSaveMigrationManifest,
|
||||
} from '../services/storyEngine/saveMigrationManifest';
|
||||
import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine';
|
||||
import { GameState, StoryMoment } from '../types';
|
||||
import { BottomTab } from './useGameFlow';
|
||||
import type { GameState, StoryMoment } from '../types';
|
||||
import type { BottomTab } from './useGameFlow';
|
||||
import { resumeServerRuntimeStory } from './story/runtimeStoryCoordinator';
|
||||
|
||||
const AUTO_SAVE_DELAY_MS = 400;
|
||||
|
||||
function normalizeSavedStory(story: StoryMoment | null) {
|
||||
if (!story) return null;
|
||||
return {
|
||||
...story,
|
||||
streaming: false,
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
function normalizeCharacterChats(gameState: GameState) {
|
||||
const entries = Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [
|
||||
characterId,
|
||||
{
|
||||
history: Array.isArray(record?.history)
|
||||
? record.history
|
||||
.filter(turn => turn && typeof turn.text === 'string' && (turn.speaker === 'player' || turn.speaker === 'character'))
|
||||
.map(turn => ({
|
||||
speaker: turn.speaker,
|
||||
text: turn.text,
|
||||
}))
|
||||
: [],
|
||||
summary: typeof record?.summary === 'string' ? record.summary : '',
|
||||
updatedAt: typeof record?.updatedAt === 'string' ? record.updatedAt : null,
|
||||
},
|
||||
] as const);
|
||||
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
function normalizeSavedGameState(gameState: GameState) {
|
||||
const migrationManifest = buildSaveMigrationManifest({
|
||||
version: 'story-engine-v5',
|
||||
});
|
||||
const migratedState = applyStoryEngineMigration({
|
||||
state: gameState,
|
||||
manifest: migrationManifest,
|
||||
});
|
||||
const normalizedRoster = normalizeRoster(gameState.roster ?? [], gameState.companions ?? []);
|
||||
const normalizedEncounterState = !TREASURE_ENCOUNTERS_ENABLED && migratedState.currentEncounter?.kind === 'treasure'
|
||||
? ensureSceneEncounterPreview({
|
||||
...migratedState,
|
||||
currentEncounter: null,
|
||||
sceneHostileNpcs: [],
|
||||
inBattle: false,
|
||||
} as GameState)
|
||||
: migratedState;
|
||||
const normalizedRuntimeStats = normalizeGameRuntimeStats(normalizedEncounterState.runtimeStats, {
|
||||
isActiveRun: Boolean(
|
||||
normalizedEncounterState.playerCharacter &&
|
||||
normalizedEncounterState.currentScene === 'Story'
|
||||
),
|
||||
});
|
||||
const normalizedCommonState = {
|
||||
...normalizedEncounterState,
|
||||
customWorldProfile: normalizedEncounterState.customWorldProfile ?? null,
|
||||
runtimeStats: normalizedRuntimeStats,
|
||||
storyEngineMemory:
|
||||
normalizedEncounterState.storyEngineMemory ??
|
||||
createEmptyStoryEngineMemoryState(),
|
||||
chapterState:
|
||||
normalizedEncounterState.chapterState
|
||||
?? normalizedEncounterState.storyEngineMemory?.currentChapter
|
||||
?? null,
|
||||
campaignState:
|
||||
normalizedEncounterState.campaignState
|
||||
?? normalizedEncounterState.storyEngineMemory?.campaignState
|
||||
?? null,
|
||||
activeScenarioPackId:
|
||||
normalizedEncounterState.activeScenarioPackId
|
||||
?? normalizedEncounterState.customWorldProfile?.scenarioPackId
|
||||
?? null,
|
||||
activeCampaignPackId:
|
||||
normalizedEncounterState.activeCampaignPackId
|
||||
?? normalizedEncounterState.customWorldProfile?.campaignPackId
|
||||
?? null,
|
||||
npcInteractionActive: normalizedEncounterState.npcInteractionActive ?? false,
|
||||
playerCurrency: typeof gameState.playerCurrency === 'number'
|
||||
? gameState.playerCurrency
|
||||
: getInitialPlayerCurrency(gameState.worldType),
|
||||
quests: normalizeQuestLogEntries(normalizedEncounterState.quests ?? []),
|
||||
roster: normalizedRoster,
|
||||
npcStates: Object.fromEntries(
|
||||
Object.entries(normalizedEncounterState.npcStates ?? {}).map(([npcId, npcState]) => [
|
||||
npcId,
|
||||
normalizeNpcPersistentState(npcState),
|
||||
]),
|
||||
),
|
||||
characterChats: normalizeCharacterChats(normalizedEncounterState),
|
||||
activeBuildBuffs: normalizedEncounterState.activeBuildBuffs ?? [],
|
||||
} satisfies GameState;
|
||||
|
||||
if (!normalizedEncounterState.playerCharacter) {
|
||||
return {
|
||||
...normalizedCommonState,
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
} satisfies GameState;
|
||||
}
|
||||
|
||||
const resolvedEquipment = normalizedEncounterState.playerEquipment
|
||||
? normalizedEncounterState.playerEquipment
|
||||
: buildInitialEquipmentLoadout(normalizedEncounterState.playerCharacter);
|
||||
|
||||
const playerMaxHp = getCharacterMaxHp(
|
||||
normalizedEncounterState.playerCharacter,
|
||||
normalizedEncounterState.worldType,
|
||||
normalizedEncounterState.customWorldProfile,
|
||||
);
|
||||
|
||||
return applyEquipmentLoadoutToState({
|
||||
...normalizedCommonState,
|
||||
playerMaxHp,
|
||||
playerHp: Math.min(normalizedEncounterState.playerHp, playerMaxHp),
|
||||
playerMaxMana: getCharacterMaxMana(normalizedEncounterState.playerCharacter),
|
||||
playerMana: getCharacterMaxMana(normalizedEncounterState.playerCharacter),
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
} as GameState, resolvedEquipment);
|
||||
}
|
||||
|
||||
function canPersistSnapshot(gameState: GameState, story: StoryMoment | null) {
|
||||
return (
|
||||
gameState.currentScene === 'Story' &&
|
||||
@@ -154,6 +22,22 @@ function canPersistSnapshot(gameState: GameState, story: StoryMoment | null) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBottomTab(bottomTab: string | null | undefined): BottomTab {
|
||||
if (bottomTab === 'character' || bottomTab === 'inventory') {
|
||||
return bottomTab;
|
||||
}
|
||||
|
||||
return 'adventure';
|
||||
}
|
||||
|
||||
function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) {
|
||||
return {
|
||||
gameState: snapshot.gameState,
|
||||
currentStory: snapshot.currentStory ?? null,
|
||||
bottomTab: normalizeBottomTab(snapshot.bottomTab),
|
||||
};
|
||||
}
|
||||
|
||||
export function useGamePersistence({
|
||||
gameState,
|
||||
bottomTab,
|
||||
@@ -174,93 +58,202 @@ export function useGamePersistence({
|
||||
resetStoryState: () => void;
|
||||
}) {
|
||||
const [hasSavedGame, setHasSavedGame] = useState(false);
|
||||
const [savedSnapshot, setSavedSnapshot] = useState<SavedGameSnapshot | null>(null);
|
||||
const [savedSnapshot, setSavedSnapshot] =
|
||||
useState<HydratedSavedGameSnapshot | null>(null);
|
||||
const [isHydratingSnapshot, setIsHydratingSnapshot] = useState(true);
|
||||
const [isPersistingSnapshot, setIsPersistingSnapshot] = useState(false);
|
||||
const [persistenceError, setPersistenceError] = useState<string | null>(null);
|
||||
const hydrateControllerRef = useRef<AbortController | null>(null);
|
||||
const saveControllerRef = useRef<AbortController | null>(null);
|
||||
const saveRequestIdRef = useRef(0);
|
||||
|
||||
const abortActiveSave = useCallback(() => {
|
||||
saveControllerRef.current?.abort();
|
||||
saveControllerRef.current = null;
|
||||
setIsPersistingSnapshot(false);
|
||||
}, []);
|
||||
|
||||
const persistSnapshot = useCallback(
|
||||
async (params: {
|
||||
payload: {
|
||||
gameState: GameState;
|
||||
bottomTab: BottomTab;
|
||||
currentStory: StoryMoment | null;
|
||||
};
|
||||
logLabel: string;
|
||||
}) => {
|
||||
abortActiveSave();
|
||||
|
||||
const requestId = saveRequestIdRef.current + 1;
|
||||
saveRequestIdRef.current = requestId;
|
||||
const controller = new AbortController();
|
||||
saveControllerRef.current = controller;
|
||||
setIsPersistingSnapshot(true);
|
||||
setPersistenceError(null);
|
||||
|
||||
try {
|
||||
const snapshot = await putSaveSnapshot(
|
||||
{
|
||||
gameState: params.payload.gameState,
|
||||
bottomTab: params.payload.bottomTab,
|
||||
currentStory: params.payload.currentStory,
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
if (saveRequestIdRef.current !== requestId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setSavedSnapshot(snapshot);
|
||||
setHasSavedGame(true);
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message =
|
||||
error instanceof Error ? error.message : '远端存档同步失败';
|
||||
if (saveRequestIdRef.current === requestId) {
|
||||
setPersistenceError(message);
|
||||
}
|
||||
console.warn(`[useGamePersistence] ${params.logLabel}`, error);
|
||||
return null;
|
||||
} finally {
|
||||
if (saveControllerRef.current === controller) {
|
||||
saveControllerRef.current = null;
|
||||
setIsPersistingSnapshot(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[abortActiveSave],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
const controller = new AbortController();
|
||||
hydrateControllerRef.current = controller;
|
||||
setIsHydratingSnapshot(true);
|
||||
|
||||
void getSaveSnapshot()
|
||||
void getSaveSnapshot({ signal: controller.signal })
|
||||
.then((snapshot) => {
|
||||
if (!isActive) return;
|
||||
setSavedSnapshot(snapshot);
|
||||
setHasSavedGame(Boolean(snapshot));
|
||||
setPersistenceError(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[useGamePersistence] failed to load remote snapshot', error);
|
||||
if (isAbortError(error)) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : '读取远端存档失败';
|
||||
setPersistenceError(message);
|
||||
console.warn(
|
||||
'[useGamePersistence] failed to load remote snapshot',
|
||||
error,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (hydrateControllerRef.current === controller) {
|
||||
hydrateControllerRef.current = null;
|
||||
setIsHydratingSnapshot(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
controller.abort();
|
||||
if (hydrateControllerRef.current === controller) {
|
||||
hydrateControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
hydrateControllerRef.current?.abort();
|
||||
saveControllerRef.current?.abort();
|
||||
saveControllerRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const canPersist = !isLoading && canPersistSnapshot(gameState, currentStory);
|
||||
const canPersist =
|
||||
!isLoading && canPersistSnapshot(gameState, currentStory);
|
||||
|
||||
if (!canPersist) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void putSaveSnapshot({
|
||||
gameState,
|
||||
bottomTab,
|
||||
currentStory,
|
||||
})
|
||||
.then((snapshot) => {
|
||||
setSavedSnapshot(snapshot);
|
||||
setHasSavedGame(true);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[useGamePersistence] failed to autosave remote snapshot', error);
|
||||
});
|
||||
void persistSnapshot({
|
||||
payload: {
|
||||
gameState,
|
||||
bottomTab,
|
||||
currentStory,
|
||||
},
|
||||
logLabel: 'failed to autosave remote snapshot',
|
||||
});
|
||||
}, AUTO_SAVE_DELAY_MS);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [bottomTab, currentStory, gameState, isLoading]);
|
||||
}, [bottomTab, currentStory, gameState, isLoading, persistSnapshot]);
|
||||
|
||||
const saveCurrentGame = useCallback(async (override?: {
|
||||
gameState?: GameState;
|
||||
bottomTab?: BottomTab;
|
||||
currentStory?: StoryMoment | null;
|
||||
}) => {
|
||||
const nextGameState = override?.gameState ?? gameState;
|
||||
const nextBottomTab = override?.bottomTab ?? bottomTab;
|
||||
const nextStory = override?.currentStory ?? currentStory;
|
||||
const saveCurrentGame = useCallback(
|
||||
async (override?: {
|
||||
gameState?: GameState;
|
||||
bottomTab?: BottomTab;
|
||||
currentStory?: StoryMoment | null;
|
||||
}) => {
|
||||
const nextGameState = override?.gameState ?? gameState;
|
||||
const nextBottomTab = override?.bottomTab ?? bottomTab;
|
||||
const nextStory = override?.currentStory ?? currentStory;
|
||||
|
||||
if (!canPersistSnapshot(nextGameState, nextStory)) {
|
||||
return false;
|
||||
}
|
||||
if (!canPersistSnapshot(nextGameState, nextStory)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await putSaveSnapshot({
|
||||
gameState: nextGameState,
|
||||
bottomTab: nextBottomTab,
|
||||
currentStory: nextStory,
|
||||
const snapshot = await persistSnapshot({
|
||||
payload: {
|
||||
gameState: nextGameState,
|
||||
bottomTab: nextBottomTab,
|
||||
currentStory: nextStory,
|
||||
},
|
||||
logLabel: 'failed to save remote snapshot',
|
||||
});
|
||||
setSavedSnapshot(snapshot);
|
||||
setHasSavedGame(true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('[useGamePersistence] failed to save remote snapshot', error);
|
||||
return false;
|
||||
}
|
||||
}, [bottomTab, currentStory, gameState]);
|
||||
|
||||
return Boolean(snapshot);
|
||||
},
|
||||
[bottomTab, currentStory, gameState, persistSnapshot],
|
||||
);
|
||||
|
||||
const clearSavedGame = useCallback(async () => {
|
||||
abortActiveSave();
|
||||
|
||||
try {
|
||||
await deleteSaveSnapshot();
|
||||
setPersistenceError(null);
|
||||
} catch (error) {
|
||||
console.warn('[useGamePersistence] failed to delete remote snapshot', error);
|
||||
console.warn(
|
||||
'[useGamePersistence] failed to delete remote snapshot',
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
setSavedSnapshot(null);
|
||||
setHasSavedGame(false);
|
||||
}, []);
|
||||
}, [abortActiveSave]);
|
||||
|
||||
const continueSavedGame = useCallback(async () => {
|
||||
const snapshot = savedSnapshot ?? await getSaveSnapshot().catch((error) => {
|
||||
console.warn('[useGamePersistence] failed to refetch remote snapshot', error);
|
||||
return null;
|
||||
});
|
||||
const snapshot =
|
||||
savedSnapshot ??
|
||||
(await getSaveSnapshot().catch((error) => {
|
||||
if (!isAbortError(error)) {
|
||||
console.warn(
|
||||
'[useGamePersistence] failed to refetch remote snapshot',
|
||||
error,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
if (!snapshot) {
|
||||
setSavedSnapshot(null);
|
||||
setHasSavedGame(false);
|
||||
@@ -268,16 +261,44 @@ export function useGamePersistence({
|
||||
}
|
||||
|
||||
resetStoryState();
|
||||
setGameState(normalizeSavedGameState(snapshot.gameState));
|
||||
setBottomTab(snapshot.bottomTab ?? 'adventure');
|
||||
hydrateStoryState(normalizeSavedStory(snapshot.currentStory));
|
||||
const fallbackHydration = resolveRemoteSnapshotState(snapshot);
|
||||
|
||||
const resumedState = await resumeServerRuntimeStory(snapshot).catch(
|
||||
(error) => {
|
||||
if (!isAbortError(error)) {
|
||||
console.warn(
|
||||
'[useGamePersistence] failed to refresh runtime story state from server',
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
hydratedSnapshot: fallbackHydration,
|
||||
nextStory: fallbackHydration.currentStory,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
setGameState(resumedState.hydratedSnapshot.gameState);
|
||||
setBottomTab(normalizeBottomTab(resumedState.hydratedSnapshot.bottomTab));
|
||||
hydrateStoryState(resumedState.nextStory);
|
||||
setSavedSnapshot(snapshot);
|
||||
setHasSavedGame(true);
|
||||
setPersistenceError(null);
|
||||
return true;
|
||||
}, [hydrateStoryState, resetStoryState, savedSnapshot, setBottomTab, setGameState]);
|
||||
}, [
|
||||
hydrateStoryState,
|
||||
resetStoryState,
|
||||
savedSnapshot,
|
||||
setBottomTab,
|
||||
setGameState,
|
||||
]);
|
||||
|
||||
return {
|
||||
hasSavedGame,
|
||||
isHydratingSnapshot,
|
||||
isPersistingSnapshot,
|
||||
persistenceError,
|
||||
saveCurrentGame,
|
||||
continueSavedGame,
|
||||
clearSavedGame,
|
||||
|
||||
@@ -4,37 +4,71 @@ import {
|
||||
clampVolume,
|
||||
DEFAULT_MUSIC_VOLUME,
|
||||
} from '../persistence/gameSettingsStorage';
|
||||
import { isAbortError } from '../services/apiClient';
|
||||
import { getSettings, putSettings } from '../services/storageService';
|
||||
|
||||
const SETTINGS_SYNC_DELAY_MS = 180;
|
||||
|
||||
export function useGameSettings() {
|
||||
const [musicVolume, setMusicVolumeState] = useState(DEFAULT_MUSIC_VOLUME);
|
||||
const [hasHydratedSettings, setHasHydratedSettings] = useState(false);
|
||||
const [isHydratingSettings, setIsHydratingSettings] = useState(true);
|
||||
const [isPersistingSettings, setIsPersistingSettings] = useState(false);
|
||||
const [settingsError, setSettingsError] = useState<string | null>(null);
|
||||
const lastSyncedVolumeRef = useRef(DEFAULT_MUSIC_VOLUME);
|
||||
const hydrateControllerRef = useRef<AbortController | null>(null);
|
||||
const persistControllerRef = useRef<AbortController | null>(null);
|
||||
const persistRequestIdRef = useRef(0);
|
||||
|
||||
const abortActivePersist = useCallback(() => {
|
||||
persistControllerRef.current?.abort();
|
||||
persistControllerRef.current = null;
|
||||
setIsPersistingSettings(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
const controller = new AbortController();
|
||||
hydrateControllerRef.current = controller;
|
||||
setIsHydratingSettings(true);
|
||||
|
||||
void getSettings()
|
||||
void getSettings({ signal: controller.signal })
|
||||
.then((settings) => {
|
||||
if (!isActive) return;
|
||||
const nextVolume = clampVolume(settings.musicVolume);
|
||||
lastSyncedVolumeRef.current = nextVolume;
|
||||
setMusicVolumeState(nextVolume);
|
||||
setSettingsError(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isAbortError(error)) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : '读取远端设置失败';
|
||||
setSettingsError(message);
|
||||
console.warn('[useGameSettings] failed to load remote settings', error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (isActive) {
|
||||
if (hydrateControllerRef.current === controller) {
|
||||
hydrateControllerRef.current = null;
|
||||
setIsHydratingSettings(false);
|
||||
setHasHydratedSettings(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
controller.abort();
|
||||
if (hydrateControllerRef.current === controller) {
|
||||
hydrateControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
hydrateControllerRef.current?.abort();
|
||||
persistControllerRef.current?.abort();
|
||||
persistControllerRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasHydratedSettings) {
|
||||
return;
|
||||
@@ -44,21 +78,49 @@ export function useGameSettings() {
|
||||
return;
|
||||
}
|
||||
|
||||
let isActive = true;
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
abortActivePersist();
|
||||
|
||||
void putSettings({musicVolume})
|
||||
.then((settings) => {
|
||||
if (!isActive) return;
|
||||
lastSyncedVolumeRef.current = clampVolume(settings.musicVolume);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[useGameSettings] failed to persist remote settings', error);
|
||||
});
|
||||
const requestId = persistRequestIdRef.current + 1;
|
||||
persistRequestIdRef.current = requestId;
|
||||
const controller = new AbortController();
|
||||
persistControllerRef.current = controller;
|
||||
setIsPersistingSettings(true);
|
||||
setSettingsError(null);
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [hasHydratedSettings, musicVolume]);
|
||||
void putSettings({ musicVolume }, { signal: controller.signal })
|
||||
.then((settings) => {
|
||||
if (persistRequestIdRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextVolume = clampVolume(settings.musicVolume);
|
||||
lastSyncedVolumeRef.current = nextVolume;
|
||||
setMusicVolumeState((currentValue) =>
|
||||
currentValue === nextVolume ? currentValue : nextVolume,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isAbortError(error)) {
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : '保存远端设置失败';
|
||||
if (persistRequestIdRef.current === requestId) {
|
||||
setSettingsError(message);
|
||||
}
|
||||
console.warn('[useGameSettings] failed to persist remote settings', error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (persistControllerRef.current === controller) {
|
||||
persistControllerRef.current = null;
|
||||
setIsPersistingSettings(false);
|
||||
}
|
||||
});
|
||||
}, SETTINGS_SYNC_DELAY_MS);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [abortActivePersist, hasHydratedSettings, musicVolume]);
|
||||
|
||||
const setMusicVolume = useCallback((value: number) => {
|
||||
setMusicVolumeState(clampVolume(value));
|
||||
@@ -67,5 +129,9 @@ export function useGameSettings() {
|
||||
return {
|
||||
musicVolume,
|
||||
setMusicVolume,
|
||||
hasHydratedSettings,
|
||||
isHydratingSettings,
|
||||
isPersistingSettings,
|
||||
settingsError,
|
||||
};
|
||||
}
|
||||
|
||||
179
src/hooks/useGameShellRuntime.ts
Normal file
179
src/hooks/useGameShellRuntime.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { GameShellProps } from '../components/game-shell/types';
|
||||
import { activateRosterCompanion, benchActiveCompanion } from '../data/companionRoster';
|
||||
import { syncGameStatePlayTime } from '../data/runtimeStats';
|
||||
import { useBackgroundMusic } from './useBackgroundMusic';
|
||||
import { useCombatFlow } from './useCombatFlow';
|
||||
import { useGameFlow } from './useGameFlow';
|
||||
import { useGamePersistence } from './useGamePersistence';
|
||||
import { useGameSettings } from './useGameSettings';
|
||||
import { useNpcInteractionFlow } from './useNpcInteractionFlow';
|
||||
import { useStoryGeneration } from './useStoryGeneration';
|
||||
|
||||
export function useGameShellRuntime(): GameShellProps {
|
||||
const {
|
||||
gameState,
|
||||
setGameState,
|
||||
bottomTab,
|
||||
setBottomTab,
|
||||
isMapOpen,
|
||||
setIsMapOpen,
|
||||
resetGame,
|
||||
handleCustomWorldSelect: selectCustomWorld,
|
||||
handleBackToWorldSelect: backToWorldSelect,
|
||||
handleCharacterSelect: selectCharacter,
|
||||
} = useGameFlow();
|
||||
|
||||
const combatFlow = useCombatFlow({
|
||||
setGameState,
|
||||
});
|
||||
|
||||
const storyFlow = useStoryGeneration({
|
||||
gameState,
|
||||
setGameState,
|
||||
buildResolvedChoiceState: combatFlow.buildResolvedChoiceState,
|
||||
playResolvedChoice: combatFlow.playResolvedChoice,
|
||||
});
|
||||
|
||||
const { companionRenderStates, buildCompanionRenderStates } =
|
||||
useNpcInteractionFlow(gameState);
|
||||
const settings = useGameSettings();
|
||||
|
||||
const persistence = useGamePersistence({
|
||||
gameState,
|
||||
bottomTab,
|
||||
currentStory: storyFlow.currentStory,
|
||||
isLoading: storyFlow.isLoading,
|
||||
setGameState,
|
||||
setBottomTab,
|
||||
hydrateStoryState: storyFlow.hydrateStoryState,
|
||||
resetStoryState: storyFlow.resetStoryState,
|
||||
});
|
||||
|
||||
useBackgroundMusic({
|
||||
active: Boolean(
|
||||
gameState.playerCharacter && gameState.currentScene === 'Story',
|
||||
),
|
||||
volume: settings.musicVolume,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!gameState.playerCharacter || gameState.currentScene !== 'Story') {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
setGameState((currentState) => {
|
||||
if (
|
||||
!currentState.playerCharacter ||
|
||||
currentState.currentScene !== 'Story'
|
||||
) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return syncGameStatePlayTime(currentState);
|
||||
});
|
||||
}, 15000);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [gameState.currentScene, gameState.playerCharacter, setGameState]);
|
||||
|
||||
const handleCustomWorldSelect = (
|
||||
customWorldProfile: Parameters<typeof selectCustomWorld>[0],
|
||||
) => {
|
||||
storyFlow.resetStoryState();
|
||||
selectCustomWorld(customWorldProfile);
|
||||
};
|
||||
|
||||
const handleCharacterSelect = (
|
||||
character: Parameters<typeof selectCharacter>[0],
|
||||
) => {
|
||||
storyFlow.resetStoryState();
|
||||
selectCharacter(character);
|
||||
};
|
||||
|
||||
const handleBackToWorldSelect = () => {
|
||||
storyFlow.resetStoryState();
|
||||
backToWorldSelect();
|
||||
};
|
||||
|
||||
const handleContinueGame = () => {
|
||||
void persistence.continueSavedGame();
|
||||
};
|
||||
|
||||
const handleStartNewGame = () => {
|
||||
void persistence.clearSavedGame();
|
||||
storyFlow.resetStoryState();
|
||||
resetGame();
|
||||
};
|
||||
|
||||
const handleSaveAndExit = () => {
|
||||
const syncedGameState = syncGameStatePlayTime(gameState);
|
||||
void persistence.saveCurrentGame({
|
||||
gameState: syncedGameState,
|
||||
bottomTab,
|
||||
currentStory: storyFlow.currentStory,
|
||||
});
|
||||
storyFlow.resetStoryState();
|
||||
resetGame();
|
||||
};
|
||||
|
||||
const handleBenchCompanion = (npcId: string) => {
|
||||
setGameState((currentState) => benchActiveCompanion(currentState, npcId));
|
||||
};
|
||||
|
||||
const handleActivateRosterCompanion = (
|
||||
npcId: string,
|
||||
swapNpcId?: string | null,
|
||||
) => {
|
||||
setGameState((currentState) =>
|
||||
activateRosterCompanion(currentState, npcId, swapNpcId),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
session: {
|
||||
gameState,
|
||||
currentStory: storyFlow.currentStory,
|
||||
isLoading: storyFlow.isLoading,
|
||||
aiError: storyFlow.aiError,
|
||||
bottomTab,
|
||||
setBottomTab,
|
||||
isMapOpen,
|
||||
setIsMapOpen,
|
||||
},
|
||||
story: {
|
||||
displayedOptions: storyFlow.displayedOptions,
|
||||
canRefreshOptions: storyFlow.canRefreshOptions,
|
||||
handleRefreshOptions: storyFlow.handleRefreshOptions,
|
||||
handleChoice: storyFlow.handleChoice,
|
||||
handleMapTravelToScene: storyFlow.travelToSceneFromMap,
|
||||
npcUi: storyFlow.npcUi,
|
||||
characterChatUi: storyFlow.characterChatUi,
|
||||
inventoryUi: storyFlow.inventoryUi,
|
||||
battleRewardUi: storyFlow.battleRewardUi,
|
||||
questUi: storyFlow.questUi,
|
||||
goalUi: storyFlow.goalUi,
|
||||
},
|
||||
entry: {
|
||||
hasSavedGame: persistence.hasSavedGame,
|
||||
handleContinueGame,
|
||||
handleStartNewGame,
|
||||
handleSaveAndExit,
|
||||
handleCustomWorldSelect,
|
||||
handleBackToWorldSelect,
|
||||
handleCharacterSelect,
|
||||
},
|
||||
companions: {
|
||||
companionRenderStates,
|
||||
buildCompanionRenderStates,
|
||||
onBenchCompanion: handleBenchCompanion,
|
||||
onActivateRosterCompanion: handleActivateRosterCompanion,
|
||||
},
|
||||
audio: {
|
||||
musicVolume: settings.musicVolume,
|
||||
onMusicVolumeChange: settings.setMusicVolume,
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,97 +1,73 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { addInventoryItems } from '../data/npcInteractions';
|
||||
import {
|
||||
buildTreasureEncounterStoryMoment,
|
||||
buildTreasureResultText,
|
||||
resolveTreasureReward,
|
||||
} from '../data/treasureInteractions';
|
||||
import { appendStoryEngineCarrierMemory } from '../services/storyEngine/echoMemory';
|
||||
import { Character, Encounter, GameState, StoryMoment, StoryOption } from '../types';
|
||||
import type {CommitGeneratedState} from './generatedState';
|
||||
|
||||
type ProgressTreasureQuest = (state: GameState, sceneId: string | null) => GameState;
|
||||
|
||||
export function isTreasureEncounter(encounter: GameState['currentEncounter']): encounter is Encounter {
|
||||
return Boolean(encounter?.kind === 'treasure');
|
||||
}
|
||||
|
||||
export function buildTreasureStory(
|
||||
state: GameState,
|
||||
_character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
): StoryMoment {
|
||||
return buildTreasureEncounterStoryMoment({
|
||||
state,
|
||||
encounter,
|
||||
overrideText,
|
||||
});
|
||||
}
|
||||
import { resolveServerRuntimeChoice } from './story/runtimeStoryCoordinator';
|
||||
import { Character, GameState, StoryMoment, StoryOption } from '../types';
|
||||
|
||||
export function useTreasureFlow({
|
||||
gameState,
|
||||
commitGeneratedState,
|
||||
progressTreasureQuest,
|
||||
runtime,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
commitGeneratedState: CommitGeneratedState;
|
||||
progressTreasureQuest: ProgressTreasureQuest;
|
||||
runtime: {
|
||||
currentStory: StoryMoment | null;
|
||||
setGameState: (state: GameState) => void;
|
||||
setCurrentStory: (story: StoryMoment) => void;
|
||||
setAiError: (message: string | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
buildFallbackStoryForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
};
|
||||
}) {
|
||||
const handleTreasureInteraction = useCallback((option: StoryOption) => {
|
||||
if (!gameState.playerCharacter || option.interaction?.kind !== 'treasure' || gameState.currentEncounter?.kind !== 'treasure') {
|
||||
return false;
|
||||
}
|
||||
const handleTreasureInteraction = useCallback(
|
||||
async (option: StoryOption) => {
|
||||
if (
|
||||
!gameState.playerCharacter ||
|
||||
option.interaction?.kind !== 'treasure' ||
|
||||
gameState.currentEncounter?.kind !== 'treasure'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const encounter = gameState.currentEncounter;
|
||||
const action = option.interaction.action;
|
||||
const reward = action === 'leave'
|
||||
? null
|
||||
: resolveTreasureReward(gameState, encounter, action);
|
||||
const progressedState = action === 'leave'
|
||||
? gameState
|
||||
: progressTreasureQuest(gameState, gameState.currentScenePreset?.id ?? null);
|
||||
runtime.setAiError(null);
|
||||
runtime.setIsLoading(true);
|
||||
|
||||
const nextState: GameState = appendStoryEngineCarrierMemory({
|
||||
...progressedState,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: progressedState.animationState,
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: reward
|
||||
? Math.min(progressedState.playerMaxHp, progressedState.playerHp + reward.hp)
|
||||
: progressedState.playerHp,
|
||||
playerMana: reward
|
||||
? Math.min(progressedState.playerMaxMana, progressedState.playerMana + reward.mana)
|
||||
: progressedState.playerMana,
|
||||
playerCurrency: reward
|
||||
? progressedState.playerCurrency + reward.currency
|
||||
: progressedState.playerCurrency,
|
||||
playerInventory: reward
|
||||
? addInventoryItems(progressedState.playerInventory, reward.items)
|
||||
: progressedState.playerInventory,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
}, reward?.items ?? []);
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } =
|
||||
await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory: runtime.currentStory,
|
||||
option,
|
||||
});
|
||||
|
||||
void commitGeneratedState(
|
||||
nextState,
|
||||
gameState.playerCharacter,
|
||||
option.actionText,
|
||||
buildTreasureResultText(encounter, action, reward ?? undefined),
|
||||
option.functionId,
|
||||
);
|
||||
return true;
|
||||
}, [commitGeneratedState, gameState, progressTreasureQuest]);
|
||||
runtime.setGameState(hydratedSnapshot.gameState);
|
||||
runtime.setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to resolve treasure runtime action on the server:',
|
||||
error,
|
||||
);
|
||||
runtime.setAiError(
|
||||
error instanceof Error ? error.message : '宝藏动作执行失败',
|
||||
);
|
||||
if (!runtime.currentStory) {
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildFallbackStoryForState(
|
||||
gameState,
|
||||
gameState.playerCharacter,
|
||||
),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
runtime.setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[gameState, runtime],
|
||||
);
|
||||
|
||||
return {
|
||||
handleTreasureInteraction,
|
||||
|
||||
@@ -31,6 +31,58 @@ body {
|
||||
font-family: "Fusion Pixel", "Inter", ui-sans-serif, system-ui, sans-serif !important;
|
||||
}
|
||||
|
||||
.selection-hero-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.selection-hero-brand--left {
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.selection-hero-brand__title {
|
||||
font-family: "Noto Serif SC", "Georgia", serif !important;
|
||||
font-size: clamp(3rem, 10vw, 4.6rem);
|
||||
font-weight: 700;
|
||||
line-height: 0.95;
|
||||
letter-spacing: 0.22em;
|
||||
color: #fffdf7;
|
||||
text-shadow:
|
||||
0 2px 0 rgba(0, 0, 0, 0.68),
|
||||
0 10px 28px rgba(0, 0, 0, 0.48),
|
||||
0 0 18px rgba(251, 191, 36, 0.12);
|
||||
}
|
||||
|
||||
.selection-hero-brand__subtitle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: clamp(0.55rem, 2vw, 0.95rem);
|
||||
font-family: "Inter", ui-sans-serif, system-ui, sans-serif !important;
|
||||
font-size: clamp(0.72rem, 2vw, 0.92rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.42em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(228, 228, 231, 0.88);
|
||||
text-shadow: 0 1px 10px rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
.selection-hero-brand--left .selection-hero-brand__subtitle {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.selection-hero-brand__subtitle::before,
|
||||
.selection-hero-brand__subtitle::after {
|
||||
content: '';
|
||||
width: clamp(1.75rem, 8vw, 3.2rem);
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(245, 158, 11, 0.72), transparent);
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.fusion-pixel-app .story-top-tabs {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -495,6 +547,18 @@ button {
|
||||
--ui-scale: 0.8;
|
||||
}
|
||||
|
||||
.selection-hero-brand {
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.selection-hero-brand__title {
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.selection-hero-brand__subtitle {
|
||||
letter-spacing: 0.28em;
|
||||
}
|
||||
|
||||
.world-carousel {
|
||||
--world-card-height: 8.75rem;
|
||||
max-width: 100%;
|
||||
|
||||
@@ -1,58 +1,18 @@
|
||||
import {
|
||||
type SavedGameSnapshot as SharedSavedGameSnapshot,
|
||||
type SavedGameSnapshotInput as SharedSavedGameSnapshotInput,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import type {GameState, StoryMoment} from '../types';
|
||||
import type {BottomTab} from '../types/navigation';
|
||||
import {isRecord, readStoredJson, removeStoredJson, writeStoredJson} from './storage';
|
||||
|
||||
const SAVE_STORAGE_KEY = 'tavernrealms.save.v1';
|
||||
const SAVE_VERSION = 2;
|
||||
export type SavedGameSnapshot = SharedSavedGameSnapshot<
|
||||
GameState,
|
||||
BottomTab,
|
||||
StoryMoment
|
||||
>;
|
||||
|
||||
export type SavedGameSnapshot = {
|
||||
version: number;
|
||||
savedAt: string;
|
||||
gameState: GameState;
|
||||
bottomTab: BottomTab;
|
||||
currentStory: StoryMoment | null;
|
||||
};
|
||||
|
||||
export type SavedGameSnapshotInput = Omit<SavedGameSnapshot, 'savedAt' | 'version'> & {
|
||||
savedAt?: string;
|
||||
};
|
||||
|
||||
function parseSavedSnapshot(value: unknown): SavedGameSnapshot | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.version !== SAVE_VERSION || typeof value.savedAt !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!('gameState' in value) || !('bottomTab' in value) || !('currentStory' in value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value as SavedGameSnapshot;
|
||||
}
|
||||
|
||||
export function readSavedSnapshot() {
|
||||
return readStoredJson({
|
||||
key: SAVE_STORAGE_KEY,
|
||||
parse: parseSavedSnapshot,
|
||||
});
|
||||
}
|
||||
|
||||
export function writeSavedSnapshot(snapshot: SavedGameSnapshotInput) {
|
||||
return writeStoredJson({
|
||||
key: SAVE_STORAGE_KEY,
|
||||
value: {
|
||||
version: SAVE_VERSION,
|
||||
savedAt: snapshot.savedAt ?? new Date().toISOString(),
|
||||
gameState: snapshot.gameState,
|
||||
bottomTab: snapshot.bottomTab,
|
||||
currentStory: snapshot.currentStory,
|
||||
} satisfies SavedGameSnapshot,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSavedSnapshot() {
|
||||
removeStoredJson(SAVE_STORAGE_KEY);
|
||||
}
|
||||
export type SavedGameSnapshotInput = SharedSavedGameSnapshotInput<
|
||||
GameState,
|
||||
BottomTab,
|
||||
StoryMoment
|
||||
>;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import {
|
||||
DEFAULT_MUSIC_VOLUME,
|
||||
type RuntimeSettings,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import {isRecord, readStoredJson, writeStoredJson} from './storage';
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'tavernrealms.settings.v1';
|
||||
const SETTINGS_STORAGE_VERSION = 1;
|
||||
export const DEFAULT_MUSIC_VOLUME = 0.42;
|
||||
|
||||
export type SavedGameSettings = {
|
||||
musicVolume: number;
|
||||
};
|
||||
export type SavedGameSettings = RuntimeSettings;
|
||||
export { DEFAULT_MUSIC_VOLUME };
|
||||
|
||||
type StoredGameSettings = SavedGameSettings & {
|
||||
version: number;
|
||||
|
||||
75
src/persistence/runtimeSnapshot.test.ts
Normal file
75
src/persistence/runtimeSnapshot.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { GameState, StoryMoment } from '../types';
|
||||
import {
|
||||
resolveHydratedSnapshotState,
|
||||
} from './runtimeSnapshot';
|
||||
|
||||
function createStory(
|
||||
text: string,
|
||||
streaming = false,
|
||||
): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
streaming,
|
||||
};
|
||||
}
|
||||
|
||||
describe('runtimeSnapshot', () => {
|
||||
it('keeps server-hydrated snapshots unchanged', () => {
|
||||
const snapshot = {
|
||||
gameState: {
|
||||
playerCharacter: {
|
||||
id: 'sword-princess',
|
||||
},
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
runtimeActionVersion: 3,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
} as GameState,
|
||||
currentStory: createStory('服务端恢复故事'),
|
||||
bottomTab: 'inventory',
|
||||
};
|
||||
|
||||
expect(resolveHydratedSnapshotState(snapshot)).toBe(snapshot);
|
||||
});
|
||||
|
||||
it('only applies minimal local shape hydration for non-hydrated legacy snapshots', () => {
|
||||
const snapshot = {
|
||||
gameState: {
|
||||
worldType: 'WUXIA',
|
||||
customWorldProfile: null,
|
||||
playerCharacter: {
|
||||
id: 'sword-princess',
|
||||
},
|
||||
playerHp: 999,
|
||||
playerMaxHp: 12,
|
||||
playerMana: 999,
|
||||
playerMaxMana: 12,
|
||||
runtimeActionVersion: undefined,
|
||||
runtimeSessionId: undefined,
|
||||
playerEquipment: null,
|
||||
} as unknown as GameState,
|
||||
currentStory: createStory('旧快照故事', true),
|
||||
bottomTab: 'unknown',
|
||||
};
|
||||
|
||||
const hydrated = resolveHydratedSnapshotState(snapshot);
|
||||
|
||||
expect(hydrated.bottomTab).toBe('adventure');
|
||||
expect(hydrated.currentStory?.streaming).toBe(false);
|
||||
expect(hydrated.gameState.playerEquipment).toEqual({
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
});
|
||||
expect(hydrated.gameState.playerMaxHp).toBe(12);
|
||||
expect(hydrated.gameState.playerHp).toBe(12);
|
||||
expect(hydrated.gameState.playerMaxMana).toBe(12);
|
||||
expect(hydrated.gameState.playerMana).toBe(12);
|
||||
});
|
||||
});
|
||||
112
src/persistence/runtimeSnapshot.ts
Normal file
112
src/persistence/runtimeSnapshot.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { GameState, StoryMoment } from '../types';
|
||||
import type { BottomTab } from '../types/navigation';
|
||||
import type {
|
||||
HydratableGameState,
|
||||
HydratedGameState,
|
||||
HydratedSnapshotState,
|
||||
SnapshotState,
|
||||
} from './runtimeSnapshotTypes';
|
||||
|
||||
function normalizeBottomTab(
|
||||
bottomTab: string | null | undefined,
|
||||
): BottomTab {
|
||||
return bottomTab === 'character' || bottomTab === 'inventory'
|
||||
? bottomTab
|
||||
: 'adventure';
|
||||
}
|
||||
|
||||
function normalizeEquipmentLoadout(
|
||||
playerEquipment: HydratableGameState['playerEquipment'],
|
||||
) {
|
||||
if (!playerEquipment || typeof playerEquipment !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
weapon: playerEquipment.weapon ?? null,
|
||||
armor: playerEquipment.armor ?? null,
|
||||
relic: playerEquipment.relic ?? null,
|
||||
} satisfies GameState['playerEquipment'];
|
||||
}
|
||||
|
||||
function createEmptyEquipmentLoadout() {
|
||||
return {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
} satisfies GameState['playerEquipment'];
|
||||
}
|
||||
|
||||
export function normalizeSavedStory(story: StoryMoment | null) {
|
||||
if (!story) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...story,
|
||||
streaming: false,
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
export function normalizeSavedGameState(gameState: GameState) {
|
||||
const hydratableState = gameState as HydratableGameState;
|
||||
const resolvedEquipment = normalizeEquipmentLoadout(
|
||||
hydratableState.playerEquipment,
|
||||
);
|
||||
|
||||
const playerMaxHp = Math.max(1, hydratableState.playerMaxHp);
|
||||
const playerMaxMana = Math.max(1, hydratableState.playerMaxMana);
|
||||
|
||||
return {
|
||||
...hydratableState,
|
||||
playerMaxHp,
|
||||
playerHp: Math.min(hydratableState.playerHp, playerMaxHp),
|
||||
playerMaxMana,
|
||||
playerMana: Math.min(hydratableState.playerMana, playerMaxMana),
|
||||
playerEquipment: resolvedEquipment ?? createEmptyEquipmentLoadout(),
|
||||
runtimeActionVersion:
|
||||
typeof hydratableState.runtimeActionVersion === 'number'
|
||||
? hydratableState.runtimeActionVersion
|
||||
: 0,
|
||||
runtimeSessionId:
|
||||
typeof hydratableState.runtimeSessionId === 'string'
|
||||
? hydratableState.runtimeSessionId
|
||||
: null,
|
||||
} satisfies HydratedGameState;
|
||||
}
|
||||
|
||||
export function hydrateSnapshotState(snapshot: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
bottomTab: string;
|
||||
}): HydratedSnapshotState {
|
||||
return {
|
||||
gameState: normalizeSavedGameState(snapshot.gameState),
|
||||
currentStory: normalizeSavedStory(snapshot.currentStory),
|
||||
bottomTab: normalizeBottomTab(snapshot.bottomTab),
|
||||
};
|
||||
}
|
||||
|
||||
export function isHydratedSnapshotState(
|
||||
snapshot: SnapshotState,
|
||||
): snapshot is HydratedSnapshotState {
|
||||
const { gameState, currentStory, bottomTab } = snapshot;
|
||||
|
||||
return Boolean(
|
||||
(bottomTab === 'adventure' ||
|
||||
bottomTab === 'character' ||
|
||||
bottomTab === 'inventory') &&
|
||||
(!currentStory || currentStory.streaming !== true) &&
|
||||
typeof gameState.runtimeActionVersion === 'number' &&
|
||||
(gameState.runtimeSessionId === null ||
|
||||
typeof gameState.runtimeSessionId === 'string') &&
|
||||
(!gameState.playerCharacter ||
|
||||
Boolean(gameState.playerEquipment && typeof gameState.playerEquipment === 'object')),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveHydratedSnapshotState(snapshot: SnapshotState) {
|
||||
return isHydratedSnapshotState(snapshot)
|
||||
? snapshot
|
||||
: hydrateSnapshotState(snapshot);
|
||||
}
|
||||
31
src/persistence/runtimeSnapshotTypes.ts
Normal file
31
src/persistence/runtimeSnapshotTypes.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { GameState, StoryMoment } from '../types';
|
||||
import type { BottomTab } from '../types/navigation';
|
||||
import type { SavedGameSnapshot } from './gameSaveStorage';
|
||||
|
||||
export type HydratableGameState = GameState & {
|
||||
playerEquipment?: GameState['playerEquipment'] | null;
|
||||
};
|
||||
|
||||
export type HydratedGameState = GameState & {
|
||||
playerEquipment: GameState['playerEquipment'];
|
||||
runtimeActionVersion: number;
|
||||
runtimeSessionId: string | null;
|
||||
};
|
||||
|
||||
export type SnapshotState = {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
bottomTab: string;
|
||||
};
|
||||
|
||||
export type HydratedSnapshotState = {
|
||||
gameState: HydratedGameState;
|
||||
currentStory: StoryMoment | null;
|
||||
bottomTab: BottomTab;
|
||||
};
|
||||
|
||||
export type HydratedSavedGameSnapshot = Omit<
|
||||
SavedGameSnapshot,
|
||||
'gameState' | 'currentStory' | 'bottomTab'
|
||||
> &
|
||||
HydratedSnapshotState;
|
||||
@@ -935,7 +935,9 @@ describe('ai orchestration fallbacks', () => {
|
||||
'/api/custom-world/scene-image',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
|
||||
@@ -26,6 +26,12 @@ import {
|
||||
WorldStoryGraph,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import type {
|
||||
CustomWorldGenerationStep,
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
buildOfflineCharacterPanelChatReply as buildOfflineCharacterPanelChatReplyFromFallback,
|
||||
buildOfflineCharacterPanelChatSuggestions as buildOfflineCharacterPanelChatSuggestionsFromFallback,
|
||||
@@ -125,6 +131,12 @@ import {
|
||||
normalizeWorldStoryGraph,
|
||||
} from './storyEngine/worldStoryGraph';
|
||||
|
||||
export type {
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
|
||||
export type {
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
@@ -358,40 +370,6 @@ const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
|
||||
export type CustomWorldGenerationStageId =
|
||||
(typeof CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS)[number]['id'];
|
||||
|
||||
export interface CustomWorldGenerationStep {
|
||||
id: CustomWorldGenerationStageId;
|
||||
label: string;
|
||||
detail: string;
|
||||
completed: number;
|
||||
total: number;
|
||||
status: 'pending' | 'active' | 'completed';
|
||||
}
|
||||
|
||||
export interface CustomWorldGenerationProgress {
|
||||
phaseId: CustomWorldGenerationStageId;
|
||||
phaseLabel: string;
|
||||
phaseDetail: string;
|
||||
batchLabel?: string;
|
||||
overallProgress: number;
|
||||
completedWeight: number;
|
||||
totalWeight: number;
|
||||
elapsedMs: number;
|
||||
estimatedRemainingMs: number | null;
|
||||
activeStepIndex: number;
|
||||
steps: CustomWorldGenerationStep[];
|
||||
}
|
||||
|
||||
export interface GenerateCustomWorldProfileOptions {
|
||||
onProgress?: (progress: CustomWorldGenerationProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface GenerateCustomWorldProfileInput {
|
||||
settingText: string;
|
||||
creatorIntent?: CustomWorldCreatorIntent | null;
|
||||
generationMode?: CustomWorldGenerationMode;
|
||||
}
|
||||
|
||||
const FAST_CUSTOM_WORLD_PLAYABLE_COUNT = 3;
|
||||
const FAST_CUSTOM_WORLD_STORY_COUNT = 8;
|
||||
const FAST_CUSTOM_WORLD_LANDMARK_COUNT = 4;
|
||||
@@ -450,7 +428,9 @@ function resolveCustomWorldGenerationInput(
|
||||
}
|
||||
|
||||
const normalizedSettingText = input.settingText.trim();
|
||||
const creatorIntent = input.creatorIntent ?? null;
|
||||
const creatorIntent =
|
||||
(input.creatorIntent as CustomWorldCreatorIntent | null | undefined) ??
|
||||
null;
|
||||
const generationSeedText =
|
||||
creatorIntent && hasMeaningfulCustomWorldCreatorIntent(creatorIntent)
|
||||
? buildCustomWorldCreatorIntentGenerationText(creatorIntent)
|
||||
|
||||
@@ -1,50 +1,56 @@
|
||||
import { parseApiErrorMessage } from '../editor/shared/jsonClient';
|
||||
import type {
|
||||
AnswerCustomWorldSessionQuestionRequest,
|
||||
CreateCustomWorldSessionRequest,
|
||||
CustomWorldGenerationProgress,
|
||||
CustomWorldSessionRecord,
|
||||
CustomWorldSessionSummary,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import type {
|
||||
CharacterChatReplyRequest,
|
||||
CharacterChatSuggestionsRequest,
|
||||
CharacterChatSummaryRequest,
|
||||
NpcChatDialogueRequest,
|
||||
NpcRecruitDialogueRequest,
|
||||
PlainTextResponse,
|
||||
} from '../../packages/shared/src/contracts/story';
|
||||
import { parseApiErrorMessage } from '../../packages/shared/src/http';
|
||||
import type {
|
||||
AIResponse,
|
||||
Character,
|
||||
CharacterChatTurn,
|
||||
CustomWorldProfile,
|
||||
Encounter,
|
||||
SceneHostileNpc,
|
||||
StoryMoment,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
CustomWorldSceneImageRequest,
|
||||
CustomWorldSceneImageResult,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
TextStreamOptions,
|
||||
} from './ai';
|
||||
import * as aiClient from './ai';
|
||||
import {
|
||||
buildOfflineCharacterPanelChatReply,
|
||||
buildOfflineCharacterPanelChatSuggestions,
|
||||
buildOfflineCharacterPanelChatSummary,
|
||||
buildOfflineNpcChatDialogue,
|
||||
buildOfflineNpcRecruitDialogue,
|
||||
} from './aiFallbacks';
|
||||
import { fetchWithApiAuth, requestJson } from './apiClient';
|
||||
import {
|
||||
buildCharacterPanelChatPrompt,
|
||||
buildCharacterPanelChatSuggestionPrompt,
|
||||
buildCharacterPanelChatSummaryPrompt,
|
||||
CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
|
||||
CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT,
|
||||
CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
|
||||
type CharacterChatTargetStatus,
|
||||
} from './characterChatPrompt';
|
||||
import { type CharacterChatTargetStatus } from './characterChatPrompt';
|
||||
import { parseLineListContent } from './llmParsers';
|
||||
import {
|
||||
buildNpcRecruitDialoguePrompt,
|
||||
buildStrictNpcChatDialoguePrompt,
|
||||
NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
|
||||
NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
|
||||
} from './prompt';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
|
||||
type LegacyAiModule = typeof import('./ai');
|
||||
|
||||
let legacyAiModulePromise: Promise<LegacyAiModule> | null = null;
|
||||
|
||||
async function loadLegacyAiModule() {
|
||||
if (!legacyAiModulePromise) {
|
||||
legacyAiModulePromise = import('./ai');
|
||||
}
|
||||
|
||||
return legacyAiModulePromise;
|
||||
}
|
||||
|
||||
async function requestPostJson<T>(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
@@ -63,15 +69,15 @@ async function requestPostJson<T>(
|
||||
|
||||
async function requestPlainText(
|
||||
url: string,
|
||||
payload: { systemPrompt: string; userPrompt: string },
|
||||
payload: unknown,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return requestPostJson<{ text: string }>(url, payload, fallbackMessage);
|
||||
return requestPostJson<PlainTextResponse>(url, payload, fallbackMessage);
|
||||
}
|
||||
|
||||
async function requestPlainTextStream(
|
||||
url: string,
|
||||
payload: { systemPrompt: string; userPrompt: string },
|
||||
payload: unknown,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const response = await fetchWithApiAuth(url, {
|
||||
@@ -135,21 +141,6 @@ async function requestPlainTextStream(
|
||||
return accumulatedText.trim();
|
||||
}
|
||||
|
||||
function buildCharacterChatPromptContext(context: StoryGenerationContext) {
|
||||
return {
|
||||
playerHp: context.playerHp,
|
||||
playerMaxHp: context.playerMaxHp,
|
||||
playerMana: context.playerMana,
|
||||
playerMaxMana: context.playerMaxMana,
|
||||
inBattle: context.inBattle,
|
||||
playerFacing: context.playerFacing,
|
||||
playerAnimation: context.playerAnimation,
|
||||
sceneName: context.sceneName ?? null,
|
||||
sceneDescription: context.sceneDescription ?? null,
|
||||
customWorldProfile: context.customWorldProfile ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateInitialStory(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
@@ -158,6 +149,7 @@ export async function generateInitialStory(
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): Promise<AIResponse> {
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateInitialStory(
|
||||
world,
|
||||
character,
|
||||
@@ -167,32 +159,21 @@ export async function generateInitialStory(
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await requestJson<AIResponse>(
|
||||
`${RUNTIME_API_BASE}/story/initial`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
worldType: world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
}),
|
||||
},
|
||||
'剧情开局生成失败',
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[aiService] story/initial fell back to frontend implementation', error);
|
||||
return aiClient.generateInitialStory(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
return requestJson<AIResponse>(
|
||||
`${RUNTIME_API_BASE}/story/initial`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
worldType: world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
}),
|
||||
},
|
||||
'剧情开局生成失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateNextStep(
|
||||
@@ -205,6 +186,7 @@ export async function generateNextStep(
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): Promise<AIResponse> {
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateNextStep(
|
||||
world,
|
||||
character,
|
||||
@@ -216,36 +198,23 @@ export async function generateNextStep(
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await requestJson<AIResponse>(
|
||||
`${RUNTIME_API_BASE}/story/continue`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
worldType: world,
|
||||
character,
|
||||
monsters,
|
||||
history,
|
||||
choice,
|
||||
context,
|
||||
requestOptions,
|
||||
}),
|
||||
},
|
||||
'剧情续写失败',
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[aiService] story/continue fell back to frontend implementation', error);
|
||||
return aiClient.generateNextStep(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
history,
|
||||
choice,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
return requestJson<AIResponse>(
|
||||
`${RUNTIME_API_BASE}/story/continue`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
worldType: world,
|
||||
character,
|
||||
monsters,
|
||||
history,
|
||||
choice,
|
||||
context,
|
||||
requestOptions,
|
||||
}),
|
||||
},
|
||||
'剧情续写失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateCharacterPanelChatSuggestions(
|
||||
@@ -258,58 +227,37 @@ export async function generateCharacterPanelChatSuggestions(
|
||||
conversationSummary: string,
|
||||
targetStatus: CharacterChatTargetStatus,
|
||||
) {
|
||||
const fallbackSuggestions =
|
||||
buildOfflineCharacterPanelChatSuggestions(targetCharacter);
|
||||
const userPrompt = buildCharacterPanelChatSuggestionPrompt({
|
||||
world,
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateCharacterPanelChatSuggestions(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
worldType: world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context: buildCharacterChatPromptContext(context),
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
});
|
||||
} satisfies CharacterChatSuggestionsRequest;
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return aiClient.generateCharacterPanelChatSuggestions(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { text } = await requestPlainText(
|
||||
`${RUNTIME_API_BASE}/chat/character/suggestions`,
|
||||
{
|
||||
systemPrompt: CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
},
|
||||
'角色聊天建议生成失败',
|
||||
);
|
||||
const parsedSuggestions = parseLineListContent(text, 3);
|
||||
return parsedSuggestions.length > 0
|
||||
? [...parsedSuggestions, ...fallbackSuggestions].slice(0, 3)
|
||||
: fallbackSuggestions;
|
||||
} catch (error) {
|
||||
console.warn('[aiService] character suggestions fell back to frontend implementation', error);
|
||||
return aiClient.generateCharacterPanelChatSuggestions(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
const { text } = await requestPlainText(
|
||||
`${RUNTIME_API_BASE}/chat/character/suggestions`,
|
||||
payload,
|
||||
'角色聊天建议生成失败',
|
||||
);
|
||||
return parseLineListContent(text, 3);
|
||||
}
|
||||
|
||||
export async function generateCharacterPanelChatSummary(
|
||||
@@ -322,64 +270,43 @@ export async function generateCharacterPanelChatSummary(
|
||||
previousSummary: string,
|
||||
targetStatus: CharacterChatTargetStatus,
|
||||
) {
|
||||
const fallbackSummary = buildOfflineCharacterPanelChatSummary(
|
||||
targetCharacter,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
);
|
||||
const userPrompt = buildCharacterPanelChatSummaryPrompt({
|
||||
world,
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateCharacterPanelChatSummary(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
worldType: world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context: buildCharacterChatPromptContext(context),
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
});
|
||||
} satisfies CharacterChatSummaryRequest;
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return aiClient.generateCharacterPanelChatSummary(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { text } = await requestPlainText(
|
||||
`${RUNTIME_API_BASE}/chat/character/summary`,
|
||||
{
|
||||
systemPrompt: CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
},
|
||||
'角色聊天摘要生成失败',
|
||||
);
|
||||
return text.trim() || fallbackSummary;
|
||||
} catch (error) {
|
||||
console.warn('[aiService] character summary fell back to frontend implementation', error);
|
||||
return aiClient.generateCharacterPanelChatSummary(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
const { text } = await requestPlainText(
|
||||
`${RUNTIME_API_BASE}/chat/character/summary`,
|
||||
payload,
|
||||
'角色聊天摘要生成失败',
|
||||
);
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
export async function generateCustomWorldProfile(
|
||||
input: GenerateCustomWorldProfileInput | string,
|
||||
options: GenerateCustomWorldProfileOptions = {},
|
||||
) {
|
||||
): Promise<CustomWorldProfile> {
|
||||
const normalizedInput =
|
||||
typeof input === 'string'
|
||||
? {
|
||||
@@ -534,14 +461,13 @@ export async function generateCustomWorldProfile(
|
||||
throw new Error('自定义世界生成未返回结果');
|
||||
}
|
||||
|
||||
return latestProfile as unknown as Awaited<
|
||||
ReturnType<typeof aiClient.generateCustomWorldProfile>
|
||||
>;
|
||||
return latestProfile as unknown as CustomWorldProfile;
|
||||
}
|
||||
|
||||
export async function generateCustomWorldSceneImage(
|
||||
...args: Parameters<typeof aiClient.generateCustomWorldSceneImage>
|
||||
...args: [CustomWorldSceneImageRequest]
|
||||
) {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateCustomWorldSceneImage(...args);
|
||||
}
|
||||
|
||||
@@ -550,41 +476,19 @@ export async function createCustomWorldSession(payload: {
|
||||
creatorIntent?: Record<string, unknown> | null;
|
||||
generationMode: 'fast' | 'full';
|
||||
}) {
|
||||
return requestJson<{
|
||||
sessionId: string;
|
||||
status: string;
|
||||
questions: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
question: string;
|
||||
answer?: string;
|
||||
}>;
|
||||
}>(
|
||||
return requestJson<CustomWorldSessionSummary>(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify(payload satisfies CreateCustomWorldSessionRequest),
|
||||
},
|
||||
'创建自定义世界会话失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCustomWorldSession(sessionId: string) {
|
||||
return requestJson<{
|
||||
sessionId: string;
|
||||
status: string;
|
||||
settingText: string;
|
||||
generationMode: string;
|
||||
questions: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
question: string;
|
||||
answer?: string;
|
||||
}>;
|
||||
result?: Record<string, unknown>;
|
||||
lastError?: string;
|
||||
}>(
|
||||
return requestJson<CustomWorldSessionRecord>(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
@@ -597,21 +501,12 @@ export async function answerCustomWorldSessionQuestion(
|
||||
sessionId: string,
|
||||
payload: { questionId: string; answer: string },
|
||||
) {
|
||||
return requestJson<{
|
||||
sessionId: string;
|
||||
status: string;
|
||||
questions: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
question: string;
|
||||
answer?: string;
|
||||
}>;
|
||||
}>(
|
||||
return requestJson<CustomWorldSessionSummary>(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/answers`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify(payload satisfies AnswerCustomWorldSessionQuestionRequest),
|
||||
},
|
||||
'提交自定义世界补充设定失败',
|
||||
);
|
||||
@@ -629,65 +524,40 @@ export async function streamCharacterPanelChatReply(
|
||||
targetStatus: CharacterChatTargetStatus,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const userPrompt = buildCharacterPanelChatPrompt({
|
||||
world,
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.streamCharacterPanelChatReply(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
worldType: world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context: buildCharacterChatPromptContext(context),
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
});
|
||||
} satisfies CharacterChatReplyRequest;
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return aiClient.streamCharacterPanelChatReply(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const reply = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/character/reply/stream`,
|
||||
{
|
||||
systemPrompt: CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
},
|
||||
options,
|
||||
);
|
||||
return (
|
||||
reply.trim() ||
|
||||
buildOfflineCharacterPanelChatReply(
|
||||
targetCharacter,
|
||||
playerMessage,
|
||||
conversationSummary,
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[aiService] character reply stream fell back to frontend implementation', error);
|
||||
return aiClient.streamCharacterPanelChatReply(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
options,
|
||||
);
|
||||
}
|
||||
const reply = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/character/reply/stream`,
|
||||
payload,
|
||||
options,
|
||||
);
|
||||
return reply.trim();
|
||||
}
|
||||
|
||||
export async function streamNpcChatDialogue(
|
||||
@@ -701,8 +571,23 @@ export async function streamNpcChatDialogue(
|
||||
resultSummary: string,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const userPrompt = buildStrictNpcChatDialoguePrompt(
|
||||
world,
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.streamNpcChatDialogue(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
topic,
|
||||
resultSummary,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
worldType: world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
@@ -710,46 +595,14 @@ export async function streamNpcChatDialogue(
|
||||
context,
|
||||
topic,
|
||||
resultSummary,
|
||||
} satisfies NpcChatDialogueRequest;
|
||||
|
||||
const dialogue = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/npc/dialogue/stream`,
|
||||
payload,
|
||||
options,
|
||||
);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return aiClient.streamNpcChatDialogue(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
topic,
|
||||
resultSummary,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const dialogue = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/npc/dialogue/stream`,
|
||||
{
|
||||
systemPrompt: NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
},
|
||||
options,
|
||||
);
|
||||
return dialogue.trim() || buildOfflineNpcChatDialogue(encounter, topic);
|
||||
} catch (error) {
|
||||
console.warn('[aiService] npc dialogue stream fell back to frontend implementation', error);
|
||||
return aiClient.streamNpcChatDialogue(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
topic,
|
||||
resultSummary,
|
||||
options,
|
||||
);
|
||||
}
|
||||
return dialogue.trim();
|
||||
}
|
||||
|
||||
export async function streamNpcRecruitDialogue(
|
||||
@@ -763,8 +616,23 @@ export async function streamNpcRecruitDialogue(
|
||||
recruitSummary: string,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const userPrompt = buildNpcRecruitDialoguePrompt(
|
||||
world,
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.streamNpcRecruitDialogue(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
invitationText,
|
||||
recruitSummary,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
worldType: world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
@@ -772,46 +640,14 @@ export async function streamNpcRecruitDialogue(
|
||||
context,
|
||||
invitationText,
|
||||
recruitSummary,
|
||||
} satisfies NpcRecruitDialogueRequest;
|
||||
|
||||
const dialogue = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/npc/recruit/stream`,
|
||||
payload,
|
||||
options,
|
||||
);
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return aiClient.streamNpcRecruitDialogue(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
invitationText,
|
||||
recruitSummary,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const dialogue = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/npc/recruit/stream`,
|
||||
{
|
||||
systemPrompt: NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
},
|
||||
options,
|
||||
);
|
||||
return dialogue.trim() || buildOfflineNpcRecruitDialogue(encounter);
|
||||
} catch (error) {
|
||||
console.warn('[aiService] npc recruit stream fell back to frontend implementation', error);
|
||||
return aiClient.streamNpcRecruitDialogue(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
invitationText,
|
||||
recruitSummary,
|
||||
options,
|
||||
);
|
||||
}
|
||||
return dialogue.trim();
|
||||
}
|
||||
|
||||
export type {
|
||||
|
||||
243
src/services/apiClient.test.ts
Normal file
243
src/services/apiClient.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
fetchWithApiAuth,
|
||||
getStoredAccessToken,
|
||||
requestJson,
|
||||
setStoredAccessToken,
|
||||
} from './apiClient';
|
||||
|
||||
function createMemoryStorage() {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return values.has(key) ? values.get(key)! : null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
values.delete(key);
|
||||
},
|
||||
clear() {
|
||||
values.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createResponseMock(params: {
|
||||
status: number;
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
}) {
|
||||
const headers = new Map(
|
||||
Object.entries(params.headers ?? {}).map(([key, value]) => [
|
||||
key.toLowerCase(),
|
||||
value,
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
status: params.status,
|
||||
ok: params.status >= 200 && params.status < 300,
|
||||
headers: {
|
||||
get(name: string) {
|
||||
return headers.get(name.toLowerCase()) ?? null;
|
||||
},
|
||||
},
|
||||
text: vi.fn(async () => params.body ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
describe('apiClient', () => {
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createMemoryStorage(),
|
||||
dispatchEvent: vi.fn(),
|
||||
});
|
||||
fetchMock.mockReset();
|
||||
clearStoredAccessToken();
|
||||
});
|
||||
|
||||
it('attaches auth headers and clears stale tokens on unauthorized responses', async () => {
|
||||
setStoredAccessToken('jwt-token');
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
const response = await fetchWithApiAuth('/api/protected', { method: 'GET' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/protected',
|
||||
expect.objectContaining({
|
||||
credentials: 'same-origin',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer jwt-token',
|
||||
'x-genarrative-response-envelope': 'v1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/auth/refresh',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
}),
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
});
|
||||
|
||||
it('refreshes the access token once and retries the original request', async () => {
|
||||
setStoredAccessToken('expired-token');
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
token: 'fresh-token',
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
value: 7,
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await requestJson<{ value: number }>(
|
||||
'/api/runtime/protected',
|
||||
{ method: 'GET' },
|
||||
'读取受保护数据失败',
|
||||
);
|
||||
|
||||
expect(result).toEqual({ value: 7 });
|
||||
expect(getStoredAccessToken()).toBe('fresh-token');
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/auth/refresh',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/api/runtime/protected',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer fresh-token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('retries transient get requests before unwrapping the response envelope', async () => {
|
||||
fetchMock
|
||||
.mockRejectedValueOnce(new TypeError('network unavailable'))
|
||||
.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
value: 42,
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await requestJson<{ value: number }>(
|
||||
'/api/runtime/settings',
|
||||
{ method: 'GET' },
|
||||
'读取设置失败',
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual({ value: 42 });
|
||||
});
|
||||
|
||||
it('surfaces response metadata through ApiClientError', async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 503,
|
||||
body: JSON.stringify({
|
||||
ok: false,
|
||||
data: null,
|
||||
error: {
|
||||
code: 'UPSTREAM_ERROR',
|
||||
message: '上游暂不可用',
|
||||
details: {
|
||||
scope: 'runtime',
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
requestId: 'req-body',
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-request-id': 'req-header',
|
||||
'x-route-version': 'runtime.v2',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let capturedError: unknown;
|
||||
try {
|
||||
await requestJson(
|
||||
'/api/runtime/story/initial',
|
||||
{ method: 'POST' },
|
||||
'剧情生成失败',
|
||||
);
|
||||
} catch (error) {
|
||||
capturedError = error;
|
||||
}
|
||||
|
||||
expect(capturedError).toBeInstanceOf(ApiClientError);
|
||||
expect(capturedError).toMatchObject({
|
||||
status: 503,
|
||||
code: 'UPSTREAM_ERROR',
|
||||
details: {
|
||||
scope: 'runtime',
|
||||
},
|
||||
meta: {
|
||||
requestId: 'req-body',
|
||||
routeVersion: 'runtime.v2',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,317 @@
|
||||
import { parseApiErrorMessage } from '../editor/shared/jsonClient';
|
||||
import {
|
||||
API_RESPONSE_ENVELOPE_HEADER,
|
||||
API_RESPONSE_ENVELOPE_VERSION,
|
||||
API_VERSION,
|
||||
type ApiErrorPayload,
|
||||
type ApiMeta,
|
||||
parseApiErrorMessage,
|
||||
unwrapApiResponse,
|
||||
} from '../../packages/shared/src/http';
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
|
||||
const AUTO_AUTH_USERNAME_KEY = 'genarrative.auth.auto-username.v1';
|
||||
const AUTO_AUTH_PASSWORD_KEY = 'genarrative.auth.auto-password.v1';
|
||||
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
|
||||
const REQUEST_ID_HEADER = 'x-request-id';
|
||||
const API_VERSION_HEADER = 'x-api-version';
|
||||
const ROUTE_VERSION_HEADER = 'x-route-version';
|
||||
const DEFAULT_RETRYABLE_STATUS_CODES = [408, 425, 429, 502, 503, 504];
|
||||
const DEFAULT_SAFE_RETRY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
export type ApiRetryOptions = {
|
||||
maxRetries?: number;
|
||||
baseDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
retryableStatusCodes?: number[];
|
||||
retryUnsafeMethods?: boolean;
|
||||
allowRetryMethods?: string[];
|
||||
};
|
||||
|
||||
export type ApiRequestOptions = {
|
||||
retry?: ApiRetryOptions;
|
||||
skipAuth?: boolean;
|
||||
omitEnvelopeHeader?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
};
|
||||
|
||||
type ResolvedRetryOptions = {
|
||||
maxRetries: number;
|
||||
baseDelayMs: number;
|
||||
maxDelayMs: number;
|
||||
retryableStatusCodes: Set<number>;
|
||||
retryUnsafeMethods: boolean;
|
||||
allowRetryMethods: Set<string>;
|
||||
method: string;
|
||||
};
|
||||
|
||||
type ParsedApiErrorShape = {
|
||||
code: string;
|
||||
details: Record<string, unknown> | null;
|
||||
meta: Partial<ApiMeta>;
|
||||
};
|
||||
|
||||
type RefreshTokenResponse = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers?: HeadersInit) {
|
||||
const nextHeaders: Record<string, string> = {};
|
||||
|
||||
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
nextHeaders[key] = value;
|
||||
});
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
nextHeaders[key] = value;
|
||||
}
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
if (headers) {
|
||||
Object.assign(nextHeaders, headers);
|
||||
}
|
||||
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
function coerceMeta(value: unknown): Partial<ApiMeta> {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
apiVersion:
|
||||
typeof value.apiVersion === 'string' && value.apiVersion.trim()
|
||||
? value.apiVersion.trim()
|
||||
: undefined,
|
||||
requestId:
|
||||
typeof value.requestId === 'string' && value.requestId.trim()
|
||||
? value.requestId.trim()
|
||||
: undefined,
|
||||
routeVersion:
|
||||
typeof value.routeVersion === 'string' && value.routeVersion.trim()
|
||||
? value.routeVersion.trim()
|
||||
: undefined,
|
||||
operation:
|
||||
typeof value.operation === 'string' && value.operation.trim()
|
||||
? value.operation.trim()
|
||||
: value.operation === null
|
||||
? null
|
||||
: undefined,
|
||||
latencyMs:
|
||||
typeof value.latencyMs === 'number' && Number.isFinite(value.latencyMs)
|
||||
? value.latencyMs
|
||||
: undefined,
|
||||
timestamp:
|
||||
typeof value.timestamp === 'string' && value.timestamp.trim()
|
||||
? value.timestamp.trim()
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseApiErrorShape(rawText: string): ParsedApiErrorShape | null {
|
||||
if (!rawText.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawText) as
|
||||
| {
|
||||
error?: ApiErrorPayload;
|
||||
meta?: Partial<ApiMeta>;
|
||||
code?: string;
|
||||
details?: Record<string, unknown> | null;
|
||||
}
|
||||
| Record<string, unknown>;
|
||||
|
||||
if (isRecord(parsed.error)) {
|
||||
return {
|
||||
code:
|
||||
typeof parsed.error.code === 'string' && parsed.error.code.trim()
|
||||
? parsed.error.code.trim()
|
||||
: 'HTTP_ERROR',
|
||||
details:
|
||||
isRecord(parsed.error.details) || parsed.error.details === null
|
||||
? (parsed.error.details as Record<string, unknown> | null)
|
||||
: null,
|
||||
meta: coerceMeta(parsed.meta),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof parsed.code === 'string' && parsed.code.trim()) {
|
||||
return {
|
||||
code: parsed.code.trim(),
|
||||
details:
|
||||
isRecord(parsed.details) || parsed.details === null
|
||||
? (parsed.details as Record<string, unknown> | null)
|
||||
: null,
|
||||
meta: coerceMeta(parsed.meta),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed json responses.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createAbortError() {
|
||||
if (typeof DOMException !== 'undefined') {
|
||||
return new DOMException('The operation was aborted.', 'AbortError');
|
||||
}
|
||||
|
||||
const error = new Error('The operation was aborted.');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
async function waitForRetry(ms: number, signal?: AbortSignal) {
|
||||
if (ms <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve();
|
||||
}, ms);
|
||||
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(signal?.reason ?? createAbortError());
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeoutId);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
};
|
||||
|
||||
if (signal?.aborted) {
|
||||
cleanup();
|
||||
reject(signal.reason ?? createAbortError());
|
||||
return;
|
||||
}
|
||||
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function resolveRetryOptions(
|
||||
method: string,
|
||||
retry?: ApiRetryOptions,
|
||||
): ResolvedRetryOptions {
|
||||
const normalizedMethod = method.toUpperCase();
|
||||
const defaultMaxRetries = DEFAULT_SAFE_RETRY_METHODS.has(normalizedMethod)
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
return {
|
||||
maxRetries:
|
||||
typeof retry?.maxRetries === 'number' && retry.maxRetries >= 0
|
||||
? Math.floor(retry.maxRetries)
|
||||
: defaultMaxRetries,
|
||||
baseDelayMs:
|
||||
typeof retry?.baseDelayMs === 'number' && retry.baseDelayMs > 0
|
||||
? retry.baseDelayMs
|
||||
: 250,
|
||||
maxDelayMs:
|
||||
typeof retry?.maxDelayMs === 'number' && retry.maxDelayMs > 0
|
||||
? retry.maxDelayMs
|
||||
: 1500,
|
||||
retryableStatusCodes: new Set(
|
||||
retry?.retryableStatusCodes?.length
|
||||
? retry.retryableStatusCodes
|
||||
: DEFAULT_RETRYABLE_STATUS_CODES,
|
||||
),
|
||||
retryUnsafeMethods: retry?.retryUnsafeMethods === true,
|
||||
allowRetryMethods: new Set(
|
||||
(retry?.allowRetryMethods ?? []).map((value) => value.toUpperCase()),
|
||||
),
|
||||
method: normalizedMethod,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRetryResponse(
|
||||
status: number,
|
||||
attempt: number,
|
||||
retry: ResolvedRetryOptions,
|
||||
) {
|
||||
if (attempt >= retry.maxRetries) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!retry.retryableStatusCodes.has(status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
retry.retryUnsafeMethods ||
|
||||
DEFAULT_SAFE_RETRY_METHODS.has(retry.method) ||
|
||||
retry.allowRetryMethods.has(retry.method)
|
||||
);
|
||||
}
|
||||
|
||||
export function isAbortError(error: unknown) {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.name === 'AbortError' ||
|
||||
(typeof DOMException !== 'undefined' &&
|
||||
error instanceof DOMException &&
|
||||
error.name === 'AbortError'))
|
||||
);
|
||||
}
|
||||
|
||||
function shouldRetryError(error: unknown, attempt: number, retry: ResolvedRetryOptions) {
|
||||
if (attempt >= retry.maxRetries || isAbortError(error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return error instanceof TypeError;
|
||||
}
|
||||
|
||||
function buildRetryDelayMs(attempt: number, retry: ResolvedRetryOptions) {
|
||||
return Math.min(retry.maxDelayMs, retry.baseDelayMs * Math.max(1, attempt));
|
||||
}
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
details: Record<string, unknown> | null;
|
||||
meta: ApiMeta;
|
||||
responseText: string;
|
||||
|
||||
constructor(params: {
|
||||
message: string;
|
||||
status: number;
|
||||
code: string;
|
||||
details?: Record<string, unknown> | null;
|
||||
meta?: Partial<ApiMeta>;
|
||||
responseText?: string;
|
||||
}) {
|
||||
super(params.message);
|
||||
this.name = 'ApiClientError';
|
||||
this.status = params.status;
|
||||
this.code = params.code;
|
||||
this.details = params.details ?? null;
|
||||
this.meta = {
|
||||
apiVersion: params.meta?.apiVersion ?? API_VERSION,
|
||||
requestId: params.meta?.requestId,
|
||||
routeVersion: params.meta?.routeVersion,
|
||||
operation: params.meta?.operation,
|
||||
latencyMs: params.meta?.latencyMs,
|
||||
timestamp: params.meta?.timestamp,
|
||||
};
|
||||
this.responseText = params.responseText ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function canUseLocalStorage() {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||
@@ -14,7 +322,14 @@ function emitAuthStateChange() {
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent(AUTH_STATE_EVENT));
|
||||
if (typeof CustomEvent === 'function') {
|
||||
window.dispatchEvent(new CustomEvent(AUTH_STATE_EVENT));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof Event === 'function') {
|
||||
window.dispatchEvent(new Event(AUTH_STATE_EVENT));
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredAccessToken() {
|
||||
@@ -25,7 +340,12 @@ export function getStoredAccessToken() {
|
||||
return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || '';
|
||||
}
|
||||
|
||||
export function setStoredAccessToken(token: string) {
|
||||
export function setStoredAccessToken(
|
||||
token: string,
|
||||
options: {
|
||||
emit?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
@@ -36,16 +356,24 @@ export function setStoredAccessToken(token: string) {
|
||||
} else {
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
emitAuthStateChange();
|
||||
if (options.emit !== false) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredAccessToken() {
|
||||
export function clearStoredAccessToken(
|
||||
options: {
|
||||
emit?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
emitAuthStateChange();
|
||||
if (options.emit !== false) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredAutoAuthCredentials() {
|
||||
@@ -88,56 +416,165 @@ export function clearStoredAutoAuthCredentials() {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
|
||||
function withAuthorizationHeaders(headers?: HeadersInit) {
|
||||
const nextHeaders: Record<string, string> = {};
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
nextHeaders[key] = value;
|
||||
});
|
||||
} else if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
nextHeaders[key] = value;
|
||||
}
|
||||
} else if (headers) {
|
||||
Object.assign(nextHeaders, headers);
|
||||
}
|
||||
|
||||
function withAuthorizationHeaders(
|
||||
headers?: HeadersInit,
|
||||
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
|
||||
) {
|
||||
const nextHeaders = normalizeHeaders(headers);
|
||||
const token = getStoredAccessToken();
|
||||
if (token) {
|
||||
if (token && !options.skipAuth) {
|
||||
nextHeaders.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
if (!options.omitEnvelopeHeader) {
|
||||
nextHeaders[API_RESPONSE_ENVELOPE_HEADER] = API_RESPONSE_ENVELOPE_VERSION;
|
||||
}
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
let refreshAccessTokenPromise: Promise<string> | null = null;
|
||||
|
||||
async function refreshAccessToken() {
|
||||
if (refreshAccessTokenPromise) {
|
||||
return refreshAccessTokenPromise;
|
||||
}
|
||||
|
||||
refreshAccessTokenPromise = (async () => {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
[API_RESPONSE_ENVELOPE_HEADER]: API_RESPONSE_ENVELOPE_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
clearStoredAccessToken();
|
||||
throw await buildApiClientError(response, '刷新登录状态失败');
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
const payload = responseText
|
||||
? unwrapApiResponse<RefreshTokenResponse>(
|
||||
JSON.parse(responseText) as RefreshTokenResponse,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (!payload?.token?.trim()) {
|
||||
clearStoredAccessToken();
|
||||
throw new Error('刷新登录状态失败');
|
||||
}
|
||||
|
||||
setStoredAccessToken(payload.token, { emit: false });
|
||||
return payload.token;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await refreshAccessTokenPromise;
|
||||
} finally {
|
||||
refreshAccessTokenPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWithApiAuth(
|
||||
input: string,
|
||||
init: RequestInit = {},
|
||||
options: ApiRequestOptions = {},
|
||||
) {
|
||||
const response = await fetch(input, {
|
||||
credentials: 'same-origin',
|
||||
...init,
|
||||
headers: withAuthorizationHeaders(init.headers),
|
||||
});
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry = resolveRetryOptions(method, options.retry);
|
||||
let attempt = 0;
|
||||
let refreshAttempted = false;
|
||||
|
||||
if (response.status === 401) {
|
||||
clearStoredAccessToken();
|
||||
for (;;) {
|
||||
try {
|
||||
const response = await fetch(input, {
|
||||
credentials: 'same-origin',
|
||||
...init,
|
||||
headers: withAuthorizationHeaders(init.headers, options),
|
||||
});
|
||||
|
||||
if (
|
||||
response.status === 401 &&
|
||||
!options.skipAuth &&
|
||||
!options.skipRefresh &&
|
||||
!refreshAttempted
|
||||
) {
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
refreshAttempted = true;
|
||||
continue;
|
||||
} catch {
|
||||
clearStoredAccessToken();
|
||||
}
|
||||
} else if (response.status === 401) {
|
||||
clearStoredAccessToken();
|
||||
}
|
||||
|
||||
if (!shouldRetryResponse(response.status, attempt, retry)) {
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!shouldRetryError(error, attempt, retry)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
attempt += 1;
|
||||
await waitForRetry(
|
||||
buildRetryDelayMs(attempt, retry),
|
||||
init.signal ?? undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
async function buildApiClientError(
|
||||
response: Response,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
const responseText = await response.text();
|
||||
const parsedError = parseApiErrorShape(responseText);
|
||||
|
||||
return new ApiClientError({
|
||||
message: parseApiErrorMessage(responseText, fallbackMessage),
|
||||
status: response.status,
|
||||
code: parsedError?.code ?? `HTTP_${response.status || 0}`,
|
||||
details: parsedError?.details ?? null,
|
||||
meta: {
|
||||
apiVersion:
|
||||
parsedError?.meta.apiVersion ??
|
||||
response.headers.get(API_VERSION_HEADER) ??
|
||||
API_VERSION,
|
||||
requestId:
|
||||
parsedError?.meta.requestId ??
|
||||
response.headers.get(REQUEST_ID_HEADER) ??
|
||||
undefined,
|
||||
routeVersion:
|
||||
parsedError?.meta.routeVersion ??
|
||||
response.headers.get(ROUTE_VERSION_HEADER) ??
|
||||
undefined,
|
||||
operation: parsedError?.meta.operation,
|
||||
latencyMs: parsedError?.meta.latencyMs,
|
||||
timestamp: parsedError?.meta.timestamp,
|
||||
},
|
||||
responseText,
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestJson<T>(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: ApiRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const response = await fetchWithApiAuth(url, init);
|
||||
const responseText = await response.text();
|
||||
const response = await fetchWithApiAuth(url, init, options);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
|
||||
throw await buildApiClientError(response, fallbackMessage);
|
||||
}
|
||||
|
||||
return responseText ? (JSON.parse(responseText) as T) : (null as T);
|
||||
const responseText = await response.text();
|
||||
|
||||
return responseText
|
||||
? unwrapApiResponse<T>(JSON.parse(responseText) as T)
|
||||
: (null as T);
|
||||
}
|
||||
|
||||
@@ -5,15 +5,30 @@ const { requestJsonMock } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
getStoredAccessToken,
|
||||
getStoredAutoAuthCredentials,
|
||||
setStoredAccessToken,
|
||||
} from './apiClient';
|
||||
import {
|
||||
authEntryWithStoredCredentials,
|
||||
bindWechatPhone,
|
||||
changePhoneNumber,
|
||||
consumeAuthCallbackResult,
|
||||
createAutoAuthCredentials,
|
||||
ensureAutoAuthUser,
|
||||
getAuthAuditLogs,
|
||||
getAuthRiskBlocks,
|
||||
getAuthSessions,
|
||||
getCaptchaChallengeFromError,
|
||||
liftAuthRiskBlock,
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
revokeAuthSession,
|
||||
sendPhoneLoginCode,
|
||||
startWechatLogin,
|
||||
} from './authService';
|
||||
|
||||
function createMemoryStorage() {
|
||||
@@ -68,12 +83,17 @@ describe('authService auto auth', () => {
|
||||
user: {
|
||||
id: 'user_1',
|
||||
username: 'guest_abc123abc123',
|
||||
displayName: 'guest_abc123abc123',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await authEntryWithStoredCredentials({
|
||||
username: 'guest_abc123abc123',
|
||||
password: 'auto_secret_password',
|
||||
username: ' guest_abc123abc123 ',
|
||||
password: ' auto_secret_password ',
|
||||
});
|
||||
|
||||
expect(user.username).toBe('guest_abc123abc123');
|
||||
@@ -82,6 +102,16 @@ describe('authService auto auth', () => {
|
||||
username: 'guest_abc123abc123',
|
||||
password: 'auto_secret_password',
|
||||
});
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/entry',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
username: 'guest_abc123abc123',
|
||||
password: 'auto_secret_password',
|
||||
}),
|
||||
}),
|
||||
'登录失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses stored auto credentials before generating a new account', async () => {
|
||||
@@ -92,6 +122,11 @@ describe('authService auto auth', () => {
|
||||
user: {
|
||||
id: 'user_saved',
|
||||
username: 'guest_saveduser01',
|
||||
displayName: 'guest_saveduser01',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -110,4 +145,354 @@ describe('authService auto auth', () => {
|
||||
'登录失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends phone login code through the new auth endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
providerRequestId: 'mock-request-id',
|
||||
});
|
||||
|
||||
const result = await sendPhoneLoginCode(' 138 0013 8000 ');
|
||||
|
||||
expect(result.cooldownSeconds).toBe(60);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/send-code',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
scene: 'login',
|
||||
}),
|
||||
}),
|
||||
'发送验证码失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends phone change code with the correct scene', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
providerRequestId: 'mock-request-id',
|
||||
});
|
||||
|
||||
await sendPhoneLoginCode('13900139000', 'change_phone');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/send-code',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13900139000',
|
||||
scene: 'change_phone',
|
||||
}),
|
||||
}),
|
||||
'发送验证码失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts captcha challenge details from api errors', () => {
|
||||
expect(getCaptchaChallengeFromError(new Error('plain error'))).toBeNull();
|
||||
|
||||
const captchaError = new ApiClientError({
|
||||
message: '需要完成人机校验',
|
||||
status: 403,
|
||||
code: 'CAPTCHA_REQUIRED',
|
||||
details: {
|
||||
captchaChallenge: {
|
||||
challengeId: 'captcha_1',
|
||||
promptText: '请输入图中的验证码后再获取短信验证码',
|
||||
imageDataUrl: 'data:image/svg+xml;base64,abc',
|
||||
expiresInSeconds: 180,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getCaptchaChallengeFromError(captchaError)).toEqual({
|
||||
challengeId: 'captcha_1',
|
||||
promptText: '请输入图中的验证码后再获取短信验证码',
|
||||
imageDataUrl: 'data:image/svg+xml;base64,abc',
|
||||
expiresInSeconds: 180,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores jwt after phone login', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'phone-jwt-token',
|
||||
user: {
|
||||
id: 'user_phone',
|
||||
username: '138****8000',
|
||||
displayName: '138****8000',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await loginWithPhoneCode('13800138000', '123456');
|
||||
|
||||
expect(user.username).toBe('138****8000');
|
||||
expect(getStoredAccessToken()).toBe('phone-jwt-token');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/login',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
code: '123456',
|
||||
}),
|
||||
}),
|
||||
'登录失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('binds wechat phone and stores jwt after activation', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'wechat-bind-token',
|
||||
user: {
|
||||
id: 'user_wechat',
|
||||
username: '138****8000',
|
||||
displayName: '138****8000',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'wechat',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: true,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await bindWechatPhone('13800138000', '123456');
|
||||
|
||||
expect(user.wechatBound).toBe(true);
|
||||
expect(getStoredAccessToken()).toBe('wechat-bind-token');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/wechat/bind-phone',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
code: '123456',
|
||||
}),
|
||||
}),
|
||||
'绑定手机号失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('changes phone number without replacing the stored access token', async () => {
|
||||
setStoredAccessToken('active-token');
|
||||
requestJsonMock.mockResolvedValue({
|
||||
user: {
|
||||
id: 'user_phone',
|
||||
username: '139****9000',
|
||||
displayName: '139****9000',
|
||||
phoneNumberMasked: '139****9000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await changePhoneNumber('13900139000', '123456');
|
||||
|
||||
expect(user.phoneNumberMasked).toBe('139****9000');
|
||||
expect(getStoredAccessToken()).toBe('active-token');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/change',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13900139000',
|
||||
code: '123456',
|
||||
}),
|
||||
}),
|
||||
'更换手机号失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('starts wechat login by navigating to backend authorization url', async () => {
|
||||
const assignMock = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createMemoryStorage(),
|
||||
dispatchEvent: vi.fn(),
|
||||
location: {
|
||||
pathname: '/',
|
||||
hash: '',
|
||||
search: '',
|
||||
assign: assignMock,
|
||||
},
|
||||
history: {
|
||||
replaceState: vi.fn(),
|
||||
},
|
||||
});
|
||||
requestJsonMock.mockResolvedValue({
|
||||
authorizationUrl: '/api/auth/wechat/callback?mock_code=wx-user&state=state123',
|
||||
});
|
||||
|
||||
await startWechatLogin();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/wechat/start?redirectPath=%2F',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'微信登录暂不可用',
|
||||
);
|
||||
expect(assignMock).toHaveBeenCalledWith(
|
||||
'/api/auth/wechat/callback?mock_code=wx-user&state=state123',
|
||||
);
|
||||
});
|
||||
|
||||
it('consumes auth callback hash and stores token', () => {
|
||||
const replaceStateMock = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createMemoryStorage(),
|
||||
dispatchEvent: vi.fn(),
|
||||
location: {
|
||||
pathname: '/',
|
||||
search: '',
|
||||
hash: '#auth_provider=wechat&auth_token=wx-token&auth_binding_status=pending_bind_phone',
|
||||
},
|
||||
history: {
|
||||
replaceState: replaceStateMock,
|
||||
},
|
||||
});
|
||||
|
||||
const result = consumeAuthCallbackResult();
|
||||
|
||||
expect(result).toEqual({
|
||||
provider: 'wechat',
|
||||
bindingStatus: 'pending_bind_phone',
|
||||
error: null,
|
||||
});
|
||||
expect(getStoredAccessToken()).toBe('wx-token');
|
||||
expect(replaceStateMock).toHaveBeenCalledWith(null, '', '/');
|
||||
});
|
||||
|
||||
it('loads auth sessions from account center endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'usess_1',
|
||||
clientType: 'browser',
|
||||
clientLabel: '网页端浏览器',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
ipMasked: '127.0.*.*',
|
||||
isCurrent: true,
|
||||
createdAt: '2026-04-09T10:00:00.000Z',
|
||||
lastSeenAt: '2026-04-09T10:30:00.000Z',
|
||||
expiresAt: '2026-05-09T10:30:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const sessions = await getAuthSessions();
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/sessions',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取登录设备失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads recent auth audit logs', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
logs: [
|
||||
{
|
||||
id: 'audit_1',
|
||||
eventType: 'phone_login',
|
||||
title: '手机号登录',
|
||||
detail: '使用手机号 138****8000 完成登录',
|
||||
ipMasked: '127.0.*.*',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
createdAt: '2026-04-09T10:30:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const logs = await getAuthAuditLogs();
|
||||
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/audit-logs',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取账号操作记录失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads current risk blocks', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
blocks: [
|
||||
{
|
||||
scopeType: 'phone',
|
||||
title: '手机号保护中',
|
||||
detail: '该手机号因异常尝试已被临时保护,请约 30 分钟后再试',
|
||||
expiresAt: '2026-04-09T11:00:00.000Z',
|
||||
remainingSeconds: 1800,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const blocks = await getAuthRiskBlocks();
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/risk-blocks',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取安全状态失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('lifts a risk block by scope type', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
await liftAuthRiskBlock('phone');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/risk-blocks/phone/lift',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'解除保护失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('revokes a remote auth session by id', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
await revokeAuthSession('usess_123');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/sessions/usess_123/revoke',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'移除登录设备失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('clears local auth state after logout all sessions', async () => {
|
||||
setStoredAccessToken('stale-token');
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
await logoutAllAuthSessions();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/logout-all',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'退出全部设备失败',
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
import type {
|
||||
AuthAuditLogEntry,
|
||||
AuthAuditLogsResponse,
|
||||
AuthCaptchaChallenge,
|
||||
AuthEntryResponse,
|
||||
AuthLiftRiskBlockResponse,
|
||||
AuthLoginMethod,
|
||||
AuthLogoutAllResponse,
|
||||
AuthMeResponse,
|
||||
AuthPhoneChangeResponse,
|
||||
AuthPhoneLoginResponse,
|
||||
AuthPhoneSendCodeResponse,
|
||||
AuthRevokeSessionResponse,
|
||||
AuthRiskBlocksResponse,
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionsResponse,
|
||||
AuthSessionSummary,
|
||||
AuthWechatBindPhoneResponse,
|
||||
AuthWechatStartResponse,
|
||||
LogoutResponse,
|
||||
} from '../../packages/shared/src/contracts/auth';
|
||||
import {
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
getStoredAutoAuthCredentials,
|
||||
@@ -7,19 +29,77 @@ import {
|
||||
setStoredAutoAuthCredentials,
|
||||
} from './apiClient';
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
|
||||
|
||||
export type AutoAuthCredentials = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type AuthSessionSnapshot = {
|
||||
user: import('../../packages/shared/src/contracts/auth').AuthUser | null;
|
||||
availableLoginMethods: AuthLoginMethod[];
|
||||
};
|
||||
export type { AuthSessionSummary };
|
||||
export type { AuthCaptchaChallenge };
|
||||
export type { AuthAuditLogEntry };
|
||||
export type { AuthRiskBlockSummary };
|
||||
|
||||
export type ConsumedAuthCallback = {
|
||||
provider: 'wechat' | 'unknown';
|
||||
bindingStatus: string | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export function normalizePhoneInput(phoneInput: string) {
|
||||
return phoneInput.replace(/[^\d+]/gu, '').trim();
|
||||
}
|
||||
|
||||
export function getCaptchaChallengeFromError(
|
||||
error: unknown,
|
||||
): AuthCaptchaChallenge | null {
|
||||
if (
|
||||
error instanceof ApiClientError &&
|
||||
error.code === 'CAPTCHA_REQUIRED' &&
|
||||
error.details &&
|
||||
typeof error.details === 'object' &&
|
||||
'captchaChallenge' in error.details
|
||||
) {
|
||||
const challenge = (error.details as { captchaChallenge?: unknown }).captchaChallenge;
|
||||
if (
|
||||
challenge &&
|
||||
typeof challenge === 'object' &&
|
||||
'challengeId' in challenge &&
|
||||
'promptText' in challenge &&
|
||||
'imageDataUrl' in challenge &&
|
||||
'expiresInSeconds' in challenge
|
||||
) {
|
||||
return challenge as AuthCaptchaChallenge;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeCredentials(credentials: AutoAuthCredentials): AutoAuthCredentials {
|
||||
return {
|
||||
username: credentials.username.trim(),
|
||||
password: credentials.password.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRandomSegment(length: number) {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(length));
|
||||
const cryptoApi = globalThis.crypto;
|
||||
|
||||
if (!cryptoApi?.getRandomValues) {
|
||||
return Array.from(
|
||||
{length},
|
||||
() => alphabet[Math.floor(Math.random() * alphabet.length)],
|
||||
).join('');
|
||||
}
|
||||
|
||||
const bytes = cryptoApi.getRandomValues(new Uint8Array(length));
|
||||
|
||||
return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join('');
|
||||
}
|
||||
@@ -31,20 +111,111 @@ export function createAutoAuthCredentials(): AutoAuthCredentials {
|
||||
};
|
||||
}
|
||||
|
||||
export async function authEntry(username: string, password: string) {
|
||||
const response = await requestJson<{
|
||||
token: string;
|
||||
user: AuthUser;
|
||||
}>(
|
||||
'/api/auth/entry',
|
||||
export function clearAuthSession() {
|
||||
clearStoredAccessToken();
|
||||
clearStoredAutoAuthCredentials();
|
||||
}
|
||||
|
||||
export async function sendPhoneLoginCode(
|
||||
phone: string,
|
||||
scene: 'login' | 'bind_phone' | 'change_phone' = 'login',
|
||||
captcha?: {
|
||||
challengeId?: string;
|
||||
answer?: string;
|
||||
},
|
||||
) {
|
||||
const response = await requestJson<AuthPhoneSendCodeResponse>(
|
||||
'/api/auth/phone/send-code',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
password,
|
||||
phone: normalizePhoneInput(phone),
|
||||
scene,
|
||||
captchaChallengeId: captcha?.challengeId?.trim() || undefined,
|
||||
captchaAnswer: captcha?.answer?.trim() || undefined,
|
||||
}),
|
||||
},
|
||||
'发送验证码失败',
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function loginWithPhoneCode(phone: string, code: string) {
|
||||
const response = await requestJson<AuthPhoneLoginResponse>(
|
||||
'/api/auth/phone/login',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
}),
|
||||
},
|
||||
'登录失败',
|
||||
);
|
||||
|
||||
setStoredAccessToken(response.token);
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function bindWechatPhone(phone: string, code: string) {
|
||||
const response = await requestJson<AuthWechatBindPhoneResponse>(
|
||||
'/api/auth/wechat/bind-phone',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
}),
|
||||
},
|
||||
'绑定手机号失败',
|
||||
);
|
||||
|
||||
setStoredAccessToken(response.token);
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function changePhoneNumber(phone: string, code: string) {
|
||||
const response = await requestJson<AuthPhoneChangeResponse>(
|
||||
'/api/auth/phone/change',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
}),
|
||||
},
|
||||
'更换手机号失败',
|
||||
);
|
||||
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function startWechatLogin() {
|
||||
const response = await requestJson<AuthWechatStartResponse>(
|
||||
`/api/auth/wechat/start?redirectPath=${encodeURIComponent(window.location.pathname)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'微信登录暂不可用',
|
||||
);
|
||||
|
||||
window.location.assign(response.authorizationUrl);
|
||||
}
|
||||
|
||||
export async function authEntry(username: string, password: string) {
|
||||
const credentials = normalizeCredentials({ username, password });
|
||||
const response = await requestJson<AuthEntryResponse>(
|
||||
'/api/auth/entry',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credentials),
|
||||
},
|
||||
'登录失败',
|
||||
);
|
||||
|
||||
@@ -55,8 +226,12 @@ export async function authEntry(username: string, password: string) {
|
||||
export async function authEntryWithStoredCredentials(
|
||||
credentials: AutoAuthCredentials,
|
||||
) {
|
||||
const user = await authEntry(credentials.username, credentials.password);
|
||||
setStoredAutoAuthCredentials(credentials);
|
||||
const normalizedCredentials = normalizeCredentials(credentials);
|
||||
const user = await authEntry(
|
||||
normalizedCredentials.username,
|
||||
normalizedCredentials.password,
|
||||
);
|
||||
setStoredAutoAuthCredentials(normalizedCredentials);
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -71,10 +246,49 @@ export async function ensureAutoAuthUser() {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCurrentAuthUser() {
|
||||
const response = await requestJson<{
|
||||
user: AuthUser | null;
|
||||
}>(
|
||||
export function consumeAuthCallbackResult(): ConsumedAuthCallback | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hash = window.location.hash.startsWith('#')
|
||||
? window.location.hash.slice(1)
|
||||
: window.location.hash;
|
||||
if (!hash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(hash);
|
||||
const authToken = params.get('auth_token');
|
||||
const authError = params.get('auth_error');
|
||||
const providerValue = params.get('auth_provider');
|
||||
const bindingStatus = params.get('auth_binding_status');
|
||||
|
||||
if (!authToken && !authError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authToken) {
|
||||
setStoredAccessToken(authToken);
|
||||
}
|
||||
|
||||
if (typeof window.history?.replaceState === 'function') {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`${window.location.pathname}${window.location.search}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: providerValue === 'wechat' ? 'wechat' : 'unknown',
|
||||
bindingStatus,
|
||||
error: authError,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCurrentAuthUser(): Promise<AuthSessionSnapshot> {
|
||||
const response = await requestJson<AuthMeResponse>(
|
||||
'/api/auth/me',
|
||||
{
|
||||
method: 'GET',
|
||||
@@ -82,12 +296,71 @@ export async function getCurrentAuthUser() {
|
||||
'读取当前用户失败',
|
||||
);
|
||||
|
||||
return response.user;
|
||||
return {
|
||||
user: response.user,
|
||||
availableLoginMethods: response.availableLoginMethods,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAuthSessions() {
|
||||
const response = await requestJson<AuthSessionsResponse>(
|
||||
'/api/auth/sessions',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取登录设备失败',
|
||||
);
|
||||
|
||||
return response.sessions;
|
||||
}
|
||||
|
||||
export async function revokeAuthSession(sessionId: string) {
|
||||
await requestJson<AuthRevokeSessionResponse>(
|
||||
`/api/auth/sessions/${encodeURIComponent(sessionId)}/revoke`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'移除登录设备失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAuthAuditLogs() {
|
||||
const response = await requestJson<AuthAuditLogsResponse>(
|
||||
'/api/auth/audit-logs',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取账号操作记录失败',
|
||||
);
|
||||
|
||||
return response.logs;
|
||||
}
|
||||
|
||||
export async function getAuthRiskBlocks() {
|
||||
const response = await requestJson<AuthRiskBlocksResponse>(
|
||||
'/api/auth/risk-blocks',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取安全状态失败',
|
||||
);
|
||||
|
||||
return response.blocks;
|
||||
}
|
||||
|
||||
export async function liftAuthRiskBlock(scopeType: 'phone' | 'ip') {
|
||||
await requestJson<AuthLiftRiskBlockResponse>(
|
||||
`/api/auth/risk-blocks/${encodeURIComponent(scopeType)}/lift`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'解除保护失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function logoutAuthUser() {
|
||||
try {
|
||||
await requestJson<{ ok: true }>(
|
||||
await requestJson<LogoutResponse>(
|
||||
'/api/auth/logout',
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -95,7 +368,20 @@ export async function logoutAuthUser() {
|
||||
'退出登录失败',
|
||||
);
|
||||
} finally {
|
||||
clearStoredAccessToken();
|
||||
clearStoredAutoAuthCredentials();
|
||||
clearAuthSession();
|
||||
}
|
||||
}
|
||||
|
||||
export async function logoutAllAuthSessions() {
|
||||
try {
|
||||
await requestJson<AuthLogoutAllResponse>(
|
||||
'/api/auth/logout-all',
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'退出全部设备失败',
|
||||
);
|
||||
} finally {
|
||||
clearAuthSession();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ function coerceBoolean(value: string | undefined) {
|
||||
function resolveHeaders(headers?: HeadersInit) {
|
||||
const nextHeaders: Record<string, string> = {};
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
nextHeaders[key] = value;
|
||||
});
|
||||
|
||||
@@ -1,28 +1,4 @@
|
||||
export function parseJsonResponseText(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('LLM returned an empty response.');
|
||||
}
|
||||
|
||||
const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/iu);
|
||||
if (fencedMatch?.[1]) {
|
||||
return JSON.parse(fencedMatch[1].trim());
|
||||
}
|
||||
|
||||
const firstBrace = trimmed.indexOf('{');
|
||||
const lastBrace = trimmed.lastIndexOf('}');
|
||||
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||
return JSON.parse(trimmed.slice(firstBrace, lastBrace + 1));
|
||||
}
|
||||
|
||||
return JSON.parse(trimmed);
|
||||
}
|
||||
|
||||
export function parseLineListContent(text: string, maxItems = 3) {
|
||||
return text
|
||||
.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map(line => line.trim().replace(/^[-*\d.)\s]+/u, '').trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, maxItems);
|
||||
}
|
||||
export {
|
||||
parseJsonResponseText,
|
||||
parseLineListContent,
|
||||
} from '../../packages/shared/src/llm/parsers';
|
||||
|
||||
@@ -1,68 +1,4 @@
|
||||
const CJK_CHAR_PATTERN = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/gu;
|
||||
const LATIN_WORD_PATTERN = /[A-Za-z][A-Za-z'’-]{1,}/g;
|
||||
const LATIN_FRAGMENT_PATTERN =
|
||||
/[A-Za-z][A-Za-z0-9'"“”‘’()\-,:;!?/]*(?:\s+[A-Za-z0-9'"“”‘’()\-,:;!?/]+)+/gu;
|
||||
const SAFE_LATIN_TOKENS = new Set([
|
||||
'act',
|
||||
'ai',
|
||||
'boss',
|
||||
'cd',
|
||||
'hp',
|
||||
'json',
|
||||
'llm',
|
||||
'mp',
|
||||
'npc',
|
||||
'qa',
|
||||
'rpg',
|
||||
]);
|
||||
|
||||
function getCjkCharCount(text: string) {
|
||||
return text.match(CJK_CHAR_PATTERN)?.length ?? 0;
|
||||
}
|
||||
|
||||
function getSignificantLatinWords(text: string) {
|
||||
return (text.match(LATIN_WORD_PATTERN) ?? [])
|
||||
.map((word) => word.toLowerCase())
|
||||
.filter(
|
||||
(word) => word.length >= 4 && !SAFE_LATIN_TOKENS.has(word),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasMixedNarrativeLanguage(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cjkCharCount = getCjkCharCount(trimmed);
|
||||
const latinSentenceFragments = (trimmed.match(LATIN_FRAGMENT_PATTERN) ?? [])
|
||||
.map((fragment) => fragment.trim())
|
||||
.filter((fragment) => fragment.split(/\s+/u).length >= 2);
|
||||
const significantLatinWords = getSignificantLatinWords(trimmed);
|
||||
|
||||
if (latinSentenceFragments.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cjkCharCount > 0 && significantLatinWords.length >= 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return cjkCharCount === 0 && significantLatinWords.length >= 3;
|
||||
}
|
||||
|
||||
export function sanitizePromptNarrativeText(
|
||||
text: string | null | undefined,
|
||||
fallback: string | null = null,
|
||||
) {
|
||||
if (typeof text !== 'string') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return hasMixedNarrativeLanguage(trimmed) ? fallback : trimmed;
|
||||
}
|
||||
export {
|
||||
hasMixedNarrativeLanguage,
|
||||
sanitizePromptNarrativeText,
|
||||
} from '../../packages/shared/src/llm/narrativeLanguage';
|
||||
|
||||
249
src/services/runtimeStoryService.test.ts
Normal file
249
src/services/runtimeStoryService.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./apiClient', async () => {
|
||||
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||||
return {
|
||||
...actual,
|
||||
requestJson: requestJsonMock,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
buildStoryMomentFromRuntimeOptions,
|
||||
getRuntimeClientVersion,
|
||||
getRuntimeSessionId,
|
||||
isServerRuntimeFunctionId,
|
||||
isTask5RuntimeFunctionId,
|
||||
resolveRuntimeStoryAction,
|
||||
shouldUseServerRuntimeOptions,
|
||||
} from './runtimeStoryService';
|
||||
import { AnimationState } from '../types';
|
||||
|
||||
describe('runtimeStoryService', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
|
||||
it('builds runtime action requests against the dedicated story endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 2,
|
||||
viewModel: {},
|
||||
presentation: {
|
||||
actionText: '继续交谈',
|
||||
resultText: '后端已结算',
|
||||
storyText: '后端已结算',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {},
|
||||
currentStory: null,
|
||||
},
|
||||
});
|
||||
|
||||
await resolveRuntimeStoryAction({
|
||||
sessionId: 'runtime-custom',
|
||||
clientVersion: 9,
|
||||
option: {
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/story/actions/resolve',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-custom',
|
||||
clientVersion: 9,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: 'npc_chat',
|
||||
targetId: undefined,
|
||||
payload: {
|
||||
optionText: '继续交谈',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
'执行运行时动作失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('merges custom runtime payload fields into the action request body', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 3,
|
||||
viewModel: {},
|
||||
presentation: {
|
||||
actionText: '使用凝神灵液',
|
||||
resultText: '后端已结算物品使用',
|
||||
storyText: '后端已结算物品使用',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 3,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {},
|
||||
currentStory: null,
|
||||
},
|
||||
});
|
||||
|
||||
await resolveRuntimeStoryAction({
|
||||
option: {
|
||||
functionId: 'inventory_use',
|
||||
actionText: '使用凝神灵液',
|
||||
},
|
||||
payload: {
|
||||
itemId: 'focus-tonic',
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/story/actions/resolve',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: undefined,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: 'inventory_use',
|
||||
targetId: undefined,
|
||||
payload: {
|
||||
optionText: '使用凝神灵液',
|
||||
itemId: 'focus-tonic',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
'执行运行时动作失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters disabled runtime options when rebuilding a story moment', () => {
|
||||
const story = buildStoryMomentFromRuntimeOptions({
|
||||
storyText: '服务端返回的新故事',
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
scope: 'npc',
|
||||
},
|
||||
{
|
||||
functionId: 'npc_recruit',
|
||||
actionText: '邀请加入队伍',
|
||||
scope: 'npc',
|
||||
disabled: true,
|
||||
reason: '队伍已满',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(story.text).toBe('服务端返回的新故事');
|
||||
expect(story.options).toHaveLength(1);
|
||||
expect(story.options[0]?.functionId).toBe('npc_chat');
|
||||
});
|
||||
|
||||
it('recognizes server-runtime option pools for server-side legality checks', () => {
|
||||
expect(isTask5RuntimeFunctionId('npc_chat')).toBe(true);
|
||||
expect(isTask5RuntimeFunctionId('npc_trade')).toBe(false);
|
||||
expect(isServerRuntimeFunctionId('npc_trade')).toBe(true);
|
||||
expect(isServerRuntimeFunctionId('unknown_action')).toBe(false);
|
||||
expect(
|
||||
shouldUseServerRuntimeOptions([
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
text: '继续交谈',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldUseServerRuntimeOptions([
|
||||
{
|
||||
functionId: 'npc_trade',
|
||||
actionText: '交易',
|
||||
text: '交易',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldUseServerRuntimeOptions([
|
||||
{
|
||||
functionId: 'unknown_action',
|
||||
actionText: '未知动作',
|
||||
text: '未知动作',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe(false);
|
||||
expect(getRuntimeSessionId({ runtimeSessionId: '' })).toBe('runtime-main');
|
||||
expect(getRuntimeClientVersion({ runtimeActionVersion: 3 })).toBe(3);
|
||||
});
|
||||
|
||||
it('hydrates runtime option interaction metadata from the current encounter', () => {
|
||||
const story = buildStoryMomentFromRuntimeOptions({
|
||||
storyText: '服务端返回的新故事',
|
||||
gameState: {
|
||||
currentEncounter: {
|
||||
id: 'npc-merchant',
|
||||
kind: 'npc',
|
||||
npcName: '梁伯',
|
||||
npcDescription: '沿街商贩',
|
||||
npcAvatar: '',
|
||||
context: '沿街商贩',
|
||||
},
|
||||
} as never,
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_trade',
|
||||
actionText: '交易',
|
||||
scope: 'npc',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(story.options[0]?.interaction).toEqual({
|
||||
kind: 'npc',
|
||||
npcId: 'npc-merchant',
|
||||
action: 'trade',
|
||||
});
|
||||
});
|
||||
});
|
||||
218
src/services/runtimeStoryService.ts
Normal file
218
src/services/runtimeStoryService.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import type {
|
||||
RuntimeStoryActionResponse,
|
||||
RuntimeStoryChoicePayload,
|
||||
RuntimeStoryOptionView,
|
||||
ServerRuntimeFunctionId,
|
||||
Task5RuntimeFunctionId,
|
||||
} from '../../packages/shared/src/contracts/story';
|
||||
import {
|
||||
SERVER_RUNTIME_FUNCTION_IDS,
|
||||
TASK5_RUNTIME_FUNCTION_IDS,
|
||||
} from '../../packages/shared/src/contracts/story';
|
||||
import type {
|
||||
HydratedGameState,
|
||||
HydratedSavedGameSnapshot,
|
||||
} from '../persistence/runtimeSnapshotTypes';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../types';
|
||||
import { AnimationState } from '../types';
|
||||
import { requestJson, type ApiRetryOptions } from './apiClient';
|
||||
|
||||
const RUNTIME_STORY_API_BASE = '/api/runtime/story';
|
||||
const DEFAULT_SESSION_ID = 'runtime-main';
|
||||
const RUNTIME_STORY_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 220,
|
||||
maxDelayMs: 640,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
const TASK5_RUNTIME_FUNCTION_ID_SET = new Set<string>(
|
||||
TASK5_RUNTIME_FUNCTION_IDS,
|
||||
);
|
||||
const SERVER_RUNTIME_FUNCTION_ID_SET = new Set<string>([
|
||||
...SERVER_RUNTIME_FUNCTION_IDS,
|
||||
]);
|
||||
|
||||
export type RuntimeStoryServiceOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
};
|
||||
|
||||
export type RuntimeStoryResponse = RuntimeStoryActionResponse<
|
||||
HydratedGameState,
|
||||
StoryMoment
|
||||
>;
|
||||
export type { RuntimeStoryChoicePayload };
|
||||
|
||||
function requestRuntimeStoryJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
) {
|
||||
return requestJson<T>(
|
||||
`${RUNTIME_STORY_API_BASE}${path}`,
|
||||
{
|
||||
...init,
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{ retry: options.retry ?? RUNTIME_STORY_RETRY },
|
||||
);
|
||||
}
|
||||
|
||||
function buildRuntimeOptionInteraction(
|
||||
option: RuntimeStoryOptionView,
|
||||
gameState?: Pick<GameState, 'currentEncounter'>,
|
||||
): StoryOption['interaction'] {
|
||||
const encounter = gameState?.currentEncounter;
|
||||
|
||||
if (encounter?.kind === 'npc') {
|
||||
const npcId = encounter.id ?? encounter.npcName;
|
||||
const npcActionMap: Record<string, StoryOption['interaction']> = {
|
||||
npc_chat: { kind: 'npc', npcId, action: 'chat' },
|
||||
npc_help: { kind: 'npc', npcId, action: 'help' },
|
||||
npc_fight: { kind: 'npc', npcId, action: 'fight' },
|
||||
npc_leave: { kind: 'npc', npcId, action: 'leave' },
|
||||
npc_recruit: { kind: 'npc', npcId, action: 'recruit' },
|
||||
npc_spar: { kind: 'npc', npcId, action: 'spar' },
|
||||
npc_trade: { kind: 'npc', npcId, action: 'trade' },
|
||||
npc_gift: { kind: 'npc', npcId, action: 'gift' },
|
||||
npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' },
|
||||
npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' },
|
||||
};
|
||||
|
||||
return npcActionMap[option.functionId];
|
||||
}
|
||||
|
||||
if (encounter?.kind === 'treasure') {
|
||||
const treasureActionMap: Record<string, StoryOption['interaction']> = {
|
||||
treasure_secure: { kind: 'treasure', action: 'secure' },
|
||||
treasure_inspect: { kind: 'treasure', action: 'inspect' },
|
||||
treasure_leave: { kind: 'treasure', action: 'leave' },
|
||||
};
|
||||
|
||||
return treasureActionMap[option.functionId];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function createRuntimeStoryOption(
|
||||
option: RuntimeStoryOptionView,
|
||||
gameState?: Pick<GameState, 'currentEncounter'>,
|
||||
): StoryOption {
|
||||
const detailParts = [option.detailText, option.disabled ? option.reason : null]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return {
|
||||
functionId: option.functionId,
|
||||
actionText: option.actionText,
|
||||
text: option.actionText,
|
||||
detailText: detailParts || undefined,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
interaction: buildRuntimeOptionInteraction(option, gameState),
|
||||
};
|
||||
}
|
||||
|
||||
export function getRuntimeSessionId(gameState: Pick<GameState, 'runtimeSessionId'>) {
|
||||
return gameState.runtimeSessionId?.trim() || DEFAULT_SESSION_ID;
|
||||
}
|
||||
|
||||
export function getRuntimeClientVersion(
|
||||
gameState: Pick<GameState, 'runtimeActionVersion'>,
|
||||
) {
|
||||
return typeof gameState.runtimeActionVersion === 'number'
|
||||
? gameState.runtimeActionVersion
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function isTask5RuntimeFunctionId(
|
||||
functionId: string,
|
||||
): functionId is Task5RuntimeFunctionId {
|
||||
return TASK5_RUNTIME_FUNCTION_ID_SET.has(functionId);
|
||||
}
|
||||
|
||||
export function isServerRuntimeFunctionId(
|
||||
functionId: string,
|
||||
): functionId is ServerRuntimeFunctionId {
|
||||
return SERVER_RUNTIME_FUNCTION_ID_SET.has(functionId);
|
||||
}
|
||||
|
||||
export function shouldUseServerRuntimeOptions(options: StoryOption[] | null) {
|
||||
return Boolean(
|
||||
options?.length &&
|
||||
options.every((option) => isServerRuntimeFunctionId(option.functionId)),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildStoryMomentFromRuntimeOptions(params: {
|
||||
storyText: string;
|
||||
options: RuntimeStoryOptionView[];
|
||||
gameState?: Pick<GameState, 'currentEncounter'>;
|
||||
}) {
|
||||
return {
|
||||
text: params.storyText,
|
||||
options: params.options
|
||||
.filter((option) => !option.disabled)
|
||||
.map((option) => createRuntimeStoryOption(option, params.gameState)),
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
export async function getRuntimeStoryState(
|
||||
sessionId: string,
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
) {
|
||||
return requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
`/state/${encodeURIComponent(sessionId || DEFAULT_SESSION_ID)}`,
|
||||
{ method: 'GET' },
|
||||
'读取运行时故事状态失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveRuntimeStoryAction(
|
||||
params: {
|
||||
sessionId?: string;
|
||||
clientVersion?: number;
|
||||
option: Pick<StoryOption, 'functionId' | 'actionText'>;
|
||||
targetId?: string;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
},
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
) {
|
||||
return requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
'/actions/resolve',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: params.sessionId || DEFAULT_SESSION_ID,
|
||||
clientVersion: params.clientVersion,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: params.option.functionId,
|
||||
targetId: params.targetId,
|
||||
payload: {
|
||||
optionText: params.option.actionText,
|
||||
...(params.payload ?? {}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
'执行运行时动作失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {
|
||||
return response.snapshot as HydratedSavedGameSnapshot;
|
||||
}
|
||||
@@ -1,78 +1,131 @@
|
||||
import type {
|
||||
SavedGameSnapshot,
|
||||
BasicOkResult,
|
||||
CustomWorldLibraryResponse,
|
||||
RuntimeSettings,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import type {
|
||||
SavedGameSnapshotInput,
|
||||
} from '../persistence/gameSaveStorage';
|
||||
import type { SavedGameSettings } from '../persistence/gameSettingsStorage';
|
||||
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
|
||||
import type { CustomWorldProfile } from '../types';
|
||||
import { requestJson } from './apiClient';
|
||||
import { type ApiRetryOptions,requestJson } from './apiClient';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
|
||||
type CustomWorldLibraryResponse = {
|
||||
profiles?: CustomWorldProfile[];
|
||||
const RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 180,
|
||||
maxDelayMs: 480,
|
||||
};
|
||||
const RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 240,
|
||||
maxDelayMs: 640,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
export async function getSaveSnapshot() {
|
||||
return requestJson<SavedGameSnapshot | null>(
|
||||
`${RUNTIME_API_BASE}/save/snapshot`,
|
||||
{ method: 'GET' },
|
||||
'读取存档失败',
|
||||
export type RuntimeRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
};
|
||||
|
||||
function requestRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry =
|
||||
options.retry ??
|
||||
(method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY);
|
||||
|
||||
return requestJson<T>(
|
||||
`${RUNTIME_API_BASE}${path}`,
|
||||
{
|
||||
...init,
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{ retry },
|
||||
);
|
||||
}
|
||||
|
||||
export async function putSaveSnapshot(snapshot: SavedGameSnapshotInput) {
|
||||
return requestJson<SavedGameSnapshot>(
|
||||
`${RUNTIME_API_BASE}/save/snapshot`,
|
||||
export async function getSaveSnapshot(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<HydratedSavedGameSnapshot | null>(
|
||||
'/save/snapshot',
|
||||
{ method: 'GET' },
|
||||
'读取存档失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function putSaveSnapshot(
|
||||
snapshot: SavedGameSnapshotInput,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<HydratedSavedGameSnapshot>(
|
||||
'/save/snapshot',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(snapshot),
|
||||
},
|
||||
'保存存档失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteSaveSnapshot() {
|
||||
return requestJson<{ ok: true }>(
|
||||
`${RUNTIME_API_BASE}/save/snapshot`,
|
||||
export async function deleteSaveSnapshot(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<BasicOkResult>(
|
||||
'/save/snapshot',
|
||||
{ method: 'DELETE' },
|
||||
'删除存档失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSettings() {
|
||||
return requestJson<SavedGameSettings>(
|
||||
`${RUNTIME_API_BASE}/settings`,
|
||||
export async function getSettings(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<RuntimeSettings>(
|
||||
'/settings',
|
||||
{ method: 'GET' },
|
||||
'读取设置失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function putSettings(settings: SavedGameSettings) {
|
||||
return requestJson<SavedGameSettings>(
|
||||
`${RUNTIME_API_BASE}/settings`,
|
||||
export async function putSettings(
|
||||
settings: RuntimeSettings,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<RuntimeSettings>(
|
||||
'/settings',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
},
|
||||
'保存设置失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listCustomWorldLibrary() {
|
||||
const response = await requestJson<CustomWorldLibraryResponse>(
|
||||
`${RUNTIME_API_BASE}/custom-world-library`,
|
||||
export async function listCustomWorldLibrary(options: RuntimeRequestOptions = {}) {
|
||||
const response = await requestRuntimeJson<CustomWorldLibraryResponse<CustomWorldProfile>>(
|
||||
'/custom-world-library',
|
||||
{ method: 'GET' },
|
||||
'读取自定义世界库失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.profiles) ? response.profiles : [];
|
||||
}
|
||||
|
||||
export async function upsertCustomWorldProfile(profile: CustomWorldProfile) {
|
||||
const response = await requestJson<CustomWorldLibraryResponse>(
|
||||
`${RUNTIME_API_BASE}/custom-world-library/${encodeURIComponent(profile.id)}`,
|
||||
export async function upsertCustomWorldProfile(
|
||||
profile: CustomWorldProfile,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<CustomWorldLibraryResponse<CustomWorldProfile>>(
|
||||
`/custom-world-library/${encodeURIComponent(profile.id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -81,17 +134,33 @@ export async function upsertCustomWorldProfile(profile: CustomWorldProfile) {
|
||||
}),
|
||||
},
|
||||
'保存自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.profiles) ? response.profiles : [];
|
||||
}
|
||||
|
||||
export async function deleteCustomWorldProfile(profileId: string) {
|
||||
const response = await requestJson<CustomWorldLibraryResponse>(
|
||||
`${RUNTIME_API_BASE}/custom-world-library/${encodeURIComponent(profileId)}`,
|
||||
export async function deleteCustomWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<CustomWorldLibraryResponse<CustomWorldProfile>>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.profiles) ? response.profiles : [];
|
||||
}
|
||||
|
||||
export const runtimeStorageClient = {
|
||||
getSaveSnapshot,
|
||||
putSaveSnapshot,
|
||||
deleteSaveSnapshot,
|
||||
getSettings,
|
||||
putSettings,
|
||||
listCustomWorldLibrary,
|
||||
upsertCustomWorldProfile,
|
||||
deleteCustomWorldProfile,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { parseApiErrorMessage } from '../editor/shared/jsonClient';
|
||||
import {
|
||||
ASSET_API_PATHS,
|
||||
postApiJson,
|
||||
} from '../editor/shared/editorApiClient';
|
||||
|
||||
const QWEN_SPRITE_MASTER_API_PATH = '/api/qwen-sprite/master';
|
||||
const QWEN_SPRITE_SHEET_API_PATH = '/api/qwen-sprite/sheet';
|
||||
const QWEN_SPRITE_FRAME_REPAIR_API_PATH = '/api/qwen-sprite/frame-repair';
|
||||
const QWEN_SPRITE_SAVE_API_PATH = '/api/qwen-sprite/save';
|
||||
const QWEN_SPRITE_MASTER_API_PATH = ASSET_API_PATHS.qwenSpriteMaster;
|
||||
const QWEN_SPRITE_SHEET_API_PATH = ASSET_API_PATHS.qwenSpriteSheet;
|
||||
const QWEN_SPRITE_FRAME_REPAIR_API_PATH =
|
||||
ASSET_API_PATHS.qwenSpriteFrameRepair;
|
||||
const QWEN_SPRITE_SAVE_API_PATH = ASSET_API_PATHS.qwenSpriteSave;
|
||||
|
||||
export type QwenSpriteImageDraft = {
|
||||
id: string;
|
||||
@@ -48,18 +52,7 @@ async function postJson<T>(
|
||||
payload: Record<string, unknown>,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
|
||||
}
|
||||
|
||||
return JSON.parse(responseText) as T;
|
||||
return postApiJson<T>(url, payload, fallbackMessage);
|
||||
}
|
||||
|
||||
export async function generateQwenSpriteMaster(
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface GameState {
|
||||
worldType: WorldType | null;
|
||||
customWorldProfile: CustomWorldProfile | null;
|
||||
playerCharacter: Character | null;
|
||||
runtimeSessionId?: string | null;
|
||||
runtimeActionVersion?: number;
|
||||
runtimeStats: GameRuntimeStats;
|
||||
currentScene: string;
|
||||
storyHistory: StoryMoment[];
|
||||
|
||||
Reference in New Issue
Block a user