This commit is contained in:
2026-04-21 18:27:46 +08:00
parent 04bff9617d
commit 4372ab5be1
358 changed files with 30788 additions and 14737 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

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

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

View File

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

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

View File

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

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

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

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

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

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

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

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

View File

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

View File

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

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

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

View File

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