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

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