Merge branch 'codex/dev' into codex/backend-rewrite-spacetimedb
# Conflicts: # docs/technical/README.md # server-node/src/modules/assets/qwenSpriteRoutes.ts # src/components/CustomWorldResultView.test.tsx # src/components/CustomWorldResultView.tsx # src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx # src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx # src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx # src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx # src/components/rpg-entry/RpgEntryCharacterSelectView.tsx # src/components/rpg-entry/RpgEntryHomeView.tsx # src/services/apiClient.ts # src/tools/QwenSpriteSheetTool.tsx
This commit is contained in:
@@ -1,20 +1,5 @@
|
||||
import type {
|
||||
CreateCustomWorldAgentSessionRequest,
|
||||
CreateCustomWorldAgentSessionResponse,
|
||||
CustomWorldAgentActionRequest,
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
CustomWorldDraftCardDetail,
|
||||
GetCustomWorldAgentCardDetailResponse,
|
||||
ListCustomWorldWorksResponse,
|
||||
SendCustomWorldAgentMessageRequest,
|
||||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
AnswerCustomWorldSessionQuestionRequest,
|
||||
CreateCustomWorldSessionRequest,
|
||||
CustomWorldGenerationProgress,
|
||||
CustomWorldSessionRecord,
|
||||
CustomWorldSessionSummary,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
@@ -22,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,
|
||||
@@ -45,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');
|
||||
|
||||
@@ -70,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',
|
||||
@@ -86,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,
|
||||
@@ -353,522 +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,
|
||||
creatorIntent: null,
|
||||
generationMode: 'full' as const,
|
||||
}
|
||||
: {
|
||||
settingText: input.settingText,
|
||||
creatorIntent: input.creatorIntent ?? null,
|
||||
generationMode:
|
||||
input.generationMode === 'fast'
|
||||
? ('fast' as const)
|
||||
: ('full' as const),
|
||||
};
|
||||
|
||||
const session = await createCustomWorldSession({
|
||||
settingText: normalizedInput.settingText,
|
||||
creatorIntent: normalizedInput.creatorIntent as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null,
|
||||
generationMode: normalizedInput.generationMode,
|
||||
});
|
||||
|
||||
const fallbackAnswerMap: Record<string, string> = {
|
||||
world_hook:
|
||||
typeof normalizedInput.creatorIntent?.worldHook === 'string' &&
|
||||
normalizedInput.creatorIntent.worldHook.trim()
|
||||
? normalizedInput.creatorIntent.worldHook.trim()
|
||||
: normalizedInput.settingText.trim().slice(0, 120) ||
|
||||
'这是一个围绕失衡秩序展开的世界。',
|
||||
player_premise:
|
||||
typeof normalizedInput.creatorIntent?.playerPremise === 'string' &&
|
||||
normalizedInput.creatorIntent.playerPremise.trim()
|
||||
? normalizedInput.creatorIntent.playerPremise.trim()
|
||||
: '玩家是一名被卷入局势中心的行动者。',
|
||||
opening_situation:
|
||||
typeof normalizedInput.creatorIntent?.openingSituation === 'string' &&
|
||||
normalizedInput.creatorIntent.openingSituation.trim()
|
||||
? normalizedInput.creatorIntent.openingSituation.trim()
|
||||
: '故事开局时,玩家正身处风暴边缘,必须立刻判断立场与风险。',
|
||||
core_conflict:
|
||||
Array.isArray(normalizedInput.creatorIntent?.coreConflicts) &&
|
||||
normalizedInput.creatorIntent.coreConflicts.length > 0
|
||||
? normalizedInput.creatorIntent.coreConflicts
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean)
|
||||
.join(';')
|
||||
: '旧秩序与新威胁正在同时逼近,各方都在争夺主动权。',
|
||||
};
|
||||
|
||||
for (const question of session.questions ?? []) {
|
||||
if (question.answer?.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const answer =
|
||||
fallbackAnswerMap[question.id] || normalizedInput.settingText.trim();
|
||||
await answerCustomWorldSessionQuestion(session.sessionId, {
|
||||
questionId: question.id,
|
||||
answer,
|
||||
});
|
||||
}
|
||||
|
||||
return streamCustomWorldSessionGeneration(session.sessionId, options);
|
||||
}
|
||||
|
||||
export async function streamCustomWorldSessionGeneration(
|
||||
sessionId: string,
|
||||
options: GenerateCustomWorldProfileOptions = {},
|
||||
): Promise<CustomWorldProfile> {
|
||||
const response = await fetchWithApiAuth(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/generate/stream`,
|
||||
{
|
||||
method: 'GET',
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(parseApiErrorMessage(responseText, '生成自定义世界失败'));
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error('自定义世界生成流不可用');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let latestProfile: Record<string, unknown> | 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 = '';
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim();
|
||||
continue;
|
||||
}
|
||||
if (!line.startsWith('data:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const payloadText = line.slice(5).trim();
|
||||
if (!payloadText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(payloadText) as Record<string, unknown>;
|
||||
if (eventName === 'progress') {
|
||||
if (
|
||||
typeof payload.phaseId === 'string' &&
|
||||
typeof payload.phaseLabel === 'string' &&
|
||||
typeof payload.phaseDetail === 'string' &&
|
||||
typeof payload.overallProgress === 'number' &&
|
||||
Array.isArray(payload.steps)
|
||||
) {
|
||||
options.onProgress?.(
|
||||
payload as unknown as CustomWorldGenerationProgress,
|
||||
);
|
||||
} else {
|
||||
options.onProgress?.({
|
||||
phaseId: 'finalize',
|
||||
phaseLabel:
|
||||
typeof payload.phase === 'string'
|
||||
? payload.phase
|
||||
: 'generating',
|
||||
phaseDetail:
|
||||
typeof payload.phase === 'string'
|
||||
? payload.phase
|
||||
: 'generating',
|
||||
overallProgress:
|
||||
typeof payload.progress === 'number'
|
||||
? payload.progress / 100
|
||||
: 0,
|
||||
completedWeight:
|
||||
typeof payload.progress === 'number' ? payload.progress : 0,
|
||||
totalWeight: 100,
|
||||
elapsedMs: 0,
|
||||
estimatedRemainingMs: null,
|
||||
activeStepIndex: 0,
|
||||
steps: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
eventName === 'result' &&
|
||||
payload.profile &&
|
||||
typeof payload.profile === 'object'
|
||||
) {
|
||||
latestProfile = payload.profile as Record<string, unknown>;
|
||||
}
|
||||
if (eventName === 'error') {
|
||||
throw new Error(
|
||||
typeof payload.message === 'string'
|
||||
? payload.message
|
||||
: '生成自定义世界失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!latestProfile) {
|
||||
throw new Error('自定义世界生成未返回结果');
|
||||
}
|
||||
|
||||
return latestProfile as unknown as CustomWorldProfile;
|
||||
}
|
||||
|
||||
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 createCustomWorldSession(payload: {
|
||||
settingText: string;
|
||||
creatorIntent?: Record<string, unknown> | null;
|
||||
generationMode: 'fast' | 'full';
|
||||
}) {
|
||||
return requestJson<CustomWorldSessionSummary>(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload satisfies CreateCustomWorldSessionRequest),
|
||||
},
|
||||
'创建自定义世界会话失败',
|
||||
);
|
||||
}
|
||||
|
||||
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 getCustomWorldSession(sessionId: string) {
|
||||
return requestJson<CustomWorldSessionRecord>(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取自定义世界会话失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function answerCustomWorldSessionQuestion(
|
||||
sessionId: string,
|
||||
payload: { questionId: string; answer: string },
|
||||
) {
|
||||
return requestJson<CustomWorldSessionSummary>(
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/answers`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(
|
||||
payload satisfies AnswerCustomWorldSessionQuestionRequest,
|
||||
),
|
||||
},
|
||||
'提交自定义世界补充设定失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function streamCharacterPanelChatReply(
|
||||
world: WorldType,
|
||||
playerCharacter: Character,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
AUTH_STATE_EVENT,
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
fetchWithApiAuth,
|
||||
@@ -9,21 +10,21 @@ import {
|
||||
setStoredAccessToken,
|
||||
} from './apiClient';
|
||||
|
||||
function createMemoryStorage() {
|
||||
const values = new Map<string, string>();
|
||||
function createLocalStorageMock() {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return values.has(key) ? values.get(key)! : null;
|
||||
return store.has(key) ? store.get(key)! : null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
values.set(key, value);
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem(key: string) {
|
||||
values.delete(key);
|
||||
store.delete(key);
|
||||
},
|
||||
clear() {
|
||||
values.clear();
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -54,50 +55,21 @@ function createResponseMock(params: {
|
||||
|
||||
describe('apiClient', () => {
|
||||
const fetchMock = vi.fn();
|
||||
const dispatchEventMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createMemoryStorage(),
|
||||
dispatchEvent: vi.fn(),
|
||||
dispatchEvent: dispatchEventMock,
|
||||
localStorage: createLocalStorageMock(),
|
||||
});
|
||||
fetchMock.mockReset();
|
||||
clearStoredAccessToken();
|
||||
dispatchEventMock.mockReset();
|
||||
clearStoredAccessToken({ emit: false });
|
||||
});
|
||||
|
||||
it('attaches auth headers and clears stale tokens on unauthorized responses', async () => {
|
||||
setStoredAccessToken('jwt-token');
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
const response = await fetchWithApiAuth('/api/protected', { method: 'GET' });
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/protected',
|
||||
expect.objectContaining({
|
||||
credentials: 'same-origin',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer jwt-token',
|
||||
'x-genarrative-response-envelope': 'v1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/auth/refresh',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
}),
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
});
|
||||
|
||||
it('refreshes the access token once and retries the original request', async () => {
|
||||
setStoredAccessToken('expired-token');
|
||||
it('refreshes bearer token once and retries the original request', async () => {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(
|
||||
@@ -106,6 +78,7 @@ describe('apiClient', () => {
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: true,
|
||||
token: 'fresh-token',
|
||||
},
|
||||
error: null,
|
||||
@@ -138,41 +111,113 @@ describe('apiClient', () => {
|
||||
);
|
||||
|
||||
expect(result).toEqual({ value: 7 });
|
||||
expect(getStoredAccessToken()).toBe('fresh-token');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/runtime/protected',
|
||||
expect.objectContaining({
|
||||
credentials: 'same-origin',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer expired-token',
|
||||
'x-genarrative-response-envelope': 'v1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/auth/refresh',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/api/runtime/protected',
|
||||
expect.objectContaining({
|
||||
credentials: 'same-origin',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer fresh-token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchEventMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: AUTH_STATE_EVENT,
|
||||
}),
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('fresh-token');
|
||||
});
|
||||
|
||||
it('does not refresh or emit auth changes for 401 responses without auth context', async () => {
|
||||
it('does not emit auth change events when 401 probe requests opt into silent mode', async () => {
|
||||
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
const response = await fetchWithApiAuth(
|
||||
'/api/auth/me',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
{
|
||||
notifyAuthStateChange: false,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits auth change events when refresh fails on protected requests', async () => {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
const response = await fetchWithApiAuth('/api/runtime/protected', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/protected',
|
||||
expect.objectContaining({
|
||||
credentials: 'same-origin',
|
||||
}),
|
||||
);
|
||||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
});
|
||||
|
||||
it('rejects refresh responses that do not return a renewed bearer token', async () => {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: true,
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
requestJson<{ value: number }>(
|
||||
'/api/runtime/protected',
|
||||
{ method: 'GET' },
|
||||
'读取受保护数据失败',
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 401,
|
||||
message: '读取受保护数据失败',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the current access token when a public request explicitly skips auth', async () => {
|
||||
@@ -191,6 +236,14 @@ describe('apiClient', () => {
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-gallery',
|
||||
expect.objectContaining({
|
||||
headers: expect.not.objectContaining({
|
||||
Authorization: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('still-valid-token');
|
||||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
parseApiErrorMessage,
|
||||
unwrapApiResponse,
|
||||
} from '../../packages/shared/src/http';
|
||||
import type { AuthRefreshResponse } from '../../packages/shared/src/contracts/auth';
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
|
||||
const AUTO_AUTH_USERNAME_KEY = 'genarrative.auth.auto-username.v1';
|
||||
@@ -32,6 +33,8 @@ export type ApiRequestOptions = {
|
||||
skipAuth?: boolean;
|
||||
omitEnvelopeHeader?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
// 会话探测类请求需要静默处理 401,避免 AuthGate 因自发广播再次触发 hydrate。
|
||||
notifyAuthStateChange?: boolean;
|
||||
};
|
||||
|
||||
type ResolvedRetryOptions = {
|
||||
@@ -50,10 +53,6 @@ type ParsedApiErrorShape = {
|
||||
meta: Partial<ApiMeta>;
|
||||
};
|
||||
|
||||
type RefreshTokenResponse = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
@@ -317,7 +316,7 @@ function canUseLocalStorage() {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||
}
|
||||
|
||||
function emitAuthStateChange() {
|
||||
export function emitAuthStateChange() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
@@ -412,14 +411,20 @@ export function setStoredAutoAuthCredentials(credentials: {
|
||||
window.localStorage.setItem(AUTO_AUTH_PASSWORD_KEY, credentials.password.trim());
|
||||
}
|
||||
|
||||
export function clearStoredAutoAuthCredentials() {
|
||||
export function clearStoredAutoAuthCredentials(
|
||||
options: {
|
||||
emit?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(AUTO_AUTH_USERNAME_KEY);
|
||||
window.localStorage.removeItem(AUTO_AUTH_PASSWORD_KEY);
|
||||
emitAuthStateChange();
|
||||
if (options.emit !== false) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
function withAuthorizationHeaders(
|
||||
@@ -454,24 +459,25 @@ async function refreshAccessToken() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
clearStoredAccessToken();
|
||||
clearStoredAccessToken({ emit: false });
|
||||
throw await buildApiClientError(response, '刷新登录状态失败');
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
const payload = responseText
|
||||
? unwrapApiResponse<RefreshTokenResponse>(
|
||||
JSON.parse(responseText) as RefreshTokenResponse,
|
||||
? unwrapApiResponse<AuthRefreshResponse>(
|
||||
JSON.parse(responseText) as AuthRefreshResponse,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (!payload?.token?.trim()) {
|
||||
clearStoredAccessToken();
|
||||
if (payload?.ok !== true || !payload.token?.trim()) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
throw new Error('刷新登录状态失败');
|
||||
}
|
||||
|
||||
setStoredAccessToken(payload.token, { emit: false });
|
||||
return payload.token;
|
||||
const nextToken = payload.token.trim();
|
||||
setStoredAccessToken(nextToken, { emit: false });
|
||||
return nextToken;
|
||||
})();
|
||||
|
||||
try {
|
||||
@@ -488,6 +494,7 @@ export async function fetchWithApiAuth(
|
||||
) {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry = resolveRetryOptions(method, options.retry);
|
||||
const shouldNotifyAuthStateChange = options.notifyAuthStateChange !== false;
|
||||
let attempt = 0;
|
||||
let refreshAttempted = false;
|
||||
|
||||
@@ -514,13 +521,23 @@ export async function fetchWithApiAuth(
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
refreshAttempted = true;
|
||||
if (shouldNotifyAuthStateChange) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
continue;
|
||||
} catch {
|
||||
clearStoredAccessToken();
|
||||
if (hasAuthHeader) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
if (shouldNotifyAuthStateChange) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
} else if (response.status === 401 && hasAuthHeader && !options.skipAuth) {
|
||||
// 公开只读请求不能因为服务端异常 401 顺手把正式登录态清空。
|
||||
clearStoredAccessToken();
|
||||
clearStoredAccessToken({ emit: false });
|
||||
if (shouldNotifyAuthStateChange) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldRetryResponse(response.status, attempt, retry)) {
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
const apiClientMocks = vi.hoisted(() => ({
|
||||
emitAuthStateChange: vi.fn(),
|
||||
requestJson: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
getStoredAccessToken,
|
||||
getStoredAutoAuthCredentials,
|
||||
setStoredAccessToken,
|
||||
} from './apiClient';
|
||||
vi.mock('./apiClient', async () => {
|
||||
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||||
return {
|
||||
...actual,
|
||||
emitAuthStateChange: apiClientMocks.emitAuthStateChange,
|
||||
requestJson: apiClientMocks.requestJson,
|
||||
};
|
||||
});
|
||||
|
||||
import { ApiClientError } from './apiClient';
|
||||
import { clearStoredAccessToken, getStoredAccessToken } from './apiClient';
|
||||
import {
|
||||
authEntryWithStoredCredentials,
|
||||
bindWechatPhone,
|
||||
@@ -24,50 +28,55 @@ import {
|
||||
getAuthRiskBlocks,
|
||||
getAuthSessions,
|
||||
getCaptchaChallengeFromError,
|
||||
getCurrentAuthUser,
|
||||
liftAuthRiskBlock,
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
revokeAuthSession,
|
||||
sendPhoneLoginCode,
|
||||
startWechatLogin,
|
||||
} from './authService';
|
||||
|
||||
function createMemoryStorage() {
|
||||
const values = new Map<string, string>();
|
||||
function createLocalStorageMock() {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return values.has(key) ? values.get(key)! : null;
|
||||
return store.has(key) ? store.get(key)! : null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
values.set(key, value);
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem(key: string) {
|
||||
values.delete(key);
|
||||
store.delete(key);
|
||||
},
|
||||
clear() {
|
||||
values.clear();
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('./apiClient', async () => {
|
||||
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||||
function createWindowMock(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
...actual,
|
||||
requestJson: requestJsonMock,
|
||||
dispatchEvent: vi.fn(),
|
||||
localStorage: createLocalStorageMock(),
|
||||
location: {
|
||||
pathname: '/',
|
||||
hash: '',
|
||||
search: '',
|
||||
assign: vi.fn(),
|
||||
},
|
||||
history: {
|
||||
replaceState: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe('authService auto auth', () => {
|
||||
describe('authService', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createMemoryStorage(),
|
||||
dispatchEvent: vi.fn(),
|
||||
});
|
||||
requestJsonMock.mockReset();
|
||||
clearStoredAccessToken();
|
||||
clearStoredAutoAuthCredentials();
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('window', createWindowMock());
|
||||
clearStoredAccessToken({ emit: false });
|
||||
});
|
||||
|
||||
it('creates credentials that match current username/password constraints', () => {
|
||||
@@ -78,9 +87,9 @@ describe('authService auto auth', () => {
|
||||
expect(credentials.password.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it('stores jwt and auto credentials after auth entry', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'jwt-token-value',
|
||||
it('auth entry trims guest credentials and写入 access token', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
token: 'jwt-entry-token',
|
||||
user: {
|
||||
id: 'user_1',
|
||||
username: 'guest_abc123abc123',
|
||||
@@ -98,12 +107,7 @@ describe('authService auto auth', () => {
|
||||
});
|
||||
|
||||
expect(user.username).toBe('guest_abc123abc123');
|
||||
expect(getStoredAccessToken()).toBe('jwt-token-value');
|
||||
expect(getStoredAutoAuthCredentials()).toEqual({
|
||||
username: 'guest_abc123abc123',
|
||||
password: 'auto_secret_password',
|
||||
});
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/entry',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
@@ -113,13 +117,13 @@ describe('authService auto auth', () => {
|
||||
}),
|
||||
'登录失败',
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('jwt-entry-token');
|
||||
expect(window.dispatchEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reuses stored auto credentials before generating a new account', async () => {
|
||||
window.localStorage.setItem('genarrative.auth.auto-username.v1', 'guest_saveduser01');
|
||||
window.localStorage.setItem('genarrative.auth.auto-password.v1', 'auto_saved_password');
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'jwt-restored',
|
||||
it('creates a fresh guest credential pair for auto auth when a session is missing', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
token: 'jwt-auto-token',
|
||||
user: {
|
||||
id: 'user_saved',
|
||||
username: 'guest_saveduser01',
|
||||
@@ -132,24 +136,25 @@ describe('authService auto auth', () => {
|
||||
});
|
||||
|
||||
const result = await ensureAutoAuthUser();
|
||||
const authEntryBody = JSON.parse(
|
||||
apiClientMocks.requestJson.mock.calls[0]?.[1]?.body as string,
|
||||
) as {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
expect(result.user.username).toBe('guest_saveduser01');
|
||||
expect(result.credentials).toEqual({
|
||||
username: 'guest_saveduser01',
|
||||
password: 'auto_saved_password',
|
||||
});
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/entry',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'登录失败',
|
||||
expect(result.credentials.username).toMatch(/^guest_[a-z0-9]{12}$/u);
|
||||
expect(result.credentials.password).toMatch(
|
||||
/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u,
|
||||
);
|
||||
expect(authEntryBody).toEqual(result.credentials);
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('deduplicates concurrent auto auth requests', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'jwt-auto',
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
token: 'jwt-auto-shared-token',
|
||||
user: {
|
||||
id: 'user_auto',
|
||||
username: 'guest_auto',
|
||||
@@ -166,13 +171,12 @@ describe('authService auto auth', () => {
|
||||
ensureAutoAuthUser(),
|
||||
]);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledTimes(1);
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1);
|
||||
expect(firstResult).toEqual(secondResult);
|
||||
expect(getStoredAutoAuthCredentials()).toEqual(firstResult.credentials);
|
||||
});
|
||||
|
||||
it('sends phone login code through the new auth endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
it('sends phone login code through the auth endpoint', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
ok: true,
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
@@ -182,7 +186,7 @@ describe('authService auto auth', () => {
|
||||
const result = await sendPhoneLoginCode(' 138 0013 8000 ');
|
||||
|
||||
expect(result.cooldownSeconds).toBe(60);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/send-code',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
@@ -194,28 +198,6 @@ describe('authService auto auth', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('sends phone change code with the correct scene', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
providerRequestId: 'mock-request-id',
|
||||
});
|
||||
|
||||
await sendPhoneLoginCode('13900139000', 'change_phone');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/send-code',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13900139000',
|
||||
scene: 'change_phone',
|
||||
}),
|
||||
}),
|
||||
'发送验证码失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts captcha challenge details from api errors', () => {
|
||||
expect(getCaptchaChallengeFromError(new Error('plain error'))).toBeNull();
|
||||
|
||||
@@ -241,9 +223,9 @@ describe('authService auto auth', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('stores jwt after phone login', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'phone-jwt-token',
|
||||
it('stores renewed access token after phone login', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
token: 'jwt-phone-token',
|
||||
user: {
|
||||
id: 'user_phone',
|
||||
username: '138****8000',
|
||||
@@ -258,8 +240,7 @@ describe('authService auto auth', () => {
|
||||
const user = await loginWithPhoneCode('13800138000', '123456');
|
||||
|
||||
expect(user.username).toBe('138****8000');
|
||||
expect(getStoredAccessToken()).toBe('phone-jwt-token');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/login',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
@@ -269,11 +250,13 @@ describe('authService auto auth', () => {
|
||||
}),
|
||||
'登录失败',
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('jwt-phone-token');
|
||||
expect(window.dispatchEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('binds wechat phone and stores jwt after activation', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'wechat-bind-token',
|
||||
it('stores renewed access token after wechat bind activation', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
token: 'jwt-wechat-bind-token',
|
||||
user: {
|
||||
id: 'user_wechat',
|
||||
username: '138****8000',
|
||||
@@ -288,22 +271,12 @@ describe('authService auto auth', () => {
|
||||
const user = await bindWechatPhone('13800138000', '123456');
|
||||
|
||||
expect(user.wechatBound).toBe(true);
|
||||
expect(getStoredAccessToken()).toBe('wechat-bind-token');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/wechat/bind-phone',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
code: '123456',
|
||||
}),
|
||||
}),
|
||||
'绑定手机号失败',
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('jwt-wechat-bind-token');
|
||||
expect(window.dispatchEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('changes phone number without replacing the stored access token', async () => {
|
||||
setStoredAccessToken('active-token');
|
||||
requestJsonMock.mockResolvedValue({
|
||||
it('changes phone number without emitting a global auth state refresh', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
user: {
|
||||
id: 'user_phone',
|
||||
username: '139****9000',
|
||||
@@ -318,41 +291,29 @@ describe('authService auto auth', () => {
|
||||
const user = await changePhoneNumber('13900139000', '123456');
|
||||
|
||||
expect(user.phoneNumberMasked).toBe('139****9000');
|
||||
expect(getStoredAccessToken()).toBe('active-token');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/change',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13900139000',
|
||||
code: '123456',
|
||||
}),
|
||||
}),
|
||||
'更换手机号失败',
|
||||
);
|
||||
expect(apiClientMocks.emitAuthStateChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts wechat login by navigating to backend authorization url', async () => {
|
||||
const assignMock = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createMemoryStorage(),
|
||||
dispatchEvent: vi.fn(),
|
||||
location: {
|
||||
pathname: '/',
|
||||
hash: '',
|
||||
search: '',
|
||||
assign: assignMock,
|
||||
},
|
||||
history: {
|
||||
replaceState: vi.fn(),
|
||||
},
|
||||
});
|
||||
requestJsonMock.mockResolvedValue({
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
createWindowMock({
|
||||
location: {
|
||||
pathname: '/',
|
||||
hash: '',
|
||||
search: '',
|
||||
assign: assignMock,
|
||||
},
|
||||
}),
|
||||
);
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
authorizationUrl: '/api/auth/wechat/callback?mock_code=wx-user&state=state123',
|
||||
});
|
||||
|
||||
await startWechatLogin();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/wechat/start?redirectPath=%2F',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
@@ -365,14 +326,14 @@ describe('authService auto auth', () => {
|
||||
});
|
||||
|
||||
it('loads available login methods for the unauthenticated login screen', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
availableLoginMethods: ['phone', 'wechat'],
|
||||
});
|
||||
|
||||
const result = await getAuthLoginOptions();
|
||||
|
||||
expect(result.availableLoginMethods).toEqual(['phone', 'wechat']);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/login-options',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
@@ -381,20 +342,22 @@ describe('authService auto auth', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('consumes auth callback hash and stores token', () => {
|
||||
it('consumes auth callback hash and persists the returned access token', () => {
|
||||
const replaceStateMock = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createMemoryStorage(),
|
||||
dispatchEvent: vi.fn(),
|
||||
location: {
|
||||
pathname: '/',
|
||||
search: '',
|
||||
hash: '#auth_provider=wechat&auth_token=wx-token&auth_binding_status=pending_bind_phone',
|
||||
},
|
||||
history: {
|
||||
replaceState: replaceStateMock,
|
||||
},
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
createWindowMock({
|
||||
location: {
|
||||
pathname: '/',
|
||||
search: '',
|
||||
hash: '#auth_provider=wechat&auth_token=jwt-callback-token&auth_binding_status=pending_bind_phone',
|
||||
assign: vi.fn(),
|
||||
},
|
||||
history: {
|
||||
replaceState: replaceStateMock,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = consumeAuthCallbackResult();
|
||||
|
||||
@@ -403,12 +366,37 @@ describe('authService auto auth', () => {
|
||||
bindingStatus: 'pending_bind_phone',
|
||||
error: null,
|
||||
});
|
||||
expect(getStoredAccessToken()).toBe('wx-token');
|
||||
expect(getStoredAccessToken()).toBe('jwt-callback-token');
|
||||
expect(window.dispatchEvent).toHaveBeenCalledTimes(1);
|
||||
expect(replaceStateMock).toHaveBeenCalledWith(null, '', '/');
|
||||
});
|
||||
|
||||
it('gets current auth user with silent auth-state notification settings', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
user: null,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
const result = await getCurrentAuthUser();
|
||||
|
||||
expect(result).toEqual({
|
||||
user: null,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/me',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取当前用户失败',
|
||||
{
|
||||
notifyAuthStateChange: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('loads auth sessions from account center endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'usess_1',
|
||||
@@ -427,17 +415,10 @@ describe('authService auto auth', () => {
|
||||
const sessions = await getAuthSessions();
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/sessions',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取登录设备失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads recent auth audit logs', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
logs: [
|
||||
{
|
||||
id: 'audit_1',
|
||||
@@ -454,17 +435,10 @@ describe('authService auto auth', () => {
|
||||
const logs = await getAuthAuditLogs();
|
||||
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/audit-logs',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取账号操作记录失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads current risk blocks', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
blocks: [
|
||||
{
|
||||
scopeType: 'phone',
|
||||
@@ -479,23 +453,16 @@ describe('authService auto auth', () => {
|
||||
const blocks = await getAuthRiskBlocks();
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/risk-blocks',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取安全状态失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('lifts a risk block by scope type', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
await liftAuthRiskBlock('phone');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/risk-blocks/phone/lift',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
@@ -504,37 +471,20 @@ describe('authService auto auth', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('revokes a remote auth session by id', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
await revokeAuthSession('usess_123');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/sessions/usess_123/revoke',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'移除登录设备失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('clears local auth state after logout all sessions', async () => {
|
||||
setStoredAccessToken('stale-token');
|
||||
requestJsonMock.mockResolvedValue({
|
||||
it('emits auth change after logout all sessions', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
await logoutAllAuthSessions();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/logout-all',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'退出全部设备失败',
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
emitAuthStateChange,
|
||||
getStoredAutoAuthCredentials,
|
||||
requestJson,
|
||||
setStoredAccessToken,
|
||||
@@ -120,8 +121,9 @@ export function createAutoAuthCredentials(): AutoAuthCredentials {
|
||||
}
|
||||
|
||||
export function clearAuthSession() {
|
||||
clearStoredAccessToken();
|
||||
clearStoredAutoAuthCredentials();
|
||||
clearStoredAccessToken({ emit: false });
|
||||
clearStoredAutoAuthCredentials({ emit: false });
|
||||
emitAuthStateChange();
|
||||
}
|
||||
|
||||
export async function sendPhoneLoginCode(
|
||||
@@ -320,6 +322,10 @@ export async function getCurrentAuthUser(): Promise<AuthSessionSnapshot> {
|
||||
method: 'GET',
|
||||
},
|
||||
'读取当前用户失败',
|
||||
{
|
||||
// 会话恢复阶段允许 401 作为“未登录”信号,不应再广播一次全局鉴权事件。
|
||||
notifyAuthStateChange: false,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,779 +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(
|
||||
'守灯会值夜人,对外总像比别人更冷静一步。',
|
||||
);
|
||||
});
|
||||
@@ -1,458 +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[];
|
||||
};
|
||||
|
||||
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 mergeDraftRoleAssetsIntoProfile(
|
||||
baseProfile: CustomWorldProfile,
|
||||
draftRoles: AdaptedDraftCharacter[],
|
||||
roleKind: 'playable' | 'story',
|
||||
) {
|
||||
const draftRoleById = new Map(draftRoles.map((role) => [role.id, role]));
|
||||
const currentRoles =
|
||||
roleKind === 'playable' ? baseProfile.playableNpcs : baseProfile.storyNpcs;
|
||||
const mergedRoles = currentRoles.map((role) => {
|
||||
const draftRole = draftRoleById.get(role.id);
|
||||
if (!draftRole) {
|
||||
return role;
|
||||
}
|
||||
|
||||
return {
|
||||
...role,
|
||||
imageSrc: draftRole.imageSrc ?? role.imageSrc,
|
||||
generatedVisualAssetId:
|
||||
draftRole.generatedVisualAssetId ?? role.generatedVisualAssetId,
|
||||
generatedAnimationSetId:
|
||||
draftRole.generatedAnimationSetId ?? role.generatedAnimationSetId,
|
||||
animationMap: draftRole.animationMap ?? role.animationMap,
|
||||
};
|
||||
});
|
||||
|
||||
if (roleKind === 'playable') {
|
||||
return {
|
||||
...baseProfile,
|
||||
playableNpcs: mergedRoles,
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
return {
|
||||
...baseProfile,
|
||||
storyNpcs: mergedRoles,
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
function mergeDraftSceneAssetsIntoProfile(
|
||||
baseProfile: CustomWorldProfile,
|
||||
draftSceneChapters: CustomWorldProfile['sceneChapterBlueprints'],
|
||||
draftLandmarks: AdaptedDraftLandmark[],
|
||||
) {
|
||||
const normalizedDraftSceneChapters = draftSceneChapters ?? [];
|
||||
const draftSceneChapterBySceneId = new Map(
|
||||
normalizedDraftSceneChapters.map((chapter) => [chapter.sceneId, chapter]),
|
||||
);
|
||||
const draftLandmarkById = new Map(draftLandmarks.map((entry) => [entry.id, entry]));
|
||||
|
||||
const nextCamp = baseProfile.camp
|
||||
? {
|
||||
...baseProfile.camp,
|
||||
imageSrc: baseProfile.camp.imageSrc,
|
||||
}
|
||||
: baseProfile.camp;
|
||||
|
||||
const nextLandmarks = baseProfile.landmarks.map((landmark) => {
|
||||
const draftLandmark = draftLandmarkById.get(landmark.id);
|
||||
return {
|
||||
...landmark,
|
||||
imageSrc: draftLandmark?.imageSrc ?? landmark.imageSrc,
|
||||
};
|
||||
});
|
||||
|
||||
const nextSceneChapterBlueprints =
|
||||
normalizedDraftSceneChapters.length > 0
|
||||
? baseProfile.sceneChapterBlueprints?.map((chapter) => {
|
||||
const draftChapter = draftSceneChapterBySceneId.get(chapter.sceneId);
|
||||
if (!draftChapter) {
|
||||
return chapter;
|
||||
}
|
||||
|
||||
const draftActById = new Map(
|
||||
draftChapter.acts.map((act) => [act.id, act]),
|
||||
);
|
||||
|
||||
return {
|
||||
...chapter,
|
||||
acts: chapter.acts.map((act) => {
|
||||
const draftAct = draftActById.get(act.id);
|
||||
if (!draftAct) {
|
||||
return act;
|
||||
}
|
||||
|
||||
return {
|
||||
...act,
|
||||
backgroundImageSrc:
|
||||
draftAct.backgroundImageSrc ?? act.backgroundImageSrc,
|
||||
backgroundAssetId:
|
||||
draftAct.backgroundAssetId ?? act.backgroundAssetId,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}) ?? normalizedDraftSceneChapters
|
||||
: baseProfile.sceneChapterBlueprints;
|
||||
|
||||
return {
|
||||
...baseProfile,
|
||||
camp: nextCamp,
|
||||
landmarks: nextLandmarks,
|
||||
sceneChapterBlueprints: nextSceneChapterBlueprints,
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
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 legacyResultProfile = normalizeCustomWorldProfileRecord(
|
||||
draftProfile.legacyResultProfile,
|
||||
);
|
||||
if (legacyResultProfile) {
|
||||
const mergedPlayableProfile = mergeDraftRoleAssetsIntoProfile(
|
||||
legacyResultProfile,
|
||||
playableNpcs,
|
||||
'playable',
|
||||
);
|
||||
const mergedStoryProfile = mergeDraftRoleAssetsIntoProfile(
|
||||
mergedPlayableProfile,
|
||||
storyNpcs,
|
||||
'story',
|
||||
);
|
||||
return mergeDraftSceneAssetsIntoProfile(
|
||||
mergedStoryProfile,
|
||||
draftSceneChapterBlueprints,
|
||||
adaptedLandmarks,
|
||||
);
|
||||
}
|
||||
|
||||
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: isRecord(draftProfile.camp)
|
||||
? {
|
||||
name: toText(draftProfile.camp.name),
|
||||
description: toText(draftProfile.camp.description),
|
||||
dangerLevel:
|
||||
toText(draftProfile.camp.dangerLevel) ||
|
||||
toText(draftProfile.camp.mood),
|
||||
imageSrc: toText(draftProfile.camp.imageSrc) || undefined,
|
||||
}
|
||||
: undefined,
|
||||
sceneChapterBlueprints: draftSceneChapterBlueprints,
|
||||
anchorContent: session.anchorContent,
|
||||
creatorIntent: session.creatorIntent,
|
||||
anchorPack: session.anchorPack,
|
||||
lockState: session.lockState,
|
||||
generationMode: 'fast',
|
||||
generationStatus: 'key_only',
|
||||
});
|
||||
|
||||
return normalized;
|
||||
}
|
||||
@@ -295,8 +295,8 @@ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
|
||||
},
|
||||
{
|
||||
id: 'workspace',
|
||||
label: '准备精修工作区',
|
||||
detail: '正在写回草稿数据,并切回可继续精修的工作区。',
|
||||
label: '准备结果页',
|
||||
detail: '正在写回草稿数据,并打开可继续完善的结果页。',
|
||||
matchers: ['世界底稿已生成'],
|
||||
minProgress: 100,
|
||||
},
|
||||
@@ -324,7 +324,8 @@ function resolveAgentDraftFoundationStepIndexByProgress(progress: number) {
|
||||
index < AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.length - 1;
|
||||
index += 1
|
||||
) {
|
||||
if (progress >= AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index].minProgress) {
|
||||
const step = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index];
|
||||
if (step && progress >= step.minProgress) {
|
||||
matchedIndex = index;
|
||||
}
|
||||
}
|
||||
@@ -348,7 +349,7 @@ function resolveAgentDraftFoundationStepIndex(
|
||||
index -= 1
|
||||
) {
|
||||
const step = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index];
|
||||
if (step.matchers.some((matcher) => phaseLabel.includes(matcher))) {
|
||||
if (step?.matchers.some((matcher) => phaseLabel.includes(matcher))) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { WorldType } from '../types';
|
||||
|
||||
const ATTRIBUTE_LABELS = {
|
||||
strength: 'Strength',
|
||||
agility: 'Agility',
|
||||
intelligence: 'Intelligence',
|
||||
spirit: 'Spirit',
|
||||
} as const;
|
||||
|
||||
const RESOURCE_LABELS = {
|
||||
hp: 'HP',
|
||||
mp: 'MP',
|
||||
maxHp: '生命上限',
|
||||
maxMp: '灵力上限',
|
||||
damage: 'Damage',
|
||||
guard: 'Guard',
|
||||
range: 'Range',
|
||||
cooldown: 'Cooldown',
|
||||
manaCost: '灵力消耗',
|
||||
} as const;
|
||||
|
||||
export function buildThemedSkillName(_profile: unknown, style: string, index = 0) {
|
||||
return `${style || 'skill'}-${index + 1}`;
|
||||
}
|
||||
|
||||
export function buildCustomCampSceneName(profile: { name?: string; camp?: { name?: string | null } | null } | null | undefined) {
|
||||
return profile?.camp?.name?.trim() || (profile?.name ? `${profile.name}归舍` : '归舍');
|
||||
}
|
||||
|
||||
export function getAttributeLabelsForWorld(_worldType: WorldType | null) {
|
||||
return ATTRIBUTE_LABELS;
|
||||
}
|
||||
|
||||
export function getResourceLabelsForWorld(_worldType: WorldType | null) {
|
||||
return RESOURCE_LABELS;
|
||||
}
|
||||
|
||||
export function buildThemedItemName(_profile: unknown, category: string, rarity: string, seedKey: string) {
|
||||
return `${category}-${rarity}-${seedKey}`;
|
||||
}
|
||||
|
||||
export function buildThemedItemDescription(_profile: unknown, category: string, rarity: string, seedKey: string) {
|
||||
return `${category}-${rarity}-${seedKey} description`;
|
||||
}
|
||||
|
||||
export function inferCustomItemMechanics() {
|
||||
return {
|
||||
tags: [],
|
||||
equipmentSlotId: null,
|
||||
statProfile: null,
|
||||
useProfile: null,
|
||||
value: 0,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import type {
|
||||
PlatformBrowseHistoryEntry,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import type { AuthUser } from './authService';
|
||||
|
||||
export type { PlatformBrowseHistoryEntry, PlatformBrowseHistoryWriteEntry };
|
||||
|
||||
const HISTORY_STORAGE_KEY_PREFIX = 'genarrative.platform.browse-history.v1';
|
||||
const HISTORY_SYNC_KEY_PREFIX = 'genarrative.platform.browse-history.synced.v1';
|
||||
const MAX_HISTORY_ENTRIES = 20;
|
||||
|
||||
function canUseLocalStorage() {
|
||||
return (
|
||||
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
|
||||
);
|
||||
}
|
||||
|
||||
function buildHistoryStorageKey(user: AuthUser | null | undefined) {
|
||||
const accountId = user?.id?.trim() || user?.username?.trim() || 'guest';
|
||||
return `${HISTORY_STORAGE_KEY_PREFIX}:${accountId}`;
|
||||
}
|
||||
|
||||
function buildHistorySyncKey(user: AuthUser | null | undefined) {
|
||||
const accountId = user?.id?.trim() || user?.username?.trim() || 'guest';
|
||||
return `${HISTORY_SYNC_KEY_PREFIX}:${accountId}`;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function normalizeHistoryEntry(
|
||||
value: unknown,
|
||||
): PlatformBrowseHistoryEntry | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ownerUserId = readString(value.ownerUserId);
|
||||
const profileId = readString(value.profileId);
|
||||
const worldName = readString(value.worldName);
|
||||
const visitedAt = readString(value.visitedAt);
|
||||
|
||||
if (!ownerUserId || !profileId || !worldName || !visitedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
ownerUserId,
|
||||
profileId,
|
||||
worldName,
|
||||
subtitle: readString(value.subtitle),
|
||||
summaryText: readString(value.summaryText),
|
||||
coverImageSrc: readString(value.coverImageSrc) || null,
|
||||
themeMode:
|
||||
(readString(
|
||||
value.themeMode,
|
||||
) as PlatformBrowseHistoryEntry['themeMode']) || 'mythic',
|
||||
authorDisplayName: readString(value.authorDisplayName) || '玩家',
|
||||
visitedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function sortHistoryEntries(entries: PlatformBrowseHistoryEntry[]) {
|
||||
return [...entries].sort((left, right) => {
|
||||
return (
|
||||
new Date(right.visitedAt).getTime() - new Date(left.visitedAt).getTime()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function readPlatformBrowseHistory(user: AuthUser | null | undefined) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return [] as PlatformBrowseHistoryEntry[];
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(buildHistoryStorageKey(user));
|
||||
if (!raw?.trim()) {
|
||||
return [] as PlatformBrowseHistoryEntry[];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown[];
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [] as PlatformBrowseHistoryEntry[];
|
||||
}
|
||||
|
||||
return sortHistoryEntries(
|
||||
parsed
|
||||
.map((entry) => normalizeHistoryEntry(entry))
|
||||
.filter((entry): entry is PlatformBrowseHistoryEntry => Boolean(entry)),
|
||||
).slice(0, MAX_HISTORY_ENTRIES);
|
||||
} catch {
|
||||
return [] as PlatformBrowseHistoryEntry[];
|
||||
}
|
||||
}
|
||||
|
||||
export function writePlatformBrowseHistory(
|
||||
user: AuthUser | null | undefined,
|
||||
entry: PlatformBrowseHistoryWriteEntry,
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return [] as PlatformBrowseHistoryEntry[];
|
||||
}
|
||||
|
||||
const nextEntry: PlatformBrowseHistoryEntry = {
|
||||
ownerUserId: entry.ownerUserId.trim(),
|
||||
profileId: entry.profileId.trim(),
|
||||
worldName: entry.worldName.trim(),
|
||||
subtitle: entry.subtitle?.trim() || '',
|
||||
summaryText: entry.summaryText?.trim() || '',
|
||||
coverImageSrc: entry.coverImageSrc?.trim() || null,
|
||||
themeMode: entry.themeMode || 'mythic',
|
||||
authorDisplayName: entry.authorDisplayName?.trim() || '玩家',
|
||||
visitedAt: entry.visitedAt?.trim() || new Date().toISOString(),
|
||||
};
|
||||
const deduped = readPlatformBrowseHistory(user).filter(
|
||||
(current) =>
|
||||
!(
|
||||
current.ownerUserId === nextEntry.ownerUserId &&
|
||||
current.profileId === nextEntry.profileId
|
||||
),
|
||||
);
|
||||
const nextEntries = sortHistoryEntries([nextEntry, ...deduped]).slice(
|
||||
0,
|
||||
MAX_HISTORY_ENTRIES,
|
||||
);
|
||||
|
||||
window.localStorage.setItem(
|
||||
buildHistoryStorageKey(user),
|
||||
JSON.stringify(nextEntries),
|
||||
);
|
||||
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
export function clearPlatformBrowseHistory(user: AuthUser | null | undefined) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(buildHistoryStorageKey(user));
|
||||
window.localStorage.removeItem(buildHistorySyncKey(user));
|
||||
}
|
||||
|
||||
export function hasPendingPlatformBrowseHistoryMigration(
|
||||
user: AuthUser | null | undefined,
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
readPlatformBrowseHistory(user).length > 0 &&
|
||||
window.localStorage.getItem(buildHistorySyncKey(user)) !== '1'
|
||||
);
|
||||
}
|
||||
|
||||
export function markPlatformBrowseHistoryMigrated(
|
||||
user: AuthUser | null | undefined,
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(buildHistorySyncKey(user), '1');
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
import {
|
||||
getNpcDisclosureStage,
|
||||
getNpcWarmthStage,
|
||||
} from '../data/npcInteractions';
|
||||
import {
|
||||
buildFallbackQuestIntent,
|
||||
compileQuestIntentToQuest,
|
||||
evaluateQuestOpportunity,
|
||||
} from '../data/questFlow';
|
||||
import type { Encounter, GameState, QuestLogEntry } from '../types';
|
||||
import type { QuestGenerationContext } from './aiTypes';
|
||||
import { requestJson } from './apiClient';
|
||||
import { requestChatMessageContent } from './llmClient';
|
||||
import { parseJsonResponseText } from './llmParsers';
|
||||
import {
|
||||
buildQuestIntentPrompt,
|
||||
QUEST_INTENT_SYSTEM_PROMPT,
|
||||
} from './questPrompt';
|
||||
import type { QuestIntent, QuestPreviewRequest } from './questTypes';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
} from './storyEngine/actorNarrativeProfile';
|
||||
import { buildThemePackFromWorldProfile } from './storyEngine/themePack';
|
||||
import { buildFallbackWorldStoryGraph } from './storyEngine/worldStoryGraph';
|
||||
|
||||
const QUEST_DIRECTOR_TIMEOUT_MS = 12000;
|
||||
|
||||
function coerceString(value: unknown, fallback: string) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function coerceQuestTitle(value: unknown, fallback: string) {
|
||||
const title = coerceString(value, fallback)
|
||||
.replace(/[《》「」“”"']/gu, '')
|
||||
.replace(/[,。!?;:,.!?;:].*$/u, '')
|
||||
.trim();
|
||||
|
||||
if (title.length <= 12) {
|
||||
return title;
|
||||
}
|
||||
|
||||
return fallback.length <= 12 ? fallback : fallback.slice(0, 10);
|
||||
}
|
||||
|
||||
function coerceStringArray(value: unknown, fallback: string[]) {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const items = value
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean);
|
||||
|
||||
return items.length > 0 ? items : fallback;
|
||||
}
|
||||
|
||||
function resolveIssuerNarrativeProfile(state: GameState, encounter: Encounter) {
|
||||
if (encounter.narrativeProfile) {
|
||||
return encounter.narrativeProfile;
|
||||
}
|
||||
if (!state.customWorldProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const role =
|
||||
state.customWorldProfile.storyNpcs.find(
|
||||
(npc) => npc.id === encounter.id || npc.name === encounter.npcName,
|
||||
) ??
|
||||
state.customWorldProfile.playableNpcs.find(
|
||||
(npc) => npc.id === encounter.id || npc.name === encounter.npcName,
|
||||
);
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const themePack =
|
||||
state.customWorldProfile.themePack ??
|
||||
buildThemePackFromWorldProfile(state.customWorldProfile);
|
||||
const storyGraph =
|
||||
state.customWorldProfile.storyGraph ??
|
||||
buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
|
||||
|
||||
return normalizeActorNarrativeProfile(
|
||||
role.narrativeProfile,
|
||||
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeQuestIntent(
|
||||
rawIntent: unknown,
|
||||
fallback: QuestIntent,
|
||||
): QuestIntent {
|
||||
if (!rawIntent || typeof rawIntent !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const intent = rawIntent as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
title: coerceQuestTitle(intent.title, fallback.title),
|
||||
description: coerceString(intent.description, fallback.description),
|
||||
summary: coerceString(intent.summary, fallback.summary),
|
||||
narrativeType:
|
||||
typeof intent.narrativeType === 'string' &&
|
||||
[
|
||||
'bounty',
|
||||
'escort',
|
||||
'investigation',
|
||||
'retrieval',
|
||||
'relationship',
|
||||
'trial',
|
||||
].includes(intent.narrativeType)
|
||||
? (intent.narrativeType as QuestIntent['narrativeType'])
|
||||
: fallback.narrativeType,
|
||||
dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed),
|
||||
issuerGoal: coerceString(intent.issuerGoal, fallback.issuerGoal),
|
||||
playerHook: coerceString(intent.playerHook, fallback.playerHook),
|
||||
worldReason: coerceString(intent.worldReason, fallback.worldReason),
|
||||
recommendedObjectiveKinds: coerceStringArray(
|
||||
intent.recommendedObjectiveKinds,
|
||||
fallback.recommendedObjectiveKinds,
|
||||
).filter((kind) =>
|
||||
[
|
||||
'defeat_hostile_npc',
|
||||
'inspect_treasure',
|
||||
'spar_with_npc',
|
||||
'talk_to_npc',
|
||||
'reach_scene',
|
||||
'deliver_item',
|
||||
].includes(kind),
|
||||
) as QuestIntent['recommendedObjectiveKinds'],
|
||||
urgency:
|
||||
typeof intent.urgency === 'string' &&
|
||||
['low', 'medium', 'high'].includes(intent.urgency)
|
||||
? (intent.urgency as QuestIntent['urgency'])
|
||||
: fallback.urgency,
|
||||
intimacy:
|
||||
typeof intent.intimacy === 'string' &&
|
||||
['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy)
|
||||
? (intent.intimacy as QuestIntent['intimacy'])
|
||||
: fallback.intimacy,
|
||||
rewardTheme:
|
||||
typeof intent.rewardTheme === 'string' &&
|
||||
['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(
|
||||
intent.rewardTheme,
|
||||
)
|
||||
? (intent.rewardTheme as QuestIntent['rewardTheme'])
|
||||
: fallback.rewardTheme,
|
||||
followupHooks: coerceStringArray(
|
||||
intent.followupHooks,
|
||||
fallback.followupHooks,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildQuestGenerationContextFromState(params: {
|
||||
state: GameState;
|
||||
encounter: Encounter;
|
||||
}): QuestGenerationContext {
|
||||
const { state, encounter } = params;
|
||||
const issuerNpcId = encounter.id ?? encounter.npcName;
|
||||
const issuerState = state.npcStates[issuerNpcId];
|
||||
const issuerNarrativeProfile = resolveIssuerNarrativeProfile(
|
||||
state,
|
||||
encounter,
|
||||
);
|
||||
|
||||
return {
|
||||
worldType: state.worldType,
|
||||
customWorldProfile: state.customWorldProfile ?? null,
|
||||
actState: state.storyEngineMemory?.actState ?? null,
|
||||
currentSceneId: state.currentScenePreset?.id ?? null,
|
||||
currentSceneName: state.currentScenePreset?.name ?? null,
|
||||
currentSceneDescription: state.currentScenePreset?.description ?? null,
|
||||
issuerNpcId,
|
||||
issuerNpcName: encounter.npcName,
|
||||
issuerNpcContext: encounter.context,
|
||||
issuerAffinity: issuerState?.affinity ?? 0,
|
||||
issuerNarrativeProfile,
|
||||
issuerDisclosureStage: getNpcDisclosureStage(issuerState?.affinity ?? 0),
|
||||
issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0),
|
||||
activeThreadIds:
|
||||
state.storyEngineMemory?.activeThreadIds?.slice(0, 4) ??
|
||||
issuerNarrativeProfile?.relatedThreadIds?.slice(0, 4) ??
|
||||
[],
|
||||
encounterKind: encounter.kind ?? 'npc',
|
||||
currentSceneTreasureHintCount:
|
||||
state.currentScenePreset?.treasureHints?.length ?? 0,
|
||||
currentSceneHostileNpcIds: (state.currentScenePreset?.npcs ?? [])
|
||||
.filter((npc) => Boolean(npc.hostile || npc.monsterPresetId))
|
||||
.map((npc) => npc.id),
|
||||
recentStoryMoments: state.storyHistory.slice(-6),
|
||||
playerCharacter: state.playerCharacter,
|
||||
playerProgression: state.playerProgression ?? null,
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
playerMana: state.playerMana,
|
||||
playerMaxMana: state.playerMaxMana,
|
||||
playerInventory: state.playerInventory,
|
||||
playerEquipment: state.playerEquipment,
|
||||
activeCompanions: state.companions,
|
||||
rosterCompanions: state.roster,
|
||||
currentQuestSummary: state.quests.map((quest) => ({
|
||||
id: quest.id,
|
||||
title: quest.title,
|
||||
status: quest.status,
|
||||
issuerNpcId: quest.issuerNpcId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateQuestForNpcEncounter(params: {
|
||||
state: GameState;
|
||||
encounter: Encounter;
|
||||
}): Promise<QuestLogEntry | null> {
|
||||
const { state, encounter } = params;
|
||||
const issuerNpcId = encounter.id ?? encounter.npcName;
|
||||
const request: QuestPreviewRequest = {
|
||||
issuerNpcId,
|
||||
issuerNpcName: encounter.npcName,
|
||||
roleText: encounter.context,
|
||||
scene: state.currentScenePreset,
|
||||
worldType: state.worldType,
|
||||
currentQuests: state.quests.map((quest) => ({
|
||||
id: quest.id,
|
||||
issuerNpcId: quest.issuerNpcId,
|
||||
status: quest.status,
|
||||
})),
|
||||
context: buildQuestGenerationContextFromState({ state, encounter }),
|
||||
origin: 'ai_compiled',
|
||||
};
|
||||
const opportunity = evaluateQuestOpportunity(request);
|
||||
if (!opportunity.shouldOffer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackIntent = buildFallbackQuestIntent(request);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
return await requestJson<QuestLogEntry | null>(
|
||||
'/api/runtime/quests/generate',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
},
|
||||
'任务生成失败',
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[QuestDirector] backend quest generation failed, using deterministic fallback',
|
||||
error,
|
||||
);
|
||||
return compileQuestIntentToQuest(
|
||||
{
|
||||
...request,
|
||||
origin: 'fallback_builder',
|
||||
},
|
||||
fallbackIntent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await requestChatMessageContent(
|
||||
QUEST_INTENT_SYSTEM_PROMPT,
|
||||
buildQuestIntentPrompt({
|
||||
context: request.context!,
|
||||
scene: request.scene,
|
||||
opportunity,
|
||||
}),
|
||||
{
|
||||
timeoutMs: QUEST_DIRECTOR_TIMEOUT_MS,
|
||||
debugLabel: 'quest-intent',
|
||||
},
|
||||
);
|
||||
const parsed = parseJsonResponseText(content) as { intent?: unknown };
|
||||
const intent = sanitizeQuestIntent(parsed.intent, fallbackIntent);
|
||||
return compileQuestIntentToQuest(
|
||||
{
|
||||
...request,
|
||||
origin: 'ai_compiled',
|
||||
},
|
||||
intent,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[QuestDirector] falling back to deterministic quest intent',
|
||||
error,
|
||||
);
|
||||
return compileQuestIntentToQuest(
|
||||
{
|
||||
...request,
|
||||
origin: 'fallback_builder',
|
||||
},
|
||||
fallbackIntent,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../prompts/questPrompts';
|
||||
48
src/services/rpg-creation/index.ts
Normal file
48
src/services/rpg-creation/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export {
|
||||
createRpgCreationSession,
|
||||
executeRpgCreationAction,
|
||||
getRpgCreationCardDetail,
|
||||
getRpgCreationOperation,
|
||||
getRpgCreationSession,
|
||||
rpgCreationAgentClient,
|
||||
sendRpgCreationMessage,
|
||||
streamRpgCreationMessage,
|
||||
} from './rpgCreationAgentClient';
|
||||
export { rpgCreationAssetClient } from './rpgCreationAssetClient';
|
||||
export {
|
||||
generateRpgWorldCoverImage,
|
||||
generateRpgWorldLandmark,
|
||||
generateRpgWorldPlayableNpc,
|
||||
generateRpgWorldSceneImage,
|
||||
generateRpgWorldSceneNpc,
|
||||
generateRpgWorldStoryNpc,
|
||||
uploadRpgWorldCoverImage,
|
||||
} from './rpgCreationAssetClient';
|
||||
export type {
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from './rpgCreationGenerationClient';
|
||||
export {
|
||||
generateCustomWorldProfile as generateLegacyCustomWorldProfile,
|
||||
generateRpgWorldProfile,
|
||||
} from './rpgCreationGenerationClient';
|
||||
export {
|
||||
deleteRpgWorldProfile,
|
||||
getRpgWorldGalleryDetail,
|
||||
listRpgWorldGallery,
|
||||
listRpgWorldLibrary,
|
||||
publishRpgWorldProfile,
|
||||
rpgCreationLibraryClient,
|
||||
unpublishRpgWorldProfile,
|
||||
upsertRpgWorldProfile,
|
||||
} from './rpgCreationLibraryClient';
|
||||
export {
|
||||
buildRpgCreationPreviewFromResultPreview,
|
||||
buildRpgCreationPreviewFromSession,
|
||||
rpgCreationPreviewAdapter,
|
||||
} from './rpgCreationPreviewAdapter';
|
||||
export {
|
||||
listRpgCreationWorks,
|
||||
rpgCreationWorkClient,
|
||||
} from './rpgCreationWorkClient';
|
||||
208
src/services/rpg-creation/rpgCreationAgentClient.ts
Normal file
208
src/services/rpg-creation/rpgCreationAgentClient.ts
Normal file
@@ -0,0 +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 {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作包 D 开始让 RPG 创作 Agent client 持有真实请求实现,
|
||||
* 旧 `aiService.ts` 仅保留兼容导出,避免主链请求继续回流到通用服务文件。
|
||||
*/
|
||||
export const rpgCreationAgentClient = {
|
||||
createSession: createRpgCreationSession,
|
||||
getSession: getRpgCreationSession,
|
||||
sendMessage: sendRpgCreationMessage,
|
||||
streamMessage: streamRpgCreationMessage,
|
||||
executeAction: executeRpgCreationAction,
|
||||
getOperation: getRpgCreationOperation,
|
||||
getCardDetail: getRpgCreationCardDetail,
|
||||
};
|
||||
116
src/services/rpg-creation/rpgCreationAssetClient.ts
Normal file
116
src/services/rpg-creation/rpgCreationAssetClient.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作包 D 把结果页与编辑器依赖的资产请求迁入 RPG 创作域 client,
|
||||
* 保留封面资产服务的既有边界,不把逻辑重新塞回 `aiService.ts`。
|
||||
*/
|
||||
export const rpgCreationAssetClient = {
|
||||
generateSceneImage: generateRpgWorldSceneImage,
|
||||
generateSceneNpc: generateRpgWorldSceneNpc,
|
||||
generatePlayableNpc: generateRpgWorldPlayableNpc,
|
||||
generateStoryNpc: generateRpgWorldStoryNpc,
|
||||
generateLandmark: generateRpgWorldLandmark,
|
||||
generateCoverImage: generateCustomWorldCoverImage,
|
||||
uploadCoverImage: uploadCustomWorldCoverImage,
|
||||
};
|
||||
|
||||
export {
|
||||
generateCustomWorldCoverImage as generateRpgWorldCoverImage,
|
||||
uploadCustomWorldCoverImage as uploadRpgWorldCoverImage,
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import { generateRpgWorldProfile } from './rpgCreationGenerationClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
describe('rpgCreationGenerationClient', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({
|
||||
id: 'custom-world-1',
|
||||
name: '测试世界',
|
||||
subtitle: '副标题',
|
||||
summary: '概述',
|
||||
tone: '基调',
|
||||
playerGoal: '目标',
|
||||
settingText: '设定',
|
||||
});
|
||||
});
|
||||
|
||||
it('posts world generation to the runtime custom world profile route', async () => {
|
||||
await generateRpgWorldProfile('一个被灵潮反复改写地形的边境世界');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world/profile',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'生成自定义世界失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects immediately when the caller aborts before sending', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('手动中断生成'));
|
||||
|
||||
await expect(
|
||||
generateRpgWorldProfile('一个会被中断的世界', {
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).rejects.toThrow('手动中断生成');
|
||||
|
||||
expect(requestJsonMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
68
src/services/rpg-creation/rpgCreationGenerationClient.ts
Normal file
68
src/services/rpg-creation/rpgCreationGenerationClient.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { requestJson } from '../apiClient';
|
||||
|
||||
type LegacyAiModule = typeof import('../ai');
|
||||
|
||||
let legacyAiModulePromise: Promise<LegacyAiModule> | null = null;
|
||||
|
||||
async function loadLegacyAiModule() {
|
||||
if (!legacyAiModulePromise) {
|
||||
legacyAiModulePromise = import('../ai');
|
||||
}
|
||||
|
||||
return legacyAiModulePromise;
|
||||
}
|
||||
|
||||
export async function generateRpgWorldProfile(
|
||||
input: GenerateCustomWorldProfileInput | string,
|
||||
options: GenerateCustomWorldProfileOptions = {},
|
||||
): Promise<CustomWorldProfile> {
|
||||
const normalizedInput =
|
||||
typeof input === 'string'
|
||||
? {
|
||||
settingText: input,
|
||||
}
|
||||
: input;
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateCustomWorldProfile(normalizedInput, options);
|
||||
}
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
throw options.signal.reason instanceof Error
|
||||
? options.signal.reason
|
||||
: new Error('世界生成已中断。');
|
||||
}
|
||||
|
||||
const profile = await requestJson<CustomWorldProfile>(
|
||||
'/api/runtime/custom-world/profile',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(normalizedInput),
|
||||
},
|
||||
'生成自定义世界失败',
|
||||
);
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
throw options.signal.reason instanceof Error
|
||||
? options.signal.reason
|
||||
: new Error('世界生成已中断。');
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
export type {
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
};
|
||||
|
||||
export { generateRpgWorldProfile as generateCustomWorldProfile };
|
||||
150
src/services/rpg-creation/rpgCreationLibraryClient.ts
Normal file
150
src/services/rpg-creation/rpgCreationLibraryClient.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type {
|
||||
CustomWorldGalleryDetailResponse,
|
||||
CustomWorldGalleryResponse,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作包 D 把作品库与作品广场请求迁入 RPG 创作域 client,
|
||||
* 后续前端调用优先从这里进入,不再反向依赖通用存储聚合层。
|
||||
*/
|
||||
export const rpgCreationLibraryClient = {
|
||||
listLibrary: listRpgWorldLibrary,
|
||||
upsertProfile: upsertRpgWorldProfile,
|
||||
deleteProfile: deleteRpgWorldProfile,
|
||||
publishProfile: publishRpgWorldProfile,
|
||||
unpublishProfile: unpublishRpgWorldProfile,
|
||||
listGallery: listRpgWorldGallery,
|
||||
getGalleryDetail: getRpgWorldGalleryDetail,
|
||||
};
|
||||
121
src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts
Normal file
121
src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import {
|
||||
buildRpgCreationPreviewFromResultPreview,
|
||||
buildRpgCreationPreviewFromSession,
|
||||
} from './rpgCreationPreviewAdapter';
|
||||
|
||||
const sessionWithPreview: CustomWorldAgentSessionSnapshot = {
|
||||
sessionId: 'session-preview-1',
|
||||
currentTurn: 3,
|
||||
anchorContent: {
|
||||
worldPromise: null,
|
||||
playerFantasy: null,
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: [],
|
||||
hiddenLines: null,
|
||||
iconicElements: null,
|
||||
},
|
||||
progressPercent: 100,
|
||||
lastAssistantReply: '第一版世界底稿已经准备好了。',
|
||||
stage: 'object_refining',
|
||||
focusCardId: null,
|
||||
creatorIntent: null,
|
||||
creatorIntentReadiness: {
|
||||
isReady: true,
|
||||
completedKeys: [],
|
||||
missingKeys: [],
|
||||
},
|
||||
anchorPack: null,
|
||||
lockState: null,
|
||||
draftProfile: {
|
||||
name: '只作为 fallback 的本地草稿名',
|
||||
subtitle: 'fallback',
|
||||
summary: 'fallback',
|
||||
tone: 'fallback',
|
||||
playerGoal: 'fallback',
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
landmarks: [],
|
||||
},
|
||||
messages: [],
|
||||
draftCards: [
|
||||
{
|
||||
id: 'world-foundation',
|
||||
kind: 'world',
|
||||
title: '世界底稿',
|
||||
subtitle: '阶段三预览',
|
||||
summary: '测试服务端 result preview 优先级。',
|
||||
status: 'warning',
|
||||
linkedIds: [],
|
||||
warningCount: 0,
|
||||
},
|
||||
],
|
||||
pendingClarifications: [],
|
||||
suggestedActions: [],
|
||||
recommendedReplies: [],
|
||||
qualityFindings: [],
|
||||
assetCoverage: {
|
||||
roleAssets: [],
|
||||
sceneAssets: [],
|
||||
allRoleAssetsReady: false,
|
||||
allSceneAssetsReady: false,
|
||||
},
|
||||
resultPreview: {
|
||||
source: 'session_preview',
|
||||
preview: {
|
||||
id: 'preview-profile-1',
|
||||
settingText: '被海雾吞没的旧航路群岛',
|
||||
name: '服务端结果预览',
|
||||
subtitle: '优先于前端 fallback',
|
||||
summary: '结果页应该优先消费 session.resultPreview。',
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
playerGoal: '查清沉船与禁航区异动的真相。',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['守灯会', '航运公会'],
|
||||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
sessionId: 'session-preview-1',
|
||||
},
|
||||
generatedAt: '2026-04-21T10:00:00.000Z',
|
||||
qualityFindings: [],
|
||||
blockers: [],
|
||||
},
|
||||
updatedAt: '2026-04-21T10:00:00.000Z',
|
||||
};
|
||||
|
||||
test('buildRpgCreationPreviewFromResultPreview normalizes server preview envelope', () => {
|
||||
const profile = buildRpgCreationPreviewFromResultPreview(
|
||||
sessionWithPreview.resultPreview,
|
||||
);
|
||||
|
||||
expect(profile?.name).toBe('服务端结果预览');
|
||||
expect(profile?.subtitle).toBe('优先于前端 fallback');
|
||||
expect(profile?.id).toBe('preview-profile-1');
|
||||
expect(profile?.settingText).toBe('被海雾吞没的旧航路群岛');
|
||||
});
|
||||
|
||||
test('buildRpgCreationPreviewFromSession prefers server resultPreview over draft fallback', () => {
|
||||
const profile = buildRpgCreationPreviewFromSession(sessionWithPreview);
|
||||
|
||||
expect(profile?.name).toBe('服务端结果预览');
|
||||
expect(profile?.name).not.toBe('只作为 fallback 的本地草稿名');
|
||||
expect(profile?.summary).toBe('结果页应该优先消费 session.resultPreview。');
|
||||
});
|
||||
|
||||
test('buildRpgCreationPreviewFromSession returns null when server resultPreview is missing', () => {
|
||||
const profile = buildRpgCreationPreviewFromSession({
|
||||
...sessionWithPreview,
|
||||
resultPreview: null,
|
||||
});
|
||||
|
||||
expect(profile).toBeNull();
|
||||
});
|
||||
39
src/services/rpg-creation/rpgCreationPreviewAdapter.ts
Normal file
39
src/services/rpg-creation/rpgCreationPreviewAdapter.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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 后该适配层只负责:
|
||||
* 1. 把服务端 resultPreview 转成前端 view model
|
||||
* 2. 保持前端 session 读模型入口稳定
|
||||
*/
|
||||
export const rpgCreationPreviewAdapter = {
|
||||
buildPreviewFromSession: buildCustomWorldProfileFromAgentSession,
|
||||
buildPreviewFromResultPreview: buildCustomWorldProfileFromResultPreview,
|
||||
};
|
||||
|
||||
export {
|
||||
buildCustomWorldProfileFromAgentSession as buildRpgCreationPreviewFromSession,
|
||||
buildCustomWorldProfileFromResultPreview as buildRpgCreationPreviewFromResultPreview,
|
||||
};
|
||||
41
src/services/rpg-creation/rpgCreationRequestHelpers.ts
Normal file
41
src/services/rpg-creation/rpgCreationRequestHelpers.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { parseApiErrorMessage } from '../../../packages/shared/src/http';
|
||||
import { fetchWithApiAuth, requestJson } from '../apiClient';
|
||||
|
||||
export async function requestRpgCreationPostJson<T>(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return requestJson<T>(
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
fallbackMessage,
|
||||
);
|
||||
}
|
||||
|
||||
export async function openRpgCreationSsePost(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
const response = await fetchWithApiAuth(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
60
src/services/rpg-creation/rpgCreationRuntimeClient.ts
Normal file
60
src/services/rpg-creation/rpgCreationRuntimeClient.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
const RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 180,
|
||||
maxDelayMs: 480,
|
||||
};
|
||||
const RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 240,
|
||||
maxDelayMs: 640,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
export type RpgCreationRuntimeRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
skipAuth?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
};
|
||||
|
||||
export function requestRpgCreationRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry =
|
||||
options.retry ??
|
||||
(method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY);
|
||||
|
||||
return requestJson<T>(
|
||||
`${RUNTIME_API_BASE}${path}`,
|
||||
{
|
||||
...init,
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{
|
||||
retry,
|
||||
skipAuth: options.skipAuth,
|
||||
skipRefresh: options.skipRefresh,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function requestPublicRpgCreationRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgCreationRuntimeJson<T>(path, init, fallbackMessage, {
|
||||
...options,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
});
|
||||
}
|
||||
19
src/services/rpg-creation/rpgCreationWorkClient.ts
Normal file
19
src/services/rpg-creation/rpgCreationWorkClient.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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 : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作包 D 把作品列表请求正式迁入 RPG 创作域 client。
|
||||
*/
|
||||
export const rpgCreationWorkClient = {
|
||||
listWorks: listRpgCreationWorks,
|
||||
};
|
||||
25
src/services/rpg-entry/index.ts
Normal file
25
src/services/rpg-entry/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export {
|
||||
deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldGalleryDetail,
|
||||
listRpgEntryWorldGallery,
|
||||
listRpgEntryWorldLibrary,
|
||||
publishRpgEntryWorldProfile,
|
||||
rpgEntryLibraryClient,
|
||||
type RuntimeRequestOptions,
|
||||
unpublishRpgEntryWorldProfile,
|
||||
upsertRpgEntryWorldProfile,
|
||||
} from './rpgEntryLibraryClient';
|
||||
export {
|
||||
clearRpgProfileBrowseHistory,
|
||||
getRpgProfileDashboard,
|
||||
getRpgProfilePlayStats,
|
||||
getRpgProfileSettings,
|
||||
getRpgProfileWalletLedger,
|
||||
listRpgProfileBrowseHistory,
|
||||
listRpgProfileSaveArchives,
|
||||
putRpgProfileSettings,
|
||||
resumeRpgProfileSaveArchive,
|
||||
rpgProfileClient,
|
||||
syncRpgProfileBrowseHistory,
|
||||
upsertRpgProfileBrowseHistory,
|
||||
} from './rpgProfileClient';
|
||||
@@ -5,28 +5,30 @@ const { requestJsonMock } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
clearProfileBrowseHistory,
|
||||
getCustomWorldGalleryDetail,
|
||||
listCustomWorldGallery,
|
||||
listProfileBrowseHistory,
|
||||
listProfileSaveArchives,
|
||||
resumeProfileSaveArchive,
|
||||
syncProfileBrowseHistory,
|
||||
upsertProfileBrowseHistory,
|
||||
} from './storageService';
|
||||
clearRpgProfileBrowseHistory,
|
||||
listRpgProfileBrowseHistory,
|
||||
listRpgProfileSaveArchives,
|
||||
resumeRpgProfileSaveArchive,
|
||||
syncRpgProfileBrowseHistory,
|
||||
upsertRpgProfileBrowseHistory,
|
||||
} from './rpgProfileClient';
|
||||
import {
|
||||
getRpgEntryWorldGalleryDetail,
|
||||
listRpgEntryWorldGallery,
|
||||
} from './rpgEntryLibraryClient';
|
||||
|
||||
vi.mock('./apiClient', () => ({
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
describe('storageService browse history routes', () => {
|
||||
describe('rpgEntry profile browse history routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads browse history from the runtime profile route', async () => {
|
||||
await listProfileBrowseHistory();
|
||||
await listRpgProfileBrowseHistory();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
@@ -39,7 +41,7 @@ describe('storageService browse history routes', () => {
|
||||
});
|
||||
|
||||
it('writes browse history through the runtime profile route', async () => {
|
||||
await upsertProfileBrowseHistory({
|
||||
await upsertRpgProfileBrowseHistory({
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
worldName: '测试世界',
|
||||
@@ -67,7 +69,7 @@ describe('storageService browse history routes', () => {
|
||||
});
|
||||
|
||||
it('syncs browse history through the runtime profile route', async () => {
|
||||
await syncProfileBrowseHistory([
|
||||
await syncRpgProfileBrowseHistory([
|
||||
{
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
@@ -92,7 +94,7 @@ describe('storageService browse history routes', () => {
|
||||
});
|
||||
|
||||
it('clears browse history through the runtime profile route', async () => {
|
||||
await clearProfileBrowseHistory();
|
||||
await clearRpgProfileBrowseHistory();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
@@ -108,14 +110,14 @@ describe('storageService browse history routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('storageService public custom world gallery routes', () => {
|
||||
describe('rpgEntry public custom world gallery routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads the public gallery without attaching auth or refresh coupling', async () => {
|
||||
await listCustomWorldGallery();
|
||||
await listRpgEntryWorldGallery();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-gallery',
|
||||
@@ -137,7 +139,7 @@ describe('storageService public custom world gallery routes', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await getCustomWorldGalleryDetail('user-1', 'profile-1');
|
||||
await getRpgEntryWorldGalleryDetail('user-1', 'profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-gallery/user-1/profile-1',
|
||||
@@ -152,14 +154,14 @@ describe('storageService public custom world gallery routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('storageService save archive routes', () => {
|
||||
describe('rpgEntry save archive routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads save archives from the runtime profile route', async () => {
|
||||
await listProfileSaveArchives();
|
||||
await listRpgProfileSaveArchives();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/save-archives',
|
||||
@@ -187,7 +189,7 @@ describe('storageService save archive routes', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await resumeProfileSaveArchive('custom:world-1');
|
||||
await resumeRpgProfileSaveArchive('custom:world-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/save-archives/custom%3Aworld-1',
|
||||
165
src/services/rpg-entry/rpgEntryLibraryClient.test.ts
Normal file
165
src/services/rpg-entry/rpgEntryLibraryClient.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldGalleryDetail,
|
||||
listRpgEntryWorldGallery,
|
||||
listRpgEntryWorldLibrary,
|
||||
publishRpgEntryWorldProfile,
|
||||
unpublishRpgEntryWorldProfile,
|
||||
upsertRpgEntryWorldProfile,
|
||||
} from './rpgEntryLibraryClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
describe('rpgEntryLibraryClient world library routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads world library from the runtime entry route', async () => {
|
||||
await listRpgEntryWorldLibrary();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-library',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取自定义世界库失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads world gallery from the public runtime entry route', async () => {
|
||||
await listRpgEntryWorldGallery();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-gallery',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取作品广场失败',
|
||||
expect.objectContaining({
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads gallery detail from the public runtime entry route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
ownerUserId: 'owner-1',
|
||||
profileId: 'profile-1',
|
||||
},
|
||||
});
|
||||
|
||||
await getRpgEntryWorldGalleryDetail('owner-1', 'profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-gallery/owner-1/profile-1',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取作品详情失败',
|
||||
expect.objectContaining({
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes world profile through the runtime entry route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
profileId: 'profile-1',
|
||||
},
|
||||
entries: [],
|
||||
});
|
||||
|
||||
await upsertRpgEntryWorldProfile({
|
||||
id: 'profile-1',
|
||||
name: '测试世界',
|
||||
} as never);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-library/profile-1',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'保存自定义世界失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes world profile through the runtime entry route', async () => {
|
||||
await deleteRpgEntryWorldProfile('profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-library/profile-1',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
'删除自定义世界失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('publishes world profile through the runtime entry route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
profileId: 'profile-1',
|
||||
},
|
||||
entries: [],
|
||||
});
|
||||
|
||||
await publishRpgEntryWorldProfile('profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-library/profile-1/publish',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'发布自定义世界失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('unpublishes world profile through the runtime entry route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
profileId: 'profile-1',
|
||||
},
|
||||
entries: [],
|
||||
});
|
||||
|
||||
await unpublishRpgEntryWorldProfile('profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-library/profile-1/unpublish',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'下架自定义世界失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
152
src/services/rpg-entry/rpgEntryLibraryClient.ts
Normal file
152
src/services/rpg-entry/rpgEntryLibraryClient.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
type RuntimeRequestOptions,
|
||||
requestPublicRpgRuntimeJson,
|
||||
requestRpgRuntimeJson,
|
||||
} from '../rpg-runtime/rpgRuntimeRequest';
|
||||
import type {
|
||||
CustomWorldGalleryDetailResponse,
|
||||
CustomWorldGalleryResponse,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type { RuntimeRequestOptions };
|
||||
|
||||
/**
|
||||
* RPG 入口世界库 client 的真实实现。
|
||||
* 第三批收口后,平台首页/详情页开始游戏链直接走 rpg-entry 域请求,不再反向穿旧 storageService 兼容层。
|
||||
*/
|
||||
export async function listRpgEntryWorldLibrary(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
'/custom-world-library',
|
||||
{ method: 'GET' },
|
||||
'读取自定义世界库失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function listRpgEntryWorldGallery(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestPublicRpgRuntimeJson<CustomWorldGalleryResponse>(
|
||||
'/custom-world-gallery',
|
||||
{ method: 'GET' },
|
||||
'读取作品广场失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function getRpgEntryWorldGalleryDetail(
|
||||
ownerUserId: string,
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestPublicRpgRuntimeJson<
|
||||
CustomWorldGalleryDetailResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取作品详情失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
}
|
||||
|
||||
export async function upsertRpgEntryWorldProfile(
|
||||
profile: CustomWorldProfile,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profile.id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profile,
|
||||
}),
|
||||
},
|
||||
'保存自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteRpgEntryWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function publishRpgEntryWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/publish`,
|
||||
{ method: 'POST' },
|
||||
'发布自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function unpublishRpgEntryWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/unpublish`,
|
||||
{ method: 'POST' },
|
||||
'下架自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export const rpgEntryLibraryClient = {
|
||||
listWorldLibrary: listRpgEntryWorldLibrary,
|
||||
listWorldGallery: listRpgEntryWorldGallery,
|
||||
getWorldGalleryDetail: getRpgEntryWorldGalleryDetail,
|
||||
upsertWorldProfile: upsertRpgEntryWorldProfile,
|
||||
deleteWorldProfile: deleteRpgEntryWorldProfile,
|
||||
publishWorldProfile: publishRpgEntryWorldProfile,
|
||||
unpublishWorldProfile: unpublishRpgEntryWorldProfile,
|
||||
};
|
||||
158
src/services/rpg-entry/rpgProfileClient.test.ts
Normal file
158
src/services/rpg-entry/rpgProfileClient.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
clearRpgProfileBrowseHistory,
|
||||
listRpgProfileBrowseHistory,
|
||||
listRpgProfileSaveArchives,
|
||||
resumeRpgProfileSaveArchive,
|
||||
syncRpgProfileBrowseHistory,
|
||||
upsertRpgProfileBrowseHistory,
|
||||
} from './rpgProfileClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
describe('rpgProfileClient browse history routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads browse history from the runtime profile route', async () => {
|
||||
await listRpgProfileBrowseHistory();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取浏览历史失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes browse history through the runtime profile route', async () => {
|
||||
await upsertRpgProfileBrowseHistory({
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
worldName: '测试世界',
|
||||
subtitle: '测试副标题',
|
||||
summaryText: '测试摘要',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'mythic',
|
||||
authorDisplayName: '测试作者',
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'写入浏览历史失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('syncs browse history through the runtime profile route', async () => {
|
||||
await syncRpgProfileBrowseHistory([
|
||||
{
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
worldName: '测试世界',
|
||||
subtitle: '测试副标题',
|
||||
summaryText: '测试摘要',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'mythic',
|
||||
authorDisplayName: '测试作者',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'同步浏览历史失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears browse history through the runtime profile route', async () => {
|
||||
await clearRpgProfileBrowseHistory();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
'清空浏览历史失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rpgProfileClient save archive routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads save archives from the runtime profile route', async () => {
|
||||
await listRpgProfileSaveArchives();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/save-archives',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取存档列表失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes a save archive through the runtime profile route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
worldKey: 'custom:world-1',
|
||||
},
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-19T10:15:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await resumeRpgProfileSaveArchive('custom:world-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/save-archives/custom%3Aworld-1',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'恢复存档失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
187
src/services/rpg-entry/rpgProfileClient.ts
Normal file
187
src/services/rpg-entry/rpgProfileClient.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type {
|
||||
PlatformBrowseHistoryBatchSyncRequest,
|
||||
PlatformBrowseHistoryResponse,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileSaveArchiveListResponse,
|
||||
ProfileSaveArchiveResumeResponse,
|
||||
ProfileWalletLedgerResponse,
|
||||
RuntimeSettings,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
requestRpgRuntimeJson,
|
||||
type RuntimeRequestOptions,
|
||||
} from '../rpg-runtime/rpgRuntimeRequest';
|
||||
|
||||
export type { RuntimeRequestOptions };
|
||||
|
||||
/**
|
||||
* RPG profile 域 client。
|
||||
* 工作包 C 需要把继续游戏归档与资料读取收进新域目录,避免继续堆在 `storageService`。
|
||||
*/
|
||||
export function getRpgProfileSettings(options: RuntimeRequestOptions = {}) {
|
||||
return requestRpgRuntimeJson<RuntimeSettings>(
|
||||
'/settings',
|
||||
{ method: 'GET' },
|
||||
'读取设置失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function putRpgProfileSettings(
|
||||
settings: RuntimeSettings,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<RuntimeSettings>(
|
||||
'/settings',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
},
|
||||
'保存设置失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRpgProfileDashboard(options: RuntimeRequestOptions = {}) {
|
||||
return requestRpgRuntimeJson<ProfileDashboardSummary>(
|
||||
'/profile/dashboard',
|
||||
{ method: 'GET' },
|
||||
'读取个人看板失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRpgProfileWalletLedger(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<ProfileWalletLedgerResponse>(
|
||||
'/profile/wallet-ledger',
|
||||
{ method: 'GET' },
|
||||
'读取资产流水失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) {
|
||||
return requestRpgRuntimeJson<ProfilePlayStatsResponse>(
|
||||
'/profile/play-stats',
|
||||
{ method: 'GET' },
|
||||
'读取游玩统计失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listRpgProfileSaveArchives(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<ProfileSaveArchiveListResponse>(
|
||||
'/profile/save-archives',
|
||||
{ method: 'GET' },
|
||||
'读取存档列表失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function resumeRpgProfileSaveArchive(
|
||||
worldKey: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<ProfileSaveArchiveResumeResponse>(
|
||||
`/profile/save-archives/${encodeURIComponent(worldKey)}`,
|
||||
{ method: 'POST' },
|
||||
'恢复存档失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
snapshot: rehydrateSavedSnapshot(
|
||||
response.snapshot as HydratedSavedGameSnapshot,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listRpgProfileBrowseHistory(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{ method: 'GET' },
|
||||
'读取浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function upsertRpgProfileBrowseHistory(
|
||||
entry: PlatformBrowseHistoryWriteEntry,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(entry),
|
||||
},
|
||||
'写入浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function syncRpgProfileBrowseHistory(
|
||||
entries: PlatformBrowseHistoryWriteEntry[],
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
entries,
|
||||
} satisfies PlatformBrowseHistoryBatchSyncRequest),
|
||||
},
|
||||
'同步浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function clearRpgProfileBrowseHistory(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{ method: 'DELETE' },
|
||||
'清空浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export const rpgProfileClient = {
|
||||
getDashboard: getRpgProfileDashboard,
|
||||
getPlayStats: getRpgProfilePlayStats,
|
||||
getWalletLedger: getRpgProfileWalletLedger,
|
||||
getSettings: getRpgProfileSettings,
|
||||
putSettings: putRpgProfileSettings,
|
||||
listSaveArchives: listRpgProfileSaveArchives,
|
||||
resumeSaveArchive: resumeRpgProfileSaveArchive,
|
||||
listBrowseHistory: listRpgProfileBrowseHistory,
|
||||
upsertBrowseHistory: upsertRpgProfileBrowseHistory,
|
||||
syncBrowseHistory: syncRpgProfileBrowseHistory,
|
||||
clearBrowseHistory: clearRpgProfileBrowseHistory,
|
||||
};
|
||||
32
src/services/rpg-runtime/index.ts
Normal file
32
src/services/rpg-runtime/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export {
|
||||
deleteRpgSaveSnapshot,
|
||||
getRpgSaveSnapshot,
|
||||
putRpgSaveSnapshot,
|
||||
rpgSnapshotClient,
|
||||
type RuntimeRequestOptions,
|
||||
} from './rpgSnapshotClient';
|
||||
export {
|
||||
getRpgCharacterChatSuggestions,
|
||||
getRpgCharacterChatSummary,
|
||||
rpgRuntimeChatClient,
|
||||
streamRpgCharacterChatReply,
|
||||
streamRpgNpcChatDialogue,
|
||||
streamRpgNpcChatTurn,
|
||||
streamRpgNpcRecruitDialogue,
|
||||
} from './rpgRuntimeChatClient';
|
||||
export {
|
||||
getRpgRuntimeActionSnapshot,
|
||||
getRpgRuntimeClientVersion,
|
||||
getRpgRuntimeSessionId,
|
||||
getRpgRuntimeStoryState,
|
||||
isRpgRuntimeServerFunctionId,
|
||||
isRpgRuntimeTaskFunctionId,
|
||||
resolveRpgRuntimeStoryAction,
|
||||
resolveRpgRuntimeStoryMoment,
|
||||
rpgRuntimeStoryClient,
|
||||
shouldUseRpgRuntimeServerOptions,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryResponse,
|
||||
type RpgRuntimeStoryClientOptions,
|
||||
type RuntimeStorySnapshotRequest,
|
||||
} from './rpgRuntimeStoryClient';
|
||||
29
src/services/rpg-runtime/rpgRuntimeChatClient.ts
Normal file
29
src/services/rpg-runtime/rpgRuntimeChatClient.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
generateCharacterPanelChatSuggestions,
|
||||
generateCharacterPanelChatSummary,
|
||||
streamCharacterPanelChatReply,
|
||||
streamNpcChatDialogue,
|
||||
streamNpcChatTurn,
|
||||
streamNpcRecruitDialogue,
|
||||
} from '../aiService';
|
||||
|
||||
/**
|
||||
* RPG 运行时聊天相关 client 的兼容收口层。
|
||||
* 当前仍桥接旧 `aiService`,避免工作包 A 提前改动聊天与 NPC 对话请求语义。
|
||||
*/
|
||||
export const getRpgCharacterChatSuggestions =
|
||||
generateCharacterPanelChatSuggestions;
|
||||
export const getRpgCharacterChatSummary = generateCharacterPanelChatSummary;
|
||||
export const streamRpgCharacterChatReply = streamCharacterPanelChatReply;
|
||||
export const streamRpgNpcChatDialogue = streamNpcChatDialogue;
|
||||
export const streamRpgNpcChatTurn = streamNpcChatTurn;
|
||||
export const streamRpgNpcRecruitDialogue = streamNpcRecruitDialogue;
|
||||
|
||||
export const rpgRuntimeChatClient = {
|
||||
getCharacterChatSuggestions: getRpgCharacterChatSuggestions,
|
||||
getCharacterChatSummary: getRpgCharacterChatSummary,
|
||||
streamCharacterChatReply: streamRpgCharacterChatReply,
|
||||
streamNpcChatDialogue: streamRpgNpcChatDialogue,
|
||||
streamNpcChatTurn: streamRpgNpcChatTurn,
|
||||
streamNpcRecruitDialogue: streamRpgNpcRecruitDialogue,
|
||||
};
|
||||
66
src/services/rpg-runtime/rpgRuntimeRequest.ts
Normal file
66
src/services/rpg-runtime/rpgRuntimeRequest.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
const RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 180,
|
||||
maxDelayMs: 480,
|
||||
};
|
||||
const RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 240,
|
||||
maxDelayMs: 640,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
export type RuntimeRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
skipAuth?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一封装 RPG 运行时域的请求重试与鉴权透传,避免各 client 重复维护同一套规则。
|
||||
*/
|
||||
export function requestRpgRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry =
|
||||
options.retry ??
|
||||
(method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY);
|
||||
|
||||
return requestJson<T>(
|
||||
`${RUNTIME_API_BASE}${path}`,
|
||||
{
|
||||
...init,
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{
|
||||
retry,
|
||||
skipAuth: options.skipAuth,
|
||||
skipRefresh: options.skipRefresh,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共世界广场等匿名接口统一走公开请求入口,避免误附带鉴权状态。
|
||||
*/
|
||||
export function requestPublicRpgRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<T>(path, init, fallbackMessage, {
|
||||
...options,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
});
|
||||
}
|
||||
@@ -4,28 +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,
|
||||
isServerRuntimeFunctionId,
|
||||
isTask5RuntimeFunctionId,
|
||||
resolveRuntimeStoryAction,
|
||||
resolveRuntimeStoryMoment,
|
||||
shouldUseServerRuntimeOptions,
|
||||
} from './runtimeStoryService';
|
||||
getRpgRuntimeClientVersion,
|
||||
getRpgRuntimeSessionId,
|
||||
getRpgRuntimeStoryState,
|
||||
isRpgRuntimeServerFunctionId,
|
||||
isRpgRuntimeTaskFunctionId,
|
||||
resolveRpgRuntimeStoryAction,
|
||||
resolveRpgRuntimeStoryMoment,
|
||||
shouldUseRpgRuntimeServerOptions,
|
||||
} from './rpgRuntimeStoryClient';
|
||||
|
||||
describe('runtimeStoryService', () => {
|
||||
describe('rpgRuntimeStoryClient', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
@@ -51,7 +52,7 @@ describe('runtimeStoryService', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await resolveRuntimeStoryAction({
|
||||
await resolveRpgRuntimeStoryAction({
|
||||
sessionId: 'runtime-custom',
|
||||
clientVersion: 9,
|
||||
option: {
|
||||
@@ -75,6 +76,7 @@ describe('runtimeStoryService', () => {
|
||||
optionText: '继续交谈',
|
||||
},
|
||||
},
|
||||
snapshot: undefined,
|
||||
}),
|
||||
}),
|
||||
'执行运行时动作失败',
|
||||
@@ -103,7 +105,7 @@ describe('runtimeStoryService', () => {
|
||||
},
|
||||
});
|
||||
|
||||
await resolveRuntimeStoryAction({
|
||||
await resolveRpgRuntimeStoryAction({
|
||||
option: {
|
||||
functionId: 'inventory_use',
|
||||
actionText: '使用凝神灵液',
|
||||
@@ -129,6 +131,7 @@ describe('runtimeStoryService', () => {
|
||||
itemId: 'focus-tonic',
|
||||
},
|
||||
},
|
||||
snapshot: undefined,
|
||||
}),
|
||||
}),
|
||||
'执行运行时动作失败',
|
||||
@@ -136,6 +139,80 @@ describe('runtimeStoryService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('submits runtime state resolution with snapshot context to the server', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 4,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '服务端故事',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {},
|
||||
currentStory: null,
|
||||
},
|
||||
});
|
||||
|
||||
await getRpgRuntimeStoryState({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 7,
|
||||
snapshot: {
|
||||
gameState: { currentScene: 'Story' } as never,
|
||||
bottomTab: 'adventure',
|
||||
currentStory: {
|
||||
text: '本地故事',
|
||||
options: [],
|
||||
} as never,
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/story/state/resolve',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 7,
|
||||
snapshot: {
|
||||
gameState: {
|
||||
currentScene: 'Story',
|
||||
},
|
||||
bottomTab: 'adventure',
|
||||
currentStory: {
|
||||
text: '本地故事',
|
||||
options: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
'读取运行时故事状态失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps disabled runtime options when rebuilding a story moment', () => {
|
||||
const story = buildStoryMomentFromRuntimeOptions({
|
||||
storyText: '服务端返回的新故事',
|
||||
@@ -164,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: '继续交谈',
|
||||
@@ -187,7 +264,7 @@ describe('runtimeStoryService', () => {
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldUseServerRuntimeOptions([
|
||||
shouldUseRpgRuntimeServerOptions([
|
||||
{
|
||||
functionId: 'npc_trade',
|
||||
actionText: '交易',
|
||||
@@ -204,7 +281,7 @@ describe('runtimeStoryService', () => {
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldUseServerRuntimeOptions([
|
||||
shouldUseRpgRuntimeServerOptions([
|
||||
{
|
||||
functionId: 'unknown_action',
|
||||
actionText: '未知动作',
|
||||
@@ -220,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', () => {
|
||||
@@ -249,7 +328,7 @@ describe('runtimeStoryService', () => {
|
||||
});
|
||||
|
||||
it('prefers the richer snapshot story when the server persisted dialogue mode', () => {
|
||||
const story = resolveRuntimeStoryMoment({
|
||||
const story = resolveRpgRuntimeStoryMoment({
|
||||
response: {
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 4,
|
||||
@@ -1,22 +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';
|
||||
@@ -34,22 +35,26 @@ 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<
|
||||
GameState,
|
||||
StoryMoment
|
||||
>['snapshot'];
|
||||
|
||||
function requestRuntimeStoryJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
options: RpgRuntimeStoryClientOptions = {},
|
||||
) {
|
||||
return requestJson<T>(
|
||||
`${RUNTIME_STORY_API_BASE}${path}`,
|
||||
@@ -170,15 +175,35 @@ export function resolveRuntimeStoryMoment(params: {
|
||||
}
|
||||
|
||||
export async function getRuntimeStoryState(
|
||||
sessionId: string,
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
params: {
|
||||
sessionId: string;
|
||||
clientVersion?: number;
|
||||
snapshot?: RuntimeStorySnapshotRequest;
|
||||
},
|
||||
options: RpgRuntimeStoryClientOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
`/state/${encodeURIComponent(sessionId || DEFAULT_SESSION_ID)}`,
|
||||
{ method: 'GET' },
|
||||
'读取运行时故事状态失败',
|
||||
options,
|
||||
);
|
||||
const normalizedSessionId = params.sessionId || DEFAULT_SESSION_ID;
|
||||
const response = params.snapshot
|
||||
? await requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
'/state/resolve',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: normalizedSessionId,
|
||||
clientVersion: params.clientVersion,
|
||||
snapshot: params.snapshot,
|
||||
} satisfies RuntimeStoryStateRequest<GameState, StoryMoment>),
|
||||
},
|
||||
'读取运行时故事状态失败',
|
||||
options,
|
||||
)
|
||||
: await requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
`/state/${encodeURIComponent(normalizedSessionId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取运行时故事状态失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
...response,
|
||||
@@ -195,8 +220,9 @@ export async function resolveRuntimeStoryAction(
|
||||
option: Pick<StoryOption, 'functionId' | 'actionText'>;
|
||||
targetId?: string;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
snapshot?: RuntimeStorySnapshotRequest;
|
||||
},
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
options: RpgRuntimeStoryClientOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
'/actions/resolve',
|
||||
@@ -215,7 +241,8 @@ export async function resolveRuntimeStoryAction(
|
||||
...(params.payload ?? {}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
snapshot: params.snapshot,
|
||||
} satisfies RuntimeStoryActionRequest),
|
||||
},
|
||||
'执行运行时动作失败',
|
||||
options,
|
||||
@@ -232,3 +259,23 @@ export async function resolveRuntimeStoryAction(
|
||||
export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {
|
||||
return rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot);
|
||||
}
|
||||
|
||||
export const getRpgRuntimeActionSnapshot = getRuntimeActionSnapshot;
|
||||
export const getRpgRuntimeClientVersion = getRuntimeClientVersion;
|
||||
export const getRpgRuntimeSessionId = getRuntimeSessionId;
|
||||
export const getRpgRuntimeStoryState = getRuntimeStoryState;
|
||||
export const isRpgRuntimeServerFunctionId = isServerRuntimeFunctionId;
|
||||
export const isRpgRuntimeTaskFunctionId = isTask5RuntimeFunctionId;
|
||||
export const resolveRpgRuntimeStoryAction = resolveRuntimeStoryAction;
|
||||
export const resolveRpgRuntimeStoryMoment = resolveRuntimeStoryMoment;
|
||||
export const shouldUseRpgRuntimeServerOptions = shouldUseServerRuntimeOptions;
|
||||
|
||||
export const rpgRuntimeStoryClient = {
|
||||
getActionSnapshot: getRpgRuntimeActionSnapshot,
|
||||
getClientVersion: getRpgRuntimeClientVersion,
|
||||
getSessionId: getRpgRuntimeSessionId,
|
||||
getState: getRpgRuntimeStoryState,
|
||||
resolveAction: resolveRpgRuntimeStoryAction,
|
||||
resolveMoment: resolveRpgRuntimeStoryMoment,
|
||||
shouldUseServerOptions: shouldUseRpgRuntimeServerOptions,
|
||||
};
|
||||
89
src/services/rpg-runtime/rpgSnapshotClient.test.ts
Normal file
89
src/services/rpg-runtime/rpgSnapshotClient.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
deleteRpgSaveSnapshot,
|
||||
getRpgSaveSnapshot,
|
||||
putRpgSaveSnapshot,
|
||||
} from './rpgSnapshotClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
describe('rpgSnapshotClient routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
|
||||
it('reads the current save snapshot from the runtime save route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce(null);
|
||||
|
||||
await getRpgSaveSnapshot();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/save/snapshot',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取存档失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes the current save snapshot through the runtime save route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
version: 2,
|
||||
savedAt: '2026-04-21T09:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
},
|
||||
});
|
||||
|
||||
await putRpgSaveSnapshot({
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/save/snapshot',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'保存存档失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes the current save snapshot through the runtime save route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
await deleteRpgSaveSnapshot();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/save/snapshot',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
'删除存档失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
60
src/services/rpg-runtime/rpgSnapshotClient.ts
Normal file
60
src/services/rpg-runtime/rpgSnapshotClient.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { BasicOkResult } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SavedGameSnapshotInput } from '../../persistence/gameSaveStorage';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
requestRpgRuntimeJson,
|
||||
type RuntimeRequestOptions,
|
||||
} from './rpgRuntimeRequest';
|
||||
|
||||
export type { RuntimeRequestOptions };
|
||||
|
||||
/**
|
||||
* RPG 运行时快照 client。
|
||||
* 工作包 C 起由新域目录承载真实实现,旧 `storageService` 仅保留兼容转发。
|
||||
*/
|
||||
export async function getRpgSaveSnapshot(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const snapshot = await requestRpgRuntimeJson<HydratedSavedGameSnapshot | null>(
|
||||
'/save/snapshot',
|
||||
{ method: 'GET' },
|
||||
'读取存档失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return snapshot ? rehydrateSavedSnapshot(snapshot) : null;
|
||||
}
|
||||
|
||||
export async function putRpgSaveSnapshot(
|
||||
snapshot: SavedGameSnapshotInput,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const savedSnapshot = await requestRpgRuntimeJson<HydratedSavedGameSnapshot>(
|
||||
'/save/snapshot',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(snapshot),
|
||||
},
|
||||
'保存存档失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return rehydrateSavedSnapshot(savedSnapshot);
|
||||
}
|
||||
|
||||
export function deleteRpgSaveSnapshot(options: RuntimeRequestOptions = {}) {
|
||||
return requestRpgRuntimeJson<BasicOkResult>(
|
||||
'/save/snapshot',
|
||||
{ method: 'DELETE' },
|
||||
'删除存档失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export const rpgSnapshotClient = {
|
||||
getSnapshot: getRpgSaveSnapshot,
|
||||
putSnapshot: putRpgSaveSnapshot,
|
||||
deleteSnapshot: deleteRpgSaveSnapshot,
|
||||
};
|
||||
@@ -1,134 +1,24 @@
|
||||
import {buildRuntimeItemAiIntent} from '../data/runtimeItemNarrative';
|
||||
import type {
|
||||
RuntimeItemAiIntent,
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
} from '../types';
|
||||
import { requestJson } from './apiClient';
|
||||
import {requestChatMessageContent} from './llmClient';
|
||||
import {parseJsonResponseText} from './llmParsers';
|
||||
import {
|
||||
buildRuntimeItemIntentPrompt,
|
||||
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
|
||||
} from './runtimeItemAiPrompt';
|
||||
|
||||
const RUNTIME_ITEM_INTENT_TIMEOUT_MS = 9000;
|
||||
|
||||
function coerceString(value: unknown, fallback: string) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function coerceStringArray(value: unknown, fallback: string[], limit: number) {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.map(item => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean)
|
||||
.slice(0, limit);
|
||||
|
||||
return normalized.length > 0 ? normalized : fallback;
|
||||
}
|
||||
|
||||
function sanitizeRuntimeItemAiIntent(
|
||||
rawIntent: unknown,
|
||||
fallback: RuntimeItemAiIntent,
|
||||
): RuntimeItemAiIntent {
|
||||
if (!rawIntent || typeof rawIntent !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const intent = rawIntent as Record<string, unknown>;
|
||||
const desiredFunctionalBias = coerceStringArray(
|
||||
intent.desiredFunctionalBias,
|
||||
fallback.desiredFunctionalBias,
|
||||
2,
|
||||
).filter(
|
||||
(
|
||||
item,
|
||||
): item is RuntimeItemAiIntent['desiredFunctionalBias'][number] =>
|
||||
['heal', 'mana', 'cooldown', 'guard', 'damage'].includes(item),
|
||||
);
|
||||
const tone = coerceString(intent.tone, fallback.tone);
|
||||
|
||||
return {
|
||||
shortNameSeed: coerceString(intent.shortNameSeed, fallback.shortNameSeed),
|
||||
sourcePhrase: coerceString(intent.sourcePhrase, fallback.sourcePhrase),
|
||||
reasonToAppear: coerceString(intent.reasonToAppear, fallback.reasonToAppear),
|
||||
relationHooks: coerceStringArray(intent.relationHooks, fallback.relationHooks, 2),
|
||||
desiredBuildTags: coerceStringArray(intent.desiredBuildTags, fallback.desiredBuildTags, 3),
|
||||
desiredFunctionalBias:
|
||||
desiredFunctionalBias.length > 0
|
||||
? desiredFunctionalBias
|
||||
: fallback.desiredFunctionalBias,
|
||||
tone: ['grim', 'mysterious', 'martial', 'ritual', 'survival'].includes(tone)
|
||||
? (tone as RuntimeItemAiIntent['tone'])
|
||||
: fallback.tone,
|
||||
visibleClue: coerceString(intent.visibleClue, fallback.visibleClue ?? ''),
|
||||
witnessMark: coerceString(intent.witnessMark, fallback.witnessMark ?? ''),
|
||||
unfinishedBusiness: coerceString(
|
||||
intent.unfinishedBusiness,
|
||||
fallback.unfinishedBusiness ?? '',
|
||||
),
|
||||
hiddenHook: coerceString(intent.hiddenHook, fallback.hiddenHook ?? ''),
|
||||
reactionHooks: coerceStringArray(
|
||||
intent.reactionHooks,
|
||||
fallback.reactionHooks ?? [],
|
||||
4,
|
||||
),
|
||||
namingPattern: coerceString(intent.namingPattern, fallback.namingPattern ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateRuntimeItemAiIntents(params: {
|
||||
context: RuntimeItemGenerationContext;
|
||||
plans: RuntimeItemPlan[];
|
||||
}) {
|
||||
const fallbackIntents = params.plans.map(plan =>
|
||||
buildRuntimeItemAiIntent(params.context, plan),
|
||||
);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const response = await requestJson<{
|
||||
intents?: unknown[];
|
||||
}>(
|
||||
'/api/runtime/items/runtime-intent',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
},
|
||||
'运行时物品意图生成失败',
|
||||
);
|
||||
const rawIntents = Array.isArray(response.intents) ? response.intents : [];
|
||||
return params.plans.map((_, index) =>
|
||||
sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[runtimeItemAiDirector] backend intent generation failed, using deterministic fallback',
|
||||
error,
|
||||
);
|
||||
return fallbackIntents;
|
||||
}
|
||||
}
|
||||
|
||||
const content = await requestChatMessageContent(
|
||||
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
|
||||
buildRuntimeItemIntentPrompt(params),
|
||||
const response = await requestJson<{
|
||||
intents?: RuntimeItemAiIntent[];
|
||||
}>(
|
||||
'/api/runtime/items/runtime-intent',
|
||||
{
|
||||
timeoutMs: RUNTIME_ITEM_INTENT_TIMEOUT_MS,
|
||||
debugLabel: 'runtime-item-intent',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
},
|
||||
'运行时物品意图生成失败',
|
||||
);
|
||||
const parsed = parseJsonResponseText(content) as {
|
||||
intents?: unknown[];
|
||||
};
|
||||
const rawIntents = Array.isArray(parsed.intents) ? parsed.intents : [];
|
||||
|
||||
return params.plans.map((_, index) =>
|
||||
sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!),
|
||||
);
|
||||
return Array.isArray(response.intents) ? response.intents : [];
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../prompts/runtimeItemPrompts';
|
||||
@@ -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 };
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildContentDependencyGraph } from './contentDependencyGraph';
|
||||
|
||||
describe('contentDependencyGraph', () => {
|
||||
it('connects scenario, campaign, world, companions, and threads', () => {
|
||||
const graph = buildContentDependencyGraph({
|
||||
scenarioPack: {
|
||||
id: 'scenario-1',
|
||||
title: 'Scenario',
|
||||
version: '0.1.0',
|
||||
worldPackIds: ['world-1'],
|
||||
campaignIds: ['campaign-1'],
|
||||
sharedConstraintPackIds: [],
|
||||
},
|
||||
campaignPack: {
|
||||
id: 'campaign-1',
|
||||
scenarioPackId: 'scenario-1',
|
||||
title: 'Campaign',
|
||||
authoringStyle: 'classic',
|
||||
campaignStateSeed: {
|
||||
id: 'campaign-state',
|
||||
title: 'Campaign',
|
||||
currentActId: 'act-1',
|
||||
currentActIndex: 0,
|
||||
},
|
||||
actTemplates: [],
|
||||
requiredCompanionIds: [],
|
||||
},
|
||||
profile: {
|
||||
id: 'world-1',
|
||||
name: 'World',
|
||||
playableNpcs: [{ id: 'npc-1', name: 'A' }],
|
||||
storyGraph: {
|
||||
visibleThreads: [{ id: 'thread-1', title: 'T1' }],
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(graph.nodes.length).toBeGreaterThan(2);
|
||||
expect(graph.edges.some((edge) => edge.from === 'campaign-1' && edge.to === 'world-1')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import type {
|
||||
CampaignPack,
|
||||
CustomWorldProfile,
|
||||
ScenarioPack,
|
||||
} from '../../types';
|
||||
|
||||
export interface ContentDependencyNode {
|
||||
id: string;
|
||||
type: 'scenario' | 'campaign' | 'world' | 'thread' | 'companion' | 'constraint';
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ContentDependencyEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function buildContentDependencyGraph(params: {
|
||||
scenarioPack: ScenarioPack;
|
||||
campaignPack: CampaignPack;
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const nodes: ContentDependencyNode[] = [
|
||||
{
|
||||
id: params.scenarioPack.id,
|
||||
type: 'scenario',
|
||||
label: params.scenarioPack.title,
|
||||
},
|
||||
{
|
||||
id: params.campaignPack.id,
|
||||
type: 'campaign',
|
||||
label: params.campaignPack.title,
|
||||
},
|
||||
{
|
||||
id: params.profile.id,
|
||||
type: 'world',
|
||||
label: params.profile.name,
|
||||
},
|
||||
...params.profile.playableNpcs.map((npc) => ({
|
||||
id: npc.id,
|
||||
type: 'companion',
|
||||
label: npc.name,
|
||||
} as ContentDependencyNode)),
|
||||
...(params.profile.storyGraph?.visibleThreads ?? []).map((thread) => ({
|
||||
id: thread.id,
|
||||
type: 'thread',
|
||||
label: thread.title,
|
||||
} as ContentDependencyNode)),
|
||||
];
|
||||
|
||||
const edges: ContentDependencyEdge[] = [
|
||||
{
|
||||
from: params.scenarioPack.id,
|
||||
to: params.campaignPack.id,
|
||||
reason: 'scenario contains campaign',
|
||||
},
|
||||
{
|
||||
from: params.campaignPack.id,
|
||||
to: params.profile.id,
|
||||
reason: 'campaign depends on world profile',
|
||||
},
|
||||
...params.profile.playableNpcs.map((npc) => ({
|
||||
from: params.campaignPack.id,
|
||||
to: npc.id,
|
||||
reason: 'campaign references companion',
|
||||
})),
|
||||
...(params.profile.storyGraph?.visibleThreads ?? []).map((thread) => ({
|
||||
from: params.campaignPack.id,
|
||||
to: thread.id,
|
||||
reason: 'campaign advances thread',
|
||||
})),
|
||||
];
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export function getTypewriterDelay(char: string) {
|
||||
if (/[。!??!]/u.test(char)) return 240;
|
||||
if (/[,、;:,;:]/u.test(char)) return 150;
|
||||
if (/\s/u.test(char)) return 45;
|
||||
return 90;
|
||||
}
|
||||
Reference in New Issue
Block a user