1
This commit is contained in:
@@ -1,14 +1,3 @@
|
||||
import type {
|
||||
CreateCustomWorldAgentSessionRequest,
|
||||
CreateCustomWorldAgentSessionResponse,
|
||||
CustomWorldAgentActionRequest,
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
CustomWorldDraftCardDetail,
|
||||
GetCustomWorldAgentCardDetailResponse,
|
||||
ListCustomWorldWorksResponse,
|
||||
SendCustomWorldAgentMessageRequest,
|
||||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
@@ -18,22 +7,18 @@ import type {
|
||||
CharacterChatReplyRequest,
|
||||
CharacterChatSuggestionsRequest,
|
||||
CharacterChatSummaryRequest,
|
||||
NpcChatDialogueRequest,
|
||||
NpcChatTurnDirective,
|
||||
NpcChatDialogueRequest,
|
||||
NpcChatTurnRequest,
|
||||
NpcChatTurnResult,
|
||||
NpcRecruitDialogueRequest,
|
||||
PlainTextResponse,
|
||||
} from '../../packages/shared/src/contracts/story';
|
||||
} from '../../packages/shared/src/contracts/rpgRuntimeChat';
|
||||
import { parseApiErrorMessage } from '../../packages/shared/src/http';
|
||||
import type {
|
||||
AIResponse,
|
||||
Character,
|
||||
CharacterChatTurn,
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
Encounter,
|
||||
GameState,
|
||||
SceneHostileNpc,
|
||||
@@ -41,18 +26,16 @@ import type {
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import type {
|
||||
CustomWorldSceneImageRequest,
|
||||
CustomWorldSceneImageResult,
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
TextStreamOptions,
|
||||
CustomWorldSceneImageResult,
|
||||
} from './aiTypes';
|
||||
import { fetchWithApiAuth, requestJson } from './apiClient';
|
||||
import { type CharacterChatTargetStatus } from './characterChatPrompt';
|
||||
import { parseLineListContent } from './llmParsers';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
const CUSTOM_WORLD_API_BASE = '/api';
|
||||
|
||||
type LegacyAiModule = typeof import('./ai');
|
||||
|
||||
@@ -66,12 +49,12 @@ async function loadLegacyAiModule() {
|
||||
return legacyAiModulePromise;
|
||||
}
|
||||
|
||||
async function requestPostJson<T>(
|
||||
async function requestPlainText(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return requestJson<T>(
|
||||
return requestJson<PlainTextResponse>(
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -82,14 +65,6 @@ async function requestPostJson<T>(
|
||||
);
|
||||
}
|
||||
|
||||
async function requestPlainText(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return requestPostJson<PlainTextResponse>(url, payload, fallbackMessage);
|
||||
}
|
||||
|
||||
async function requestPlainTextStream(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
@@ -349,326 +324,6 @@ export async function generateCharacterPanelChatSummary(
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
export async function generateCustomWorldProfile(
|
||||
input: GenerateCustomWorldProfileInput | string,
|
||||
options: GenerateCustomWorldProfileOptions = {},
|
||||
): Promise<CustomWorldProfile> {
|
||||
const normalizedInput =
|
||||
typeof input === 'string'
|
||||
? {
|
||||
settingText: input,
|
||||
}
|
||||
: input;
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateCustomWorldProfile(normalizedInput, options);
|
||||
}
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
throw options.signal.reason instanceof Error
|
||||
? options.signal.reason
|
||||
: new Error('世界生成已中断。');
|
||||
}
|
||||
|
||||
const profile = await requestPostJson<CustomWorldProfile>(
|
||||
`${RUNTIME_API_BASE}/custom-world/profile`,
|
||||
normalizedInput,
|
||||
'生成自定义世界失败',
|
||||
);
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
throw options.signal.reason instanceof Error
|
||||
? options.signal.reason
|
||||
: new Error('世界生成已中断。');
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
export async function generateCustomWorldSceneImage(
|
||||
payload: CustomWorldSceneImageRequest,
|
||||
) {
|
||||
return requestJson<CustomWorldSceneImageResult>(
|
||||
`${CUSTOM_WORLD_API_BASE}/custom-world/scene-image`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'生成自定义世界场景图失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateCustomWorldSceneNpc(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
landmarkId: string;
|
||||
}) {
|
||||
const response = await requestPostJson<{ npc: CustomWorldNpc }>(
|
||||
`${CUSTOM_WORLD_API_BASE}/custom-world/scene-npc`,
|
||||
payload,
|
||||
'生成场景 NPC 失败',
|
||||
);
|
||||
|
||||
return response.npc;
|
||||
}
|
||||
|
||||
async function requestCustomWorldEntity<T>(
|
||||
payload: {
|
||||
profile: CustomWorldProfile;
|
||||
kind: 'playable' | 'story' | 'landmark';
|
||||
},
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return requestPostJson<{
|
||||
kind: 'playable' | 'story' | 'landmark';
|
||||
entity: T;
|
||||
}>(`${CUSTOM_WORLD_API_BASE}/custom-world/entity`, payload, fallbackMessage);
|
||||
}
|
||||
|
||||
export async function generateCustomWorldPlayableNpc(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const response = await requestCustomWorldEntity<CustomWorldPlayableNpc>(
|
||||
{
|
||||
...payload,
|
||||
kind: 'playable',
|
||||
},
|
||||
'生成可扮演角色失败',
|
||||
);
|
||||
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
export async function generateCustomWorldStoryNpc(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const response = await requestCustomWorldEntity<CustomWorldNpc>(
|
||||
{
|
||||
...payload,
|
||||
kind: 'story',
|
||||
},
|
||||
'生成场景角色失败',
|
||||
);
|
||||
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
export async function generateCustomWorldLandmark(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const response = await requestCustomWorldEntity<CustomWorldLandmark>(
|
||||
{
|
||||
...payload,
|
||||
kind: 'landmark',
|
||||
},
|
||||
'生成场景失败',
|
||||
);
|
||||
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
export async function listCustomWorldWorks() {
|
||||
const response = await requestJson<ListCustomWorldWorksResponse>(
|
||||
`${RUNTIME_API_BASE}/custom-world/works`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取创作作品列表失败',
|
||||
);
|
||||
|
||||
return Array.isArray(response?.items) ? response.items : [];
|
||||
}
|
||||
|
||||
export async function createCustomWorldAgentSession(
|
||||
payload: CreateCustomWorldAgentSessionRequest,
|
||||
) {
|
||||
return requestJson<CreateCustomWorldAgentSessionResponse>(
|
||||
`${RUNTIME_API_BASE}/custom-world/agent/sessions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'创建世界共创会话失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCustomWorldAgentSession(sessionId: string) {
|
||||
return requestJson<CustomWorldAgentSessionSnapshot>(
|
||||
`${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取世界共创会话失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendCustomWorldAgentMessage(
|
||||
sessionId: string,
|
||||
payload: SendCustomWorldAgentMessageRequest,
|
||||
) {
|
||||
return requestJson<{ operation: CustomWorldAgentOperationRecord }>(
|
||||
`${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'发送共创消息失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function streamCustomWorldAgentMessage(
|
||||
sessionId: string,
|
||||
payload: SendCustomWorldAgentMessageRequest,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const response = await fetchWithApiAuth(
|
||||
`${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/messages/stream`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(parseApiErrorMessage(responseText, '发送共创消息失败'));
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let finalSession: CustomWorldAgentSessionSnapshot | null = null;
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
while (buffer.includes('\n\n')) {
|
||||
const boundary = buffer.indexOf('\n\n');
|
||||
const eventBlock = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || 'message';
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = dataLines.join('\n');
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(data) as Record<string, unknown>;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
if (eventName === 'reply_delta' && parsed) {
|
||||
const text = parsed.text;
|
||||
if (typeof text === 'string') {
|
||||
options.onUpdate?.(text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'session' && parsed?.session) {
|
||||
finalSession = parsed.session as CustomWorldAgentSessionSnapshot;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'error' && parsed) {
|
||||
const message =
|
||||
typeof parsed.message === 'string' && parsed.message.trim()
|
||||
? parsed.message.trim()
|
||||
: '发送共创消息失败';
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalSession) {
|
||||
throw new Error('共创消息流式结果不完整');
|
||||
}
|
||||
|
||||
return finalSession;
|
||||
}
|
||||
|
||||
export async function executeCustomWorldAgentAction(
|
||||
sessionId: string,
|
||||
payload: CustomWorldAgentActionRequest,
|
||||
) {
|
||||
return requestJson<{ operation: CustomWorldAgentOperationRecord }>(
|
||||
`${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/actions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'执行共创操作失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCustomWorldAgentOperation(
|
||||
sessionId: string,
|
||||
operationId: string,
|
||||
): Promise<CustomWorldAgentOperationRecord> {
|
||||
const response = await requestJson<
|
||||
{
|
||||
operation?: CustomWorldAgentOperationRecord;
|
||||
data?: CustomWorldAgentOperationRecord;
|
||||
} & Partial<CustomWorldAgentOperationRecord>
|
||||
>(
|
||||
`${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/operations/${encodeURIComponent(operationId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取共创操作状态失败',
|
||||
);
|
||||
|
||||
return (response.operation ??
|
||||
response.data ??
|
||||
response) as CustomWorldAgentOperationRecord;
|
||||
}
|
||||
|
||||
export async function getCustomWorldAgentCardDetail(
|
||||
sessionId: string,
|
||||
cardId: string,
|
||||
) {
|
||||
const response = await requestJson<GetCustomWorldAgentCardDetailResponse>(
|
||||
`${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/cards/${encodeURIComponent(cardId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取草稿卡详情失败',
|
||||
);
|
||||
|
||||
return response.card as CustomWorldDraftCardDetail;
|
||||
}
|
||||
|
||||
export async function streamCharacterPanelChatReply(
|
||||
world: WorldType,
|
||||
playerCharacter: Character,
|
||||
|
||||
@@ -1,861 +0,0 @@
|
||||
import { afterEach, expect, test } from 'vitest';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { buildCustomWorldRuntimeCharacters } from '../data/characterPresets';
|
||||
import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
|
||||
import { getScenePresetsByWorld } from '../data/scenePresets';
|
||||
import { WorldType } from '../types';
|
||||
import { buildCustomWorldProfileFromAgentDraft } from './customWorldAgentDraftResult';
|
||||
|
||||
afterEach(() => {
|
||||
setRuntimeCustomWorldProfile(null);
|
||||
});
|
||||
|
||||
const session: CustomWorldAgentSessionSnapshot = {
|
||||
sessionId: 'session-1',
|
||||
currentTurn: 6,
|
||||
anchorContent: {
|
||||
worldPromise: {
|
||||
hook: '被海雾吞没的旧航路群岛',
|
||||
differentiator: '灯塔与禁航令共同决定谁能活着穿过去。',
|
||||
desiredExperience: '压抑、悬疑、潮湿',
|
||||
},
|
||||
playerFantasy: {
|
||||
playerRole: '玩家回到群岛调查沉船真相。',
|
||||
corePursuit: '找出失控航路背后的真相。',
|
||||
fearOfLoss: '失去最后一个还能对上旧案的人。',
|
||||
},
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: [],
|
||||
hiddenLines: null,
|
||||
iconicElements: {
|
||||
iconicMotifs: ['会移动的海雾'],
|
||||
institutionsOrArtifacts: ['旧灯塔'],
|
||||
hardRules: [],
|
||||
},
|
||||
},
|
||||
progressPercent: 100,
|
||||
lastAssistantReply: '八锚点已经齐备,可以进入游戏设定草稿生成。',
|
||||
stage: 'object_refining',
|
||||
focusCardId: null,
|
||||
creatorIntent: {
|
||||
sourceMode: 'card',
|
||||
worldHook: '被海雾吞没的旧航路群岛',
|
||||
playerPremise: '玩家回到群岛调查沉船真相。',
|
||||
themeKeywords: ['海雾', '旧航路'],
|
||||
toneDirectives: ['压抑', '悬疑'],
|
||||
openingSituation: '首夜就有陌生船只闯入禁航区。',
|
||||
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
|
||||
keyFactions: [],
|
||||
keyCharacters: [],
|
||||
keyLandmarks: [],
|
||||
iconicElements: ['会移动的海雾'],
|
||||
forbiddenDirectives: [],
|
||||
rawSettingText: '',
|
||||
},
|
||||
creatorIntentReadiness: {
|
||||
isReady: true,
|
||||
completedKeys: [],
|
||||
missingKeys: [],
|
||||
},
|
||||
anchorPack: {},
|
||||
lockState: {},
|
||||
draftProfile: {
|
||||
name: '潮雾列岛',
|
||||
subtitle: '旧灯塔与失控航路',
|
||||
summary: '第一版世界底稿已经整理完成。',
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
playerGoal: '查清沉船与禁航区异动的真相。',
|
||||
majorFactions: ['守灯会', '航运公会'],
|
||||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-1',
|
||||
name: '沈砺',
|
||||
title: '旧航路引路人',
|
||||
role: '关键同行者',
|
||||
publicIdentity: '最熟悉旧航路的人。',
|
||||
publicMask: '看上去像可靠旧友。',
|
||||
currentPressure: '他必须在两股势力间站队。',
|
||||
hiddenHook: '暗中替沉船商盟引路。',
|
||||
relationToPlayer: '旧友兼潜在背叛者',
|
||||
threadIds: ['thread-1'],
|
||||
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-1',
|
||||
name: '顾潮音',
|
||||
title: '守灯会值夜人',
|
||||
role: '场景关键角色',
|
||||
publicIdentity: '负责夜间巡灯与封锁。',
|
||||
publicMask: '对外一直冷静克制。',
|
||||
currentPressure: '她知道更多禁航区真相。',
|
||||
hiddenHook: '曾亲眼见过失控海雾吞船。',
|
||||
relationToPlayer: '最早愿意交换线索的人',
|
||||
threadIds: ['thread-1'],
|
||||
summary: '她总像比所有人更早知道海雾会往哪一侧压下来。',
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '回潮旧灯塔',
|
||||
purpose: '观察雾潮与往来船只',
|
||||
mood: '潮湿、压抑、风声不止',
|
||||
importance: '开局核心场景',
|
||||
characterIds: ['story-1'],
|
||||
threadIds: ['thread-1'],
|
||||
summary: '旧灯塔是整片群岛最先看见异动的地方。',
|
||||
},
|
||||
],
|
||||
factions: [],
|
||||
threads: [],
|
||||
chapters: [],
|
||||
worldHook: '被海雾吞没的旧航路群岛',
|
||||
playerPremise: '玩家回到群岛调查沉船真相。',
|
||||
openingSituation: '首夜就有陌生船只闯入禁航区。',
|
||||
iconicElements: ['会移动的海雾'],
|
||||
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
|
||||
},
|
||||
messages: [],
|
||||
draftCards: [],
|
||||
pendingClarifications: [],
|
||||
suggestedActions: [],
|
||||
recommendedReplies: [],
|
||||
qualityFindings: [],
|
||||
assetCoverage: {
|
||||
roleAssets: [],
|
||||
sceneAssets: [],
|
||||
allRoleAssetsReady: false,
|
||||
allSceneAssetsReady: false,
|
||||
},
|
||||
updatedAt: '2026-04-15T10:00:00.000Z',
|
||||
};
|
||||
|
||||
function buildBackstoryReveal(label: string) {
|
||||
return {
|
||||
publicSummary: `${label}的公开背景`,
|
||||
privateChatUnlockAffinity: 40,
|
||||
chapters: [
|
||||
{
|
||||
id: `${label}-surface`,
|
||||
title: '表层来意',
|
||||
affinityRequired: 15,
|
||||
teaser: `${label}先只肯说表面的来意。`,
|
||||
content: `${label}表面上只愿意谈当前局势。`,
|
||||
contextSnippet: `${label}表面上还在收着话。`,
|
||||
},
|
||||
{
|
||||
id: `${label}-scar`,
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: 30,
|
||||
teaser: `${label}背后还有一段旧伤。`,
|
||||
content: `${label}曾在旧案里留下无法轻易揭开的伤口。`,
|
||||
contextSnippet: `${label}和旧案之间有一段没说开的裂痕。`,
|
||||
},
|
||||
{
|
||||
id: `${label}-hidden`,
|
||||
title: '隐藏执念',
|
||||
affinityRequired: 60,
|
||||
teaser: `${label}真正想追的不是表面那件事。`,
|
||||
content: `${label}真正挂着的是旧案里还没结的那条线。`,
|
||||
contextSnippet: `${label}真正执念指向旧案深处。`,
|
||||
},
|
||||
{
|
||||
id: `${label}-final`,
|
||||
title: '最终底牌',
|
||||
affinityRequired: 90,
|
||||
teaser: `${label}手里还压着最后一张牌。`,
|
||||
content: `${label}手里还握着能直接证明真相的关键证据。`,
|
||||
contextSnippet: `${label}最后的底牌足以改写局势。`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildLegacyResultProfile() {
|
||||
return {
|
||||
id: 'legacy-profile-1',
|
||||
settingText: '被海雾吞没的旧航路群岛',
|
||||
name: '旧版完整结果',
|
||||
subtitle: '直接展示',
|
||||
summary: '优先使用服务端编译好的旧版 profile。',
|
||||
tone: '压抑',
|
||||
playerGoal: '查明真相',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
compatibilityTemplateWorldType: WorldType.WUXIA,
|
||||
majorFactions: ['守灯会', '航运公会'],
|
||||
coreConflicts: ['争夺航路控制权', '沉船真相'],
|
||||
attributeSchema: {
|
||||
id: 'schema:test',
|
||||
worldId: 'CUSTOM',
|
||||
schemaVersion: 1,
|
||||
schemaName: '测试',
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '旧版完整结果',
|
||||
settingSummary: '测试',
|
||||
tone: '测试',
|
||||
conflictCore: '测试',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-1',
|
||||
name: '沈砺',
|
||||
title: '旧航路引路人',
|
||||
role: '关键同行者',
|
||||
description: '最熟悉旧航路的人。',
|
||||
backstory: '曾在沉船夜里带着半支船队逃出海雾。',
|
||||
personality: '表面沉稳,心里一直在算退路。',
|
||||
motivation: '想赶在守灯会封航前查清真相。',
|
||||
combatStyle: '借地形和潮路换位,先拉扯再压近。',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['旧友', '沉船旧案'],
|
||||
tags: ['潮路', '引路'],
|
||||
backstoryReveal: buildBackstoryReveal('沈砺'),
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-playable-1',
|
||||
name: '潮行引路',
|
||||
summary: '踩着旧潮阶切线前压,替队伍打开角度。',
|
||||
style: '机动周旋',
|
||||
},
|
||||
{
|
||||
id: 'skill-playable-2',
|
||||
name: '回雾折返',
|
||||
summary: '借海雾遮住身位,再从侧线拉开。',
|
||||
style: '起手压制',
|
||||
},
|
||||
{
|
||||
id: 'skill-playable-3',
|
||||
name: '旧图定标',
|
||||
summary: '用旧潮图锁定退路和突入口。',
|
||||
style: '爆发终结',
|
||||
},
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
id: 'item-playable-1',
|
||||
name: '旧潮短刃',
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '专门在湿滑甲板上近身换位用的短刃。',
|
||||
tags: ['潮路', '近战'],
|
||||
},
|
||||
{
|
||||
id: 'item-playable-2',
|
||||
name: '雾盐药包',
|
||||
category: '消耗品',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
description: '压住寒潮后遗症的随身药包。',
|
||||
tags: ['补给'],
|
||||
},
|
||||
{
|
||||
id: 'item-playable-3',
|
||||
name: '旧潮图残页',
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '只剩半页,但足够指向沉船夜的另一条线。',
|
||||
tags: ['线索', '真相'],
|
||||
},
|
||||
],
|
||||
attributeProfile: {
|
||||
schemaId: 'schema:test',
|
||||
values: { axis_a: 48, axis_b: 72, axis_c: 78 },
|
||||
topTraits: ['浪步', '舟识'],
|
||||
evidence: [
|
||||
{
|
||||
slotId: 'axis_b',
|
||||
reason: '长期依赖潮路换位与切线。',
|
||||
},
|
||||
],
|
||||
},
|
||||
narrativeProfile: {
|
||||
publicMask: '像个只想把旧路再走通一次的熟路人。',
|
||||
firstContactMask: '先别问太深,至少今晚这条路我还认得。',
|
||||
visibleLine: '他明面上只想护着队伍别再走错一次潮线。',
|
||||
hiddenLine: '真正让他回来的是沉船夜里被人卖掉的那条航线。',
|
||||
contradiction: '嘴上说只想带路,实际每一步都在找能对上旧案的证据。',
|
||||
debtOrBurden: '背着半支船队没能活着回来的旧债。',
|
||||
taboo: '最忌讳别人轻描淡写地提起那晚的失踪名单。',
|
||||
immediatePressure: '守灯会封航在即,他必须赶在封口前找到证据。',
|
||||
relatedThreadIds: ['thread-visible-1'],
|
||||
relatedScarIds: ['scar-1'],
|
||||
reactionHooks: ['沉船夜', '封航记录'],
|
||||
},
|
||||
templateCharacterId: 'archer-hero',
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-1',
|
||||
name: '顾潮音',
|
||||
title: '守灯会值夜人',
|
||||
role: '场景关键角色',
|
||||
description: '夜里巡灯与封锁禁航区的人。',
|
||||
backstory: '在失控海雾第一次吞船那夜,她是最后一个还留在灯塔顶的人。',
|
||||
personality: '冷静克制,但提到旧灯册时会显得过分警觉。',
|
||||
motivation: '想守住灯塔记录,也想找到谁先改动了禁航信号。',
|
||||
combatStyle: '借塔顶视角和风向压制,再用灯火错位扰乱。',
|
||||
initialAffinity: 8,
|
||||
relationshipHooks: ['禁航记录', '灯塔值夜'],
|
||||
tags: ['守灯会', '灯塔'],
|
||||
backstoryReveal: buildBackstoryReveal('顾潮音'),
|
||||
skills: [
|
||||
{
|
||||
id: 'skill-story-1',
|
||||
name: '夜潮灯语',
|
||||
summary: '借灯语与潮声干扰对方判断。',
|
||||
style: '起手压制',
|
||||
},
|
||||
{
|
||||
id: 'skill-story-2',
|
||||
name: '禁航暗潮',
|
||||
summary: '封住错误航线,把人逼回她熟悉的区域。',
|
||||
style: '机动周旋',
|
||||
},
|
||||
{
|
||||
id: 'skill-story-3',
|
||||
name: '回声巡线',
|
||||
summary: '借塔顶回声迅速锁定异动方向。',
|
||||
style: '爆发终结',
|
||||
},
|
||||
],
|
||||
initialItems: [
|
||||
{
|
||||
id: 'item-story-1',
|
||||
name: '值夜灯尺',
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '兼作警械和测灯尺的长柄器具。',
|
||||
tags: ['守灯会'],
|
||||
},
|
||||
{
|
||||
id: 'item-story-2',
|
||||
name: '防潮火折',
|
||||
category: '消耗品',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
description: '在潮雾里也能点亮的值夜火折。',
|
||||
tags: ['值夜'],
|
||||
},
|
||||
{
|
||||
id: 'item-story-3',
|
||||
name: '封灯令副本',
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '一份被她私下截留下来的封灯令副本。',
|
||||
tags: ['证据', '灯册'],
|
||||
},
|
||||
],
|
||||
imageSrc: '/custom/npcs/gu-chaoyin.png',
|
||||
attributeProfile: {
|
||||
schemaId: 'schema:test',
|
||||
values: { axis_a: 54, axis_c: 82, axis_f: 61 },
|
||||
topTraits: ['舟识', '回澜'],
|
||||
evidence: [
|
||||
{
|
||||
slotId: 'axis_c',
|
||||
reason: '长期依赖值夜观察和读灯判断局势。',
|
||||
},
|
||||
],
|
||||
},
|
||||
narrativeProfile: {
|
||||
publicMask: '守灯会值夜人,对外总像比别人更冷静一步。',
|
||||
firstContactMask: '想进塔就按规矩来,今晚谁都别想乱闯禁航区。',
|
||||
visibleLine: '她表面上只是在守灯和封线。',
|
||||
hiddenLine: '她真正盯着的是那本被改过的原始灯册。',
|
||||
contradiction: '越强调规矩,越像在掩住自己手里那份没上报的记录。',
|
||||
debtOrBurden: '她扛着第一次海雾吞船夜里没能及时点亮备用灯的旧责。',
|
||||
taboo: '最忌讳别人把那夜的失踪当成单纯天灾。',
|
||||
immediatePressure: '新的封航令马上要落下来,她必须先把旧灯册转出去。',
|
||||
relatedThreadIds: ['thread-visible-1'],
|
||||
relatedScarIds: ['scar-1'],
|
||||
reactionHooks: ['原始灯册', '封灯令'],
|
||||
},
|
||||
visual: {
|
||||
race: 'human',
|
||||
bodyColor: 'blue',
|
||||
headIndex: 2,
|
||||
hairColorIndex: 3,
|
||||
hairStyleFrame: 4,
|
||||
facialHairEnabled: false,
|
||||
facialHairColorIndex: 0,
|
||||
facialHairStyleFrame: 0,
|
||||
offHand: {
|
||||
type: 'magic',
|
||||
file: 'lantern.png',
|
||||
frameIndex: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
items: [
|
||||
{
|
||||
id: 'item-world-1',
|
||||
name: '潮雾罗盘',
|
||||
category: '饰品',
|
||||
rarity: 'rare',
|
||||
description: '会在假航灯附近偏转的旧罗盘。',
|
||||
tags: ['线索', '潮雾'],
|
||||
attributeResonance: {
|
||||
resonanceVector: { axis_c: 0.88, axis_e: 0.31 },
|
||||
explanation: '它会把持有者的判断力牵到潮雾最异常的地方。',
|
||||
},
|
||||
},
|
||||
],
|
||||
camp: {
|
||||
name: '回潮暂栖所',
|
||||
description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。',
|
||||
dangerLevel: 'low',
|
||||
imageSrc: '/custom/camp/huichao.png',
|
||||
},
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '回潮旧灯塔',
|
||||
description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。',
|
||||
dangerLevel: 'high',
|
||||
imageSrc: '/custom/scenes/lighthouse.png',
|
||||
sceneNpcIds: ['story-1'],
|
||||
connections: [
|
||||
{
|
||||
targetLandmarkId: 'landmark-2',
|
||||
relativePosition: 'forward',
|
||||
summary: '沿着旧潮阶继续前压到雾栈尽头。',
|
||||
},
|
||||
],
|
||||
narrativeResidues: [
|
||||
{
|
||||
id: 'residue-1',
|
||||
title: '潮痕',
|
||||
visibleClue: '塔壁上有一圈不该出现在高处的潮痕。',
|
||||
linkedFactIds: ['fact-1'],
|
||||
linkedThreadIds: ['thread-visible-1'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'landmark-2',
|
||||
name: '雾栈尽头',
|
||||
description: '旧栈桥尽头只剩断索、潮板和被抹掉的船名。',
|
||||
dangerLevel: 'high',
|
||||
imageSrc: '/custom/scenes/pier.png',
|
||||
sceneNpcIds: [],
|
||||
connections: [
|
||||
{
|
||||
targetLandmarkId: 'landmark-1',
|
||||
relativePosition: 'back',
|
||||
summary: '退回灯塔还能重新整理路线。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
themePack: {
|
||||
id: 'theme-pack:tide',
|
||||
displayName: '潮雾悬疑',
|
||||
toneRange: ['压抑', '潮湿', '悬疑'],
|
||||
institutionLexicon: ['守灯会', '航运公会'],
|
||||
tabooLexicon: ['假航灯', '封灯令'],
|
||||
artifactClasses: ['旧潮图', '灯册', '罗盘'],
|
||||
actorArchetypes: ['引路人', '值夜人'],
|
||||
conflictForms: ['封航争夺', '旧案追查'],
|
||||
clueForms: ['灯册残页', '潮痕'],
|
||||
namingPatterns: ['潮', '雾', '灯'],
|
||||
revealStyles: ['试探式回应'],
|
||||
},
|
||||
storyGraph: {
|
||||
visibleThreads: [
|
||||
{
|
||||
id: 'thread-visible-1',
|
||||
title: '封航争夺',
|
||||
visibility: 'visible',
|
||||
summary: '守灯会与航运公会正在争夺旧航路的解释权。',
|
||||
conflictType: '控制权争夺',
|
||||
stakes: '谁能定义禁航区,就能决定谁能活着穿过去。',
|
||||
involvedFactionIds: ['faction-guard', 'faction-guild'],
|
||||
involvedActorIds: ['playable-1', 'story-1'],
|
||||
relatedLocationIds: ['landmark-1', 'landmark-2'],
|
||||
},
|
||||
],
|
||||
hiddenThreads: [
|
||||
{
|
||||
id: 'thread-hidden-1',
|
||||
title: '沉船旧案',
|
||||
visibility: 'hidden',
|
||||
summary: '沉船夜的航灯与灯册被人动过手脚。',
|
||||
conflictType: '真相遮蔽',
|
||||
stakes: '真相一旦坐实,守灯会内部会先崩。',
|
||||
involvedFactionIds: ['faction-guard'],
|
||||
involvedActorIds: ['playable-1', 'story-1'],
|
||||
relatedLocationIds: ['landmark-1'],
|
||||
},
|
||||
],
|
||||
scars: [
|
||||
{
|
||||
id: 'scar-1',
|
||||
title: '沉船夜',
|
||||
pastEvent: '假航灯把整支船队引进了死潮区。',
|
||||
publicResidue: '每逢潮夜,灯塔下总有人提起那晚的失踪名单。',
|
||||
hiddenTruth: '禁航记录和灯册都在事后被篡改过。',
|
||||
relatedActorIds: ['playable-1', 'story-1'],
|
||||
relatedLocationIds: ['landmark-1'],
|
||||
},
|
||||
],
|
||||
motifs: [
|
||||
{
|
||||
id: 'motif-1',
|
||||
label: '假航灯',
|
||||
semanticRole: 'technology',
|
||||
lexicalHints: ['假灯', '偏航', '禁航记录'],
|
||||
},
|
||||
],
|
||||
},
|
||||
knowledgeFacts: [
|
||||
{
|
||||
id: 'fact-1',
|
||||
title: '高处潮痕',
|
||||
content: '回潮旧灯塔的高处潮痕说明那晚海面高度异常。',
|
||||
ownerActorIds: ['story-1'],
|
||||
relatedThreadIds: ['thread-visible-1'],
|
||||
relatedScarIds: ['scar-1'],
|
||||
sourceType: 'scene',
|
||||
visibility: 'discoverable',
|
||||
sayability: 'indirect',
|
||||
},
|
||||
],
|
||||
threadContracts: [
|
||||
{
|
||||
id: 'contract-1',
|
||||
threadId: 'thread-visible-1',
|
||||
issuerActorId: 'story-1',
|
||||
narrativeType: 'investigation',
|
||||
currentStepId: 'contract-step-1',
|
||||
visibleStage: 1,
|
||||
steps: [
|
||||
{
|
||||
id: 'contract-step-1',
|
||||
title: '查灯塔',
|
||||
revealText: '先查清灯塔顶上的高处潮痕。',
|
||||
completionSignalIds: ['inspect_scene:landmark-1'],
|
||||
optionalFactIds: ['fact-1'],
|
||||
},
|
||||
],
|
||||
followupThreadIds: ['thread-hidden-1'],
|
||||
},
|
||||
],
|
||||
scenarioPackId: 'scenario-pack:tide',
|
||||
campaignPackId: 'campaign-pack:tide',
|
||||
generationMode: 'fast',
|
||||
generationStatus: 'key_only',
|
||||
};
|
||||
}
|
||||
|
||||
function buildProfileFromEmbeddedLegacyResult() {
|
||||
return buildCustomWorldProfileFromAgentDraft({
|
||||
...session,
|
||||
draftProfile: {
|
||||
...session.draftProfile,
|
||||
legacyResultProfile: buildLegacyResultProfile(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('adapts agent draft profile into legacy custom world result profile', () => {
|
||||
const profile = buildCustomWorldProfileFromAgentDraft(session);
|
||||
|
||||
expect(profile?.name).toBe('潮雾列岛');
|
||||
expect(profile?.generationStatus).toBe('key_only');
|
||||
expect(profile?.playableNpcs[0]?.name).toBe('沈砺');
|
||||
expect(profile?.storyNpcs[0]?.name).toBe('顾潮音');
|
||||
expect(profile?.landmarks[0]?.name).toBe('回潮旧灯塔');
|
||||
});
|
||||
|
||||
test('agent draft result keeps generated role portraits and scene act backgrounds', () => {
|
||||
const profile = buildCustomWorldProfileFromAgentDraft({
|
||||
...session,
|
||||
draftProfile: {
|
||||
...session.draftProfile,
|
||||
playableNpcs: [
|
||||
{
|
||||
...session.draftProfile.playableNpcs[0],
|
||||
imageSrc: '/generated-characters/playable-1/visual/asset-1/master.png',
|
||||
generatedVisualAssetId: 'asset-1',
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
...session.draftProfile.storyNpcs[0],
|
||||
imageSrc: '/generated-characters/story-1/visual/asset-2/master.png',
|
||||
generatedVisualAssetId: 'asset-2',
|
||||
},
|
||||
],
|
||||
sceneChapters: [
|
||||
{
|
||||
id: 'scene-chapter-1',
|
||||
sceneId: 'landmark-1',
|
||||
sceneName: '回潮旧灯塔',
|
||||
title: '灯塔初章',
|
||||
summary: '围绕灯塔推进的首个场景章节。',
|
||||
linkedThreadIds: ['thread-1'],
|
||||
linkedLandmarkIds: ['landmark-1'],
|
||||
acts: [
|
||||
{
|
||||
id: 'scene-act-1',
|
||||
title: '第一幕',
|
||||
summary: '先接住回潮灯塔的入口压力。',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc:
|
||||
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
|
||||
backgroundAssetId: 'scene-asset-1',
|
||||
encounterNpcIds: ['story-1'],
|
||||
primaryNpcId: 'story-1',
|
||||
linkedThreadIds: ['thread-1'],
|
||||
actGoal: '接住首幕入口',
|
||||
transitionHook: '向第二幕推进。',
|
||||
advanceRule: 'after_primary_contact',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
|
||||
'/generated-characters/playable-1/visual/asset-1/master.png',
|
||||
);
|
||||
expect(profile?.playableNpcs[0]?.generatedVisualAssetId).toBe('asset-1');
|
||||
expect(profile?.storyNpcs[0]?.imageSrc).toBe(
|
||||
'/generated-characters/story-1/visual/asset-2/master.png',
|
||||
);
|
||||
expect(profile?.storyNpcs[0]?.generatedVisualAssetId).toBe('asset-2');
|
||||
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundImageSrc).toBe(
|
||||
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
|
||||
);
|
||||
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundAssetId).toBe(
|
||||
'scene-asset-1',
|
||||
);
|
||||
});
|
||||
|
||||
test('prefers embedded legacy result profile without dropping compiled runtime fields', () => {
|
||||
const profile = buildProfileFromEmbeddedLegacyResult();
|
||||
|
||||
expect(profile?.name).toBe('旧版完整结果');
|
||||
expect(profile?.majorFactions).toEqual(['守灯会', '航运公会']);
|
||||
expect(profile?.coreConflicts).toEqual(['争夺航路控制权', '沉船真相']);
|
||||
expect(profile?.themePack?.id).toBe('theme-pack:tide');
|
||||
expect(profile?.storyGraph?.visibleThreads[0]?.id).toBe('thread-visible-1');
|
||||
expect(profile?.knowledgeFacts?.[0]?.id).toBe('fact-1');
|
||||
expect(profile?.threadContracts?.[0]?.id).toBe('contract-1');
|
||||
expect(profile?.scenarioPackId).toBe('scenario-pack:tide');
|
||||
expect(profile?.campaignPackId).toBe('campaign-pack:tide');
|
||||
expect(profile?.playableNpcs[0]?.attributeProfile?.schemaId).toBe(
|
||||
'schema:test',
|
||||
);
|
||||
expect(profile?.storyNpcs[0]?.narrativeProfile?.publicMask).toContain(
|
||||
'守灯会值夜人',
|
||||
);
|
||||
expect(profile?.items[0]?.attributeResonance?.explanation).toContain('潮雾');
|
||||
expect(profile?.landmarks[0]?.narrativeResidues?.[0]?.title).toBe('潮痕');
|
||||
});
|
||||
|
||||
test('embedded legacy result profile merges latest draft asset fields for result view', () => {
|
||||
const profile = buildCustomWorldProfileFromAgentDraft({
|
||||
...session,
|
||||
draftProfile: {
|
||||
...session.draftProfile,
|
||||
legacyResultProfile: buildLegacyResultProfile(),
|
||||
playableNpcs: [
|
||||
{
|
||||
...session.draftProfile.playableNpcs[0],
|
||||
imageSrc: '/generated-characters/playable-1/visual/asset-runtime/master.png',
|
||||
generatedVisualAssetId: 'asset-runtime-playable',
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
...session.draftProfile.storyNpcs[0],
|
||||
imageSrc: '/generated-characters/story-1/visual/asset-runtime/master.png',
|
||||
generatedVisualAssetId: 'asset-runtime-story',
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
...session.draftProfile.landmarks[0],
|
||||
imageSrc: '/generated-custom-world-scenes/landmark-1/scene.png',
|
||||
},
|
||||
],
|
||||
sceneChapters: [
|
||||
{
|
||||
id: 'scene-chapter-1',
|
||||
sceneId: 'landmark-1',
|
||||
sceneName: '回潮旧灯塔',
|
||||
title: '灯塔初章',
|
||||
summary: '围绕灯塔推进的首个场景章节。',
|
||||
linkedThreadIds: ['thread-1'],
|
||||
linkedLandmarkIds: ['landmark-1'],
|
||||
acts: [
|
||||
{
|
||||
id: 'scene-act-1',
|
||||
title: '第一幕',
|
||||
summary: '先接住回潮灯塔的入口压力。',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc:
|
||||
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
|
||||
backgroundAssetId: 'scene-asset-runtime',
|
||||
encounterNpcIds: ['story-1'],
|
||||
primaryNpcId: 'story-1',
|
||||
linkedThreadIds: ['thread-1'],
|
||||
actGoal: '接住首幕入口',
|
||||
transitionHook: '向第二幕推进。',
|
||||
advanceRule: 'after_primary_contact',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(profile?.name).toBe('旧版完整结果');
|
||||
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
|
||||
'/generated-characters/playable-1/visual/asset-runtime/master.png',
|
||||
);
|
||||
expect(profile?.playableNpcs[0]?.generatedVisualAssetId).toBe(
|
||||
'asset-runtime-playable',
|
||||
);
|
||||
expect(profile?.storyNpcs[0]?.imageSrc).toBe(
|
||||
'/generated-characters/story-1/visual/asset-runtime/master.png',
|
||||
);
|
||||
expect(profile?.storyNpcs[0]?.generatedVisualAssetId).toBe(
|
||||
'asset-runtime-story',
|
||||
);
|
||||
expect(profile?.landmarks[0]?.imageSrc).toBe(
|
||||
'/generated-custom-world-scenes/landmark-1/scene.png',
|
||||
);
|
||||
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundImageSrc).toBe(
|
||||
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
|
||||
);
|
||||
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundAssetId).toBe(
|
||||
'scene-asset-runtime',
|
||||
);
|
||||
});
|
||||
|
||||
test('embedded legacy result profile keeps result-page settings in runtime characters and scenes', () => {
|
||||
const profile = buildProfileFromEmbeddedLegacyResult();
|
||||
|
||||
expect(profile).toBeTruthy();
|
||||
|
||||
setRuntimeCustomWorldProfile(profile);
|
||||
|
||||
const runtimeCharacters = buildCustomWorldRuntimeCharacters(profile);
|
||||
const leadCharacter = runtimeCharacters.find(
|
||||
(character) => character.id === 'playable-1',
|
||||
);
|
||||
const lighthouseScene = getScenePresetsByWorld(WorldType.CUSTOM).find(
|
||||
(scene) => scene.name === '回潮旧灯塔',
|
||||
);
|
||||
const guardNpc = lighthouseScene?.npcs.find((npc) => npc.id === 'story-1');
|
||||
|
||||
expect(leadCharacter?.skills[0]?.name).toBe('潮行引路');
|
||||
expect(leadCharacter?.backstoryReveal?.publicSummary).toBe('沈砺的公开背景');
|
||||
expect(lighthouseScene?.connections[0]?.summary).toBe(
|
||||
'沿着旧潮阶继续前压到雾栈尽头。',
|
||||
);
|
||||
expect(lighthouseScene?.narrativeResidues?.[0]?.title).toBe('潮痕');
|
||||
expect(guardNpc?.narrativeProfile?.publicMask).toBe(
|
||||
'守灯会值夜人,对外总像比别人更冷静一步。',
|
||||
);
|
||||
});
|
||||
|
||||
test('embedded legacy result profile uses latest draft role collection when legacy role ids drift', () => {
|
||||
const profile = buildCustomWorldProfileFromAgentDraft({
|
||||
...session,
|
||||
draftProfile: {
|
||||
...session.draftProfile,
|
||||
legacyResultProfile: buildLegacyResultProfile(),
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-npc-latest-1',
|
||||
name: '林教授',
|
||||
title: '深海学院导师',
|
||||
role: '场景关键角色',
|
||||
publicIdentity: '研究古代海洋遗迹的资深学者。',
|
||||
publicMask: '总是先观察,再给出判断。',
|
||||
currentPressure: '必须在遗迹崩塌前带出关键样本。',
|
||||
hiddenHook: '他知道遗迹深处那扇门为何会苏醒。',
|
||||
relationToPlayer: '最早愿意共享海图的人',
|
||||
threadIds: ['thread-1'],
|
||||
summary: '他像学者,也像提前看见灾变的人。',
|
||||
imageSrc:
|
||||
'/generated-characters/story-npc-latest-1/visual/asset-latest/master.png',
|
||||
generatedVisualAssetId: 'asset-latest-story',
|
||||
},
|
||||
],
|
||||
sceneChapters: [
|
||||
{
|
||||
id: 'scene-chapter-latest-1',
|
||||
sceneId: 'landmark-1',
|
||||
sceneName: '回潮旧灯塔',
|
||||
title: '灯塔新章',
|
||||
summary: '围绕林教授推进的新章节。',
|
||||
linkedThreadIds: ['thread-1'],
|
||||
linkedLandmarkIds: ['landmark-1'],
|
||||
acts: [
|
||||
{
|
||||
id: 'scene-act-latest-1',
|
||||
title: '第一幕',
|
||||
summary: '先接林教授的入口信息。',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc:
|
||||
'/generated-custom-world-scenes/landmark-1/scene-act-latest-1/scene.png',
|
||||
backgroundAssetId: 'scene-asset-latest',
|
||||
encounterNpcIds: ['story-npc-latest-1'],
|
||||
primaryNpcId: 'story-npc-latest-1',
|
||||
linkedThreadIds: ['thread-1'],
|
||||
actGoal: '接住新的入口信息',
|
||||
transitionHook: '向下一幕推进。',
|
||||
advanceRule: 'after_primary_contact',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
...session.draftProfile.landmarks[0],
|
||||
imageSrc: '/generated-custom-world-scenes/landmark-1/latest-scene.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(profile?.storyNpcs).toHaveLength(1);
|
||||
expect(profile?.storyNpcs[0]?.id).toBe('story-npc-latest-1');
|
||||
expect(profile?.storyNpcs[0]?.name).toBe('林教授');
|
||||
expect(profile?.storyNpcs[0]?.imageSrc).toBe(
|
||||
'/generated-characters/story-npc-latest-1/visual/asset-latest/master.png',
|
||||
);
|
||||
expect(profile?.storyNpcs[0]?.generatedVisualAssetId).toBe(
|
||||
'asset-latest-story',
|
||||
);
|
||||
expect(profile?.storyNpcs[0]?.narrativeProfile).toBeFalsy();
|
||||
expect(profile?.landmarks[0]?.imageSrc).toBe(
|
||||
'/generated-custom-world-scenes/landmark-1/latest-scene.png',
|
||||
);
|
||||
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.primaryNpcId).toBe(
|
||||
'story-npc-latest-1',
|
||||
);
|
||||
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundImageSrc).toBe(
|
||||
'/generated-custom-world-scenes/landmark-1/scene-act-latest-1/scene.png',
|
||||
);
|
||||
});
|
||||
@@ -1,591 +0,0 @@
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary';
|
||||
import { type CustomWorldProfile, WorldType } from '../types';
|
||||
import { buildAgentDraftFoundationSettingText } from './customWorldAgentGenerationProgress';
|
||||
|
||||
function toText(value: unknown, fallback = '') {
|
||||
return typeof value === 'string' ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toRecordArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is Record<string, unknown> => isRecord(item))
|
||||
: [];
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown, max = 8) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice(
|
||||
0,
|
||||
max,
|
||||
);
|
||||
}
|
||||
|
||||
function inferTemplateWorldType(settingText: string) {
|
||||
return /[仙灵修真飞升灵脉宗门法器天宫秘境道骨星舰]/u.test(settingText)
|
||||
? WorldType.XIANXIA
|
||||
: WorldType.WUXIA;
|
||||
}
|
||||
|
||||
function buildCharacterSummaryText(record: Record<string, unknown>) {
|
||||
return (
|
||||
toText(record.summary) ||
|
||||
toText(record.publicIdentity) ||
|
||||
toText(record.publicMask) ||
|
||||
toText(record.currentPressure) ||
|
||||
toText(record.relationToPlayer)
|
||||
);
|
||||
}
|
||||
|
||||
function buildCharacterBackstoryText(record: Record<string, unknown>) {
|
||||
return [
|
||||
toText(record.publicIdentity),
|
||||
toText(record.currentPressure),
|
||||
toText(record.hiddenHook) ? `暗线 ${toText(record.hiddenHook)}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
}
|
||||
|
||||
function buildRelationshipHooks(record: Record<string, unknown>) {
|
||||
return [
|
||||
toText(record.relationToPlayer),
|
||||
toText(record.currentPressure),
|
||||
toText(record.hiddenHook) ? `暗线 ${toText(record.hiddenHook)}` : '',
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function buildCharacterTags(
|
||||
record: Record<string, unknown>,
|
||||
roleKind: 'playable' | 'story',
|
||||
) {
|
||||
const threadIds = toStringArray(record.threadIds, 4);
|
||||
return [...threadIds, roleKind === 'playable' ? '草稿主角' : '草稿角色'];
|
||||
}
|
||||
|
||||
type AdaptedDraftCharacter = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
description: string;
|
||||
backstory: string;
|
||||
personality: string;
|
||||
motivation: string;
|
||||
combatStyle: string;
|
||||
initialAffinity: number;
|
||||
relationshipHooks: string[];
|
||||
tags: string[];
|
||||
imageSrc?: string;
|
||||
generatedVisualAssetId?: string;
|
||||
generatedAnimationSetId?: string;
|
||||
animationMap?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function adaptDraftCharacters(value: unknown, roleKind: 'playable' | 'story') {
|
||||
return toRecordArray(value)
|
||||
.map((record, index) => {
|
||||
const name = toText(record.name);
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title =
|
||||
toText(record.title) ||
|
||||
toText(record.role) ||
|
||||
(roleKind === 'playable' ? '关键角色' : '场景角色');
|
||||
const role = toText(record.role) || title;
|
||||
const description = buildCharacterSummaryText(record);
|
||||
const relationshipHooks = buildRelationshipHooks(record);
|
||||
|
||||
return {
|
||||
id: toText(record.id) || `${roleKind}-draft-${index + 1}`,
|
||||
name,
|
||||
title,
|
||||
role,
|
||||
description,
|
||||
backstory: buildCharacterBackstoryText(record),
|
||||
personality:
|
||||
toText(record.publicMask) ||
|
||||
toText(record.publicIdentity) ||
|
||||
description,
|
||||
motivation:
|
||||
toText(record.relationToPlayer) ||
|
||||
toText(record.currentPressure) ||
|
||||
toText(record.hiddenHook),
|
||||
combatStyle: role,
|
||||
initialAffinity: roleKind === 'playable' ? 18 : 6,
|
||||
relationshipHooks,
|
||||
tags: buildCharacterTags(record, roleKind),
|
||||
imageSrc: toText(record.imageSrc) || undefined,
|
||||
generatedVisualAssetId:
|
||||
toText(record.generatedVisualAssetId) || undefined,
|
||||
generatedAnimationSetId:
|
||||
toText(record.generatedAnimationSetId) || undefined,
|
||||
animationMap: isRecord(record.animationMap)
|
||||
? record.animationMap
|
||||
: undefined,
|
||||
} satisfies AdaptedDraftCharacter;
|
||||
})
|
||||
.filter(Boolean) as AdaptedDraftCharacter[];
|
||||
}
|
||||
|
||||
type AdaptedDraftLandmark = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
dangerLevel: string;
|
||||
imageSrc?: string;
|
||||
sceneNpcIds: string[];
|
||||
connections: never[];
|
||||
};
|
||||
|
||||
type AdaptedDraftCamp = {
|
||||
name: string;
|
||||
description: string;
|
||||
dangerLevel: string;
|
||||
imageSrc?: string;
|
||||
};
|
||||
|
||||
function adaptDraftLandmarks(value: unknown, storyNpcIdSet: Set<string>) {
|
||||
return toRecordArray(value)
|
||||
.map((record, index) => {
|
||||
const name = toText(record.name);
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: toText(record.id) || `landmark-draft-${index + 1}`,
|
||||
name,
|
||||
description:
|
||||
toText(record.description) ||
|
||||
toText(record.summary) ||
|
||||
[toText(record.purpose), toText(record.mood)]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
dangerLevel:
|
||||
toText(record.dangerLevel) ||
|
||||
toText(record.importance) ||
|
||||
toText(record.mood),
|
||||
imageSrc: toText(record.imageSrc) || undefined,
|
||||
sceneNpcIds: toStringArray(record.characterIds).filter((id) =>
|
||||
storyNpcIdSet.has(id),
|
||||
),
|
||||
connections: [],
|
||||
} satisfies AdaptedDraftLandmark;
|
||||
})
|
||||
.filter(Boolean) as AdaptedDraftLandmark[];
|
||||
}
|
||||
|
||||
function adaptDraftCamp(value: unknown): AdaptedDraftCamp | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = toText(value.name);
|
||||
const description = toText(value.description);
|
||||
if (!name && !description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: name || '开局据点',
|
||||
description: description || '开局落脚点仍待继续精修。',
|
||||
dangerLevel:
|
||||
toText(value.dangerLevel) || toText(value.mood) || 'medium',
|
||||
imageSrc: toText(value.imageSrc) || undefined,
|
||||
} satisfies AdaptedDraftCamp;
|
||||
}
|
||||
|
||||
function normalizeMatchText(value: unknown) {
|
||||
return toText(value).toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function findRecordMatchIndex(
|
||||
records: Record<string, unknown>[],
|
||||
matcher: (record: Record<string, unknown>) => boolean,
|
||||
usedIndexes: Set<number>,
|
||||
) {
|
||||
const matchedIndex = records.findIndex(
|
||||
(record, index) => !usedIndexes.has(index) && matcher(record),
|
||||
);
|
||||
if (matchedIndex >= 0) {
|
||||
usedIndexes.add(matchedIndex);
|
||||
}
|
||||
return matchedIndex;
|
||||
}
|
||||
|
||||
function mergeDraftRolesIntoProfileRecord(params: {
|
||||
baseRoles: unknown;
|
||||
draftRoles: AdaptedDraftCharacter[];
|
||||
}) {
|
||||
const baseRoles = toRecordArray(params.baseRoles);
|
||||
if (params.draftRoles.length <= 0) {
|
||||
return baseRoles;
|
||||
}
|
||||
|
||||
const usedIndexes = new Set<number>();
|
||||
|
||||
// 当前 draft 才是最新角色集合;legacy 只负责为同一对象补运行时富字段,
|
||||
// 不能再让旧列表继续主导结果页,否则会把新角色主图和新对象列表吞掉。
|
||||
return params.draftRoles.map((draftRole) => {
|
||||
let matchedIndex = findRecordMatchIndex(
|
||||
baseRoles,
|
||||
(record) => toText(record.id) === draftRole.id,
|
||||
usedIndexes,
|
||||
);
|
||||
|
||||
if (matchedIndex < 0) {
|
||||
matchedIndex = findRecordMatchIndex(
|
||||
baseRoles,
|
||||
(record) => normalizeMatchText(record.name) === normalizeMatchText(draftRole.name),
|
||||
usedIndexes,
|
||||
);
|
||||
}
|
||||
|
||||
const baseRole = matchedIndex >= 0 ? baseRoles[matchedIndex] : null;
|
||||
const baseImageSrc = toText(baseRole?.imageSrc) || undefined;
|
||||
const baseGeneratedVisualAssetId =
|
||||
toText(baseRole?.generatedVisualAssetId) || undefined;
|
||||
const baseGeneratedAnimationSetId =
|
||||
toText(baseRole?.generatedAnimationSetId) || undefined;
|
||||
return {
|
||||
...(baseRole ?? {}),
|
||||
...draftRole,
|
||||
imageSrc: draftRole.imageSrc ?? baseImageSrc,
|
||||
generatedVisualAssetId:
|
||||
draftRole.generatedVisualAssetId ?? baseGeneratedVisualAssetId,
|
||||
generatedAnimationSetId:
|
||||
draftRole.generatedAnimationSetId ?? baseGeneratedAnimationSetId,
|
||||
animationMap:
|
||||
draftRole.animationMap ??
|
||||
(isRecord(baseRole?.animationMap) ? baseRole?.animationMap : undefined),
|
||||
} satisfies Record<string, unknown>;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeDraftLandmarksIntoProfileRecord(params: {
|
||||
baseLandmarks: unknown;
|
||||
draftLandmarks: AdaptedDraftLandmark[];
|
||||
}) {
|
||||
const baseLandmarks = toRecordArray(params.baseLandmarks);
|
||||
if (params.draftLandmarks.length <= 0) {
|
||||
return baseLandmarks;
|
||||
}
|
||||
|
||||
const usedIndexes = new Set<number>();
|
||||
const mergedLandmarks = params.draftLandmarks.map((draftLandmark) => {
|
||||
let matchedIndex = findRecordMatchIndex(
|
||||
baseLandmarks,
|
||||
(record) => toText(record.id) === draftLandmark.id,
|
||||
usedIndexes,
|
||||
);
|
||||
|
||||
if (matchedIndex < 0) {
|
||||
matchedIndex = findRecordMatchIndex(
|
||||
baseLandmarks,
|
||||
(record) =>
|
||||
normalizeMatchText(record.name) === normalizeMatchText(draftLandmark.name),
|
||||
usedIndexes,
|
||||
);
|
||||
}
|
||||
|
||||
const baseLandmark = matchedIndex >= 0 ? baseLandmarks[matchedIndex] : null;
|
||||
const baseImageSrc = toText(baseLandmark?.imageSrc) || undefined;
|
||||
return {
|
||||
...(baseLandmark ?? {}),
|
||||
id: draftLandmark.id,
|
||||
name: draftLandmark.name,
|
||||
description: draftLandmark.description,
|
||||
dangerLevel: draftLandmark.dangerLevel,
|
||||
imageSrc: draftLandmark.imageSrc ?? baseImageSrc,
|
||||
sceneNpcIds:
|
||||
draftLandmark.sceneNpcIds.length > 0
|
||||
? draftLandmark.sceneNpcIds
|
||||
: toStringArray(baseLandmark?.sceneNpcIds),
|
||||
} satisfies Record<string, unknown>;
|
||||
});
|
||||
|
||||
const remainingLegacyLandmarks = baseLandmarks.filter(
|
||||
(_entry, index) => !usedIndexes.has(index),
|
||||
);
|
||||
|
||||
return [...mergedLandmarks, ...remainingLegacyLandmarks];
|
||||
}
|
||||
|
||||
function mergeDraftSceneChaptersIntoProfileRecord(params: {
|
||||
baseSceneChapters: unknown;
|
||||
draftSceneChapters: CustomWorldProfile['sceneChapterBlueprints'];
|
||||
}) {
|
||||
const baseSceneChapters = toRecordArray(params.baseSceneChapters);
|
||||
const draftSceneChapters = params.draftSceneChapters ?? [];
|
||||
if (draftSceneChapters.length <= 0) {
|
||||
return baseSceneChapters;
|
||||
}
|
||||
|
||||
const usedChapterIndexes = new Set<number>();
|
||||
return draftSceneChapters.map((draftChapter) => {
|
||||
let matchedChapterIndex = findRecordMatchIndex(
|
||||
baseSceneChapters,
|
||||
(record) => toText(record.sceneId) === draftChapter.sceneId,
|
||||
usedChapterIndexes,
|
||||
);
|
||||
|
||||
if (matchedChapterIndex < 0) {
|
||||
matchedChapterIndex = findRecordMatchIndex(
|
||||
baseSceneChapters,
|
||||
(record) =>
|
||||
normalizeMatchText(record.title) === normalizeMatchText(draftChapter.title),
|
||||
usedChapterIndexes,
|
||||
);
|
||||
}
|
||||
|
||||
const baseChapter =
|
||||
matchedChapterIndex >= 0 ? baseSceneChapters[matchedChapterIndex] : null;
|
||||
const baseActs = toRecordArray(baseChapter?.acts);
|
||||
const usedActIndexes = new Set<number>();
|
||||
const mergedActs = draftChapter.acts.map((draftAct) => {
|
||||
let matchedActIndex = findRecordMatchIndex(
|
||||
baseActs,
|
||||
(record) => toText(record.id) === draftAct.id,
|
||||
usedActIndexes,
|
||||
);
|
||||
|
||||
if (matchedActIndex < 0) {
|
||||
matchedActIndex = findRecordMatchIndex(
|
||||
baseActs,
|
||||
(record) =>
|
||||
normalizeMatchText(record.title) === normalizeMatchText(draftAct.title),
|
||||
usedActIndexes,
|
||||
);
|
||||
}
|
||||
|
||||
const baseAct = matchedActIndex >= 0 ? baseActs[matchedActIndex] : null;
|
||||
const baseBackgroundImageSrc =
|
||||
toText(baseAct?.backgroundImageSrc) || undefined;
|
||||
const baseBackgroundAssetId =
|
||||
toText(baseAct?.backgroundAssetId) || undefined;
|
||||
return {
|
||||
...(baseAct ?? {}),
|
||||
...draftAct,
|
||||
backgroundImageSrc: draftAct.backgroundImageSrc ?? baseBackgroundImageSrc,
|
||||
backgroundAssetId: draftAct.backgroundAssetId ?? baseBackgroundAssetId,
|
||||
} satisfies Record<string, unknown>;
|
||||
});
|
||||
|
||||
return {
|
||||
...(baseChapter ?? {}),
|
||||
...draftChapter,
|
||||
acts: mergedActs,
|
||||
} satisfies Record<string, unknown>;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeDraftCampIntoProfileRecord(params: {
|
||||
baseCamp: unknown;
|
||||
draftCamp: AdaptedDraftCamp | null;
|
||||
}) {
|
||||
if (!params.draftCamp) {
|
||||
return isRecord(params.baseCamp) ? params.baseCamp : undefined;
|
||||
}
|
||||
|
||||
const baseCamp = isRecord(params.baseCamp) ? params.baseCamp : null;
|
||||
const baseImageSrc = toText(baseCamp?.imageSrc) || undefined;
|
||||
return {
|
||||
...(baseCamp ?? {}),
|
||||
...params.draftCamp,
|
||||
imageSrc: params.draftCamp.imageSrc ?? baseImageSrc,
|
||||
} satisfies Record<string, unknown>;
|
||||
}
|
||||
|
||||
function toStageCoverage(value: unknown) {
|
||||
const stageCoverage = Array.isArray(value)
|
||||
? value
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return [...new Set(stageCoverage)];
|
||||
}
|
||||
|
||||
function adaptDraftSceneChapters(
|
||||
value: unknown,
|
||||
storyNpcIdSet: Set<string>,
|
||||
landmarkIdSet: Set<string>,
|
||||
) {
|
||||
return toRecordArray(value)
|
||||
.map((record, index) => {
|
||||
const sceneId = toText(record.sceneId);
|
||||
if (!sceneId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const acts = toRecordArray(record.acts)
|
||||
.map((actRecord, actIndex) => {
|
||||
const encounterNpcIds = toStringArray(
|
||||
actRecord.encounterNpcIds,
|
||||
).filter((entry) => storyNpcIdSet.has(entry));
|
||||
const primaryNpcId = toText(
|
||||
actRecord.primaryNpcId,
|
||||
encounterNpcIds[0] ?? '',
|
||||
);
|
||||
|
||||
return {
|
||||
id: toText(actRecord.id) || `scene-act-${sceneId}-${actIndex + 1}`,
|
||||
sceneId,
|
||||
title: toText(actRecord.title) || `第 ${actIndex + 1} 幕`,
|
||||
summary:
|
||||
toText(actRecord.summary) ||
|
||||
toText(actRecord.actGoal) ||
|
||||
`围绕${toText(record.sceneName, sceneId)}继续推进`,
|
||||
stageCoverage:
|
||||
toStageCoverage(actRecord.stageCoverage).length > 0
|
||||
? toStageCoverage(actRecord.stageCoverage)
|
||||
: actIndex === 0
|
||||
? ['opening']
|
||||
: ['climax', 'aftermath'],
|
||||
backgroundImageSrc:
|
||||
toText(actRecord.backgroundImageSrc) || undefined,
|
||||
backgroundAssetId:
|
||||
toText(actRecord.backgroundAssetId) || undefined,
|
||||
encounterNpcIds,
|
||||
primaryNpcId,
|
||||
linkedThreadIds: toStringArray(actRecord.linkedThreadIds),
|
||||
advanceRule:
|
||||
toText(actRecord.advanceRule) || 'after_active_step_complete',
|
||||
actGoal: toText(actRecord.actGoal),
|
||||
transitionHook: toText(actRecord.transitionHook),
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.encounterNpcIds.length > 0 || entry.backgroundImageSrc,
|
||||
);
|
||||
|
||||
return {
|
||||
id: toText(record.id) || `scene-chapter-${sceneId}-${index + 1}`,
|
||||
sceneId,
|
||||
title: toText(record.title) || toText(record.sceneName) || sceneId,
|
||||
summary:
|
||||
toText(record.summary) ||
|
||||
toText(record.title) ||
|
||||
toText(record.sceneName) ||
|
||||
sceneId,
|
||||
linkedThreadIds: toStringArray(record.linkedThreadIds, 8),
|
||||
linkedLandmarkIds: toStringArray(record.linkedLandmarkIds, 8).filter(
|
||||
(entry) => landmarkIdSet.has(entry),
|
||||
),
|
||||
acts,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function buildCustomWorldProfileFromAgentDraft(
|
||||
session: CustomWorldAgentSessionSnapshot | null | undefined,
|
||||
): CustomWorldProfile | null {
|
||||
if (!session || !isRecord(session.draftProfile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const draftProfile = session.draftProfile;
|
||||
const settingText = buildAgentDraftFoundationSettingText(session);
|
||||
const templateWorldType = inferTemplateWorldType(settingText);
|
||||
const playableNpcs = adaptDraftCharacters(
|
||||
draftProfile.playableNpcs,
|
||||
'playable',
|
||||
);
|
||||
const storyNpcs = adaptDraftCharacters(draftProfile.storyNpcs, 'story');
|
||||
const storyNpcIdSet = new Set(
|
||||
storyNpcs.map((entry) => toText(entry.id)).filter(Boolean),
|
||||
);
|
||||
const adaptedLandmarks = adaptDraftLandmarks(
|
||||
draftProfile.landmarks,
|
||||
storyNpcIdSet,
|
||||
);
|
||||
const landmarkIdSet = new Set(
|
||||
adaptedLandmarks.map((entry) => toText(entry.id)).filter(Boolean),
|
||||
);
|
||||
const draftSceneChapterBlueprints = adaptDraftSceneChapters(
|
||||
draftProfile.sceneChapters,
|
||||
storyNpcIdSet,
|
||||
landmarkIdSet,
|
||||
);
|
||||
const draftCamp = adaptDraftCamp(draftProfile.camp);
|
||||
const legacyResultProfile = normalizeCustomWorldProfileRecord(
|
||||
draftProfile.legacyResultProfile,
|
||||
);
|
||||
if (legacyResultProfile) {
|
||||
const mergedProfile = normalizeCustomWorldProfileRecord({
|
||||
...legacyResultProfile,
|
||||
playableNpcs: mergeDraftRolesIntoProfileRecord({
|
||||
baseRoles: legacyResultProfile.playableNpcs,
|
||||
draftRoles: playableNpcs,
|
||||
}),
|
||||
storyNpcs: mergeDraftRolesIntoProfileRecord({
|
||||
baseRoles: legacyResultProfile.storyNpcs,
|
||||
draftRoles: storyNpcs,
|
||||
}),
|
||||
landmarks: mergeDraftLandmarksIntoProfileRecord({
|
||||
baseLandmarks: legacyResultProfile.landmarks,
|
||||
draftLandmarks: adaptedLandmarks,
|
||||
}),
|
||||
camp: mergeDraftCampIntoProfileRecord({
|
||||
baseCamp: legacyResultProfile.camp,
|
||||
draftCamp,
|
||||
}),
|
||||
sceneChapterBlueprints: mergeDraftSceneChaptersIntoProfileRecord({
|
||||
baseSceneChapters: legacyResultProfile.sceneChapterBlueprints,
|
||||
draftSceneChapters: draftSceneChapterBlueprints,
|
||||
}),
|
||||
});
|
||||
|
||||
return mergedProfile ?? legacyResultProfile;
|
||||
}
|
||||
|
||||
const normalized = normalizeCustomWorldProfileRecord({
|
||||
id: `agent-draft-${session.sessionId}`,
|
||||
settingText,
|
||||
name: toText(draftProfile.name) || '未命名世界底稿',
|
||||
subtitle: toText(draftProfile.subtitle) || '第一版世界底稿',
|
||||
summary:
|
||||
toText(draftProfile.summary) ||
|
||||
settingText ||
|
||||
'第一版世界底稿已经整理完成。',
|
||||
tone: toText(draftProfile.tone) || '整体气质仍可继续精修',
|
||||
playerGoal: toText(draftProfile.playerGoal) || '先站稳开局,再判断下一步',
|
||||
templateWorldType,
|
||||
compatibilityTemplateWorldType: templateWorldType,
|
||||
majorFactions: toStringArray(draftProfile.majorFactions, 6),
|
||||
coreConflicts: toStringArray(draftProfile.coreConflicts, 6),
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
landmarks: adaptedLandmarks,
|
||||
camp: draftCamp
|
||||
? {
|
||||
name: draftCamp.name,
|
||||
description: draftCamp.description,
|
||||
dangerLevel: draftCamp.dangerLevel,
|
||||
imageSrc: draftCamp.imageSrc,
|
||||
}
|
||||
: undefined,
|
||||
sceneChapterBlueprints: draftSceneChapterBlueprints,
|
||||
anchorContent: session.anchorContent,
|
||||
creatorIntent: session.creatorIntent,
|
||||
anchorPack: session.anchorPack,
|
||||
lockState: session.lockState,
|
||||
generationMode: 'fast',
|
||||
generationStatus: 'key_only',
|
||||
});
|
||||
|
||||
return normalized;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NpcChatTurnDirective } from '../../packages/shared/src/contracts/story';
|
||||
import type { NpcChatTurnDirective } from '../../packages/shared/src/contracts/rpgRuntimeChat';
|
||||
import type {
|
||||
CustomWorldProfile,
|
||||
GameState,
|
||||
|
||||
@@ -18,6 +18,15 @@ export {
|
||||
generateRpgWorldStoryNpc,
|
||||
uploadRpgWorldCoverImage,
|
||||
} from './rpgCreationAssetClient';
|
||||
export type {
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from './rpgCreationGenerationClient';
|
||||
export {
|
||||
generateCustomWorldProfile as generateLegacyCustomWorldProfile,
|
||||
generateRpgWorldProfile,
|
||||
} from './rpgCreationGenerationClient';
|
||||
export {
|
||||
deleteRpgWorldProfile,
|
||||
getRpgWorldGalleryDetail,
|
||||
@@ -29,7 +38,11 @@ export {
|
||||
upsertRpgWorldProfile,
|
||||
} from './rpgCreationLibraryClient';
|
||||
export {
|
||||
buildRpgCreationPreviewFromAgentDraft,
|
||||
buildRpgCreationPreviewFromResultPreview,
|
||||
buildRpgCreationPreviewFromSession,
|
||||
rpgCreationPreviewAdapter,
|
||||
} from './rpgCreationPreviewAdapter';
|
||||
export { listRpgCreationWorks, rpgCreationWorkClient } from './rpgCreationWorkClient';
|
||||
export {
|
||||
listRpgCreationWorks,
|
||||
rpgCreationWorkClient,
|
||||
} from './rpgCreationWorkClient';
|
||||
|
||||
@@ -1,33 +1,208 @@
|
||||
import type {
|
||||
CreateRpgAgentSessionRequest,
|
||||
CreateRpgAgentSessionResponse,
|
||||
GetRpgAgentCardDetailResponse,
|
||||
RpgAgentDraftCardDetail,
|
||||
RpgAgentOperationRecord,
|
||||
RpgAgentSessionSnapshot,
|
||||
SendRpgAgentMessageRequest,
|
||||
} from '../../../packages/shared/src';
|
||||
import type { RpgAgentActionRequest } from '../../../packages/shared/src';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
import {
|
||||
createCustomWorldAgentSession,
|
||||
executeCustomWorldAgentAction,
|
||||
getCustomWorldAgentCardDetail,
|
||||
getCustomWorldAgentOperation,
|
||||
getCustomWorldAgentSession,
|
||||
sendCustomWorldAgentMessage,
|
||||
streamCustomWorldAgentMessage,
|
||||
} from '../aiService';
|
||||
openRpgCreationSsePost,
|
||||
requestRpgCreationPostJson,
|
||||
} from './rpgCreationRequestHelpers';
|
||||
import { requestRpgCreationRuntimeJson } from './rpgCreationRuntimeClient';
|
||||
|
||||
const RPG_AGENT_API_BASE = '/custom-world/agent/sessions';
|
||||
|
||||
export async function createRpgCreationSession(
|
||||
payload: CreateRpgAgentSessionRequest,
|
||||
) {
|
||||
return requestRpgCreationRuntimeJson<CreateRpgAgentSessionResponse>(
|
||||
RPG_AGENT_API_BASE,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'创建世界共创会话失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function getRpgCreationSession(sessionId: string) {
|
||||
return requestRpgCreationRuntimeJson<RpgAgentSessionSnapshot>(
|
||||
`${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取世界共创会话失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendRpgCreationMessage(
|
||||
sessionId: string,
|
||||
payload: SendRpgAgentMessageRequest,
|
||||
) {
|
||||
return requestRpgCreationPostJson<{ operation: RpgAgentOperationRecord }>(
|
||||
`/api/runtime${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages`,
|
||||
payload,
|
||||
'发送共创消息失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function streamRpgCreationMessage(
|
||||
sessionId: string,
|
||||
payload: SendRpgAgentMessageRequest,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const response = await openRpgCreationSsePost(
|
||||
`/api/runtime${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`,
|
||||
payload,
|
||||
'发送共创消息失败',
|
||||
);
|
||||
|
||||
const streamBody = response.body;
|
||||
if (!streamBody) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = streamBody.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let finalSession: RpgAgentSessionSnapshot | null = null;
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
while (buffer.includes('\n\n')) {
|
||||
const boundary = buffer.indexOf('\n\n');
|
||||
const eventBlock = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || 'message';
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = dataLines.join('\n');
|
||||
let parsed: Record<string, unknown> | null = null;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(data) as Record<string, unknown>;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
if (eventName === 'reply_delta' && parsed) {
|
||||
const text = parsed.text;
|
||||
if (typeof text === 'string') {
|
||||
options.onUpdate?.(text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'session' && parsed?.session) {
|
||||
finalSession = parsed.session as RpgAgentSessionSnapshot;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'error' && parsed) {
|
||||
const message =
|
||||
typeof parsed.message === 'string' && parsed.message.trim()
|
||||
? parsed.message.trim()
|
||||
: '发送共创消息失败';
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalSession) {
|
||||
throw new Error('共创消息流式结果不完整');
|
||||
}
|
||||
|
||||
return finalSession;
|
||||
}
|
||||
|
||||
export async function executeRpgCreationAction(
|
||||
sessionId: string,
|
||||
payload: RpgAgentActionRequest,
|
||||
) {
|
||||
return requestRpgCreationRuntimeJson<{ operation: RpgAgentOperationRecord }>(
|
||||
`${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/actions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'执行共创操作失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function getRpgCreationOperation(
|
||||
sessionId: string,
|
||||
operationId: string,
|
||||
): Promise<RpgAgentOperationRecord> {
|
||||
const response = await requestRpgCreationRuntimeJson<
|
||||
{
|
||||
operation?: RpgAgentOperationRecord;
|
||||
data?: RpgAgentOperationRecord;
|
||||
} & Partial<RpgAgentOperationRecord>
|
||||
>(
|
||||
`${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/operations/${encodeURIComponent(operationId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取共创操作状态失败',
|
||||
);
|
||||
|
||||
return (response.operation ?? response.data ?? response) as RpgAgentOperationRecord;
|
||||
}
|
||||
|
||||
export async function getRpgCreationCardDetail(
|
||||
sessionId: string,
|
||||
cardId: string,
|
||||
) {
|
||||
const response = await requestRpgCreationRuntimeJson<GetRpgAgentCardDetailResponse>(
|
||||
`${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/cards/${encodeURIComponent(cardId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取草稿卡详情失败',
|
||||
);
|
||||
|
||||
return response.card as RpgAgentDraftCardDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作包 A 先把 RPG 创作 Agent 请求聚合到独立 client 命名空间。
|
||||
* 当前仍直接透传旧实现,后续工作包 D 再逐步把 `aiService.ts` 中的相关接口迁出。
|
||||
* 工作包 D 开始让 RPG 创作 Agent client 持有真实请求实现,
|
||||
* 旧 `aiService.ts` 仅保留兼容导出,避免主链请求继续回流到通用服务文件。
|
||||
*/
|
||||
export const rpgCreationAgentClient = {
|
||||
createSession: createCustomWorldAgentSession,
|
||||
getSession: getCustomWorldAgentSession,
|
||||
sendMessage: sendCustomWorldAgentMessage,
|
||||
streamMessage: streamCustomWorldAgentMessage,
|
||||
executeAction: executeCustomWorldAgentAction,
|
||||
getOperation: getCustomWorldAgentOperation,
|
||||
getCardDetail: getCustomWorldAgentCardDetail,
|
||||
};
|
||||
|
||||
export {
|
||||
createCustomWorldAgentSession as createRpgCreationSession,
|
||||
executeCustomWorldAgentAction as executeRpgCreationAction,
|
||||
getCustomWorldAgentCardDetail as getRpgCreationCardDetail,
|
||||
getCustomWorldAgentOperation as getRpgCreationOperation,
|
||||
getCustomWorldAgentSession as getRpgCreationSession,
|
||||
sendCustomWorldAgentMessage as sendRpgCreationMessage,
|
||||
streamCustomWorldAgentMessage as streamRpgCreationMessage,
|
||||
createSession: createRpgCreationSession,
|
||||
getSession: getRpgCreationSession,
|
||||
sendMessage: sendRpgCreationMessage,
|
||||
streamMessage: streamRpgCreationMessage,
|
||||
executeAction: executeRpgCreationAction,
|
||||
getOperation: getRpgCreationOperation,
|
||||
getCardDetail: getRpgCreationCardDetail,
|
||||
};
|
||||
|
||||
@@ -1,35 +1,116 @@
|
||||
import {
|
||||
generateCustomWorldLandmark,
|
||||
generateCustomWorldPlayableNpc,
|
||||
generateCustomWorldSceneImage,
|
||||
generateCustomWorldSceneNpc,
|
||||
generateCustomWorldStoryNpc,
|
||||
} from '../aiService';
|
||||
import type { CustomWorldSceneImageRequest, CustomWorldSceneImageResult } from '../aiTypes';
|
||||
import {
|
||||
generateCustomWorldCoverImage,
|
||||
uploadCustomWorldCoverImage,
|
||||
} from '../customWorldCoverAssetService';
|
||||
import { requestJson } from '../apiClient';
|
||||
import type {
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
} from '../../types';
|
||||
import { requestRpgCreationPostJson } from './rpgCreationRequestHelpers';
|
||||
|
||||
const RPG_CREATION_ASSET_API_BASE = '/api/custom-world';
|
||||
|
||||
export async function generateRpgWorldSceneImage(
|
||||
payload: CustomWorldSceneImageRequest,
|
||||
) {
|
||||
return requestJson<CustomWorldSceneImageResult>(
|
||||
`${RPG_CREATION_ASSET_API_BASE}/scene-image`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'生成自定义世界场景图失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateRpgWorldSceneNpc(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
landmarkId: string;
|
||||
}) {
|
||||
const response = await requestRpgCreationPostJson<{ npc: CustomWorldNpc }>(
|
||||
`${RPG_CREATION_ASSET_API_BASE}/scene-npc`,
|
||||
payload,
|
||||
'生成场景 NPC 失败',
|
||||
);
|
||||
|
||||
return response.npc;
|
||||
}
|
||||
|
||||
async function requestRpgWorldEntity<T>(
|
||||
payload: {
|
||||
profile: CustomWorldProfile;
|
||||
kind: 'playable' | 'story' | 'landmark';
|
||||
},
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return requestRpgCreationPostJson<{
|
||||
kind: 'playable' | 'story' | 'landmark';
|
||||
entity: T;
|
||||
}>(`${RPG_CREATION_ASSET_API_BASE}/entity`, payload, fallbackMessage);
|
||||
}
|
||||
|
||||
export async function generateRpgWorldPlayableNpc(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const response = await requestRpgWorldEntity<CustomWorldPlayableNpc>(
|
||||
{
|
||||
...payload,
|
||||
kind: 'playable',
|
||||
},
|
||||
'生成可扮演角色失败',
|
||||
);
|
||||
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
export async function generateRpgWorldStoryNpc(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const response = await requestRpgWorldEntity<CustomWorldNpc>(
|
||||
{
|
||||
...payload,
|
||||
kind: 'story',
|
||||
},
|
||||
'生成场景角色失败',
|
||||
);
|
||||
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
export async function generateRpgWorldLandmark(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const response = await requestRpgWorldEntity<CustomWorldLandmark>(
|
||||
{
|
||||
...payload,
|
||||
kind: 'landmark',
|
||||
},
|
||||
'生成场景失败',
|
||||
);
|
||||
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作包 A 先把 RPG 创作资产相关请求收口到独立 client。
|
||||
* 当前仍桥接旧接口,后续工作包 C、D 会继续细分角色资产、场景资产和封面资产职责。
|
||||
* 工作包 D 把结果页与编辑器依赖的资产请求迁入 RPG 创作域 client,
|
||||
* 保留封面资产服务的既有边界,不把逻辑重新塞回 `aiService.ts`。
|
||||
*/
|
||||
export const rpgCreationAssetClient = {
|
||||
generateSceneImage: generateCustomWorldSceneImage,
|
||||
generateSceneNpc: generateCustomWorldSceneNpc,
|
||||
generatePlayableNpc: generateCustomWorldPlayableNpc,
|
||||
generateStoryNpc: generateCustomWorldStoryNpc,
|
||||
generateLandmark: generateCustomWorldLandmark,
|
||||
generateSceneImage: generateRpgWorldSceneImage,
|
||||
generateSceneNpc: generateRpgWorldSceneNpc,
|
||||
generatePlayableNpc: generateRpgWorldPlayableNpc,
|
||||
generateStoryNpc: generateRpgWorldStoryNpc,
|
||||
generateLandmark: generateRpgWorldLandmark,
|
||||
generateCoverImage: generateCustomWorldCoverImage,
|
||||
uploadCoverImage: uploadCustomWorldCoverImage,
|
||||
};
|
||||
|
||||
export {
|
||||
generateCustomWorldCoverImage as generateRpgWorldCoverImage,
|
||||
generateCustomWorldLandmark as generateRpgWorldLandmark,
|
||||
generateCustomWorldPlayableNpc as generateRpgWorldPlayableNpc,
|
||||
generateCustomWorldSceneImage as generateRpgWorldSceneImage,
|
||||
generateCustomWorldSceneNpc as generateRpgWorldSceneNpc,
|
||||
generateCustomWorldStoryNpc as generateRpgWorldStoryNpc,
|
||||
uploadCustomWorldCoverImage as uploadRpgWorldCoverImage,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import { generateRpgWorldProfile } from './rpgCreationGenerationClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
describe('rpgCreationGenerationClient', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({
|
||||
id: 'custom-world-1',
|
||||
name: '测试世界',
|
||||
subtitle: '副标题',
|
||||
summary: '概述',
|
||||
tone: '基调',
|
||||
playerGoal: '目标',
|
||||
settingText: '设定',
|
||||
});
|
||||
});
|
||||
|
||||
it('posts world generation to the runtime custom world profile route', async () => {
|
||||
await generateRpgWorldProfile('一个被灵潮反复改写地形的边境世界');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world/profile',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'生成自定义世界失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects immediately when the caller aborts before sending', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('手动中断生成'));
|
||||
|
||||
await expect(
|
||||
generateRpgWorldProfile('一个会被中断的世界', {
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).rejects.toThrow('手动中断生成');
|
||||
|
||||
expect(requestJsonMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
68
src/services/rpg-creation/rpgCreationGenerationClient.ts
Normal file
68
src/services/rpg-creation/rpgCreationGenerationClient.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { requestJson } from '../apiClient';
|
||||
|
||||
type LegacyAiModule = typeof import('../ai');
|
||||
|
||||
let legacyAiModulePromise: Promise<LegacyAiModule> | null = null;
|
||||
|
||||
async function loadLegacyAiModule() {
|
||||
if (!legacyAiModulePromise) {
|
||||
legacyAiModulePromise = import('../ai');
|
||||
}
|
||||
|
||||
return legacyAiModulePromise;
|
||||
}
|
||||
|
||||
export async function generateRpgWorldProfile(
|
||||
input: GenerateCustomWorldProfileInput | string,
|
||||
options: GenerateCustomWorldProfileOptions = {},
|
||||
): Promise<CustomWorldProfile> {
|
||||
const normalizedInput =
|
||||
typeof input === 'string'
|
||||
? {
|
||||
settingText: input,
|
||||
}
|
||||
: input;
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateCustomWorldProfile(normalizedInput, options);
|
||||
}
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
throw options.signal.reason instanceof Error
|
||||
? options.signal.reason
|
||||
: new Error('世界生成已中断。');
|
||||
}
|
||||
|
||||
const profile = await requestJson<CustomWorldProfile>(
|
||||
'/api/runtime/custom-world/profile',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(normalizedInput),
|
||||
},
|
||||
'生成自定义世界失败',
|
||||
);
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
throw options.signal.reason instanceof Error
|
||||
? options.signal.reason
|
||||
: new Error('世界生成已中断。');
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
export type {
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
};
|
||||
|
||||
export { generateRpgWorldProfile as generateCustomWorldProfile };
|
||||
@@ -1,33 +1,150 @@
|
||||
import type {
|
||||
CustomWorldGalleryDetailResponse,
|
||||
CustomWorldGalleryResponse,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
deleteCustomWorldProfile,
|
||||
getCustomWorldGalleryDetail,
|
||||
listCustomWorldGallery,
|
||||
listCustomWorldLibrary,
|
||||
publishCustomWorldProfile,
|
||||
unpublishCustomWorldProfile,
|
||||
upsertCustomWorldProfile,
|
||||
} from '../storageService';
|
||||
requestPublicRpgCreationRuntimeJson,
|
||||
requestRpgCreationRuntimeJson,
|
||||
type RpgCreationRuntimeRequestOptions,
|
||||
} from './rpgCreationRuntimeClient';
|
||||
|
||||
export async function listRpgWorldLibrary(
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgCreationRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
'/custom-world-library',
|
||||
{ method: 'GET' },
|
||||
'读取自定义世界库失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function upsertRpgWorldProfile(
|
||||
profile: CustomWorldProfile,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgCreationRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profile.id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profile,
|
||||
}),
|
||||
},
|
||||
'保存自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteRpgWorldProfile(
|
||||
profileId: string,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgCreationRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function publishRpgWorldProfile(
|
||||
profileId: string,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgCreationRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/publish`,
|
||||
{ method: 'POST' },
|
||||
'发布自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function unpublishRpgWorldProfile(
|
||||
profileId: string,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgCreationRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/unpublish`,
|
||||
{ method: 'POST' },
|
||||
'下架自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function listRpgWorldGallery(
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestPublicRpgCreationRuntimeJson<CustomWorldGalleryResponse>(
|
||||
'/custom-world-gallery',
|
||||
{ method: 'GET' },
|
||||
'读取作品广场失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function getRpgWorldGalleryDetail(
|
||||
ownerUserId: string,
|
||||
profileId: string,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestPublicRpgCreationRuntimeJson<
|
||||
CustomWorldGalleryDetailResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取作品详情失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作包 A 先建立 RPG 世界作品库和广场相关请求的统一入口。
|
||||
* 当前实现仍透传旧 `storageService.ts`,后续工作包 D 会把作品链路彻底迁入这里。
|
||||
* 工作包 D 把作品库与作品广场请求迁入 RPG 创作域 client,
|
||||
* 后续前端调用优先从这里进入,不再反向依赖通用存储聚合层。
|
||||
*/
|
||||
export const rpgCreationLibraryClient = {
|
||||
listLibrary: listCustomWorldLibrary,
|
||||
upsertProfile: upsertCustomWorldProfile,
|
||||
deleteProfile: deleteCustomWorldProfile,
|
||||
publishProfile: publishCustomWorldProfile,
|
||||
unpublishProfile: unpublishCustomWorldProfile,
|
||||
listGallery: listCustomWorldGallery,
|
||||
getGalleryDetail: getCustomWorldGalleryDetail,
|
||||
};
|
||||
|
||||
export {
|
||||
deleteCustomWorldProfile as deleteRpgWorldProfile,
|
||||
getCustomWorldGalleryDetail as getRpgWorldGalleryDetail,
|
||||
listCustomWorldGallery as listRpgWorldGallery,
|
||||
listCustomWorldLibrary as listRpgWorldLibrary,
|
||||
publishCustomWorldProfile as publishRpgWorldProfile,
|
||||
unpublishCustomWorldProfile as unpublishRpgWorldProfile,
|
||||
upsertCustomWorldProfile as upsertRpgWorldProfile,
|
||||
listLibrary: listRpgWorldLibrary,
|
||||
upsertProfile: upsertRpgWorldProfile,
|
||||
deleteProfile: deleteRpgWorldProfile,
|
||||
publishProfile: publishRpgWorldProfile,
|
||||
unpublishProfile: unpublishRpgWorldProfile,
|
||||
listGallery: listRpgWorldGallery,
|
||||
getGalleryDetail: getRpgWorldGalleryDetail,
|
||||
};
|
||||
|
||||
121
src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts
Normal file
121
src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import {
|
||||
buildRpgCreationPreviewFromResultPreview,
|
||||
buildRpgCreationPreviewFromSession,
|
||||
} from './rpgCreationPreviewAdapter';
|
||||
|
||||
const sessionWithPreview: CustomWorldAgentSessionSnapshot = {
|
||||
sessionId: 'session-preview-1',
|
||||
currentTurn: 3,
|
||||
anchorContent: {
|
||||
worldPromise: null,
|
||||
playerFantasy: null,
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: [],
|
||||
hiddenLines: null,
|
||||
iconicElements: null,
|
||||
},
|
||||
progressPercent: 100,
|
||||
lastAssistantReply: '第一版世界底稿已经准备好了。',
|
||||
stage: 'object_refining',
|
||||
focusCardId: null,
|
||||
creatorIntent: null,
|
||||
creatorIntentReadiness: {
|
||||
isReady: true,
|
||||
completedKeys: [],
|
||||
missingKeys: [],
|
||||
},
|
||||
anchorPack: null,
|
||||
lockState: null,
|
||||
draftProfile: {
|
||||
name: '只作为 fallback 的本地草稿名',
|
||||
subtitle: 'fallback',
|
||||
summary: 'fallback',
|
||||
tone: 'fallback',
|
||||
playerGoal: 'fallback',
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
landmarks: [],
|
||||
},
|
||||
messages: [],
|
||||
draftCards: [
|
||||
{
|
||||
id: 'world-foundation',
|
||||
kind: 'world',
|
||||
title: '世界底稿',
|
||||
subtitle: '阶段三预览',
|
||||
summary: '测试服务端 result preview 优先级。',
|
||||
status: 'warning',
|
||||
linkedIds: [],
|
||||
warningCount: 0,
|
||||
},
|
||||
],
|
||||
pendingClarifications: [],
|
||||
suggestedActions: [],
|
||||
recommendedReplies: [],
|
||||
qualityFindings: [],
|
||||
assetCoverage: {
|
||||
roleAssets: [],
|
||||
sceneAssets: [],
|
||||
allRoleAssetsReady: false,
|
||||
allSceneAssetsReady: false,
|
||||
},
|
||||
resultPreview: {
|
||||
source: 'session_preview',
|
||||
preview: {
|
||||
id: 'preview-profile-1',
|
||||
settingText: '被海雾吞没的旧航路群岛',
|
||||
name: '服务端结果预览',
|
||||
subtitle: '优先于前端 fallback',
|
||||
summary: '结果页应该优先消费 session.resultPreview。',
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
playerGoal: '查清沉船与禁航区异动的真相。',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['守灯会', '航运公会'],
|
||||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
sessionId: 'session-preview-1',
|
||||
},
|
||||
generatedAt: '2026-04-21T10:00:00.000Z',
|
||||
qualityFindings: [],
|
||||
blockers: [],
|
||||
},
|
||||
updatedAt: '2026-04-21T10:00:00.000Z',
|
||||
};
|
||||
|
||||
test('buildRpgCreationPreviewFromResultPreview normalizes server preview envelope', () => {
|
||||
const profile = buildRpgCreationPreviewFromResultPreview(
|
||||
sessionWithPreview.resultPreview,
|
||||
);
|
||||
|
||||
expect(profile?.name).toBe('服务端结果预览');
|
||||
expect(profile?.subtitle).toBe('优先于前端 fallback');
|
||||
expect(profile?.id).toBe('preview-profile-1');
|
||||
expect(profile?.settingText).toBe('被海雾吞没的旧航路群岛');
|
||||
});
|
||||
|
||||
test('buildRpgCreationPreviewFromSession prefers server resultPreview over draft fallback', () => {
|
||||
const profile = buildRpgCreationPreviewFromSession(sessionWithPreview);
|
||||
|
||||
expect(profile?.name).toBe('服务端结果预览');
|
||||
expect(profile?.name).not.toBe('只作为 fallback 的本地草稿名');
|
||||
expect(profile?.summary).toBe('结果页应该优先消费 session.resultPreview。');
|
||||
});
|
||||
|
||||
test('buildRpgCreationPreviewFromSession returns null when server resultPreview is missing', () => {
|
||||
const profile = buildRpgCreationPreviewFromSession({
|
||||
...sessionWithPreview,
|
||||
resultPreview: null,
|
||||
});
|
||||
|
||||
expect(profile).toBeNull();
|
||||
});
|
||||
@@ -1,13 +1,39 @@
|
||||
import { buildCustomWorldProfileFromAgentDraft } from '../customWorldAgentDraftResult';
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
/**
|
||||
* Phase 5 起结果页只消费服务端回传的 result preview。
|
||||
* 前端不再承担 session draft -> runtime profile 的本地兼容编译职责。
|
||||
*/
|
||||
export function buildCustomWorldProfileFromResultPreview(
|
||||
resultPreview: CustomWorldAgentSessionSnapshot['resultPreview'] | null | undefined,
|
||||
): CustomWorldProfile | null {
|
||||
return normalizeCustomWorldProfileRecord(resultPreview?.preview ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一“从 session 取结果页 profile”的主入口。
|
||||
* Phase 5 后主链没有 preview 就视为服务端未准备完成,而不是继续做前端本地编译。
|
||||
*/
|
||||
export function buildCustomWorldProfileFromAgentSession(
|
||||
session: CustomWorldAgentSessionSnapshot | null | undefined,
|
||||
): CustomWorldProfile | null {
|
||||
return buildCustomWorldProfileFromResultPreview(session?.resultPreview);
|
||||
}
|
||||
|
||||
/**
|
||||
* 这是工作包 A 提供的新命名兼容层。
|
||||
* 当前仍复用旧的前端草稿编译逻辑,后续 Phase 3 会继续把结果预览编译后移到服务端。
|
||||
* Phase 3 后该适配层只负责:
|
||||
* 1. 把服务端 resultPreview 转成前端 view model
|
||||
* 2. 保持前端 session 读模型入口稳定
|
||||
*/
|
||||
export const rpgCreationPreviewAdapter = {
|
||||
buildPreviewFromAgentDraft: buildCustomWorldProfileFromAgentDraft,
|
||||
buildPreviewFromSession: buildCustomWorldProfileFromAgentSession,
|
||||
buildPreviewFromResultPreview: buildCustomWorldProfileFromResultPreview,
|
||||
};
|
||||
|
||||
export {
|
||||
buildCustomWorldProfileFromAgentDraft as buildRpgCreationPreviewFromAgentDraft,
|
||||
buildCustomWorldProfileFromAgentSession as buildRpgCreationPreviewFromSession,
|
||||
buildCustomWorldProfileFromResultPreview as buildRpgCreationPreviewFromResultPreview,
|
||||
};
|
||||
|
||||
41
src/services/rpg-creation/rpgCreationRequestHelpers.ts
Normal file
41
src/services/rpg-creation/rpgCreationRequestHelpers.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { parseApiErrorMessage } from '../../../packages/shared/src/http';
|
||||
import { fetchWithApiAuth, requestJson } from '../apiClient';
|
||||
|
||||
export async function requestRpgCreationPostJson<T>(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return requestJson<T>(
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
fallbackMessage,
|
||||
);
|
||||
}
|
||||
|
||||
export async function openRpgCreationSsePost(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
const response = await fetchWithApiAuth(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
60
src/services/rpg-creation/rpgCreationRuntimeClient.ts
Normal file
60
src/services/rpg-creation/rpgCreationRuntimeClient.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
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 type RpgCreationRuntimeRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
skipAuth?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
};
|
||||
|
||||
export function requestRpgCreationRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
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,
|
||||
skipAuth: options.skipAuth,
|
||||
skipRefresh: options.skipRefresh,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function requestPublicRpgCreationRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgCreationRuntimeJson<T>(path, init, fallbackMessage, {
|
||||
...options,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
import { listCustomWorldWorks } from '../storageService';
|
||||
import type { ListRpgCreationWorksResponse } from '../../../packages/shared/src';
|
||||
import { requestRpgCreationRuntimeJson } from './rpgCreationRuntimeClient';
|
||||
|
||||
export async function listRpgCreationWorks() {
|
||||
const response = await requestRpgCreationRuntimeJson<ListRpgCreationWorksResponse>(
|
||||
'/custom-world/works',
|
||||
{ method: 'GET' },
|
||||
'读取创作作品列表失败',
|
||||
);
|
||||
|
||||
return Array.isArray(response?.items) ? response.items : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作包 A 先建立 RPG 创作作品流的独立 client。
|
||||
* 当前作品列表仍复用旧存储服务,后续工作包 D 再切出真正的专属请求实现。
|
||||
* 工作包 D 把作品列表请求正式迁入 RPG 创作域 client。
|
||||
*/
|
||||
export const rpgCreationWorkClient = {
|
||||
listWorks: listCustomWorldWorks,
|
||||
listWorks: listRpgCreationWorks,
|
||||
};
|
||||
|
||||
export { listCustomWorldWorks as listRpgCreationWorks };
|
||||
|
||||
25
src/services/rpg-entry/index.ts
Normal file
25
src/services/rpg-entry/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export {
|
||||
deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldGalleryDetail,
|
||||
listRpgEntryWorldGallery,
|
||||
listRpgEntryWorldLibrary,
|
||||
publishRpgEntryWorldProfile,
|
||||
rpgEntryLibraryClient,
|
||||
type RuntimeRequestOptions,
|
||||
unpublishRpgEntryWorldProfile,
|
||||
upsertRpgEntryWorldProfile,
|
||||
} from './rpgEntryLibraryClient';
|
||||
export {
|
||||
clearRpgProfileBrowseHistory,
|
||||
getRpgProfileDashboard,
|
||||
getRpgProfilePlayStats,
|
||||
getRpgProfileSettings,
|
||||
getRpgProfileWalletLedger,
|
||||
listRpgProfileBrowseHistory,
|
||||
listRpgProfileSaveArchives,
|
||||
putRpgProfileSettings,
|
||||
resumeRpgProfileSaveArchive,
|
||||
rpgProfileClient,
|
||||
syncRpgProfileBrowseHistory,
|
||||
upsertRpgProfileBrowseHistory,
|
||||
} from './rpgProfileClient';
|
||||
@@ -5,28 +5,30 @@ const { requestJsonMock } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
clearProfileBrowseHistory,
|
||||
getCustomWorldGalleryDetail,
|
||||
listCustomWorldGallery,
|
||||
listProfileBrowseHistory,
|
||||
listProfileSaveArchives,
|
||||
resumeProfileSaveArchive,
|
||||
syncProfileBrowseHistory,
|
||||
upsertProfileBrowseHistory,
|
||||
} from './storageService';
|
||||
clearRpgProfileBrowseHistory,
|
||||
listRpgProfileBrowseHistory,
|
||||
listRpgProfileSaveArchives,
|
||||
resumeRpgProfileSaveArchive,
|
||||
syncRpgProfileBrowseHistory,
|
||||
upsertRpgProfileBrowseHistory,
|
||||
} from './rpgProfileClient';
|
||||
import {
|
||||
getRpgEntryWorldGalleryDetail,
|
||||
listRpgEntryWorldGallery,
|
||||
} from './rpgEntryLibraryClient';
|
||||
|
||||
vi.mock('./apiClient', () => ({
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
describe('storageService browse history routes', () => {
|
||||
describe('rpgEntry profile browse history routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads browse history from the runtime profile route', async () => {
|
||||
await listProfileBrowseHistory();
|
||||
await listRpgProfileBrowseHistory();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
@@ -39,7 +41,7 @@ describe('storageService browse history routes', () => {
|
||||
});
|
||||
|
||||
it('writes browse history through the runtime profile route', async () => {
|
||||
await upsertProfileBrowseHistory({
|
||||
await upsertRpgProfileBrowseHistory({
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
worldName: '测试世界',
|
||||
@@ -67,7 +69,7 @@ describe('storageService browse history routes', () => {
|
||||
});
|
||||
|
||||
it('syncs browse history through the runtime profile route', async () => {
|
||||
await syncProfileBrowseHistory([
|
||||
await syncRpgProfileBrowseHistory([
|
||||
{
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
@@ -92,7 +94,7 @@ describe('storageService browse history routes', () => {
|
||||
});
|
||||
|
||||
it('clears browse history through the runtime profile route', async () => {
|
||||
await clearProfileBrowseHistory();
|
||||
await clearRpgProfileBrowseHistory();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
@@ -108,14 +110,14 @@ describe('storageService browse history routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('storageService public custom world gallery routes', () => {
|
||||
describe('rpgEntry public custom world gallery routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads the public gallery without attaching auth or refresh coupling', async () => {
|
||||
await listCustomWorldGallery();
|
||||
await listRpgEntryWorldGallery();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-gallery',
|
||||
@@ -137,7 +139,7 @@ describe('storageService public custom world gallery routes', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await getCustomWorldGalleryDetail('user-1', 'profile-1');
|
||||
await getRpgEntryWorldGalleryDetail('user-1', 'profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-gallery/user-1/profile-1',
|
||||
@@ -152,14 +154,14 @@ describe('storageService public custom world gallery routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('storageService save archive routes', () => {
|
||||
describe('rpgEntry save archive routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads save archives from the runtime profile route', async () => {
|
||||
await listProfileSaveArchives();
|
||||
await listRpgProfileSaveArchives();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/save-archives',
|
||||
@@ -187,7 +189,7 @@ describe('storageService save archive routes', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await resumeProfileSaveArchive('custom:world-1');
|
||||
await resumeRpgProfileSaveArchive('custom:world-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/save-archives/custom%3Aworld-1',
|
||||
165
src/services/rpg-entry/rpgEntryLibraryClient.test.ts
Normal file
165
src/services/rpg-entry/rpgEntryLibraryClient.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldGalleryDetail,
|
||||
listRpgEntryWorldGallery,
|
||||
listRpgEntryWorldLibrary,
|
||||
publishRpgEntryWorldProfile,
|
||||
unpublishRpgEntryWorldProfile,
|
||||
upsertRpgEntryWorldProfile,
|
||||
} from './rpgEntryLibraryClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
describe('rpgEntryLibraryClient world library routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads world library from the runtime entry route', async () => {
|
||||
await listRpgEntryWorldLibrary();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-library',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取自定义世界库失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads world gallery from the public runtime entry route', async () => {
|
||||
await listRpgEntryWorldGallery();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-gallery',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取作品广场失败',
|
||||
expect.objectContaining({
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads gallery detail from the public runtime entry route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
ownerUserId: 'owner-1',
|
||||
profileId: 'profile-1',
|
||||
},
|
||||
});
|
||||
|
||||
await getRpgEntryWorldGalleryDetail('owner-1', 'profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-gallery/owner-1/profile-1',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取作品详情失败',
|
||||
expect.objectContaining({
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes world profile through the runtime entry route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
profileId: 'profile-1',
|
||||
},
|
||||
entries: [],
|
||||
});
|
||||
|
||||
await upsertRpgEntryWorldProfile({
|
||||
id: 'profile-1',
|
||||
name: '测试世界',
|
||||
} as never);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-library/profile-1',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'保存自定义世界失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes world profile through the runtime entry route', async () => {
|
||||
await deleteRpgEntryWorldProfile('profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-library/profile-1',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
'删除自定义世界失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('publishes world profile through the runtime entry route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
profileId: 'profile-1',
|
||||
},
|
||||
entries: [],
|
||||
});
|
||||
|
||||
await publishRpgEntryWorldProfile('profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-library/profile-1/publish',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'发布自定义世界失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('unpublishes world profile through the runtime entry route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
profileId: 'profile-1',
|
||||
},
|
||||
entries: [],
|
||||
});
|
||||
|
||||
await unpublishRpgEntryWorldProfile('profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-library/profile-1/unpublish',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'下架自定义世界失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
152
src/services/rpg-entry/rpgEntryLibraryClient.ts
Normal file
152
src/services/rpg-entry/rpgEntryLibraryClient.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
type RuntimeRequestOptions,
|
||||
requestPublicRpgRuntimeJson,
|
||||
requestRpgRuntimeJson,
|
||||
} from '../rpg-runtime/rpgRuntimeRequest';
|
||||
import type {
|
||||
CustomWorldGalleryDetailResponse,
|
||||
CustomWorldGalleryResponse,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type { RuntimeRequestOptions };
|
||||
|
||||
/**
|
||||
* RPG 入口世界库 client 的真实实现。
|
||||
* 第三批收口后,平台首页/详情页开始游戏链直接走 rpg-entry 域请求,不再反向穿旧 storageService 兼容层。
|
||||
*/
|
||||
export async function listRpgEntryWorldLibrary(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
'/custom-world-library',
|
||||
{ method: 'GET' },
|
||||
'读取自定义世界库失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function listRpgEntryWorldGallery(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestPublicRpgRuntimeJson<CustomWorldGalleryResponse>(
|
||||
'/custom-world-gallery',
|
||||
{ method: 'GET' },
|
||||
'读取作品广场失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function getRpgEntryWorldGalleryDetail(
|
||||
ownerUserId: string,
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestPublicRpgRuntimeJson<
|
||||
CustomWorldGalleryDetailResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取作品详情失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
}
|
||||
|
||||
export async function upsertRpgEntryWorldProfile(
|
||||
profile: CustomWorldProfile,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profile.id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profile,
|
||||
}),
|
||||
},
|
||||
'保存自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteRpgEntryWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function publishRpgEntryWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/publish`,
|
||||
{ method: 'POST' },
|
||||
'发布自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function unpublishRpgEntryWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/unpublish`,
|
||||
{ method: 'POST' },
|
||||
'下架自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export const rpgEntryLibraryClient = {
|
||||
listWorldLibrary: listRpgEntryWorldLibrary,
|
||||
listWorldGallery: listRpgEntryWorldGallery,
|
||||
getWorldGalleryDetail: getRpgEntryWorldGalleryDetail,
|
||||
upsertWorldProfile: upsertRpgEntryWorldProfile,
|
||||
deleteWorldProfile: deleteRpgEntryWorldProfile,
|
||||
publishWorldProfile: publishRpgEntryWorldProfile,
|
||||
unpublishWorldProfile: unpublishRpgEntryWorldProfile,
|
||||
};
|
||||
158
src/services/rpg-entry/rpgProfileClient.test.ts
Normal file
158
src/services/rpg-entry/rpgProfileClient.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
clearRpgProfileBrowseHistory,
|
||||
listRpgProfileBrowseHistory,
|
||||
listRpgProfileSaveArchives,
|
||||
resumeRpgProfileSaveArchive,
|
||||
syncRpgProfileBrowseHistory,
|
||||
upsertRpgProfileBrowseHistory,
|
||||
} from './rpgProfileClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
describe('rpgProfileClient browse history routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads browse history from the runtime profile route', async () => {
|
||||
await listRpgProfileBrowseHistory();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取浏览历史失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes browse history through the runtime profile route', async () => {
|
||||
await upsertRpgProfileBrowseHistory({
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
worldName: '测试世界',
|
||||
subtitle: '测试副标题',
|
||||
summaryText: '测试摘要',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'mythic',
|
||||
authorDisplayName: '测试作者',
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'写入浏览历史失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('syncs browse history through the runtime profile route', async () => {
|
||||
await syncRpgProfileBrowseHistory([
|
||||
{
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
worldName: '测试世界',
|
||||
subtitle: '测试副标题',
|
||||
summaryText: '测试摘要',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'mythic',
|
||||
authorDisplayName: '测试作者',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'同步浏览历史失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears browse history through the runtime profile route', async () => {
|
||||
await clearRpgProfileBrowseHistory();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
'清空浏览历史失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rpgProfileClient save archive routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads save archives from the runtime profile route', async () => {
|
||||
await listRpgProfileSaveArchives();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/save-archives',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取存档列表失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes a save archive through the runtime profile route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
worldKey: 'custom:world-1',
|
||||
},
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-19T10:15:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await resumeRpgProfileSaveArchive('custom:world-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/save-archives/custom%3Aworld-1',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'恢复存档失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
187
src/services/rpg-entry/rpgProfileClient.ts
Normal file
187
src/services/rpg-entry/rpgProfileClient.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type {
|
||||
PlatformBrowseHistoryBatchSyncRequest,
|
||||
PlatformBrowseHistoryResponse,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileSaveArchiveListResponse,
|
||||
ProfileSaveArchiveResumeResponse,
|
||||
ProfileWalletLedgerResponse,
|
||||
RuntimeSettings,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
requestRpgRuntimeJson,
|
||||
type RuntimeRequestOptions,
|
||||
} from '../rpg-runtime/rpgRuntimeRequest';
|
||||
|
||||
export type { RuntimeRequestOptions };
|
||||
|
||||
/**
|
||||
* RPG profile 域 client。
|
||||
* 工作包 C 需要把继续游戏归档与资料读取收进新域目录,避免继续堆在 `storageService`。
|
||||
*/
|
||||
export function getRpgProfileSettings(options: RuntimeRequestOptions = {}) {
|
||||
return requestRpgRuntimeJson<RuntimeSettings>(
|
||||
'/settings',
|
||||
{ method: 'GET' },
|
||||
'读取设置失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function putRpgProfileSettings(
|
||||
settings: RuntimeSettings,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<RuntimeSettings>(
|
||||
'/settings',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
},
|
||||
'保存设置失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRpgProfileDashboard(options: RuntimeRequestOptions = {}) {
|
||||
return requestRpgRuntimeJson<ProfileDashboardSummary>(
|
||||
'/profile/dashboard',
|
||||
{ method: 'GET' },
|
||||
'读取个人看板失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRpgProfileWalletLedger(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<ProfileWalletLedgerResponse>(
|
||||
'/profile/wallet-ledger',
|
||||
{ method: 'GET' },
|
||||
'读取资产流水失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) {
|
||||
return requestRpgRuntimeJson<ProfilePlayStatsResponse>(
|
||||
'/profile/play-stats',
|
||||
{ method: 'GET' },
|
||||
'读取游玩统计失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listRpgProfileSaveArchives(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<ProfileSaveArchiveListResponse>(
|
||||
'/profile/save-archives',
|
||||
{ method: 'GET' },
|
||||
'读取存档列表失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function resumeRpgProfileSaveArchive(
|
||||
worldKey: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<ProfileSaveArchiveResumeResponse>(
|
||||
`/profile/save-archives/${encodeURIComponent(worldKey)}`,
|
||||
{ method: 'POST' },
|
||||
'恢复存档失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
snapshot: rehydrateSavedSnapshot(
|
||||
response.snapshot as HydratedSavedGameSnapshot,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listRpgProfileBrowseHistory(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{ method: 'GET' },
|
||||
'读取浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function upsertRpgProfileBrowseHistory(
|
||||
entry: PlatformBrowseHistoryWriteEntry,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(entry),
|
||||
},
|
||||
'写入浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function syncRpgProfileBrowseHistory(
|
||||
entries: PlatformBrowseHistoryWriteEntry[],
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
entries,
|
||||
} satisfies PlatformBrowseHistoryBatchSyncRequest),
|
||||
},
|
||||
'同步浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function clearRpgProfileBrowseHistory(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{ method: 'DELETE' },
|
||||
'清空浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export const rpgProfileClient = {
|
||||
getDashboard: getRpgProfileDashboard,
|
||||
getPlayStats: getRpgProfilePlayStats,
|
||||
getWalletLedger: getRpgProfileWalletLedger,
|
||||
getSettings: getRpgProfileSettings,
|
||||
putSettings: putRpgProfileSettings,
|
||||
listSaveArchives: listRpgProfileSaveArchives,
|
||||
resumeSaveArchive: resumeRpgProfileSaveArchive,
|
||||
listBrowseHistory: listRpgProfileBrowseHistory,
|
||||
upsertBrowseHistory: upsertRpgProfileBrowseHistory,
|
||||
syncBrowseHistory: syncRpgProfileBrowseHistory,
|
||||
clearBrowseHistory: clearRpgProfileBrowseHistory,
|
||||
};
|
||||
32
src/services/rpg-runtime/index.ts
Normal file
32
src/services/rpg-runtime/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export {
|
||||
deleteRpgSaveSnapshot,
|
||||
getRpgSaveSnapshot,
|
||||
putRpgSaveSnapshot,
|
||||
rpgSnapshotClient,
|
||||
type RuntimeRequestOptions,
|
||||
} from './rpgSnapshotClient';
|
||||
export {
|
||||
getRpgCharacterChatSuggestions,
|
||||
getRpgCharacterChatSummary,
|
||||
rpgRuntimeChatClient,
|
||||
streamRpgCharacterChatReply,
|
||||
streamRpgNpcChatDialogue,
|
||||
streamRpgNpcChatTurn,
|
||||
streamRpgNpcRecruitDialogue,
|
||||
} from './rpgRuntimeChatClient';
|
||||
export {
|
||||
getRpgRuntimeActionSnapshot,
|
||||
getRpgRuntimeClientVersion,
|
||||
getRpgRuntimeSessionId,
|
||||
getRpgRuntimeStoryState,
|
||||
isRpgRuntimeServerFunctionId,
|
||||
isRpgRuntimeTaskFunctionId,
|
||||
resolveRpgRuntimeStoryAction,
|
||||
resolveRpgRuntimeStoryMoment,
|
||||
rpgRuntimeStoryClient,
|
||||
shouldUseRpgRuntimeServerOptions,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryResponse,
|
||||
type RpgRuntimeStoryClientOptions,
|
||||
type RuntimeStorySnapshotRequest,
|
||||
} from './rpgRuntimeStoryClient';
|
||||
29
src/services/rpg-runtime/rpgRuntimeChatClient.ts
Normal file
29
src/services/rpg-runtime/rpgRuntimeChatClient.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
generateCharacterPanelChatSuggestions,
|
||||
generateCharacterPanelChatSummary,
|
||||
streamCharacterPanelChatReply,
|
||||
streamNpcChatDialogue,
|
||||
streamNpcChatTurn,
|
||||
streamNpcRecruitDialogue,
|
||||
} from '../aiService';
|
||||
|
||||
/**
|
||||
* RPG 运行时聊天相关 client 的兼容收口层。
|
||||
* 当前仍桥接旧 `aiService`,避免工作包 A 提前改动聊天与 NPC 对话请求语义。
|
||||
*/
|
||||
export const getRpgCharacterChatSuggestions =
|
||||
generateCharacterPanelChatSuggestions;
|
||||
export const getRpgCharacterChatSummary = generateCharacterPanelChatSummary;
|
||||
export const streamRpgCharacterChatReply = streamCharacterPanelChatReply;
|
||||
export const streamRpgNpcChatDialogue = streamNpcChatDialogue;
|
||||
export const streamRpgNpcChatTurn = streamNpcChatTurn;
|
||||
export const streamRpgNpcRecruitDialogue = streamNpcRecruitDialogue;
|
||||
|
||||
export const rpgRuntimeChatClient = {
|
||||
getCharacterChatSuggestions: getRpgCharacterChatSuggestions,
|
||||
getCharacterChatSummary: getRpgCharacterChatSummary,
|
||||
streamCharacterChatReply: streamRpgCharacterChatReply,
|
||||
streamNpcChatDialogue: streamRpgNpcChatDialogue,
|
||||
streamNpcChatTurn: streamRpgNpcChatTurn,
|
||||
streamNpcRecruitDialogue: streamRpgNpcRecruitDialogue,
|
||||
};
|
||||
66
src/services/rpg-runtime/rpgRuntimeRequest.ts
Normal file
66
src/services/rpg-runtime/rpgRuntimeRequest.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
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 type RuntimeRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
skipAuth?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一封装 RPG 运行时域的请求重试与鉴权透传,避免各 client 重复维护同一套规则。
|
||||
*/
|
||||
export function requestRpgRuntimeJson<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,
|
||||
skipAuth: options.skipAuth,
|
||||
skipRefresh: options.skipRefresh,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共世界广场等匿名接口统一走公开请求入口,避免误附带鉴权状态。
|
||||
*/
|
||||
export function requestPublicRpgRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<T>(path, init, fallbackMessage, {
|
||||
...options,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
});
|
||||
}
|
||||
@@ -4,29 +4,29 @@ const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./apiClient', async () => {
|
||||
vi.mock('../apiClient', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||||
await vi.importActual<typeof import('../apiClient')>('../apiClient');
|
||||
return {
|
||||
...actual,
|
||||
requestJson: requestJsonMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { AnimationState } from '../types';
|
||||
import { AnimationState } from '../../types';
|
||||
import {
|
||||
buildStoryMomentFromRuntimeOptions,
|
||||
getRuntimeClientVersion,
|
||||
getRuntimeSessionId,
|
||||
getRuntimeStoryState,
|
||||
isServerRuntimeFunctionId,
|
||||
isTask5RuntimeFunctionId,
|
||||
resolveRuntimeStoryAction,
|
||||
resolveRuntimeStoryMoment,
|
||||
shouldUseServerRuntimeOptions,
|
||||
} from './runtimeStoryService';
|
||||
getRpgRuntimeClientVersion,
|
||||
getRpgRuntimeSessionId,
|
||||
getRpgRuntimeStoryState,
|
||||
isRpgRuntimeServerFunctionId,
|
||||
isRpgRuntimeTaskFunctionId,
|
||||
resolveRpgRuntimeStoryAction,
|
||||
resolveRpgRuntimeStoryMoment,
|
||||
shouldUseRpgRuntimeServerOptions,
|
||||
} from './rpgRuntimeStoryClient';
|
||||
|
||||
describe('runtimeStoryService', () => {
|
||||
describe('rpgRuntimeStoryClient', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
@@ -52,7 +52,7 @@ describe('runtimeStoryService', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await resolveRuntimeStoryAction({
|
||||
await resolveRpgRuntimeStoryAction({
|
||||
sessionId: 'runtime-custom',
|
||||
clientVersion: 9,
|
||||
option: {
|
||||
@@ -105,7 +105,7 @@ describe('runtimeStoryService', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await resolveRuntimeStoryAction({
|
||||
await resolveRpgRuntimeStoryAction({
|
||||
option: {
|
||||
functionId: 'inventory_use',
|
||||
actionText: '使用凝神灵液',
|
||||
@@ -176,7 +176,7 @@ describe('runtimeStoryService', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await getRuntimeStoryState({
|
||||
await getRpgRuntimeStoryState({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 7,
|
||||
snapshot: {
|
||||
@@ -241,13 +241,13 @@ describe('runtimeStoryService', () => {
|
||||
});
|
||||
|
||||
it('recognizes server-runtime option pools for server-side legality checks', () => {
|
||||
expect(isTask5RuntimeFunctionId('npc_chat')).toBe(true);
|
||||
expect(isTask5RuntimeFunctionId('battle_attack_basic')).toBe(true);
|
||||
expect(isTask5RuntimeFunctionId('npc_trade')).toBe(false);
|
||||
expect(isServerRuntimeFunctionId('npc_trade')).toBe(true);
|
||||
expect(isServerRuntimeFunctionId('unknown_action')).toBe(false);
|
||||
expect(isRpgRuntimeTaskFunctionId('npc_chat')).toBe(true);
|
||||
expect(isRpgRuntimeTaskFunctionId('battle_attack_basic')).toBe(true);
|
||||
expect(isRpgRuntimeTaskFunctionId('npc_trade')).toBe(false);
|
||||
expect(isRpgRuntimeServerFunctionId('npc_trade')).toBe(true);
|
||||
expect(isRpgRuntimeServerFunctionId('unknown_action')).toBe(false);
|
||||
expect(
|
||||
shouldUseServerRuntimeOptions([
|
||||
shouldUseRpgRuntimeServerOptions([
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
@@ -264,7 +264,7 @@ describe('runtimeStoryService', () => {
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldUseServerRuntimeOptions([
|
||||
shouldUseRpgRuntimeServerOptions([
|
||||
{
|
||||
functionId: 'npc_trade',
|
||||
actionText: '交易',
|
||||
@@ -281,7 +281,7 @@ describe('runtimeStoryService', () => {
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldUseServerRuntimeOptions([
|
||||
shouldUseRpgRuntimeServerOptions([
|
||||
{
|
||||
functionId: 'unknown_action',
|
||||
actionText: '未知动作',
|
||||
@@ -297,8 +297,10 @@ describe('runtimeStoryService', () => {
|
||||
},
|
||||
]),
|
||||
).toBe(false);
|
||||
expect(getRuntimeSessionId({ runtimeSessionId: '' })).toBe('runtime-main');
|
||||
expect(getRuntimeClientVersion({ runtimeActionVersion: 3 })).toBe(3);
|
||||
expect(getRpgRuntimeSessionId({ runtimeSessionId: '' })).toBe(
|
||||
'runtime-main',
|
||||
);
|
||||
expect(getRpgRuntimeClientVersion({ runtimeActionVersion: 3 })).toBe(3);
|
||||
});
|
||||
|
||||
it('preserves runtime option interaction metadata from the server response', () => {
|
||||
@@ -326,7 +328,7 @@ describe('runtimeStoryService', () => {
|
||||
});
|
||||
|
||||
it('prefers the richer snapshot story when the server persisted dialogue mode', () => {
|
||||
const story = resolveRuntimeStoryMoment({
|
||||
const story = resolveRpgRuntimeStoryMoment({
|
||||
response: {
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 4,
|
||||
@@ -1,24 +1,23 @@
|
||||
import type {
|
||||
RuntimeStoryActionRequest,
|
||||
RuntimeStoryActionResponse,
|
||||
RuntimeStoryChoicePayload,
|
||||
RuntimeStoryOptionView,
|
||||
RuntimeStoryStateRequest,
|
||||
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryState';
|
||||
import type {
|
||||
RuntimeStoryChoicePayload,
|
||||
ServerRuntimeFunctionId,
|
||||
Task5RuntimeFunctionId,
|
||||
} from '../../packages/shared/src/contracts/story';
|
||||
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryAction';
|
||||
import {
|
||||
SERVER_RUNTIME_FUNCTION_IDS,
|
||||
TASK5_RUNTIME_FUNCTION_IDS,
|
||||
} from '../../packages/shared/src/contracts/story';
|
||||
import { rehydrateSavedSnapshot } from '../persistence/runtimeSnapshot';
|
||||
import type {
|
||||
HydratedGameState,
|
||||
HydratedSavedGameSnapshot,
|
||||
} from '../persistence/runtimeSnapshotTypes';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../types';
|
||||
import { AnimationState } from '../types';
|
||||
import { type ApiRetryOptions, requestJson } from './apiClient';
|
||||
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryAction';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import { AnimationState } from '../../types';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const RUNTIME_STORY_API_BASE = '/api/runtime/story';
|
||||
const DEFAULT_SESSION_ID = 'runtime-main';
|
||||
@@ -36,18 +35,18 @@ const SERVER_RUNTIME_FUNCTION_ID_SET = new Set<string>([
|
||||
...SERVER_RUNTIME_FUNCTION_IDS,
|
||||
]);
|
||||
|
||||
export type RuntimeStoryServiceOptions = {
|
||||
export type RpgRuntimeStoryClientOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
};
|
||||
|
||||
export type RuntimeStoryResponse = RuntimeStoryActionResponse<
|
||||
HydratedGameState,
|
||||
GameState,
|
||||
StoryMoment
|
||||
>;
|
||||
export type { RuntimeStoryChoicePayload };
|
||||
export type RuntimeStorySnapshotRequest = RuntimeStoryStateRequest<
|
||||
HydratedGameState,
|
||||
GameState,
|
||||
StoryMoment
|
||||
>['snapshot'];
|
||||
|
||||
@@ -55,7 +54,7 @@ function requestRuntimeStoryJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
options: RpgRuntimeStoryClientOptions = {},
|
||||
) {
|
||||
return requestJson<T>(
|
||||
`${RUNTIME_STORY_API_BASE}${path}`,
|
||||
@@ -181,7 +180,7 @@ export async function getRuntimeStoryState(
|
||||
clientVersion?: number;
|
||||
snapshot?: RuntimeStorySnapshotRequest;
|
||||
},
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
options: RpgRuntimeStoryClientOptions = {},
|
||||
) {
|
||||
const normalizedSessionId = params.sessionId || DEFAULT_SESSION_ID;
|
||||
const response = params.snapshot
|
||||
@@ -194,7 +193,7 @@ export async function getRuntimeStoryState(
|
||||
sessionId: normalizedSessionId,
|
||||
clientVersion: params.clientVersion,
|
||||
snapshot: params.snapshot,
|
||||
} satisfies RuntimeStoryStateRequest),
|
||||
} satisfies RuntimeStoryStateRequest<GameState, StoryMoment>),
|
||||
},
|
||||
'读取运行时故事状态失败',
|
||||
options,
|
||||
@@ -223,7 +222,7 @@ export async function resolveRuntimeStoryAction(
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
snapshot?: RuntimeStorySnapshotRequest;
|
||||
},
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
options: RpgRuntimeStoryClientOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
'/actions/resolve',
|
||||
@@ -260,3 +259,23 @@ export async function resolveRuntimeStoryAction(
|
||||
export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {
|
||||
return rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot);
|
||||
}
|
||||
|
||||
export const getRpgRuntimeActionSnapshot = getRuntimeActionSnapshot;
|
||||
export const getRpgRuntimeClientVersion = getRuntimeClientVersion;
|
||||
export const getRpgRuntimeSessionId = getRuntimeSessionId;
|
||||
export const getRpgRuntimeStoryState = getRuntimeStoryState;
|
||||
export const isRpgRuntimeServerFunctionId = isServerRuntimeFunctionId;
|
||||
export const isRpgRuntimeTaskFunctionId = isTask5RuntimeFunctionId;
|
||||
export const resolveRpgRuntimeStoryAction = resolveRuntimeStoryAction;
|
||||
export const resolveRpgRuntimeStoryMoment = resolveRuntimeStoryMoment;
|
||||
export const shouldUseRpgRuntimeServerOptions = shouldUseServerRuntimeOptions;
|
||||
|
||||
export const rpgRuntimeStoryClient = {
|
||||
getActionSnapshot: getRpgRuntimeActionSnapshot,
|
||||
getClientVersion: getRpgRuntimeClientVersion,
|
||||
getSessionId: getRpgRuntimeSessionId,
|
||||
getState: getRpgRuntimeStoryState,
|
||||
resolveAction: resolveRpgRuntimeStoryAction,
|
||||
resolveMoment: resolveRpgRuntimeStoryMoment,
|
||||
shouldUseServerOptions: shouldUseRpgRuntimeServerOptions,
|
||||
};
|
||||
89
src/services/rpg-runtime/rpgSnapshotClient.test.ts
Normal file
89
src/services/rpg-runtime/rpgSnapshotClient.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
deleteRpgSaveSnapshot,
|
||||
getRpgSaveSnapshot,
|
||||
putRpgSaveSnapshot,
|
||||
} from './rpgSnapshotClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
describe('rpgSnapshotClient routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
|
||||
it('reads the current save snapshot from the runtime save route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce(null);
|
||||
|
||||
await getRpgSaveSnapshot();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/save/snapshot',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取存档失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes the current save snapshot through the runtime save route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
version: 2,
|
||||
savedAt: '2026-04-21T09:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
},
|
||||
});
|
||||
|
||||
await putRpgSaveSnapshot({
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/save/snapshot',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'保存存档失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes the current save snapshot through the runtime save route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
await deleteRpgSaveSnapshot();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/save/snapshot',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
'删除存档失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
60
src/services/rpg-runtime/rpgSnapshotClient.ts
Normal file
60
src/services/rpg-runtime/rpgSnapshotClient.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { BasicOkResult } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SavedGameSnapshotInput } from '../../persistence/gameSaveStorage';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
requestRpgRuntimeJson,
|
||||
type RuntimeRequestOptions,
|
||||
} from './rpgRuntimeRequest';
|
||||
|
||||
export type { RuntimeRequestOptions };
|
||||
|
||||
/**
|
||||
* RPG 运行时快照 client。
|
||||
* 工作包 C 起由新域目录承载真实实现,旧 `storageService` 仅保留兼容转发。
|
||||
*/
|
||||
export async function getRpgSaveSnapshot(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const snapshot = await requestRpgRuntimeJson<HydratedSavedGameSnapshot | null>(
|
||||
'/save/snapshot',
|
||||
{ method: 'GET' },
|
||||
'读取存档失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return snapshot ? rehydrateSavedSnapshot(snapshot) : null;
|
||||
}
|
||||
|
||||
export async function putRpgSaveSnapshot(
|
||||
snapshot: SavedGameSnapshotInput,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const savedSnapshot = await requestRpgRuntimeJson<HydratedSavedGameSnapshot>(
|
||||
'/save/snapshot',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(snapshot),
|
||||
},
|
||||
'保存存档失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return rehydrateSavedSnapshot(savedSnapshot);
|
||||
}
|
||||
|
||||
export function deleteRpgSaveSnapshot(options: RuntimeRequestOptions = {}) {
|
||||
return requestRpgRuntimeJson<BasicOkResult>(
|
||||
'/save/snapshot',
|
||||
{ method: 'DELETE' },
|
||||
'删除存档失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export const rpgSnapshotClient = {
|
||||
getSnapshot: getRpgSaveSnapshot,
|
||||
putSnapshot: putRpgSaveSnapshot,
|
||||
deleteSnapshot: deleteRpgSaveSnapshot,
|
||||
};
|
||||
@@ -1,440 +0,0 @@
|
||||
import type { ListCustomWorldWorksResponse } from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
BasicOkResult,
|
||||
CustomWorldGalleryDetailResponse,
|
||||
CustomWorldGalleryResponse,
|
||||
CustomWorldLibraryEntry,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse,
|
||||
PlatformBrowseHistoryBatchSyncRequest,
|
||||
PlatformBrowseHistoryEntry,
|
||||
PlatformBrowseHistoryResponse,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfileSaveArchiveListResponse,
|
||||
ProfileSaveArchiveResumeResponse,
|
||||
ProfileSaveArchiveSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileWalletLedgerResponse,
|
||||
RuntimeSettings,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import type { SavedGameSnapshotInput } from '../persistence/gameSaveStorage';
|
||||
import { rehydrateSavedSnapshot } from '../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
|
||||
import type { CustomWorldProfile } from '../types';
|
||||
import { type ApiRetryOptions, requestJson } from './apiClient';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
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 type RuntimeRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
skipAuth?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
};
|
||||
|
||||
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,
|
||||
skipAuth: options.skipAuth,
|
||||
skipRefresh: options.skipRefresh,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function requestPublicRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<T>(path, init, fallbackMessage, {
|
||||
...options,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSaveSnapshot(options: RuntimeRequestOptions = {}) {
|
||||
const snapshot = await requestRuntimeJson<HydratedSavedGameSnapshot | null>(
|
||||
'/save/snapshot',
|
||||
{ method: 'GET' },
|
||||
'读取存档失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return snapshot ? rehydrateSavedSnapshot(snapshot) : null;
|
||||
}
|
||||
|
||||
export async function putSaveSnapshot(
|
||||
snapshot: SavedGameSnapshotInput,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const savedSnapshot = await requestRuntimeJson<HydratedSavedGameSnapshot>(
|
||||
'/save/snapshot',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(snapshot),
|
||||
},
|
||||
'保存存档失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return rehydrateSavedSnapshot(savedSnapshot);
|
||||
}
|
||||
|
||||
export async function deleteSaveSnapshot(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<BasicOkResult>(
|
||||
'/save/snapshot',
|
||||
{ method: 'DELETE' },
|
||||
'删除存档失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSettings(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<RuntimeSettings>(
|
||||
'/settings',
|
||||
{ method: 'GET' },
|
||||
'读取设置失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getProfileDashboard(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<ProfileDashboardSummary>(
|
||||
'/profile/dashboard',
|
||||
{ method: 'GET' },
|
||||
'读取个人看板失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getProfileWalletLedger(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<ProfileWalletLedgerResponse>(
|
||||
'/profile/wallet-ledger',
|
||||
{ method: 'GET' },
|
||||
'读取资产流水失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getProfilePlayStats(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<ProfilePlayStatsResponse>(
|
||||
'/profile/play-stats',
|
||||
{ method: 'GET' },
|
||||
'读取游玩统计失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listProfileSaveArchives(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<ProfileSaveArchiveListResponse>(
|
||||
'/profile/save-archives',
|
||||
{ method: 'GET' },
|
||||
'读取存档列表失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function resumeProfileSaveArchive(
|
||||
worldKey: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
ProfileSaveArchiveResumeResponse
|
||||
>(
|
||||
`/profile/save-archives/${encodeURIComponent(worldKey)}`,
|
||||
{ method: 'POST' },
|
||||
'恢复存档失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
snapshot: rehydrateSavedSnapshot(
|
||||
response.snapshot as HydratedSavedGameSnapshot,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
'/custom-world-library',
|
||||
{ method: 'GET' },
|
||||
'读取自定义世界库失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function listCustomWorldWorks(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<ListCustomWorldWorksResponse>(
|
||||
'/custom-world/works',
|
||||
{ method: 'GET' },
|
||||
'读取创作作品列表失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.items) ? response.items : [];
|
||||
}
|
||||
|
||||
export async function upsertCustomWorldProfile(
|
||||
profile: CustomWorldProfile,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profile.id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profile,
|
||||
}),
|
||||
},
|
||||
'保存自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
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?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function publishCustomWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/publish`,
|
||||
{ method: 'POST' },
|
||||
'发布自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function unpublishCustomWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/unpublish`,
|
||||
{ method: 'POST' },
|
||||
'下架自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function listCustomWorldGallery(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestPublicRuntimeJson<CustomWorldGalleryResponse>(
|
||||
'/custom-world-gallery',
|
||||
{ method: 'GET' },
|
||||
'读取作品广场失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function getCustomWorldGalleryDetail(
|
||||
ownerUserId: string,
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestPublicRuntimeJson<
|
||||
CustomWorldGalleryDetailResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取作品详情失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
}
|
||||
|
||||
export async function listProfileBrowseHistory(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{ method: 'GET' },
|
||||
'读取浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function upsertProfileBrowseHistory(
|
||||
entry: PlatformBrowseHistoryWriteEntry,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(entry),
|
||||
},
|
||||
'写入浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function syncProfileBrowseHistory(
|
||||
entries: PlatformBrowseHistoryWriteEntry[],
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
entries,
|
||||
} satisfies PlatformBrowseHistoryBatchSyncRequest),
|
||||
},
|
||||
'同步浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function clearProfileBrowseHistory(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{ method: 'DELETE' },
|
||||
'清空浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export const runtimeStorageClient = {
|
||||
getSaveSnapshot,
|
||||
putSaveSnapshot,
|
||||
deleteSaveSnapshot,
|
||||
getSettings,
|
||||
putSettings,
|
||||
getProfileDashboard,
|
||||
getProfileWalletLedger,
|
||||
getProfilePlayStats,
|
||||
listProfileSaveArchives,
|
||||
resumeProfileSaveArchive,
|
||||
listCustomWorldLibrary,
|
||||
listCustomWorldWorks,
|
||||
upsertCustomWorldProfile,
|
||||
deleteCustomWorldProfile,
|
||||
publishCustomWorldProfile,
|
||||
unpublishCustomWorldProfile,
|
||||
listCustomWorldGallery,
|
||||
getCustomWorldGalleryDetail,
|
||||
listProfileBrowseHistory,
|
||||
upsertProfileBrowseHistory,
|
||||
syncProfileBrowseHistory,
|
||||
clearProfileBrowseHistory,
|
||||
};
|
||||
|
||||
export type { CustomWorldLibraryEntry };
|
||||
export type { PlatformBrowseHistoryEntry };
|
||||
export type { ProfileSaveArchiveSummary };
|
||||
Reference in New Issue
Block a user