@@ -15,6 +15,26 @@ import {
|
||||
getDefaultFunctionIdsForContext,
|
||||
resolveFunctionOption,
|
||||
} from '../data/stateFunctions';
|
||||
import {
|
||||
buildCustomWorldActorNarrativeProfileBatchJsonRepairPrompt,
|
||||
buildCustomWorldActorNarrativeProfileBatchPrompt,
|
||||
buildCustomWorldFrameworkJsonRepairPrompt,
|
||||
buildCustomWorldFrameworkPrompt,
|
||||
buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt,
|
||||
buildCustomWorldLandmarkNetworkBatchPrompt,
|
||||
buildCustomWorldLandmarkSeedBatchJsonRepairPrompt,
|
||||
buildCustomWorldLandmarkSeedBatchPrompt,
|
||||
buildCustomWorldRoleBatchJsonRepairPrompt,
|
||||
buildCustomWorldRoleBatchPrompt,
|
||||
buildCustomWorldRoleOutlineBatchJsonRepairPrompt,
|
||||
buildCustomWorldRoleOutlineBatchPrompt,
|
||||
buildCustomWorldSceneImagePrompt,
|
||||
buildCustomWorldStoryGraphJsonRepairPrompt,
|
||||
buildCustomWorldStoryGraphPrompt,
|
||||
buildCustomWorldThemePackJsonRepairPrompt,
|
||||
buildCustomWorldThemePackPrompt,
|
||||
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
|
||||
} from '../prompts/customWorldPrompts';
|
||||
import {
|
||||
AIResponse,
|
||||
Character,
|
||||
@@ -40,6 +60,8 @@ import {
|
||||
buildOfflineNpcRecruitDialogue as buildOfflineNpcRecruitDialogueFromFallback,
|
||||
} from './aiFallbacks';
|
||||
import type {
|
||||
CustomWorldSceneImageRequest,
|
||||
CustomWorldSceneImageResult,
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
TextStreamOptions,
|
||||
@@ -56,30 +78,12 @@ import {
|
||||
CharacterChatTargetStatus,
|
||||
} from './characterChatPrompt';
|
||||
import {
|
||||
buildCustomWorldActorNarrativeProfileBatchJsonRepairPrompt,
|
||||
buildCustomWorldActorNarrativeProfileBatchPrompt,
|
||||
buildCustomWorldFrameworkJsonRepairPrompt,
|
||||
buildCustomWorldFrameworkPrompt,
|
||||
buildCustomWorldLandmarkNetworkBatchJsonRepairPrompt,
|
||||
buildCustomWorldLandmarkNetworkBatchPrompt,
|
||||
buildCustomWorldLandmarkSeedBatchJsonRepairPrompt,
|
||||
buildCustomWorldLandmarkSeedBatchPrompt,
|
||||
buildCustomWorldRawProfileFromFramework,
|
||||
buildCustomWorldRoleBatchJsonRepairPrompt,
|
||||
buildCustomWorldRoleBatchPrompt,
|
||||
buildCustomWorldRoleOutlineBatchJsonRepairPrompt,
|
||||
buildCustomWorldRoleOutlineBatchPrompt,
|
||||
buildCustomWorldSceneImagePrompt,
|
||||
buildCustomWorldStoryGraphJsonRepairPrompt,
|
||||
buildCustomWorldStoryGraphPrompt,
|
||||
buildCustomWorldThemePackJsonRepairPrompt,
|
||||
buildCustomWorldThemePackPrompt,
|
||||
type CustomWorldGenerationFramework,
|
||||
type CustomWorldGenerationLandmarkOutline,
|
||||
type CustomWorldGenerationRoleBatchStage,
|
||||
type CustomWorldGenerationRoleBatchType,
|
||||
type CustomWorldGenerationRoleOutline,
|
||||
DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT,
|
||||
MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||||
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
@@ -137,6 +141,8 @@ export type {
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
export type {
|
||||
CustomWorldSceneImageRequest,
|
||||
CustomWorldSceneImageResult,
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
TextStreamOptions,
|
||||
@@ -154,8 +160,7 @@ type MergeableCustomWorldRoleEntry = {
|
||||
};
|
||||
|
||||
const CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL =
|
||||
ENV.VITE_SCENE_IMAGE_PROXY_BASE_URL ||
|
||||
'/api/custom-world/scene-image';
|
||||
ENV.VITE_SCENE_IMAGE_PROXY_BASE_URL || '/api/custom-world/scene-image';
|
||||
const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
|
||||
你会收到一段本应为单个 JSON 对象的文本。
|
||||
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
|
||||
@@ -179,38 +184,6 @@ const CUSTOM_WORLD_SCENE_IMAGE_REQUEST_TIMEOUT_MS = (() => {
|
||||
return Number.isFinite(rawValue) && rawValue > 0 ? rawValue : 150000;
|
||||
})();
|
||||
|
||||
export interface CustomWorldSceneImageRequest {
|
||||
profile: Pick<
|
||||
CustomWorldProfile,
|
||||
| 'id'
|
||||
| 'name'
|
||||
| 'subtitle'
|
||||
| 'summary'
|
||||
| 'tone'
|
||||
| 'playerGoal'
|
||||
| 'settingText'
|
||||
>;
|
||||
landmark: Pick<
|
||||
CustomWorldProfile['landmarks'][number],
|
||||
'id' | 'name' | 'description' | 'dangerLevel'
|
||||
>;
|
||||
userPrompt?: string;
|
||||
prompt?: string;
|
||||
negativePrompt?: string;
|
||||
size?: string;
|
||||
referenceImageSrc?: string;
|
||||
}
|
||||
|
||||
export interface CustomWorldSceneImageResult {
|
||||
imageSrc: string;
|
||||
assetId: string;
|
||||
model: string;
|
||||
size: string;
|
||||
taskId: string;
|
||||
prompt: string;
|
||||
actualPrompt?: string;
|
||||
}
|
||||
|
||||
const CUSTOM_WORLD_GENERATION_STAGE_DEFINITIONS = [
|
||||
{
|
||||
id: 'framework',
|
||||
@@ -1992,23 +1965,26 @@ export async function generateCustomWorldSceneImage({
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetchWithApiAuth(CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profileId: profile.id,
|
||||
worldName: profile.name,
|
||||
landmarkId: landmark.id,
|
||||
landmarkName: landmark.name,
|
||||
prompt: resolvedPrompt,
|
||||
negativePrompt: resolvedNegativePrompt,
|
||||
size,
|
||||
...(referenceImageSrc?.trim()
|
||||
? { referenceImageSrc: referenceImageSrc.trim() }
|
||||
: {}),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const response = await fetchWithApiAuth(
|
||||
CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profileId: profile.id,
|
||||
worldName: profile.name,
|
||||
landmarkId: landmark.id,
|
||||
landmarkName: landmark.name,
|
||||
prompt: resolvedPrompt,
|
||||
negativePrompt: resolvedNegativePrompt,
|
||||
size,
|
||||
...(referenceImageSrc?.trim()
|
||||
? { referenceImageSrc: referenceImageSrc.trim() }
|
||||
: {}),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -32,12 +32,13 @@ import { parseApiErrorMessage } from '../../packages/shared/src/http';
|
||||
import type {
|
||||
AIResponse,
|
||||
Character,
|
||||
CharacterChatTurn,
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CharacterChatTurn,
|
||||
CustomWorldProfile,
|
||||
Encounter,
|
||||
GameState,
|
||||
SceneHostileNpc,
|
||||
StoryMoment,
|
||||
WorldType,
|
||||
@@ -48,7 +49,7 @@ import type {
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
TextStreamOptions,
|
||||
} from './ai';
|
||||
} from './aiTypes';
|
||||
import { fetchWithApiAuth, requestJson } from './apiClient';
|
||||
import { type CharacterChatTargetStatus } from './characterChatPrompt';
|
||||
import { parseLineListContent } from './llmParsers';
|
||||
@@ -365,13 +366,18 @@ export async function generateCustomWorldProfile(
|
||||
: {
|
||||
settingText: input.settingText,
|
||||
creatorIntent: input.creatorIntent ?? null,
|
||||
generationMode: input.generationMode === 'fast' ? 'fast' as const : 'full' as const,
|
||||
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,
|
||||
creatorIntent: normalizedInput.creatorIntent as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null,
|
||||
generationMode: normalizedInput.generationMode,
|
||||
});
|
||||
|
||||
@@ -380,7 +386,8 @@ export async function generateCustomWorldProfile(
|
||||
typeof normalizedInput.creatorIntent?.worldHook === 'string' &&
|
||||
normalizedInput.creatorIntent.worldHook.trim()
|
||||
? normalizedInput.creatorIntent.worldHook.trim()
|
||||
: normalizedInput.settingText.trim().slice(0, 120) || '这是一个围绕失衡秩序展开的世界。',
|
||||
: normalizedInput.settingText.trim().slice(0, 120) ||
|
||||
'这是一个围绕失衡秩序展开的世界。',
|
||||
player_premise:
|
||||
typeof normalizedInput.creatorIntent?.playerPremise === 'string' &&
|
||||
normalizedInput.creatorIntent.playerPremise.trim()
|
||||
@@ -395,9 +402,9 @@ export async function generateCustomWorldProfile(
|
||||
Array.isArray(normalizedInput.creatorIntent?.coreConflicts) &&
|
||||
normalizedInput.creatorIntent.coreConflicts.length > 0
|
||||
? normalizedInput.creatorIntent.coreConflicts
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean)
|
||||
.join(';')
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean)
|
||||
.join(';')
|
||||
: '旧秩序与新威胁正在同时逼近,各方都在争夺主动权。',
|
||||
};
|
||||
|
||||
@@ -406,7 +413,8 @@ export async function generateCustomWorldProfile(
|
||||
continue;
|
||||
}
|
||||
|
||||
const answer = fallbackAnswerMap[question.id] || normalizedInput.settingText.trim();
|
||||
const answer =
|
||||
fallbackAnswerMap[question.id] || normalizedInput.settingText.trim();
|
||||
await answerCustomWorldSessionQuestion(session.sessionId, {
|
||||
questionId: question.id,
|
||||
answer,
|
||||
@@ -475,20 +483,30 @@ export async function streamCustomWorldSessionGeneration(
|
||||
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)
|
||||
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);
|
||||
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',
|
||||
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,
|
||||
typeof payload.progress === 'number'
|
||||
? payload.progress / 100
|
||||
: 0,
|
||||
completedWeight:
|
||||
typeof payload.progress === 'number' ? payload.progress : 0,
|
||||
totalWeight: 100,
|
||||
@@ -499,7 +517,11 @@ export async function streamCustomWorldSessionGeneration(
|
||||
});
|
||||
}
|
||||
}
|
||||
if (eventName === 'result' && payload.profile && typeof payload.profile === 'object') {
|
||||
if (
|
||||
eventName === 'result' &&
|
||||
payload.profile &&
|
||||
typeof payload.profile === 'object'
|
||||
) {
|
||||
latestProfile = payload.profile as Record<string, unknown>;
|
||||
}
|
||||
if (eventName === 'error') {
|
||||
@@ -521,10 +543,17 @@ export async function streamCustomWorldSessionGeneration(
|
||||
}
|
||||
|
||||
export async function generateCustomWorldSceneImage(
|
||||
...args: [CustomWorldSceneImageRequest]
|
||||
payload: CustomWorldSceneImageRequest,
|
||||
) {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateCustomWorldSceneImage(...args);
|
||||
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: {
|
||||
@@ -779,10 +808,12 @@ export async function getCustomWorldAgentOperation(
|
||||
sessionId: string,
|
||||
operationId: string,
|
||||
): Promise<CustomWorldAgentOperationRecord> {
|
||||
const response = await requestJson<{
|
||||
operation?: CustomWorldAgentOperationRecord;
|
||||
data?: CustomWorldAgentOperationRecord;
|
||||
} & Partial<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',
|
||||
@@ -790,7 +821,9 @@ export async function getCustomWorldAgentOperation(
|
||||
'读取共创操作状态失败',
|
||||
);
|
||||
|
||||
return (response.operation ?? response.data ?? response) as CustomWorldAgentOperationRecord;
|
||||
return (response.operation ??
|
||||
response.data ??
|
||||
response) as CustomWorldAgentOperationRecord;
|
||||
}
|
||||
|
||||
export async function getCustomWorldAgentCardDetail(
|
||||
@@ -827,7 +860,9 @@ export async function answerCustomWorldSessionQuestion(
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload satisfies AnswerCustomWorldSessionQuestionRequest),
|
||||
body: JSON.stringify(
|
||||
payload satisfies AnswerCustomWorldSessionQuestionRequest,
|
||||
),
|
||||
},
|
||||
'提交自定义世界补充设定失败',
|
||||
);
|
||||
@@ -938,6 +973,10 @@ export async function streamNpcChatTurn(
|
||||
npcState: Record<string, unknown>,
|
||||
options: {
|
||||
onReplyUpdate?: (text: string) => void;
|
||||
questOfferContext?: {
|
||||
state: GameState;
|
||||
turnCount: number;
|
||||
} | null;
|
||||
} = {},
|
||||
) {
|
||||
const payload = {
|
||||
@@ -952,13 +991,23 @@ export async function streamNpcChatTurn(
|
||||
dialogue: conversationHistory ?? [],
|
||||
playerMessage,
|
||||
npcState,
|
||||
questOfferContext: options.questOfferContext
|
||||
? {
|
||||
state: options.questOfferContext.state,
|
||||
encounter,
|
||||
turnCount: options.questOfferContext.turnCount,
|
||||
}
|
||||
: null,
|
||||
} satisfies NpcChatTurnRequest;
|
||||
|
||||
const response = await fetchWithApiAuth(`${RUNTIME_API_BASE}/chat/npc/turn/stream`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const response = await fetchWithApiAuth(
|
||||
`${RUNTIME_API_BASE}/chat/npc/turn/stream`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
@@ -998,7 +1047,10 @@ export async function streamNpcChatTurn(
|
||||
}
|
||||
|
||||
if (parsedEvent.event === 'reply_delta') {
|
||||
const payloadRecord = JSON.parse(parsedEvent.data) as Record<string, unknown>;
|
||||
const payloadRecord = JSON.parse(parsedEvent.data) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const nextText =
|
||||
typeof payloadRecord.text === 'string' ? payloadRecord.text : '';
|
||||
accumulatedReply = nextText;
|
||||
@@ -1014,7 +1066,10 @@ export async function streamNpcChatTurn(
|
||||
}
|
||||
|
||||
if (parsedEvent.event === 'error') {
|
||||
const payloadRecord = JSON.parse(parsedEvent.data) as Record<string, unknown>;
|
||||
const payloadRecord = JSON.parse(parsedEvent.data) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
throw new Error(
|
||||
typeof payloadRecord.message === 'string'
|
||||
? payloadRecord.message
|
||||
|
||||
@@ -42,7 +42,7 @@ import type {
|
||||
WorldMutation,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import type {ConversationPressure, ConversationSituation} from '../types';
|
||||
import type { ConversationPressure, ConversationSituation } from '../types';
|
||||
|
||||
export interface StoryRequestOptions {
|
||||
availableOptions?: StoryOption[];
|
||||
@@ -53,6 +53,39 @@ export interface TextStreamOptions {
|
||||
onUpdate?: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface CustomWorldSceneImageRequest {
|
||||
profile: {
|
||||
id: string;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
summary: string;
|
||||
tone: string;
|
||||
playerGoal: string;
|
||||
settingText: string;
|
||||
};
|
||||
landmark: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
dangerLevel: string;
|
||||
};
|
||||
userPrompt?: string;
|
||||
prompt?: string;
|
||||
negativePrompt?: string;
|
||||
size?: string;
|
||||
referenceImageSrc?: string;
|
||||
}
|
||||
|
||||
export interface CustomWorldSceneImageResult {
|
||||
imageSrc: string;
|
||||
assetId: string;
|
||||
model: string;
|
||||
size: string;
|
||||
taskId: string;
|
||||
prompt: string;
|
||||
actualPrompt?: string;
|
||||
}
|
||||
|
||||
export interface StoryGenerationContext {
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
@@ -88,7 +121,12 @@ export interface StoryGenerationContext {
|
||||
encounterAllowedTopics?: string[] | null;
|
||||
encounterBlockedTopics?: string[] | null;
|
||||
isFirstMeaningfulContact?: boolean;
|
||||
firstContactRelationStance?: 'guarded' | 'neutral' | 'cooperative' | 'bonded' | null;
|
||||
firstContactRelationStance?:
|
||||
| 'guarded'
|
||||
| 'neutral'
|
||||
| 'cooperative'
|
||||
| 'bonded'
|
||||
| null;
|
||||
conversationSituation?: ConversationSituation | null;
|
||||
conversationPressure?: ConversationPressure | null;
|
||||
recentSharedEvent?: string | null;
|
||||
|
||||
@@ -1,333 +1 @@
|
||||
import {
|
||||
buildSchemaSummary,
|
||||
describeTopAttributes,
|
||||
formatAttributeList,
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from '../data/attributeResolver';
|
||||
import {
|
||||
buildCharacterBackstoryPromptContext,
|
||||
getCharacterPublicBackstorySummary,
|
||||
getLockedCharacterBackstoryChapters,
|
||||
} from '../data/characterPresets';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CharacterChatTurn,
|
||||
CustomWorldProfile,
|
||||
FacingDirection,
|
||||
StoryMoment,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { buildCustomWorldReferenceText } from './customWorld';
|
||||
import { buildStoryPromptHistory } from './storyHistory';
|
||||
|
||||
export interface CharacterChatTargetStatus {
|
||||
roleLabel?: string | null;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
mana: number;
|
||||
maxMana: number;
|
||||
affinity?: number | null;
|
||||
}
|
||||
|
||||
export interface CharacterChatPromptContext {
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
playerMana: number;
|
||||
playerMaxMana: number;
|
||||
inBattle: boolean;
|
||||
playerFacing: FacingDirection;
|
||||
playerAnimation: AnimationState;
|
||||
sceneName?: string | null;
|
||||
sceneDescription?: string | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
}
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 里的同行角色。
|
||||
只回复这名角色此刻会对玩家说的话。
|
||||
不要输出角色名、引号、旁白、动作提示、Markdown、JSON 或解释。
|
||||
保持人设,结合最近剧情和关系变化,回复简洁自然。`;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `生成恰好 3 条玩家回复建议。
|
||||
只输出纯文本,共 3 行,每行一条。
|
||||
不要加编号、项目符号、Markdown 或额外说明。
|
||||
三条建议语气要有区分:关心、追问、轻松或拉近关系。`;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名角色之间不断变化的关系。
|
||||
只输出一段简洁文字。
|
||||
包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`;
|
||||
|
||||
function describeWorld(world: WorldType) {
|
||||
if (world === WorldType.WUXIA) return '边城模板';
|
||||
if (world === WorldType.XIANXIA) return '灵潮模板';
|
||||
return '自定义世界';
|
||||
}
|
||||
|
||||
function describeCustomWorldSection(customWorldProfile?: CustomWorldProfile | null) {
|
||||
return customWorldProfile
|
||||
? `自定义世界参考:\n${buildCustomWorldReferenceText(customWorldProfile)}`
|
||||
: null;
|
||||
}
|
||||
|
||||
function describeGender(gender: Character['gender']) {
|
||||
if (gender === 'female') return '女';
|
||||
if (gender === 'male') return '男';
|
||||
return '未知';
|
||||
}
|
||||
|
||||
function describeFacing(facing: FacingDirection) {
|
||||
return facing === 'left' ? '左' : '右';
|
||||
}
|
||||
|
||||
function describeHpBand(ratio: number) {
|
||||
if (ratio >= 0.95) return '几乎无伤';
|
||||
if (ratio >= 0.75) return '状态稳健';
|
||||
if (ratio >= 0.55) return '略有消耗';
|
||||
if (ratio >= 0.35) return '伤势明显';
|
||||
if (ratio >= 0.15) return '伤势沉重';
|
||||
return '濒临极限';
|
||||
}
|
||||
|
||||
function describeManaBand(ratio: number) {
|
||||
if (ratio >= 0.9) return '充盈';
|
||||
if (ratio >= 0.7) return '稳定';
|
||||
if (ratio >= 0.45) return '尚可';
|
||||
if (ratio >= 0.2) return '偏低';
|
||||
if (ratio > 0) return '接近枯竭';
|
||||
return '耗尽';
|
||||
}
|
||||
|
||||
function describeStoryHistory(history: StoryMoment[]) {
|
||||
const promptHistory = buildStoryPromptHistory(history);
|
||||
|
||||
if (!promptHistory.previousSummary && promptHistory.recentOriginalRounds.length === 0) {
|
||||
return '近期剧情:暂无。';
|
||||
}
|
||||
|
||||
return [
|
||||
promptHistory.previousSummary
|
||||
? `更早剧情摘要:\n${promptHistory.previousSummary}`
|
||||
: '更早剧情摘要:暂无。',
|
||||
promptHistory.recentOriginalRounds.length > 0
|
||||
? `最近 3 轮剧情:\n${promptHistory.recentOriginalRounds
|
||||
.map((item, index) => `- 第 ${index + 1} 轮:\n${item}`)
|
||||
.join('\n')}`
|
||||
: '最近 3 轮剧情:暂无。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeBackstoryContext(label: string, snippets: string[]) {
|
||||
const normalized = snippets
|
||||
.map(snippet => snippet.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return [`${label}:暂无公开信息。`];
|
||||
}
|
||||
|
||||
return normalized.map((snippet, index) =>
|
||||
`${label}${index === 0 ? '(公开层)' : `(已解锁片段 ${index})`}:${snippet}`,
|
||||
);
|
||||
}
|
||||
|
||||
function describeCharacterInfo(
|
||||
label: string,
|
||||
character: Character,
|
||||
world: WorldType,
|
||||
customWorldProfile?: CustomWorldProfile | null,
|
||||
options: {
|
||||
affinity?: number | null;
|
||||
includeUnlockProgress?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const schema = resolveAttributeSchema(world, customWorldProfile);
|
||||
const attributeProfile = resolveCharacterAttributeProfile(character, world, customWorldProfile);
|
||||
const skills = character.skills.length > 0
|
||||
? character.skills
|
||||
.map(
|
||||
skill => `${skill.name}(伤害 ${skill.damage}/灵力 ${skill.manaCost}/冷却 ${skill.cooldownTurns})`,
|
||||
)
|
||||
.join(' | ')
|
||||
: '无';
|
||||
const backgroundLines = options.affinity == null
|
||||
? [getCharacterPublicBackstorySummary(character, world)]
|
||||
: buildCharacterBackstoryPromptContext(character, options.affinity, world);
|
||||
const nextLockedChapter = options.includeUnlockProgress && options.affinity != null
|
||||
? getLockedCharacterBackstoryChapters(character, options.affinity, world)[0] ?? null
|
||||
: null;
|
||||
const schemaSummary = buildSchemaSummary(schema)
|
||||
.map(slot => `${slot.name}(${slot.definition})`)
|
||||
.join(' | ');
|
||||
const topAttributes = describeTopAttributes(attributeProfile, schema).join('、') || '无';
|
||||
const attributeDetails = formatAttributeList(attributeProfile, schema)
|
||||
.map(entry => `${entry.slot.name} ${entry.value}`)
|
||||
.join(' | ');
|
||||
|
||||
return [
|
||||
`${label}姓名:${character.name}`,
|
||||
`${label}称号:${character.title}`,
|
||||
`${label}性别:${describeGender(character.gender ?? 'unknown')}`,
|
||||
`${label}描述:${character.description}`,
|
||||
...describeBackstoryContext(`${label}背景`, backgroundLines),
|
||||
nextLockedChapter
|
||||
? `${label}未解锁背景:${nextLockedChapter.title}(需好感 ${nextLockedChapter.affinityRequired},当前只知道:${nextLockedChapter.teaser})`
|
||||
: null,
|
||||
`${label}性格:${character.personality}`,
|
||||
`${label}世界属性框架:${schemaSummary}`,
|
||||
`${label}主要属性:${topAttributes}`,
|
||||
`${label}属性详情:${attributeDetails}`,
|
||||
`${label}技能:${skills}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeChatContext(world: WorldType, context: CharacterChatPromptContext) {
|
||||
const hpRatio = context.playerHp / Math.max(context.playerMaxHp, 1);
|
||||
const manaRatio = context.playerMana / Math.max(context.playerMaxMana, 1);
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
`玩家战斗状态:${context.inBattle ? '战斗中' : '非战斗'}`,
|
||||
`场景:${context.sceneName ?? '当前区域'}`,
|
||||
`场景描述:${context.sceneDescription ?? '周围气氛仍未安定。'}`,
|
||||
`玩家状态:生命 ${context.playerHp}/${context.playerMaxHp}(${describeHpBand(hpRatio)}),灵力 ${context.playerMana}/${context.playerMaxMana}(${describeManaBand(manaRatio)}),朝向 ${describeFacing(context.playerFacing)},动作 ${context.playerAnimation}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeTargetStatus(status: CharacterChatTargetStatus) {
|
||||
const hpRatio = status.hp / Math.max(status.maxHp, 1);
|
||||
const manaRatio = status.mana / Math.max(status.maxMana, 1);
|
||||
|
||||
return [
|
||||
`对方身份:${status.roleLabel ?? '同行角色'}`,
|
||||
`对方状态:生命 ${status.hp}/${status.maxHp}(${describeHpBand(hpRatio)}),灵力 ${status.mana}/${status.maxMana}(${describeManaBand(manaRatio)})`,
|
||||
status.affinity != null ? `当前好感:${status.affinity}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function describeCharacterChatHistory(history: CharacterChatTurn[]) {
|
||||
if (history.length === 0) {
|
||||
return '聊天记录:暂无。';
|
||||
}
|
||||
|
||||
return [
|
||||
'聊天记录:',
|
||||
...history.slice(-12).map(turn => `- ${turn.speaker === 'player' ? '玩家' : '角色'}:${turn.text}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
}: {
|
||||
world: WorldType;
|
||||
playerCharacter: Character;
|
||||
targetCharacter: Character;
|
||||
storyHistory: StoryMoment[];
|
||||
context: CharacterChatPromptContext;
|
||||
conversationHistory: CharacterChatTurn[];
|
||||
conversationSummary: string;
|
||||
playerMessage: string;
|
||||
targetStatus: CharacterChatTargetStatus;
|
||||
}) {
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
describeChatContext(world, context),
|
||||
describeCustomWorldSection(context.customWorldProfile),
|
||||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||||
affinity: targetStatus.affinity ?? null,
|
||||
includeUnlockProgress: true,
|
||||
}),
|
||||
describeTargetStatus(targetStatus),
|
||||
describeStoryHistory(storyHistory),
|
||||
conversationSummary ? `之前聊天摘要:${conversationSummary}` : '之前聊天摘要:暂无。',
|
||||
describeCharacterChatHistory(conversationHistory),
|
||||
`玩家刚刚对 ${targetCharacter.name} 说:${playerMessage}`,
|
||||
`现在请以 ${targetCharacter.name} 的身份,直接回复玩家。`,
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatSuggestionPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
}: {
|
||||
world: WorldType;
|
||||
playerCharacter: Character;
|
||||
targetCharacter: Character;
|
||||
storyHistory: StoryMoment[];
|
||||
context: CharacterChatPromptContext;
|
||||
conversationHistory: CharacterChatTurn[];
|
||||
conversationSummary: string;
|
||||
targetStatus: CharacterChatTargetStatus;
|
||||
}) {
|
||||
const latestCharacterReply = [...conversationHistory]
|
||||
.reverse()
|
||||
.find(turn => turn.speaker === 'character')?.text ?? null;
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
describeChatContext(world, context),
|
||||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||||
affinity: targetStatus.affinity ?? null,
|
||||
includeUnlockProgress: true,
|
||||
}),
|
||||
describeTargetStatus(targetStatus),
|
||||
describeStoryHistory(storyHistory),
|
||||
conversationSummary ? `之前聊天摘要:${conversationSummary}` : '之前聊天摘要:暂无。',
|
||||
describeCharacterChatHistory(conversationHistory),
|
||||
latestCharacterReply
|
||||
? `角色刚刚的回复:${latestCharacterReply}`
|
||||
: `玩家正准备与 ${targetCharacter.name} 开始一段新的私聊。`,
|
||||
'生成 3 条可以直接发送的简短玩家回复候选。',
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatSummaryPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
}: {
|
||||
world: WorldType;
|
||||
playerCharacter: Character;
|
||||
targetCharacter: Character;
|
||||
storyHistory: StoryMoment[];
|
||||
context: CharacterChatPromptContext;
|
||||
conversationHistory: CharacterChatTurn[];
|
||||
previousSummary: string;
|
||||
targetStatus: CharacterChatTargetStatus;
|
||||
}) {
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
describeChatContext(world, context),
|
||||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||||
affinity: targetStatus.affinity ?? null,
|
||||
includeUnlockProgress: true,
|
||||
}),
|
||||
describeTargetStatus(targetStatus),
|
||||
describeStoryHistory(storyHistory),
|
||||
previousSummary ? `旧摘要:${previousSummary}` : '旧摘要:暂无。',
|
||||
describeCharacterChatHistory(conversationHistory),
|
||||
'请把旧摘要与最新聊天合并成一段更新后的关系摘要,供后续剧情推理使用。',
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
export * from '../prompts/characterChatPrompts';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
131
src/services/customWorldCover.ts
Normal file
131
src/services/customWorldCover.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
|
||||
import type {
|
||||
CustomWorldCoverProfile,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
} from '../types';
|
||||
|
||||
export type CustomWorldCoverRenderMode = 'image' | 'scene_with_roles';
|
||||
|
||||
export type CustomWorldCoverPresentation = {
|
||||
imageSrc: string | null;
|
||||
renderMode: CustomWorldCoverRenderMode;
|
||||
characterImageSrcs: string[];
|
||||
sourceType: CustomWorldCoverProfile['sourceType'];
|
||||
};
|
||||
|
||||
function resolveOpeningSceneImageSrc(profile: CustomWorldProfile) {
|
||||
const campImageSrc = profile.camp?.imageSrc?.trim() || '';
|
||||
if (campImageSrc) {
|
||||
return campImageSrc;
|
||||
}
|
||||
|
||||
return (
|
||||
profile.landmarks
|
||||
.map((landmark) => landmark.imageSrc?.trim() || '')
|
||||
.find(Boolean) || null
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePlayableCoverImageSrc(role: CustomWorldPlayableNpc) {
|
||||
const explicitImageSrc = role.imageSrc?.trim() || '';
|
||||
if (explicitImageSrc) {
|
||||
return explicitImageSrc;
|
||||
}
|
||||
|
||||
if (!role.templateCharacterId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
ROLE_TEMPLATE_CHARACTERS.find(
|
||||
(character) => character.id === role.templateCharacterId,
|
||||
)?.portrait ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCoverCharacterRoleIds(
|
||||
profile: Pick<CustomWorldProfile, 'playableNpcs'>,
|
||||
roleIds?: string[] | null,
|
||||
) {
|
||||
const availableIds = new Set(
|
||||
profile.playableNpcs.map((role) => role.id.trim()).filter(Boolean),
|
||||
);
|
||||
const selectedIds = Array.isArray(roleIds)
|
||||
? [
|
||||
...new Set(
|
||||
roleIds
|
||||
.map((roleId) => roleId.trim())
|
||||
.filter((roleId) => roleId && availableIds.has(roleId)),
|
||||
),
|
||||
].slice(0, 3)
|
||||
: [];
|
||||
|
||||
if (selectedIds.length > 0) {
|
||||
return selectedIds;
|
||||
}
|
||||
|
||||
return profile.playableNpcs
|
||||
.map((role) => role.id.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
export function buildDefaultCustomWorldCoverProfile(
|
||||
profile: Pick<CustomWorldProfile, 'playableNpcs'>,
|
||||
): CustomWorldCoverProfile {
|
||||
return {
|
||||
sourceType: 'default',
|
||||
imageSrc: null,
|
||||
characterRoleIds: normalizeCoverCharacterRoleIds(profile),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCustomWorldCoverPresentation(
|
||||
profile: CustomWorldProfile,
|
||||
): CustomWorldCoverPresentation {
|
||||
const cover = profile.cover;
|
||||
const sourceType =
|
||||
cover?.sourceType === 'uploaded' || cover?.sourceType === 'generated'
|
||||
? cover.sourceType
|
||||
: 'default';
|
||||
const explicitImageSrc = cover?.imageSrc?.trim() || '';
|
||||
|
||||
if (sourceType !== 'default' && explicitImageSrc) {
|
||||
return {
|
||||
imageSrc: explicitImageSrc,
|
||||
renderMode: 'image',
|
||||
characterImageSrcs: [],
|
||||
sourceType,
|
||||
};
|
||||
}
|
||||
|
||||
const openingSceneImageSrc = resolveOpeningSceneImageSrc(profile);
|
||||
const roleById = new Map(
|
||||
profile.playableNpcs.map((role) => [role.id.trim(), role] as const),
|
||||
);
|
||||
const characterImageSrcs = normalizeCoverCharacterRoleIds(
|
||||
profile,
|
||||
cover?.characterRoleIds,
|
||||
)
|
||||
.map((roleId) => roleById.get(roleId))
|
||||
.map((role) => (role ? resolvePlayableCoverImageSrc(role) : null))
|
||||
.filter((imageSrc): imageSrc is string => Boolean(imageSrc));
|
||||
const leadPlayableImageSrc =
|
||||
profile.playableNpcs
|
||||
.map((role) => resolvePlayableCoverImageSrc(role))
|
||||
.find(Boolean) || null;
|
||||
|
||||
return {
|
||||
imageSrc: openingSceneImageSrc || leadPlayableImageSrc,
|
||||
renderMode:
|
||||
openingSceneImageSrc && characterImageSrcs.length > 0
|
||||
? 'scene_with_roles'
|
||||
: 'image',
|
||||
characterImageSrcs:
|
||||
openingSceneImageSrc && characterImageSrcs.length > 0
|
||||
? characterImageSrcs
|
||||
: [],
|
||||
sourceType: 'default',
|
||||
};
|
||||
}
|
||||
57
src/services/customWorldCoverAssetService.ts
Normal file
57
src/services/customWorldCoverAssetService.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { requestJson } from './apiClient';
|
||||
import type { CustomWorldProfile } from '../types';
|
||||
|
||||
const CUSTOM_WORLD_COVER_API_BASE = '/api/runtime/custom-world';
|
||||
|
||||
export interface CustomWorldCoverAssetResult {
|
||||
imageSrc: string;
|
||||
assetId: string;
|
||||
sourceType: 'uploaded' | 'generated';
|
||||
model?: string;
|
||||
size?: string;
|
||||
taskId?: string;
|
||||
prompt?: string;
|
||||
actualPrompt?: string;
|
||||
}
|
||||
|
||||
export interface GenerateCustomWorldCoverImageRequest {
|
||||
profile: CustomWorldProfile;
|
||||
userPrompt?: string;
|
||||
referenceImageSrc?: string;
|
||||
characterRoleIds?: string[];
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export interface UploadCustomWorldCoverImageRequest {
|
||||
profileId: string;
|
||||
worldName: string;
|
||||
imageDataUrl: string;
|
||||
}
|
||||
|
||||
export async function generateCustomWorldCoverImage(
|
||||
payload: GenerateCustomWorldCoverImageRequest,
|
||||
) {
|
||||
return requestJson<CustomWorldCoverAssetResult>(
|
||||
`${CUSTOM_WORLD_COVER_API_BASE}/cover-image`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'生成作品封面失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadCustomWorldCoverImage(
|
||||
payload: UploadCustomWorldCoverImageRequest,
|
||||
) {
|
||||
return requestJson<CustomWorldCoverAssetResult>(
|
||||
`${CUSTOM_WORLD_COVER_API_BASE}/cover-upload`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'上传作品封面失败',
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -230,7 +230,17 @@ export async function generateQuestForNpcEncounter(params: {
|
||||
'任务生成失败',
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[QuestDirector] backend quest generation failed, falling back', error);
|
||||
console.warn(
|
||||
'[QuestDirector] backend quest generation failed, using deterministic fallback',
|
||||
error,
|
||||
);
|
||||
return compileQuestIntentToQuest(
|
||||
{
|
||||
...request,
|
||||
origin: 'fallback_builder',
|
||||
},
|
||||
fallbackIntent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,175 +1 @@
|
||||
import type {QuestGenerationContext} from './aiTypes';
|
||||
import type {QuestOpportunity, QuestSceneSnapshot} from './questTypes';
|
||||
import { buildQuestVisibilitySlice } from './storyEngine/visibilityEngine';
|
||||
|
||||
function describeWorld(worldType: QuestGenerationContext['worldType']) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '边城模板';
|
||||
case 'XIANXIA':
|
||||
return '灵潮模板';
|
||||
case 'CUSTOM':
|
||||
return '自定义世界';
|
||||
default:
|
||||
return '未知世界';
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeRecentStoryMoments(context: QuestGenerationContext) {
|
||||
const moments = context.recentStoryMoments
|
||||
.slice(-4)
|
||||
.map(moment => `- ${moment.text}`)
|
||||
.join('\n');
|
||||
|
||||
return moments || '- 暂无近期剧情记录';
|
||||
}
|
||||
|
||||
function summarizeCurrentQuests(context: QuestGenerationContext) {
|
||||
const summary = context.currentQuestSummary?.map(quest =>
|
||||
`- ${quest.title}(${quest.status}),发布者 ${quest.issuerNpcId}`,
|
||||
).join('\n');
|
||||
|
||||
return summary || '- 当前没有进行中的任务';
|
||||
}
|
||||
|
||||
function summarizeCompanions(context: QuestGenerationContext) {
|
||||
const active = context.activeCompanions?.map(companion => companion.characterId).join('、') || '无';
|
||||
const roster = context.rosterCompanions?.map(companion => companion.characterId).join('、') || '无';
|
||||
return `当前同行角色:${active}\n队伍名册:${roster}`;
|
||||
}
|
||||
|
||||
function summarizePlayerState(context: QuestGenerationContext) {
|
||||
const playerName = context.playerCharacter?.name ?? '未知角色';
|
||||
const playerTitle = context.playerCharacter?.title ?? '未知称号';
|
||||
const hp = `${context.playerHp ?? 0}/${context.playerMaxHp ?? 0}`;
|
||||
const mana = `${context.playerMana ?? 0}/${context.playerMaxMana ?? 0}`;
|
||||
const inventory = context.playerInventory?.slice(0, 8).map(item => item.name).join('、') || '无';
|
||||
|
||||
return [
|
||||
`玩家:${playerName}(${playerTitle})`,
|
||||
`生命:${hp}`,
|
||||
`灵力:${mana}`,
|
||||
`背包快照:${inventory}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function summarizeScene(scene: QuestSceneSnapshot | null, context: QuestGenerationContext) {
|
||||
const hostileNpcIds = context.currentSceneHostileNpcIds?.join('、') || '无';
|
||||
const treasureHintCount = context.currentSceneTreasureHintCount ?? 0;
|
||||
|
||||
return [
|
||||
`场景:${scene?.name ?? context.currentSceneName ?? '未知区域'}`,
|
||||
`场景描述:${scene?.description ?? context.currentSceneDescription ?? '暂无'}`,
|
||||
`敌对角色 ID:${hostileNpcIds}`,
|
||||
`宝藏线索数量:${treasureHintCount}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function summarizeActiveThreads(context: QuestGenerationContext) {
|
||||
if (!context.activeThreadIds?.length) {
|
||||
return '暂无明确激活线程';
|
||||
}
|
||||
|
||||
const storyGraph = context.customWorldProfile?.storyGraph;
|
||||
const labels = context.activeThreadIds.map((threadId) =>
|
||||
[...(storyGraph?.visibleThreads ?? []), ...(storyGraph?.hiddenThreads ?? [])]
|
||||
.find((thread) => thread.id === threadId)?.title ?? threadId,
|
||||
);
|
||||
|
||||
return labels.join('、');
|
||||
}
|
||||
|
||||
function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) {
|
||||
const profile = context.issuerNarrativeProfile;
|
||||
if (!profile) {
|
||||
return '暂无额外叙事档案';
|
||||
}
|
||||
|
||||
return [
|
||||
`公开面:${profile.publicMask}`,
|
||||
`表层线:${profile.visibleLine}`,
|
||||
`当前压力:${profile.immediatePressure}`,
|
||||
profile.reactionHooks.length > 0
|
||||
? `反应钩子:${profile.reactionHooks.join('、')}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function summarizeQuestVisibility(context: QuestGenerationContext) {
|
||||
const slice = buildQuestVisibilitySlice({
|
||||
issuerNarrativeProfile: context.issuerNarrativeProfile,
|
||||
activeThreadIds: context.activeThreadIds,
|
||||
});
|
||||
|
||||
return [
|
||||
`可直接披露:${slice.sayableFactIds.join('、') || '无'}`,
|
||||
`只宜写成推测:${slice.inferredFactIds.join('、') || '无'}`,
|
||||
`禁止直接说破:${slice.forbiddenFactIds.join('、') || '无'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export const QUEST_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的任务导演。
|
||||
只返回 JSON,不要输出 Markdown。
|
||||
|
||||
输出结构:
|
||||
{
|
||||
"intent": {
|
||||
"title": "中文任务标题",
|
||||
"description": "中文任务描述",
|
||||
"summary": "中文短摘要",
|
||||
"narrativeType": "bounty|escort|investigation|retrieval|relationship|trial",
|
||||
"dramaticNeed": "string",
|
||||
"issuerGoal": "string",
|
||||
"playerHook": "string",
|
||||
"worldReason": "string",
|
||||
"recommendedObjectiveKinds": ["defeat_hostile_npc" | "inspect_treasure" | "spar_with_npc" | "talk_to_npc" | "reach_scene" | "deliver_item"],
|
||||
"urgency": "low|medium|high",
|
||||
"intimacy": "transactional|cooperative|trust_based",
|
||||
"rewardTheme": "currency|resource|relationship|intel|rare_item",
|
||||
"followupHooks": ["string"]
|
||||
}
|
||||
}
|
||||
|
||||
规则:
|
||||
- 所有自然语言字段都必须使用中文。
|
||||
- 任务必须扎根于当前场景、发布者和近期剧情。
|
||||
- 不要编造奖励、ID、数量、状态或不受支持的规则变化。
|
||||
- recommendedObjectiveKinds 只是语义建议,不是硬编码结果。
|
||||
- 优先给出简洁、可玩的任务框架,不要堆砌华丽辞藻。
|
||||
- title 必须是 4 到 10 个中文字符左右的短任务名,不要写成长句。
|
||||
- description 解释任务为什么在当前剧情里成立,避免纯规则说明。
|
||||
- summary 必须是清晰达成条件,优先使用“击败 / 调查 / 返回 / 前往 / 交付 / 切磋”等明确动词,不要写“处理一下”“看看情况”这类模糊表达。`;
|
||||
|
||||
export function buildQuestIntentPrompt(params: {
|
||||
context: QuestGenerationContext;
|
||||
scene: QuestSceneSnapshot | null;
|
||||
opportunity: QuestOpportunity;
|
||||
}) {
|
||||
const {context, scene, opportunity} = params;
|
||||
const customWorldSummary = context.customWorldProfile
|
||||
? `${context.customWorldProfile.name}: ${context.customWorldProfile.summary}`
|
||||
: '无';
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(context.worldType)}`,
|
||||
`自定义世界摘要:${customWorldSummary}`,
|
||||
`发布角色:${context.issuerNpcName ?? '未知'}(${context.issuerNpcId ?? '未知'})`,
|
||||
`发布者身份:${context.issuerNpcContext ?? '暂无'}`,
|
||||
`发布者好感:${context.issuerAffinity ?? 0}`,
|
||||
`发布者信息揭示阶段:${context.issuerDisclosureStage ?? '未知'}`,
|
||||
`发布者亲疏阶段:${context.issuerWarmthStage ?? '未知'}`,
|
||||
`当前激活线程:${summarizeActiveThreads(context)}`,
|
||||
`发布者叙事档案:\n${summarizeIssuerNarrativeProfile(context)}`,
|
||||
`任务可见性切片:\n${summarizeQuestVisibility(context)}`,
|
||||
`当前遭遇类型:${context.encounterKind ?? '无'}`,
|
||||
summarizeScene(scene, context),
|
||||
summarizePlayerState(context),
|
||||
summarizeCompanions(context),
|
||||
`当前任务机会:${opportunity.reason}`,
|
||||
`当前任务列表:\n${summarizeCurrentQuests(context)}`,
|
||||
`近期剧情片段:\n${summarizeRecentStoryMoments(context)}`,
|
||||
'现在请基于这次具体局势,生成一个自然生长出来的任务意图。',
|
||||
].join('\n\n');
|
||||
}
|
||||
export * from '../prompts/questPrompts';
|
||||
|
||||
@@ -107,7 +107,11 @@ export async function generateRuntimeItemAiIntents(params: {
|
||||
sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[runtimeItemAiDirector] backend intent generation failed, falling back', error);
|
||||
console.warn(
|
||||
'[runtimeItemAiDirector] backend intent generation failed, using deterministic fallback',
|
||||
error,
|
||||
);
|
||||
return fallbackIntents;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,119 +1 @@
|
||||
import {
|
||||
buildRuntimeItemAiIntent,
|
||||
buildRuntimeItemAiPromptInput,
|
||||
} from '../data/runtimeItemNarrative';
|
||||
import type {
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
RuntimeRelationAnchor,
|
||||
} from '../types';
|
||||
import { buildRuntimeItemStoryFingerprint } from './storyEngine/carrierNarrativeCompiler';
|
||||
import { buildCarrierVisibilitySlice } from './storyEngine/visibilityEngine';
|
||||
|
||||
function describeRelationAnchor(anchor: RuntimeRelationAnchor) {
|
||||
switch (anchor.type) {
|
||||
case 'npc':
|
||||
return `NPC:${anchor.npcName}`;
|
||||
case 'scene':
|
||||
return `场景:${anchor.sceneName}`;
|
||||
case 'monster':
|
||||
return `怪物:${anchor.monsterName}`;
|
||||
case 'quest':
|
||||
return `任务:${anchor.questName}`;
|
||||
case 'faction':
|
||||
return `势力:${anchor.factionName}`;
|
||||
default:
|
||||
return `地标:${anchor.landmarkName}`;
|
||||
}
|
||||
}
|
||||
|
||||
function describeCarrierFactId(factId: string) {
|
||||
if (factId === 'visibleClue') return '可见线索';
|
||||
if (factId === 'currentAppearanceReason') return '当前出现理由';
|
||||
if (factId === 'witnessMark') return '见证痕';
|
||||
if (factId === 'unresolvedQuestion') return '未完成问题';
|
||||
if (factId.startsWith('thread:')) return `线程索引(${factId.slice('thread:'.length)})`;
|
||||
return factId;
|
||||
}
|
||||
|
||||
function describePlan(
|
||||
context: RuntimeItemGenerationContext,
|
||||
plan: RuntimeItemPlan,
|
||||
index: number,
|
||||
) {
|
||||
const promptInput = buildRuntimeItemAiPromptInput(context, plan);
|
||||
const fallbackIntent = buildRuntimeItemAiIntent(context, plan);
|
||||
const fallbackFingerprint = buildRuntimeItemStoryFingerprint({
|
||||
context,
|
||||
plan,
|
||||
intent: fallbackIntent,
|
||||
});
|
||||
const visibilitySlice = buildCarrierVisibilitySlice({
|
||||
activeThreadIds: context.activeThreadIds,
|
||||
storyFingerprint: fallbackFingerprint,
|
||||
});
|
||||
|
||||
return [
|
||||
`物品 ${index + 1}`,
|
||||
`- slot: ${plan.slot}`,
|
||||
`- 物品类型: ${promptInput.desiredItemKind}`,
|
||||
`- 持续性: ${promptInput.permanence}`,
|
||||
`- 关系锚点: ${describeRelationAnchor(plan.relationAnchor)}`,
|
||||
`- 世界摘要: ${promptInput.worldSummary}`,
|
||||
`- 场景摘要: ${promptInput.sceneSummary || '无'}`,
|
||||
`- 遭遇摘要: ${promptInput.encounterSummary || '无'}`,
|
||||
`- 相关人物: ${promptInput.relatedNpcSummary}`,
|
||||
`- 当前激活线程: ${promptInput.activeThreadSummary || '暂无'}`,
|
||||
`- 近期剧情: ${promptInput.recentStorySummary}`,
|
||||
`- 玩家当前 build: ${promptInput.playerBuildDirection.join('、') || '均衡'}`,
|
||||
`- 玩家待补缺口: ${promptInput.playerBuildGaps.join('、') || '无明显缺口'}`,
|
||||
`- 本次目标方向: ${plan.targetBuildDirection.join('、') || '均衡'}`,
|
||||
`- 物件可直写线索: ${visibilitySlice.sayableFactIds.map(describeCarrierFactId).join('、') || '无'}`,
|
||||
`- 物件只宜暗写: ${visibilitySlice.inferredFactIds.map(describeCarrierFactId).join('、') || '无'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。
|
||||
你只返回 JSON,不要输出 Markdown、解释或代码块。
|
||||
|
||||
输出结构:
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"shortNameSeed": "中文短种子",
|
||||
"sourcePhrase": "中文来源短语",
|
||||
"reasonToAppear": "中文出现理由",
|
||||
"relationHooks": ["中文关系钩子"],
|
||||
"desiredBuildTags": ["中文 build 标签"],
|
||||
"desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"],
|
||||
"tone": "grim|mysterious|martial|ritual|survival",
|
||||
"visibleClue": "玩家第一眼能抓到的痕迹",
|
||||
"witnessMark": "它见证过什么的使用痕",
|
||||
"unfinishedBusiness": "背后仍未结清的问题",
|
||||
"hiddenHook": "更深一层但别直接讲穿的钩子",
|
||||
"reactionHooks": ["以后谁会对它起反应"],
|
||||
"namingPattern": "命名范式建议"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
规则:
|
||||
- intents 数量必须与输入物品数量完全一致,顺序也必须一致。
|
||||
- 所有自然语言字段都必须使用中文。
|
||||
- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。
|
||||
- visibleClue / witnessMark / unfinishedBusiness / hiddenHook 要优先围绕当前激活线程、相关角色压力和旧痕来写。
|
||||
- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。
|
||||
- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。
|
||||
- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`;
|
||||
|
||||
export function buildRuntimeItemIntentPrompt(params: {
|
||||
context: RuntimeItemGenerationContext;
|
||||
plans: RuntimeItemPlan[];
|
||||
}) {
|
||||
return [
|
||||
`生成渠道:${params.context.generationChannel}`,
|
||||
`以下每个物品都需要给出一条可编译的运行时物品意图。`,
|
||||
...params.plans.map((plan, index) => describePlan(params.context, plan, index)),
|
||||
'请严格返回 JSON。',
|
||||
].join('\n\n');
|
||||
}
|
||||
export * from '../prompts/runtimeItemPrompts';
|
||||
|
||||
@@ -5,7 +5,8 @@ const { requestJsonMock } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock('./apiClient', async () => {
|
||||
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||||
const actual =
|
||||
await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||||
return {
|
||||
...actual,
|
||||
requestJson: requestJsonMock,
|
||||
@@ -223,24 +224,19 @@ describe('runtimeStoryService', () => {
|
||||
expect(getRuntimeClientVersion({ runtimeActionVersion: 3 })).toBe(3);
|
||||
});
|
||||
|
||||
it('hydrates runtime option interaction metadata from the current encounter', () => {
|
||||
it('preserves runtime option interaction metadata from the server response', () => {
|
||||
const story = buildStoryMomentFromRuntimeOptions({
|
||||
storyText: '服务端返回的新故事',
|
||||
gameState: {
|
||||
currentEncounter: {
|
||||
id: 'npc-merchant',
|
||||
kind: 'npc',
|
||||
npcName: '梁伯',
|
||||
npcDescription: '沿街商贩',
|
||||
npcAvatar: '',
|
||||
context: '沿街商贩',
|
||||
},
|
||||
} as never,
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_trade',
|
||||
actionText: '交易',
|
||||
scope: 'npc',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-merchant',
|
||||
action: 'trade',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
} from '../persistence/runtimeSnapshotTypes';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../types';
|
||||
import { AnimationState } from '../types';
|
||||
import { type ApiRetryOptions,requestJson } from './apiClient';
|
||||
import { type ApiRetryOptions, requestJson } from './apiClient';
|
||||
|
||||
const RUNTIME_STORY_API_BASE = '/api/runtime/story';
|
||||
const DEFAULT_SESSION_ID = 'runtime-main';
|
||||
@@ -62,46 +62,9 @@ function requestRuntimeStoryJson<T>(
|
||||
);
|
||||
}
|
||||
|
||||
function buildRuntimeOptionInteraction(
|
||||
option: RuntimeStoryOptionView,
|
||||
gameState?: Pick<GameState, 'currentEncounter'>,
|
||||
): StoryOption['interaction'] {
|
||||
const encounter = gameState?.currentEncounter;
|
||||
|
||||
if (encounter?.kind === 'npc') {
|
||||
const npcId = encounter.id ?? encounter.npcName;
|
||||
const npcActionMap: Record<string, StoryOption['interaction']> = {
|
||||
npc_chat: { kind: 'npc', npcId, action: 'chat' },
|
||||
npc_help: { kind: 'npc', npcId, action: 'help' },
|
||||
npc_fight: { kind: 'npc', npcId, action: 'fight' },
|
||||
npc_leave: { kind: 'npc', npcId, action: 'leave' },
|
||||
npc_recruit: { kind: 'npc', npcId, action: 'recruit' },
|
||||
npc_spar: { kind: 'npc', npcId, action: 'spar' },
|
||||
npc_trade: { kind: 'npc', npcId, action: 'trade' },
|
||||
npc_gift: { kind: 'npc', npcId, action: 'gift' },
|
||||
npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' },
|
||||
npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' },
|
||||
};
|
||||
|
||||
return npcActionMap[option.functionId];
|
||||
}
|
||||
|
||||
if (encounter?.kind === 'treasure') {
|
||||
const treasureActionMap: Record<string, StoryOption['interaction']> = {
|
||||
treasure_secure: { kind: 'treasure', action: 'secure' },
|
||||
treasure_inspect: { kind: 'treasure', action: 'inspect' },
|
||||
treasure_leave: { kind: 'treasure', action: 'leave' },
|
||||
};
|
||||
|
||||
return treasureActionMap[option.functionId];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function createRuntimeStoryOption(
|
||||
option: RuntimeStoryOptionView,
|
||||
gameState?: Pick<GameState, 'currentEncounter'>,
|
||||
_gameState?: Pick<GameState, 'currentEncounter'>,
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId: option.functionId,
|
||||
@@ -116,14 +79,16 @@ function createRuntimeStoryOption(
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
interaction: buildRuntimeOptionInteraction(option, gameState),
|
||||
interaction: option.interaction as StoryOption['interaction'] | undefined,
|
||||
runtimePayload: option.payload,
|
||||
disabled: option.disabled,
|
||||
disabledReason: option.reason,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRuntimeSessionId(gameState: Pick<GameState, 'runtimeSessionId'>) {
|
||||
export function getRuntimeSessionId(
|
||||
gameState: Pick<GameState, 'runtimeSessionId'>,
|
||||
) {
|
||||
return gameState.runtimeSessionId?.trim() || DEFAULT_SESSION_ID;
|
||||
}
|
||||
|
||||
@@ -158,7 +123,7 @@ export function buildStoryMomentFromRuntimeOptions(params: {
|
||||
storyText: string;
|
||||
options: RuntimeStoryOptionView[];
|
||||
gameState?: Pick<GameState, 'currentEncounter'>;
|
||||
}) {
|
||||
}): StoryMoment {
|
||||
return {
|
||||
text: params.storyText,
|
||||
options: params.options.map((option) =>
|
||||
@@ -170,11 +135,9 @@ export function buildStoryMomentFromRuntimeOptions(params: {
|
||||
function shouldPreferSnapshotStory(story: StoryMoment | null) {
|
||||
return Boolean(
|
||||
story &&
|
||||
(
|
||||
story.displayMode === 'dialogue' ||
|
||||
(story.displayMode === 'dialogue' ||
|
||||
story.deferredOptions?.length ||
|
||||
story.dialogue?.length
|
||||
),
|
||||
story.dialogue?.length),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,7 +182,9 @@ export async function getRuntimeStoryState(
|
||||
|
||||
return {
|
||||
...response,
|
||||
snapshot: rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot),
|
||||
snapshot: rehydrateSavedSnapshot(
|
||||
response.snapshot as HydratedSavedGameSnapshot,
|
||||
),
|
||||
} satisfies RuntimeStoryResponse;
|
||||
}
|
||||
|
||||
@@ -258,12 +223,12 @@ export async function resolveRuntimeStoryAction(
|
||||
|
||||
return {
|
||||
...response,
|
||||
snapshot: rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot),
|
||||
snapshot: rehydrateSavedSnapshot(
|
||||
response.snapshot as HydratedSavedGameSnapshot,
|
||||
),
|
||||
} satisfies RuntimeStoryResponse;
|
||||
}
|
||||
|
||||
export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {
|
||||
return rehydrateSavedSnapshot(
|
||||
response.snapshot as HydratedSavedGameSnapshot,
|
||||
);
|
||||
return rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ const { requestJsonMock } = vi.hoisted(() => ({
|
||||
|
||||
import {
|
||||
clearProfileBrowseHistory,
|
||||
getCustomWorldGalleryDetail,
|
||||
listCustomWorldGallery,
|
||||
listProfileBrowseHistory,
|
||||
listProfileSaveArchives,
|
||||
resumeProfileSaveArchive,
|
||||
@@ -106,6 +108,50 @@ describe('storageService browse history routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('storageService 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();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-gallery',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取作品广场失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads public gallery detail without attaching auth or refresh coupling', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
},
|
||||
});
|
||||
|
||||
await getCustomWorldGalleryDetail('user-1', 'profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-gallery/user-1/profile-1',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取作品详情失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storageService save archive routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
|
||||
@@ -40,6 +40,8 @@ const RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
export type RuntimeRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
skipAuth?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
};
|
||||
|
||||
function requestRuntimeJson<T>(
|
||||
@@ -60,10 +62,27 @@ function requestRuntimeJson<T>(
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{ retry },
|
||||
{
|
||||
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',
|
||||
@@ -300,7 +319,7 @@ export async function unpublishCustomWorldProfile(
|
||||
export async function listCustomWorldGallery(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<CustomWorldGalleryResponse>(
|
||||
const response = await requestPublicRuntimeJson<CustomWorldGalleryResponse>(
|
||||
'/custom-world-gallery',
|
||||
{ method: 'GET' },
|
||||
'读取作品广场失败',
|
||||
@@ -315,7 +334,7 @@ export async function getCustomWorldGalleryDetail(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
const response = await requestPublicRuntimeJson<
|
||||
CustomWorldGalleryDetailResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}`,
|
||||
|
||||
Reference in New Issue
Block a user