Integrate role asset studio into custom world agent flow
This commit is contained in:
@@ -1,3 +1,14 @@
|
||||
import type {
|
||||
CreateCustomWorldAgentSessionRequest,
|
||||
CreateCustomWorldAgentSessionResponse,
|
||||
CustomWorldAgentActionRequest,
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
CustomWorldDraftCardDetail,
|
||||
GetCustomWorldAgentCardDetailResponse,
|
||||
ListCustomWorldWorksResponse,
|
||||
SendCustomWorldAgentMessageRequest,
|
||||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
AnswerCustomWorldSessionQuestionRequest,
|
||||
CreateCustomWorldSessionRequest,
|
||||
@@ -365,10 +376,18 @@ export async function generateCustomWorldProfile(
|
||||
});
|
||||
}
|
||||
|
||||
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(session.sessionId)}/generate/stream`,
|
||||
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/generate/stream`,
|
||||
{
|
||||
method: 'GET',
|
||||
signal: options.signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
@@ -487,6 +506,105 @@ export async function createCustomWorldSession(payload: {
|
||||
);
|
||||
}
|
||||
|
||||
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 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)}`,
|
||||
|
||||
@@ -147,6 +147,30 @@ describe('authService auto auth', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('deduplicates concurrent auto auth requests', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'jwt-auto',
|
||||
user: {
|
||||
id: 'user_auto',
|
||||
username: 'guest_auto',
|
||||
displayName: 'guest_auto',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([
|
||||
ensureAutoAuthUser(),
|
||||
ensureAutoAuthUser(),
|
||||
]);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledTimes(1);
|
||||
expect(firstResult).toEqual(secondResult);
|
||||
expect(getStoredAutoAuthCredentials()).toEqual(firstResult.credentials);
|
||||
});
|
||||
|
||||
it('sends phone login code through the new auth endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionsResponse,
|
||||
AuthSessionSummary,
|
||||
AuthUser,
|
||||
AuthWechatBindPhoneResponse,
|
||||
AuthWechatStartResponse,
|
||||
LogoutResponse,
|
||||
@@ -53,6 +54,11 @@ export type ConsumedAuthCallback = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
let pendingAutoAuthUser: Promise<{
|
||||
user: AuthUser;
|
||||
credentials: AutoAuthCredentials;
|
||||
}> | null = null;
|
||||
|
||||
export function normalizePhoneInput(phoneInput: string) {
|
||||
return phoneInput.replace(/[^\d+]/gu, '').trim();
|
||||
}
|
||||
@@ -248,14 +254,22 @@ export async function authEntryWithStoredCredentials(
|
||||
}
|
||||
|
||||
export async function ensureAutoAuthUser() {
|
||||
const credentials =
|
||||
getStoredAutoAuthCredentials() ?? createAutoAuthCredentials();
|
||||
const user = await authEntryWithStoredCredentials(credentials);
|
||||
pendingAutoAuthUser ??= (async () => {
|
||||
const credentials =
|
||||
getStoredAutoAuthCredentials() ?? createAutoAuthCredentials();
|
||||
const user = await authEntryWithStoredCredentials(credentials);
|
||||
|
||||
return {
|
||||
user,
|
||||
credentials,
|
||||
};
|
||||
return {
|
||||
user,
|
||||
credentials,
|
||||
};
|
||||
})();
|
||||
|
||||
try {
|
||||
return await pendingAutoAuthUser;
|
||||
} finally {
|
||||
pendingAutoAuthUser = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeAuthCallbackResult(): ConsumedAuthCallback | null {
|
||||
|
||||
61
src/services/customWorldAgentUiState.test.ts
Normal file
61
src/services/customWorldAgentUiState.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
clearCustomWorldAgentUiState,
|
||||
readCustomWorldAgentUiState,
|
||||
writeCustomWorldAgentUiState,
|
||||
} from './customWorldAgentUiState';
|
||||
|
||||
function createMemoryStorage() {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return store.get(key) ?? null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('custom world agent ui state reads from query first and persists to session storage', () => {
|
||||
const sessionStorage = createMemoryStorage();
|
||||
let currentUrl = '/play';
|
||||
const env = {
|
||||
location: {
|
||||
pathname: '/play',
|
||||
get search() {
|
||||
const [, search = ''] = currentUrl.split('?');
|
||||
return search ? `?${search}` : '';
|
||||
},
|
||||
},
|
||||
history: {
|
||||
replaceState: (_data: unknown, _unused: string, nextUrl?: string | URL | null) => {
|
||||
currentUrl = String(nextUrl ?? '/play');
|
||||
},
|
||||
},
|
||||
sessionStorage,
|
||||
};
|
||||
|
||||
writeCustomWorldAgentUiState(
|
||||
{
|
||||
activeSessionId: 'session-1',
|
||||
activeOperationId: 'operation-1',
|
||||
},
|
||||
env,
|
||||
);
|
||||
|
||||
expect(currentUrl).toContain('customWorldSessionId=session-1');
|
||||
expect(currentUrl).toContain('customWorldOperationId=operation-1');
|
||||
expect(readCustomWorldAgentUiState(env)).toEqual({
|
||||
activeSessionId: 'session-1',
|
||||
activeOperationId: 'operation-1',
|
||||
});
|
||||
|
||||
clearCustomWorldAgentUiState(env);
|
||||
expect(readCustomWorldAgentUiState(env)).toEqual({});
|
||||
});
|
||||
139
src/services/customWorldAgentUiState.ts
Normal file
139
src/services/customWorldAgentUiState.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { CustomWorldAgentUiState } from '../types';
|
||||
|
||||
export const CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY = 'customWorldSessionId';
|
||||
export const CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY = 'customWorldOperationId';
|
||||
export const CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY =
|
||||
'genarrative.custom-world-agent-ui.v1';
|
||||
|
||||
type CustomWorldAgentUiEnvironment = {
|
||||
location?: {
|
||||
pathname: string;
|
||||
search: string;
|
||||
} | null;
|
||||
history?: {
|
||||
replaceState: (
|
||||
data: unknown,
|
||||
unused: string,
|
||||
url?: string | URL | null,
|
||||
) => void;
|
||||
} | null;
|
||||
sessionStorage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | null;
|
||||
};
|
||||
|
||||
function resolveEnvironment(
|
||||
env?: CustomWorldAgentUiEnvironment,
|
||||
): Required<CustomWorldAgentUiEnvironment> {
|
||||
if (env) {
|
||||
return {
|
||||
location: env.location ?? null,
|
||||
history: env.history ?? null,
|
||||
sessionStorage: env.sessionStorage ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
location: null,
|
||||
history: null,
|
||||
sessionStorage: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
location: window.location,
|
||||
history: window.history,
|
||||
sessionStorage: window.sessionStorage,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeValue(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
export function readCustomWorldAgentUiState(
|
||||
env?: CustomWorldAgentUiEnvironment,
|
||||
): CustomWorldAgentUiState {
|
||||
const resolved = resolveEnvironment(env);
|
||||
const params = new URLSearchParams(resolved.location?.search ?? '');
|
||||
const stateFromQuery: CustomWorldAgentUiState = {
|
||||
activeSessionId: normalizeValue(
|
||||
params.get(CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY),
|
||||
),
|
||||
activeOperationId: normalizeValue(
|
||||
params.get(CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY),
|
||||
),
|
||||
};
|
||||
|
||||
if (stateFromQuery.activeSessionId || stateFromQuery.activeOperationId) {
|
||||
return stateFromQuery;
|
||||
}
|
||||
|
||||
const storedValue = resolved.sessionStorage?.getItem(
|
||||
CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY,
|
||||
);
|
||||
if (!storedValue) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(storedValue) as CustomWorldAgentUiState;
|
||||
return {
|
||||
activeSessionId: normalizeValue(parsed.activeSessionId),
|
||||
activeOperationId: normalizeValue(parsed.activeOperationId),
|
||||
};
|
||||
} catch {
|
||||
resolved.sessionStorage?.removeItem(CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCustomWorldAgentUiState(
|
||||
state: CustomWorldAgentUiState,
|
||||
env?: CustomWorldAgentUiEnvironment,
|
||||
) {
|
||||
const resolved = resolveEnvironment(env);
|
||||
const activeSessionId = normalizeValue(state.activeSessionId);
|
||||
const activeOperationId = normalizeValue(state.activeOperationId);
|
||||
const nextState: CustomWorldAgentUiState = {
|
||||
activeSessionId,
|
||||
activeOperationId,
|
||||
};
|
||||
|
||||
if (resolved.location && resolved.history?.replaceState) {
|
||||
const params = new URLSearchParams(resolved.location.search);
|
||||
if (activeSessionId) {
|
||||
params.set(CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY, activeSessionId);
|
||||
} else {
|
||||
params.delete(CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY);
|
||||
}
|
||||
|
||||
if (activeOperationId) {
|
||||
params.set(CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY, activeOperationId);
|
||||
} else {
|
||||
params.delete(CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY);
|
||||
}
|
||||
|
||||
const search = params.toString();
|
||||
const nextUrl = search
|
||||
? `${resolved.location.pathname}?${search}`
|
||||
: resolved.location.pathname;
|
||||
resolved.history.replaceState(null, '', nextUrl);
|
||||
}
|
||||
|
||||
if (resolved.sessionStorage) {
|
||||
if (activeSessionId || activeOperationId) {
|
||||
resolved.sessionStorage.setItem(
|
||||
CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY,
|
||||
JSON.stringify(nextState),
|
||||
);
|
||||
} else {
|
||||
resolved.sessionStorage.removeItem(CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearCustomWorldAgentUiState(
|
||||
env?: CustomWorldAgentUiEnvironment,
|
||||
) {
|
||||
writeCustomWorldAgentUiState({}, env);
|
||||
}
|
||||
@@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCustomWorldAnchorPackFromIntent,
|
||||
buildPendingClarifications,
|
||||
buildCustomWorldCreatorIntentDisplayText,
|
||||
createEmptyCustomWorldCreatorIntent,
|
||||
evaluateCustomWorldCreatorIntentReadiness,
|
||||
mergeCustomWorldCreatorIntent,
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
} from './customWorldCreatorIntent';
|
||||
|
||||
@@ -32,7 +35,9 @@ describe('customWorldCreatorIntent', () => {
|
||||
|
||||
const summary = buildCustomWorldCreatorIntentDisplayText(intent);
|
||||
|
||||
expect(summary).toContain('世界一句话:一个会被灵潮反复改写地形的边境世界。');
|
||||
expect(summary).toContain(
|
||||
'世界一句话:一个会被灵潮反复改写地形的边境世界。',
|
||||
);
|
||||
expect(summary).toContain('主题关键词:边境、灵潮');
|
||||
expect(summary).toContain('关键角色:沈砺 / 灰炬向导');
|
||||
});
|
||||
@@ -90,4 +95,72 @@ describe('customWorldCreatorIntent', () => {
|
||||
expect(intent?.keyCharacters[0]?.name).toBe('梁砺');
|
||||
expect(intent?.keyCharacters[0]?.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('merges creator intent patches without dropping unrelated anchors', () => {
|
||||
const baseIntent = {
|
||||
...createEmptyCustomWorldCreatorIntent('freeform'),
|
||||
worldHook: '潮雾会改写地形的列岛世界。',
|
||||
playerPremise: '玩家是失职返乡的守灯人。',
|
||||
};
|
||||
|
||||
const merged = mergeCustomWorldCreatorIntent(baseIntent, {
|
||||
coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
|
||||
toneDirectives: ['冷峻'],
|
||||
});
|
||||
if (!merged) {
|
||||
throw new Error('expected merged creator intent');
|
||||
}
|
||||
|
||||
expect(merged.worldHook).toBe('潮雾会改写地形的列岛世界。');
|
||||
expect(merged.playerPremise).toBe('玩家是失职返乡的守灯人。');
|
||||
expect(merged.coreConflicts).toEqual(['守灯会与沉船商盟争夺航道解释权']);
|
||||
expect(merged.toneDirectives).toEqual(['冷峻']);
|
||||
});
|
||||
|
||||
it('replaces array anchors when a patch marks explicit rewrite fields', () => {
|
||||
const merged = mergeCustomWorldCreatorIntent(
|
||||
{
|
||||
...createEmptyCustomWorldCreatorIntent('freeform'),
|
||||
themeKeywords: ['海岛', '旧案'],
|
||||
coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
|
||||
},
|
||||
{
|
||||
themeKeywords: ['宫廷', '悬疑'],
|
||||
coreConflicts: ['王庭继承人与旧灯塔盟约对抗'],
|
||||
replaceFields: ['themeKeywords', 'coreConflicts'],
|
||||
},
|
||||
);
|
||||
if (!merged) {
|
||||
throw new Error('expected merged creator intent');
|
||||
}
|
||||
|
||||
expect(merged.themeKeywords).toEqual(['宫廷', '悬疑']);
|
||||
expect(merged.coreConflicts).toEqual(['王庭继承人与旧灯塔盟约对抗']);
|
||||
});
|
||||
|
||||
it('evaluates readiness and limits clarifications to top gaps', () => {
|
||||
const readiness = evaluateCustomWorldCreatorIntentReadiness({
|
||||
...createEmptyCustomWorldCreatorIntent('freeform'),
|
||||
worldHook: '一个被潮雾切开的列岛世界。',
|
||||
themeKeywords: ['海岛'],
|
||||
toneDirectives: ['冷峻'],
|
||||
coreConflicts: ['旧灯塔正在被沉船商盟接管'],
|
||||
});
|
||||
const clarifications = buildPendingClarifications(
|
||||
{
|
||||
...createEmptyCustomWorldCreatorIntent('freeform'),
|
||||
worldHook: '一个被潮雾切开的列岛世界。',
|
||||
themeKeywords: ['海岛'],
|
||||
toneDirectives: ['冷峻'],
|
||||
coreConflicts: ['旧灯塔正在被沉船商盟接管'],
|
||||
},
|
||||
readiness,
|
||||
);
|
||||
|
||||
expect(readiness.isReady).toBe(false);
|
||||
expect(readiness.completedKeys).toContain('world_hook');
|
||||
expect(readiness.missingKeys).toContain('player_premise');
|
||||
expect(clarifications).toHaveLength(3);
|
||||
expect(clarifications[0]?.targetKey).toBe('player_premise');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type {
|
||||
CreatorIntentReadiness,
|
||||
CustomWorldPendingClarification,
|
||||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
ActorAnchor,
|
||||
CreatorCharacterSeed,
|
||||
@@ -10,6 +14,51 @@ import type {
|
||||
LandmarkAnchor,
|
||||
} from '../types';
|
||||
|
||||
export type CustomWorldCreatorIntentPatch = Partial<
|
||||
Pick<
|
||||
CustomWorldCreatorIntent,
|
||||
| 'rawSettingText'
|
||||
| 'worldHook'
|
||||
| 'themeKeywords'
|
||||
| 'toneDirectives'
|
||||
| 'playerPremise'
|
||||
| 'openingSituation'
|
||||
| 'coreConflicts'
|
||||
| 'keyFactions'
|
||||
| 'keyCharacters'
|
||||
| 'keyLandmarks'
|
||||
| 'iconicElements'
|
||||
| 'forbiddenDirectives'
|
||||
>
|
||||
>;
|
||||
|
||||
export type CustomWorldCreatorIntentReplaceableField =
|
||||
| 'rawSettingText'
|
||||
| 'worldHook'
|
||||
| 'themeKeywords'
|
||||
| 'toneDirectives'
|
||||
| 'playerPremise'
|
||||
| 'openingSituation'
|
||||
| 'coreConflicts'
|
||||
| 'keyFactions'
|
||||
| 'keyCharacters'
|
||||
| 'keyLandmarks'
|
||||
| 'iconicElements'
|
||||
| 'forbiddenDirectives';
|
||||
|
||||
export type CustomWorldCreatorIntentPatchInput =
|
||||
CustomWorldCreatorIntentPatch & {
|
||||
replaceFields?: CustomWorldCreatorIntentReplaceableField[];
|
||||
};
|
||||
|
||||
type CreatorIntentReadinessKey =
|
||||
| 'world_hook'
|
||||
| 'player_premise'
|
||||
| 'theme_and_tone'
|
||||
| 'core_conflict'
|
||||
| 'relationship_seed'
|
||||
| 'iconic_element';
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
@@ -19,13 +68,10 @@ function toStringArray(value: unknown, maxCount = 8) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
...new Set(
|
||||
value
|
||||
.map((item) => toText(item))
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, maxCount);
|
||||
return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice(
|
||||
0,
|
||||
maxCount,
|
||||
);
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
@@ -72,7 +118,9 @@ function normalizeCreatorFactionSeed(
|
||||
}
|
||||
|
||||
return {
|
||||
id: toText(item.id) || createSeedId('creator-faction', name || publicGoal, index),
|
||||
id:
|
||||
toText(item.id) ||
|
||||
createSeedId('creator-faction', name || publicGoal, index),
|
||||
name,
|
||||
publicGoal,
|
||||
tension,
|
||||
@@ -167,6 +215,126 @@ function normalizeAnchorArray<T>(
|
||||
.slice(0, maxCount);
|
||||
}
|
||||
|
||||
function mergeStringArray(
|
||||
base: string[],
|
||||
patch: string[] | undefined,
|
||||
maxCount: number,
|
||||
) {
|
||||
if (!patch || patch.length === 0) {
|
||||
return [...base];
|
||||
}
|
||||
|
||||
return [
|
||||
...new Set([...base, ...patch.map((item) => toText(item)).filter(Boolean)]),
|
||||
].slice(0, maxCount);
|
||||
}
|
||||
|
||||
function mergeNarrativeText(base: string, patch: string | undefined) {
|
||||
const nextText = toText(patch);
|
||||
if (!nextText) {
|
||||
return base;
|
||||
}
|
||||
if (!base) {
|
||||
return nextText;
|
||||
}
|
||||
if (base.includes(nextText)) {
|
||||
return base;
|
||||
}
|
||||
|
||||
return `${base}\n${nextText}`.trim();
|
||||
}
|
||||
|
||||
function mergeSeedArray<T extends { id: string; name?: string }>(
|
||||
base: T[],
|
||||
patch: T[] | undefined,
|
||||
maxCount: number,
|
||||
mergeEntry: (current: T, next: T) => T,
|
||||
) {
|
||||
if (!patch || patch.length === 0) {
|
||||
return [...base];
|
||||
}
|
||||
|
||||
const nextItems = [...base];
|
||||
|
||||
patch.forEach((entry) => {
|
||||
const normalizedName = toText(entry.name);
|
||||
const existingIndex = nextItems.findIndex(
|
||||
(item) =>
|
||||
item.id === entry.id ||
|
||||
(normalizedName &&
|
||||
toText(item.name).toLowerCase() === normalizedName.toLowerCase()),
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const currentItem = nextItems[existingIndex];
|
||||
if (!currentItem) {
|
||||
nextItems.push(entry);
|
||||
return;
|
||||
}
|
||||
|
||||
nextItems[existingIndex] = mergeEntry(currentItem, entry);
|
||||
return;
|
||||
}
|
||||
|
||||
nextItems.push(entry);
|
||||
});
|
||||
|
||||
return nextItems.slice(0, maxCount);
|
||||
}
|
||||
|
||||
function mergeCharacterSeed(
|
||||
current: CreatorCharacterSeed,
|
||||
next: CreatorCharacterSeed,
|
||||
): CreatorCharacterSeed {
|
||||
return {
|
||||
...current,
|
||||
...next,
|
||||
id: next.id || current.id,
|
||||
name: toText(next.name) || current.name,
|
||||
role: toText(next.role) || current.role,
|
||||
publicMask: toText(next.publicMask) || current.publicMask,
|
||||
hiddenHook: toText(next.hiddenHook) || current.hiddenHook,
|
||||
relationToPlayer: toText(next.relationToPlayer) || current.relationToPlayer,
|
||||
notes: toText(next.notes) || current.notes,
|
||||
locked:
|
||||
typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeFactionSeed(
|
||||
current: CreatorFactionSeed,
|
||||
next: CreatorFactionSeed,
|
||||
): CreatorFactionSeed {
|
||||
return {
|
||||
...current,
|
||||
...next,
|
||||
id: next.id || current.id,
|
||||
name: toText(next.name) || current.name,
|
||||
publicGoal: toText(next.publicGoal) || current.publicGoal,
|
||||
tension: toText(next.tension) || current.tension,
|
||||
notes: toText(next.notes) || current.notes,
|
||||
locked:
|
||||
typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeLandmarkSeed(
|
||||
current: CreatorLandmarkSeed,
|
||||
next: CreatorLandmarkSeed,
|
||||
): CreatorLandmarkSeed {
|
||||
return {
|
||||
...current,
|
||||
...next,
|
||||
id: next.id || current.id,
|
||||
name: toText(next.name) || current.name,
|
||||
purpose: toText(next.purpose) || current.purpose,
|
||||
mood: toText(next.mood) || current.mood,
|
||||
secret: toText(next.secret) || current.secret,
|
||||
locked:
|
||||
typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked),
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyCustomWorldCreatorIntent(
|
||||
sourceMode: CustomWorldCreatorInputMode = 'freeform',
|
||||
): CustomWorldCreatorIntent {
|
||||
@@ -259,6 +427,221 @@ export function normalizeCustomWorldCreatorIntent(
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeCustomWorldCreatorIntent(
|
||||
current: CustomWorldCreatorIntent | null | undefined,
|
||||
patch: CustomWorldCreatorIntentPatchInput | null | undefined,
|
||||
fallbackMode: CustomWorldCreatorInputMode = 'freeform',
|
||||
) {
|
||||
if (!patch) {
|
||||
return current
|
||||
? normalizeCustomWorldCreatorIntent(current, fallbackMode)
|
||||
: createEmptyCustomWorldCreatorIntent(fallbackMode);
|
||||
}
|
||||
|
||||
const base =
|
||||
normalizeCustomWorldCreatorIntent(current, fallbackMode) ??
|
||||
createEmptyCustomWorldCreatorIntent(fallbackMode);
|
||||
const replaceFields = new Set(patch.replaceFields ?? []);
|
||||
const patchIntent =
|
||||
normalizeCustomWorldCreatorIntent(
|
||||
{
|
||||
sourceMode: base.sourceMode,
|
||||
...patch,
|
||||
},
|
||||
base.sourceMode,
|
||||
) ?? createEmptyCustomWorldCreatorIntent(base.sourceMode);
|
||||
|
||||
return {
|
||||
...base,
|
||||
rawSettingText: replaceFields.has('rawSettingText')
|
||||
? toText(patchIntent.rawSettingText) || base.rawSettingText
|
||||
: mergeNarrativeText(base.rawSettingText, patchIntent.rawSettingText),
|
||||
worldHook: toText(patchIntent.worldHook) || base.worldHook,
|
||||
themeKeywords: replaceFields.has('themeKeywords')
|
||||
? [...patchIntent.themeKeywords]
|
||||
: mergeStringArray(base.themeKeywords, patchIntent.themeKeywords, 8),
|
||||
toneDirectives: replaceFields.has('toneDirectives')
|
||||
? [...patchIntent.toneDirectives]
|
||||
: mergeStringArray(base.toneDirectives, patchIntent.toneDirectives, 8),
|
||||
playerPremise: toText(patchIntent.playerPremise) || base.playerPremise,
|
||||
openingSituation:
|
||||
toText(patchIntent.openingSituation) || base.openingSituation,
|
||||
coreConflicts: replaceFields.has('coreConflicts')
|
||||
? [...patchIntent.coreConflicts]
|
||||
: mergeStringArray(base.coreConflicts, patchIntent.coreConflicts, 6),
|
||||
keyFactions: replaceFields.has('keyFactions')
|
||||
? [...patchIntent.keyFactions]
|
||||
: mergeSeedArray(
|
||||
base.keyFactions,
|
||||
patchIntent.keyFactions,
|
||||
6,
|
||||
mergeFactionSeed,
|
||||
),
|
||||
keyCharacters: replaceFields.has('keyCharacters')
|
||||
? [...patchIntent.keyCharacters]
|
||||
: mergeSeedArray(
|
||||
base.keyCharacters,
|
||||
patchIntent.keyCharacters,
|
||||
8,
|
||||
mergeCharacterSeed,
|
||||
),
|
||||
keyLandmarks: replaceFields.has('keyLandmarks')
|
||||
? [...patchIntent.keyLandmarks]
|
||||
: mergeSeedArray(
|
||||
base.keyLandmarks,
|
||||
patchIntent.keyLandmarks,
|
||||
8,
|
||||
mergeLandmarkSeed,
|
||||
),
|
||||
iconicElements: replaceFields.has('iconicElements')
|
||||
? [...patchIntent.iconicElements]
|
||||
: mergeStringArray(base.iconicElements, patchIntent.iconicElements, 8),
|
||||
forbiddenDirectives: replaceFields.has('forbiddenDirectives')
|
||||
? [...patchIntent.forbiddenDirectives]
|
||||
: mergeStringArray(
|
||||
base.forbiddenDirectives,
|
||||
patchIntent.forbiddenDirectives,
|
||||
8,
|
||||
),
|
||||
} satisfies CustomWorldCreatorIntent;
|
||||
}
|
||||
|
||||
export function evaluateCustomWorldCreatorIntentReadiness(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
): CreatorIntentReadiness {
|
||||
const normalized =
|
||||
normalizeCustomWorldCreatorIntent(intent) ??
|
||||
createEmptyCustomWorldCreatorIntent('freeform');
|
||||
const completedKeys: CreatorIntentReadinessKey[] = [];
|
||||
const missingKeys: CreatorIntentReadinessKey[] = [];
|
||||
const relationshipReady = normalized.keyCharacters.some(
|
||||
(entry) =>
|
||||
Boolean(toText(entry.name)) &&
|
||||
Boolean(toText(entry.relationToPlayer) || toText(entry.hiddenHook)),
|
||||
);
|
||||
|
||||
const keyChecks: Array<{
|
||||
key: CreatorIntentReadinessKey;
|
||||
ready: boolean;
|
||||
}> = [
|
||||
{
|
||||
key: 'world_hook',
|
||||
ready:
|
||||
normalized.worldHook.trim().length >= 8 ||
|
||||
normalized.rawSettingText.trim().length >= 24,
|
||||
},
|
||||
{
|
||||
key: 'player_premise',
|
||||
ready: Boolean(
|
||||
normalized.playerPremise.trim() && normalized.openingSituation.trim(),
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'theme_and_tone',
|
||||
ready:
|
||||
normalized.themeKeywords.length >= 1 &&
|
||||
normalized.toneDirectives.length >= 1,
|
||||
},
|
||||
{
|
||||
key: 'core_conflict',
|
||||
ready: normalized.coreConflicts.length >= 1,
|
||||
},
|
||||
{
|
||||
key: 'relationship_seed',
|
||||
ready: normalized.keyCharacters.length >= 1 && relationshipReady,
|
||||
},
|
||||
{
|
||||
key: 'iconic_element',
|
||||
ready: normalized.iconicElements.length >= 1,
|
||||
},
|
||||
];
|
||||
|
||||
keyChecks.forEach((entry) => {
|
||||
if (entry.ready) {
|
||||
completedKeys.push(entry.key);
|
||||
return;
|
||||
}
|
||||
|
||||
missingKeys.push(entry.key);
|
||||
});
|
||||
|
||||
return {
|
||||
isReady: missingKeys.length === 0,
|
||||
completedKeys,
|
||||
missingKeys,
|
||||
};
|
||||
}
|
||||
|
||||
const CLARIFICATION_DEFINITIONS: Array<{
|
||||
targetKey: CreatorIntentReadinessKey;
|
||||
priority: number;
|
||||
label: string;
|
||||
question: string;
|
||||
}> = [
|
||||
{
|
||||
targetKey: 'world_hook',
|
||||
priority: 1,
|
||||
label: '世界一句话',
|
||||
question:
|
||||
'先用一句话说清,这个世界最独特的核心幻想是什么?可以直接给我一句钉住调性的描述。',
|
||||
},
|
||||
{
|
||||
targetKey: 'player_premise',
|
||||
priority: 2,
|
||||
label: '玩家身份与开局',
|
||||
question:
|
||||
'玩家是谁,故事开场时正卡在什么局面里?你可以直接把身份和开局困境一起告诉我。',
|
||||
},
|
||||
{
|
||||
targetKey: 'core_conflict',
|
||||
priority: 3,
|
||||
label: '核心冲突',
|
||||
question:
|
||||
'现在这个世界最主要的冲突是什么?最好是能立刻推动剧情的那种对抗或危机。',
|
||||
},
|
||||
{
|
||||
targetKey: 'theme_and_tone',
|
||||
priority: 4,
|
||||
label: '主题气质',
|
||||
question:
|
||||
'你想要它整体更偏什么主题和气质?比如克制、压迫、浪漫、冷峻,或者明确不要什么。',
|
||||
},
|
||||
{
|
||||
targetKey: 'relationship_seed',
|
||||
priority: 5,
|
||||
label: '关键关系钩子',
|
||||
question:
|
||||
'给我一个最值得写的关键人物种子就行,他和玩家是什么关系,或者身上藏着什么暗线?',
|
||||
},
|
||||
{
|
||||
targetKey: 'iconic_element',
|
||||
priority: 6,
|
||||
label: '标志性要素',
|
||||
question:
|
||||
'这个世界有什么一眼就能认出来的标志性元素、意象或硬规则?先给 1 到 2 个就够。',
|
||||
},
|
||||
];
|
||||
|
||||
export function buildPendingClarifications(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
readiness = evaluateCustomWorldCreatorIntentReadiness(intent),
|
||||
) {
|
||||
return CLARIFICATION_DEFINITIONS.filter((entry) =>
|
||||
readiness.missingKeys.includes(entry.targetKey),
|
||||
)
|
||||
.sort((left, right) => left.priority - right.priority)
|
||||
.slice(0, 3)
|
||||
.map(
|
||||
(entry): CustomWorldPendingClarification => ({
|
||||
id: entry.targetKey,
|
||||
label: entry.label,
|
||||
question: entry.question,
|
||||
targetKey: entry.targetKey,
|
||||
priority: entry.priority,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldLockState(
|
||||
value: unknown,
|
||||
): CustomWorldLockState {
|
||||
@@ -308,8 +691,7 @@ export function hasMeaningfulCustomWorldCreatorIntent(
|
||||
) {
|
||||
return Boolean(
|
||||
intent &&
|
||||
(
|
||||
intent.rawSettingText ||
|
||||
(intent.rawSettingText ||
|
||||
intent.worldHook ||
|
||||
intent.themeKeywords.length > 0 ||
|
||||
intent.toneDirectives.length > 0 ||
|
||||
@@ -320,8 +702,7 @@ export function hasMeaningfulCustomWorldCreatorIntent(
|
||||
intent.keyCharacters.length > 0 ||
|
||||
intent.keyLandmarks.length > 0 ||
|
||||
intent.iconicElements.length > 0 ||
|
||||
intent.forbiddenDirectives.length > 0
|
||||
),
|
||||
intent.forbiddenDirectives.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -348,7 +729,9 @@ export function buildCustomWorldCreatorIntentDisplayText(
|
||||
'关键势力',
|
||||
intent?.keyFactions
|
||||
.map((entry) =>
|
||||
[entry.name, entry.publicGoal, entry.tension].filter(Boolean).join(' / '),
|
||||
[entry.name, entry.publicGoal, entry.tension]
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';') || '',
|
||||
@@ -477,7 +860,9 @@ function buildCharacterAnchorSummary(entry: CreatorCharacterSeed): ActorAnchor {
|
||||
};
|
||||
}
|
||||
|
||||
function buildLandmarkAnchorSummary(entry: CreatorLandmarkSeed): LandmarkAnchor {
|
||||
function buildLandmarkAnchorSummary(
|
||||
entry: CreatorLandmarkSeed,
|
||||
): LandmarkAnchor {
|
||||
const summary = clampText(
|
||||
[entry.purpose, entry.mood, entry.secret ? `秘密 ${entry.secret}` : '']
|
||||
.filter(Boolean)
|
||||
@@ -500,9 +885,15 @@ export function buildCustomWorldAnchorPackFromIntent(
|
||||
}
|
||||
|
||||
const lockedAnchorIds = [
|
||||
...(intent?.keyCharacters.filter((entry) => entry.locked).map((entry) => entry.id) ?? []),
|
||||
...(intent?.keyLandmarks.filter((entry) => entry.locked).map((entry) => entry.id) ?? []),
|
||||
...(intent?.keyFactions.filter((entry) => entry.locked).map((entry) => entry.id) ?? []),
|
||||
...(intent?.keyCharacters
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.id) ?? []),
|
||||
...(intent?.keyLandmarks
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.id) ?? []),
|
||||
...(intent?.keyFactions
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.id) ?? []),
|
||||
];
|
||||
|
||||
return {
|
||||
@@ -515,18 +906,24 @@ export function buildCustomWorldAnchorPackFromIntent(
|
||||
240,
|
||||
),
|
||||
lockedAnchorIds,
|
||||
keyConflictSummaries: intent?.coreConflicts.map((entry) => clampText(entry, 48)) ?? [],
|
||||
keyConflictSummaries:
|
||||
intent?.coreConflicts.map((entry) => clampText(entry, 48)) ?? [],
|
||||
keyFactionSummaries:
|
||||
intent?.keyFactions.map((entry) =>
|
||||
clampText(
|
||||
[entry.name, entry.publicGoal, entry.tension].filter(Boolean).join(';'),
|
||||
[entry.name, entry.publicGoal, entry.tension]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
72,
|
||||
),
|
||||
) ?? [],
|
||||
keyCharacterAnchors:
|
||||
intent?.keyCharacters.map((entry) => buildCharacterAnchorSummary(entry)) ?? [],
|
||||
intent?.keyCharacters.map((entry) =>
|
||||
buildCharacterAnchorSummary(entry),
|
||||
) ?? [],
|
||||
keyLandmarkAnchors:
|
||||
intent?.keyLandmarks.map((entry) => buildLandmarkAnchorSummary(entry)) ?? [],
|
||||
intent?.keyLandmarks.map((entry) => buildLandmarkAnchorSummary(entry)) ??
|
||||
[],
|
||||
motifDirectives: [
|
||||
...(intent?.themeKeywords ?? []),
|
||||
...(intent?.toneDirectives ?? []),
|
||||
|
||||
@@ -9,13 +9,14 @@ 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 { requestJson, type ApiRetryOptions } from './apiClient';
|
||||
import { type ApiRetryOptions,requestJson } from './apiClient';
|
||||
|
||||
const RUNTIME_STORY_API_BASE = '/api/runtime/story';
|
||||
const DEFAULT_SESSION_ID = 'runtime-main';
|
||||
@@ -171,12 +172,17 @@ export async function getRuntimeStoryState(
|
||||
sessionId: string,
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
) {
|
||||
return requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
`/state/${encodeURIComponent(sessionId || DEFAULT_SESSION_ID)}`,
|
||||
{ method: 'GET' },
|
||||
'读取运行时故事状态失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
...response,
|
||||
snapshot: rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot),
|
||||
} satisfies RuntimeStoryResponse;
|
||||
}
|
||||
|
||||
export async function resolveRuntimeStoryAction(
|
||||
@@ -189,7 +195,7 @@ export async function resolveRuntimeStoryAction(
|
||||
},
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
) {
|
||||
return requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
'/actions/resolve',
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -211,8 +217,15 @@ export async function resolveRuntimeStoryAction(
|
||||
'执行运行时动作失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
...response,
|
||||
snapshot: rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot),
|
||||
} satisfies RuntimeStoryResponse;
|
||||
}
|
||||
|
||||
export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {
|
||||
return response.snapshot as HydratedSavedGameSnapshot;
|
||||
return rehydrateSavedSnapshot(
|
||||
response.snapshot as HydratedSavedGameSnapshot,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type {
|
||||
ListCustomWorldWorksResponse,
|
||||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
BasicOkResult,
|
||||
CustomWorldLibraryResponse,
|
||||
@@ -6,6 +9,7 @@ import type {
|
||||
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';
|
||||
@@ -51,19 +55,21 @@ function requestRuntimeJson<T>(
|
||||
}
|
||||
|
||||
export async function getSaveSnapshot(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<HydratedSavedGameSnapshot | null>(
|
||||
const snapshot = await requestRuntimeJson<HydratedSavedGameSnapshot | null>(
|
||||
'/save/snapshot',
|
||||
{ method: 'GET' },
|
||||
'读取存档失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return snapshot ? rehydrateSavedSnapshot(snapshot) : null;
|
||||
}
|
||||
|
||||
export async function putSaveSnapshot(
|
||||
snapshot: SavedGameSnapshotInput,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<HydratedSavedGameSnapshot>(
|
||||
const savedSnapshot = await requestRuntimeJson<HydratedSavedGameSnapshot>(
|
||||
'/save/snapshot',
|
||||
{
|
||||
method: 'PUT',
|
||||
@@ -73,6 +79,8 @@ export async function putSaveSnapshot(
|
||||
'保存存档失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return rehydrateSavedSnapshot(savedSnapshot);
|
||||
}
|
||||
|
||||
export async function deleteSaveSnapshot(options: RuntimeRequestOptions = {}) {
|
||||
@@ -120,6 +128,17 @@ export async function listCustomWorldLibrary(options: RuntimeRequestOptions = {}
|
||||
return Array.isArray(response?.profiles) ? response.profiles : [];
|
||||
}
|
||||
|
||||
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 = {},
|
||||
@@ -161,6 +180,7 @@ export const runtimeStorageClient = {
|
||||
getSettings,
|
||||
putSettings,
|
||||
listCustomWorldLibrary,
|
||||
listCustomWorldWorks,
|
||||
upsertCustomWorldProfile,
|
||||
deleteCustomWorldProfile,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user