This commit is contained in:
2026-04-10 15:37:02 +08:00
parent 161cd32277
commit f19e482c8f
233 changed files with 43987 additions and 5127 deletions

View File

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

View File

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

View File

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

View File

@@ -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>,
'保存选项行为覆盖失败',
);

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

View File

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

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

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

View File

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

View File

@@ -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>
) : (

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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, '发布角色基础动作失败');
}

View File

@@ -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 = [
'武器',

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View 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();
});
});

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

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

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

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

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

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

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

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

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

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

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

View 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', '顺势追问')],
});
});
});

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

View 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: [],
});
});
});

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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