This commit is contained in:
1002
src/services/ai.test.ts
Normal file
1002
src/services/ai.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
2514
src/services/ai.ts
Normal file
2514
src/services/ai.ts
Normal file
File diff suppressed because it is too large
Load Diff
64
src/services/aiFallbacks.ts
Normal file
64
src/services/aiFallbacks.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type {Character, CharacterChatTurn, Encounter} from '../types';
|
||||
|
||||
export function buildOfflineNpcChatDialogue(encounter: Encounter, topic: string) {
|
||||
return [
|
||||
`你:${topic}。我想先听听你的看法。`,
|
||||
`${encounter.npcName}:你问得并不随意,看来是真想弄清这里的底细。`,
|
||||
'你:前面的局势我还没看透。你若知道什么,就别只说一半。',
|
||||
`${encounter.npcName}:我能告诉你的,是这里近来一直不太平。接下来多留神些。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildOfflineNpcRecruitDialogue(encounter: Encounter) {
|
||||
return [
|
||||
'你:这不是客套。我是真心希望你能加入队伍,和我一起走下去。',
|
||||
`${encounter.npcName}:你这番话够坦诚,我听得出你不是随口一提。`,
|
||||
'你:前路不会轻松,但我还是希望你能与我并肩同行。',
|
||||
`${encounter.npcName}:好,我答应你。从现在起,我便与你结伴同行。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildOfflineCharacterPanelChatReply(
|
||||
targetCharacter: Character,
|
||||
playerMessage: string,
|
||||
conversationSummary: string,
|
||||
) {
|
||||
const personalityCue = targetCharacter.personality
|
||||
.split(/[,。!?,.!?]/u)
|
||||
.find(Boolean)?.trim() ?? '我会按自己的方式回答你';
|
||||
const focus = playerMessage.trim() || '我听见你刚才的话了。';
|
||||
|
||||
return `${focus}${focus.endsWith('。') ? '' : '。'}${personalityCue}${personalityCue.endsWith('。') ? '' : '。'}${
|
||||
conversationSummary
|
||||
? '我还记得我们之前谈过的那些事。'
|
||||
: '既然你愿意直接来问,我也会认真回答。'
|
||||
}前路不会轻松,但如果你还想继续说下去,我会陪着你。`;
|
||||
}
|
||||
|
||||
export function buildOfflineCharacterPanelChatSuggestions(targetCharacter: Character) {
|
||||
return [
|
||||
'把你的意思再说清楚一些。',
|
||||
`${targetCharacter.name},你真正担心的到底是什么?`,
|
||||
'先别管外面的局势,我想多了解你一点。',
|
||||
];
|
||||
}
|
||||
|
||||
export function buildOfflineCharacterPanelChatSummary(
|
||||
targetCharacter: Character,
|
||||
history: CharacterChatTurn[],
|
||||
previousSummary: string,
|
||||
) {
|
||||
const latestTurns = history.slice(-4)
|
||||
.map(turn => `${turn.speaker === 'player' ? '玩家' : targetCharacter.name}:${turn.text}`)
|
||||
.join(' ');
|
||||
|
||||
const currentSummary = latestTurns
|
||||
? `${targetCharacter.name}在私下交谈中更愿意坦率回应。最近交流:${latestTurns}`
|
||||
: `${targetCharacter.name}愿意继续私下交谈,对玩家的态度也在逐渐变得更温和。`;
|
||||
|
||||
if (!previousSummary) {
|
||||
return currentSummary.slice(0, 118);
|
||||
}
|
||||
|
||||
return `${previousSummary} ${currentSummary}`.slice(0, 118);
|
||||
}
|
||||
615
src/services/aiService.ts
Normal file
615
src/services/aiService.ts
Normal file
@@ -0,0 +1,615 @@
|
||||
import type {
|
||||
CharacterChatReplyRequest,
|
||||
CharacterChatSuggestionsRequest,
|
||||
CharacterChatSummaryRequest,
|
||||
NpcChatDialogueRequest,
|
||||
NpcChatTurnDirective,
|
||||
NpcChatTurnRequest,
|
||||
NpcChatTurnResult,
|
||||
NpcRecruitDialogueRequest,
|
||||
PlainTextResponse,
|
||||
} from '../../packages/shared/src/contracts/rpgRuntimeChat';
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import { parseApiErrorMessage } from '../../packages/shared/src/http';
|
||||
import type {
|
||||
AIResponse,
|
||||
Character,
|
||||
CharacterChatTurn,
|
||||
Encounter,
|
||||
GameState,
|
||||
SceneHostileNpc,
|
||||
StoryMoment,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import type {
|
||||
CustomWorldSceneImageResult,
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
TextStreamOptions,
|
||||
} from './aiTypes';
|
||||
import { fetchWithApiAuth, requestJson } from './apiClient';
|
||||
import { type CharacterChatTargetStatus } from './characterChatPrompt';
|
||||
import { parseLineListContent } from './llmParsers';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
|
||||
type LegacyAiModule = typeof import('./ai');
|
||||
|
||||
let legacyAiModulePromise: Promise<LegacyAiModule> | null = null;
|
||||
|
||||
async function loadLegacyAiModule() {
|
||||
if (!legacyAiModulePromise) {
|
||||
legacyAiModulePromise = import('./ai');
|
||||
}
|
||||
|
||||
return legacyAiModulePromise;
|
||||
}
|
||||
|
||||
async function requestPlainText(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return requestJson<PlainTextResponse>(
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
fallbackMessage,
|
||||
);
|
||||
}
|
||||
|
||||
async function requestPlainTextStream(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const response = await fetchWithApiAuth(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(parseApiErrorMessage(responseText, '流式请求失败'));
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let accumulatedText = '';
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
while (buffer.includes('\n\n')) {
|
||||
const boundary = buffer.indexOf('\n\n');
|
||||
const eventBlock = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line.startsWith('data:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = line.slice(5).trim();
|
||||
if (!data || data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const delta = parsed?.choices?.[0]?.delta?.content;
|
||||
if (typeof delta === 'string' && delta.length > 0) {
|
||||
accumulatedText += delta;
|
||||
options.onUpdate?.(accumulatedText);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed SSE frames.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accumulatedText.trim();
|
||||
}
|
||||
|
||||
type ParsedSseEvent = {
|
||||
event: string | null;
|
||||
data: string;
|
||||
};
|
||||
|
||||
function parseSseEventBlock(eventBlock: string): ParsedSseEvent | null {
|
||||
let eventName: string | null = null;
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || null;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventName,
|
||||
data: dataLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateInitialStory(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): Promise<AIResponse> {
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateInitialStory(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
|
||||
return requestJson<AIResponse>(
|
||||
`${RUNTIME_API_BASE}/story/initial`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
worldType: world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
}),
|
||||
},
|
||||
'剧情开局生成失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateNextStep(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
choice: string,
|
||||
context: StoryGenerationContext,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): Promise<AIResponse> {
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateNextStep(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
history,
|
||||
choice,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
|
||||
return requestJson<AIResponse>(
|
||||
`${RUNTIME_API_BASE}/story/continue`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
worldType: world,
|
||||
character,
|
||||
monsters,
|
||||
history,
|
||||
choice,
|
||||
context,
|
||||
requestOptions,
|
||||
}),
|
||||
},
|
||||
'剧情续写失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateCharacterPanelChatSuggestions(
|
||||
world: WorldType,
|
||||
playerCharacter: Character,
|
||||
targetCharacter: Character,
|
||||
storyHistory: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
conversationHistory: CharacterChatTurn[],
|
||||
conversationSummary: string,
|
||||
targetStatus: CharacterChatTargetStatus,
|
||||
) {
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateCharacterPanelChatSuggestions(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
worldType: world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
} satisfies CharacterChatSuggestionsRequest;
|
||||
|
||||
const { text } = await requestPlainText(
|
||||
`${RUNTIME_API_BASE}/chat/character/suggestions`,
|
||||
payload,
|
||||
'角色聊天建议生成失败',
|
||||
);
|
||||
return parseLineListContent(text, 3);
|
||||
}
|
||||
|
||||
export async function generateCharacterPanelChatSummary(
|
||||
world: WorldType,
|
||||
playerCharacter: Character,
|
||||
targetCharacter: Character,
|
||||
storyHistory: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
conversationHistory: CharacterChatTurn[],
|
||||
previousSummary: string,
|
||||
targetStatus: CharacterChatTargetStatus,
|
||||
) {
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateCharacterPanelChatSummary(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
worldType: world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
} satisfies CharacterChatSummaryRequest;
|
||||
|
||||
const { text } = await requestPlainText(
|
||||
`${RUNTIME_API_BASE}/chat/character/summary`,
|
||||
payload,
|
||||
'角色聊天摘要生成失败',
|
||||
);
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
export async function streamCharacterPanelChatReply(
|
||||
world: WorldType,
|
||||
playerCharacter: Character,
|
||||
targetCharacter: Character,
|
||||
storyHistory: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
conversationHistory: CharacterChatTurn[],
|
||||
conversationSummary: string,
|
||||
playerMessage: string,
|
||||
targetStatus: CharacterChatTargetStatus,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.streamCharacterPanelChatReply(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
worldType: world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
} satisfies CharacterChatReplyRequest;
|
||||
|
||||
const reply = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/character/reply/stream`,
|
||||
payload,
|
||||
options,
|
||||
);
|
||||
return reply.trim();
|
||||
}
|
||||
|
||||
export async function streamNpcChatDialogue(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
topic: string,
|
||||
resultSummary: string,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.streamNpcChatDialogue(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
topic,
|
||||
resultSummary,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
worldType: world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
topic,
|
||||
resultSummary,
|
||||
} satisfies NpcChatDialogueRequest;
|
||||
|
||||
const dialogue = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/npc/dialogue/stream`,
|
||||
payload,
|
||||
options,
|
||||
);
|
||||
return dialogue.trim();
|
||||
}
|
||||
|
||||
export async function streamNpcChatTurn(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
conversationHistory: StoryMoment['dialogue'],
|
||||
playerMessage: string,
|
||||
npcState: Record<string, unknown>,
|
||||
options: {
|
||||
onReplyUpdate?: (text: string) => void;
|
||||
questOfferContext?: {
|
||||
state: GameState;
|
||||
turnCount: number;
|
||||
} | null;
|
||||
combatContext?: {
|
||||
summary: string;
|
||||
logLines: string[];
|
||||
battleOutcome: 'victory' | 'spar_complete';
|
||||
} | null;
|
||||
chatDirective?: NpcChatTurnDirective | null;
|
||||
npcInitiatesConversation?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const payload = {
|
||||
worldType: world,
|
||||
character,
|
||||
player: character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
conversationHistory: conversationHistory ?? [],
|
||||
dialogue: conversationHistory ?? [],
|
||||
playerMessage,
|
||||
npcState,
|
||||
npcInitiatesConversation: options.npcInitiatesConversation ?? false,
|
||||
questOfferContext: options.questOfferContext
|
||||
? {
|
||||
state: options.questOfferContext.state,
|
||||
encounter,
|
||||
turnCount: options.questOfferContext.turnCount,
|
||||
}
|
||||
: null,
|
||||
combatContext: options.combatContext ?? null,
|
||||
chatDirective: options.chatDirective
|
||||
? {
|
||||
...options.chatDirective,
|
||||
functionOptions: options.chatDirective.functionOptions?.map((item) => ({
|
||||
...item,
|
||||
})),
|
||||
}
|
||||
: 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),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(parseApiErrorMessage(responseText, 'NPC 聊天续写失败'));
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let accumulatedReply = '';
|
||||
let completedResult: NpcChatTurnResult | null = null;
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
while (buffer.includes('\n\n')) {
|
||||
const boundary = buffer.indexOf('\n\n');
|
||||
const eventBlock = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
|
||||
const parsedEvent = parseSseEventBlock(eventBlock);
|
||||
if (!parsedEvent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedEvent.data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedEvent.event === 'reply_delta') {
|
||||
const payloadRecord = JSON.parse(parsedEvent.data) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const nextText =
|
||||
typeof payloadRecord.text === 'string' ? payloadRecord.text : '';
|
||||
accumulatedReply = nextText;
|
||||
options.onReplyUpdate?.(accumulatedReply);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedEvent.event === 'complete') {
|
||||
completedResult = JSON.parse(parsedEvent.data) as NpcChatTurnResult;
|
||||
accumulatedReply = completedResult.npcReply;
|
||||
options.onReplyUpdate?.(accumulatedReply);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedEvent.event === 'error') {
|
||||
const payloadRecord = JSON.parse(parsedEvent.data) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
throw new Error(
|
||||
typeof payloadRecord.message === 'string'
|
||||
? payloadRecord.message
|
||||
: 'NPC 聊天续写失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!completedResult) {
|
||||
throw new Error('NPC 聊天续写结果为空');
|
||||
}
|
||||
|
||||
return completedResult;
|
||||
}
|
||||
|
||||
export async function streamNpcRecruitDialogue(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
invitationText: string,
|
||||
recruitSummary: string,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.streamNpcRecruitDialogue(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
invitationText,
|
||||
recruitSummary,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
worldType: world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
invitationText,
|
||||
recruitSummary,
|
||||
} satisfies NpcRecruitDialogueRequest;
|
||||
|
||||
const dialogue = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/npc/recruit/stream`,
|
||||
payload,
|
||||
options,
|
||||
);
|
||||
return dialogue.trim();
|
||||
}
|
||||
|
||||
export type {
|
||||
CustomWorldGenerationProgress,
|
||||
CustomWorldSceneImageResult,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
TextStreamOptions,
|
||||
};
|
||||
226
src/services/aiTypes.ts
Normal file
226
src/services/aiTypes.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import type {
|
||||
ActorNarrativeProfile,
|
||||
ActState,
|
||||
AnimationState,
|
||||
AuthorialConstraintPack,
|
||||
CampaignPack,
|
||||
CampaignState,
|
||||
CampEvent,
|
||||
ChapterState,
|
||||
Character,
|
||||
CharacterConversationStyle,
|
||||
CharacterGender,
|
||||
CompanionArcState,
|
||||
CompanionReactionRecord,
|
||||
CompanionResolution,
|
||||
CompanionStanceProfile,
|
||||
CompanionState,
|
||||
ConsequenceRecord,
|
||||
CustomWorldNpc,
|
||||
CustomWorldProfile,
|
||||
EquipmentLoadout,
|
||||
FacingDirection,
|
||||
FactionTensionState,
|
||||
GoalStackState,
|
||||
InventoryItem,
|
||||
JourneyBeat,
|
||||
KnowledgeFact,
|
||||
NarrativeQaReport,
|
||||
NpcAnswerMode,
|
||||
NpcDisclosureStage,
|
||||
NpcWarmthStage,
|
||||
PlayerStyleProfile,
|
||||
PlayerProgressionState,
|
||||
QuestStatus,
|
||||
ReleaseGateReport,
|
||||
ScenarioPack,
|
||||
SceneNarrativeDirective,
|
||||
SetpieceDirective,
|
||||
SimulationRunResult,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
VisibilitySlice,
|
||||
WorldMutation,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import type { ConversationPressure, ConversationSituation } from '../types';
|
||||
|
||||
export interface StoryRequestOptions {
|
||||
availableOptions?: StoryOption[];
|
||||
optionCatalog?: StoryOption[];
|
||||
}
|
||||
|
||||
export interface TextStreamOptions {
|
||||
onUpdate?: (text: string) => void;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
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;
|
||||
playerMana: number;
|
||||
playerMaxMana: number;
|
||||
inBattle: boolean;
|
||||
playerX: number;
|
||||
playerFacing: FacingDirection;
|
||||
playerAnimation: AnimationState;
|
||||
skillCooldowns: Record<string, number>;
|
||||
sceneId?: string | null;
|
||||
sceneName?: string | null;
|
||||
sceneDescription?: string | null;
|
||||
pendingSceneEncounter?: boolean;
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
lastObserveSignsReport?: string | null;
|
||||
recentActionResult?: string | null;
|
||||
encounterKind?: string | null;
|
||||
encounterName?: string | null;
|
||||
encounterDescription?: string | null;
|
||||
encounterContext?: string | null;
|
||||
encounterId?: string | null;
|
||||
encounterCharacterId?: string | null;
|
||||
encounterGender?: CharacterGender | null;
|
||||
encounterAffinity?: number | null;
|
||||
encounterAffinityText?: string | null;
|
||||
encounterStanceProfile?: CompanionStanceProfile | null;
|
||||
encounterConversationStyle?: CharacterConversationStyle | null;
|
||||
encounterDisclosureStage?: NpcDisclosureStage | null;
|
||||
encounterWarmthStage?: NpcWarmthStage | null;
|
||||
encounterAnswerMode?: NpcAnswerMode | null;
|
||||
encounterAllowedTopics?: string[] | null;
|
||||
encounterBlockedTopics?: string[] | null;
|
||||
isFirstMeaningfulContact?: boolean;
|
||||
firstContactRelationStance?:
|
||||
| 'guarded'
|
||||
| 'neutral'
|
||||
| 'cooperative'
|
||||
| 'bonded'
|
||||
| null;
|
||||
conversationSituation?: ConversationSituation | null;
|
||||
conversationPressure?: ConversationPressure | null;
|
||||
recentSharedEvent?: string | null;
|
||||
talkPriority?: string | null;
|
||||
encounterRelationshipSummary?: string | null;
|
||||
encounterCustomProfile?: Partial<
|
||||
Pick<
|
||||
CustomWorldNpc,
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'backstory'
|
||||
| 'personality'
|
||||
| 'motivation'
|
||||
| 'combatStyle'
|
||||
| 'relationshipHooks'
|
||||
| 'tags'
|
||||
| 'backstoryReveal'
|
||||
| 'skills'
|
||||
| 'initialItems'
|
||||
| 'imageSrc'
|
||||
| 'visual'
|
||||
| 'narrativeProfile'
|
||||
>
|
||||
> | null;
|
||||
visibilitySlice?: VisibilitySlice | null;
|
||||
sceneNarrativeDirective?: SceneNarrativeDirective | null;
|
||||
campaignState?: CampaignState | null;
|
||||
actState?: ActState | null;
|
||||
chapterState?: ChapterState | null;
|
||||
journeyBeat?: JourneyBeat | null;
|
||||
goalStack?: GoalStackState | null;
|
||||
currentCampEvent?: CampEvent | null;
|
||||
setpieceDirective?: SetpieceDirective | null;
|
||||
encounterNarrativeProfile?: ActorNarrativeProfile | null;
|
||||
knowledgeFacts?: KnowledgeFact[] | null;
|
||||
activeThreadIds?: string[] | null;
|
||||
companionArcStates?: CompanionArcState[] | null;
|
||||
companionResolutions?: CompanionResolution[] | null;
|
||||
consequenceLedger?: ConsequenceRecord[] | null;
|
||||
authorialConstraintPack?: AuthorialConstraintPack | null;
|
||||
activeScenarioPack?: ScenarioPack | null;
|
||||
activeCampaignPack?: CampaignPack | null;
|
||||
playerStyleProfile?: PlayerStyleProfile | null;
|
||||
recentCompanionReactions?: CompanionReactionRecord[] | null;
|
||||
recentCarrierEchoes?: string[] | null;
|
||||
recentWorldMutations?: WorldMutation[] | null;
|
||||
recentFactionTensionStates?: FactionTensionState[] | null;
|
||||
recentChronicleSummary?: string | null;
|
||||
narrativeQaReport?: NarrativeQaReport | null;
|
||||
releaseGateReport?: ReleaseGateReport | null;
|
||||
simulationRunResults?: SimulationRunResult[] | null;
|
||||
branchBudgetPressure?: string | null;
|
||||
partyRelationshipNotes?: string | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
}
|
||||
|
||||
export interface QuestSummarySnapshot {
|
||||
id: string;
|
||||
title: string;
|
||||
status: QuestStatus;
|
||||
issuerNpcId: string;
|
||||
}
|
||||
|
||||
export interface QuestGenerationContext {
|
||||
worldType: WorldType | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
actState?: ActState | null;
|
||||
currentSceneId?: string | null;
|
||||
currentSceneName?: string | null;
|
||||
currentSceneDescription?: string | null;
|
||||
issuerNpcId?: string | null;
|
||||
issuerNpcName?: string | null;
|
||||
issuerNpcContext?: string | null;
|
||||
issuerAffinity?: number | null;
|
||||
issuerNarrativeProfile?: ActorNarrativeProfile | null;
|
||||
issuerDisclosureStage?: NpcDisclosureStage | null;
|
||||
issuerWarmthStage?: NpcWarmthStage | null;
|
||||
activeThreadIds?: string[] | null;
|
||||
encounterKind?: 'npc' | 'treasure' | 'none' | null;
|
||||
currentSceneHostileNpcIds?: string[];
|
||||
currentSceneTreasureHintCount?: number;
|
||||
recentStoryMoments: StoryMoment[];
|
||||
playerCharacter?: Character | null;
|
||||
playerProgression?: PlayerProgressionState | null;
|
||||
playerHp?: number;
|
||||
playerMaxHp?: number;
|
||||
playerMana?: number;
|
||||
playerMaxMana?: number;
|
||||
playerInventory?: InventoryItem[];
|
||||
playerEquipment?: EquipmentLoadout | null;
|
||||
activeCompanions?: CompanionState[];
|
||||
rosterCompanions?: CompanionState[];
|
||||
currentQuestSummary?: QuestSummarySnapshot[];
|
||||
}
|
||||
460
src/services/apiClient.test.ts
Normal file
460
src/services/apiClient.test.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
AUTH_STATE_EVENT,
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
fetchWithApiAuth,
|
||||
getStoredAccessToken,
|
||||
isTimeoutError,
|
||||
requestJson,
|
||||
setStoredAccessToken,
|
||||
} from './apiClient';
|
||||
|
||||
function createLocalStorageMock() {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return store.has(key) ? store.get(key)! : null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createResponseMock(params: {
|
||||
status: number;
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
}) {
|
||||
const headers = new Map(
|
||||
Object.entries(params.headers ?? {}).map(([key, value]) => [
|
||||
key.toLowerCase(),
|
||||
value,
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
status: params.status,
|
||||
ok: params.status >= 200 && params.status < 300,
|
||||
headers: {
|
||||
get(name: string) {
|
||||
return headers.get(name.toLowerCase()) ?? null;
|
||||
},
|
||||
},
|
||||
text: vi.fn(async () => params.body ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
describe('apiClient', () => {
|
||||
const fetchMock = vi.fn();
|
||||
const dispatchEventMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal('window', {
|
||||
dispatchEvent: dispatchEventMock,
|
||||
localStorage: createLocalStorageMock(),
|
||||
});
|
||||
fetchMock.mockReset();
|
||||
dispatchEventMock.mockReset();
|
||||
clearStoredAccessToken({ emit: false });
|
||||
});
|
||||
|
||||
it('refreshes bearer token once and retries the original request', async () => {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: true,
|
||||
token: 'fresh-token',
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
value: 7,
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await requestJson<{ value: number }>(
|
||||
'/api/runtime/protected',
|
||||
{ method: 'GET' },
|
||||
'读取受保护数据失败',
|
||||
);
|
||||
|
||||
expect(result).toEqual({ value: 7 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/runtime/protected',
|
||||
expect.objectContaining({
|
||||
credentials: 'same-origin',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer expired-token',
|
||||
'x-genarrative-response-envelope': 'v1',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/auth/refresh',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/api/runtime/protected',
|
||||
expect.objectContaining({
|
||||
credentials: 'same-origin',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer fresh-token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
expect(getStoredAccessToken()).toBe('fresh-token');
|
||||
});
|
||||
|
||||
it('hydrates a missing local bearer token before the first protected request', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: true,
|
||||
token: 'fresh-token',
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
value: 9,
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await requestJson<{ value: number }>(
|
||||
'/api/runtime/protected',
|
||||
{ method: 'GET' },
|
||||
'读取受保护数据失败',
|
||||
);
|
||||
|
||||
expect(result).toEqual({ value: 9 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/auth/refresh',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
}),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/runtime/protected',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer fresh-token',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('fresh-token');
|
||||
});
|
||||
|
||||
it('does not emit auth change events when 401 probe requests opt into silent mode', async () => {
|
||||
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
const response = await fetchWithApiAuth(
|
||||
'/api/auth/me',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
{
|
||||
notifyAuthStateChange: false,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits auth change events when refresh fails on protected requests', async () => {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
const response = await fetchWithApiAuth('/api/runtime/protected', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the refreshed token when the retried protected request is still unauthorized', async () => {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: true,
|
||||
token: 'fresh-token',
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
const response = await fetchWithApiAuth('/api/runtime/puzzle/works', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(getStoredAccessToken()).toBe('fresh-token');
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects refresh responses that do not return a renewed bearer token', async () => {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
ok: true,
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
requestJson<{ value: number }>(
|
||||
'/api/runtime/protected',
|
||||
{ method: 'GET' },
|
||||
'读取受保护数据失败',
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
status: 401,
|
||||
message: '读取受保护数据失败',
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps the current access token when a public request explicitly skips auth', async () => {
|
||||
setStoredAccessToken('still-valid-token');
|
||||
vi.mocked(window.dispatchEvent).mockClear();
|
||||
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
|
||||
|
||||
const response = await fetchWithApiAuth(
|
||||
'/api/runtime/custom-world-gallery',
|
||||
{ method: 'GET' },
|
||||
{
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-gallery',
|
||||
expect.objectContaining({
|
||||
headers: expect.not.objectContaining({
|
||||
Authorization: expect.any(String),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('still-valid-token');
|
||||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries transient get requests before unwrapping the response envelope', async () => {
|
||||
fetchMock
|
||||
.mockRejectedValueOnce(new TypeError('network unavailable'))
|
||||
.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
value: 42,
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await requestJson<{ value: number }>(
|
||||
'/api/runtime/settings',
|
||||
{ method: 'GET' },
|
||||
'读取设置失败',
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual({ value: 42 });
|
||||
});
|
||||
|
||||
it('aborts requests when timeoutMs is reached', async () => {
|
||||
setStoredAccessToken('timeout-token', { emit: false });
|
||||
fetchMock.mockImplementation(
|
||||
async (_input: string, init?: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(init.signal?.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
let capturedError: unknown;
|
||||
try {
|
||||
await requestJson(
|
||||
'/api/runtime/protected',
|
||||
{ method: 'POST' },
|
||||
'创建会话失败',
|
||||
{
|
||||
timeoutMs: 20,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
capturedError = error;
|
||||
}
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(isTimeoutError(capturedError)).toBe(true);
|
||||
expect(capturedError).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('surfaces response metadata through ApiClientError', async () => {
|
||||
setStoredAccessToken('metadata-token', { emit: false });
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
createResponseMock({
|
||||
status: 503,
|
||||
body: JSON.stringify({
|
||||
ok: false,
|
||||
data: null,
|
||||
error: {
|
||||
code: 'UPSTREAM_ERROR',
|
||||
message: '上游暂不可用',
|
||||
details: {
|
||||
scope: 'runtime',
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
requestId: 'req-body',
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-request-id': 'req-header',
|
||||
'x-route-version': 'runtime.v2',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
let capturedError: unknown;
|
||||
try {
|
||||
await requestJson(
|
||||
'/api/runtime/story/initial',
|
||||
{ method: 'POST' },
|
||||
'剧情生成失败',
|
||||
);
|
||||
} catch (error) {
|
||||
capturedError = error;
|
||||
}
|
||||
|
||||
expect(capturedError).toBeInstanceOf(ApiClientError);
|
||||
expect(capturedError).toMatchObject({
|
||||
status: 503,
|
||||
code: 'UPSTREAM_ERROR',
|
||||
details: {
|
||||
scope: 'runtime',
|
||||
},
|
||||
meta: {
|
||||
requestId: 'req-body',
|
||||
routeVersion: 'runtime.v2',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
662
src/services/apiClient.ts
Normal file
662
src/services/apiClient.ts
Normal file
@@ -0,0 +1,662 @@
|
||||
import type { AuthRefreshResponse } from '../../packages/shared/src/contracts/auth';
|
||||
import {
|
||||
API_RESPONSE_ENVELOPE_HEADER,
|
||||
API_RESPONSE_ENVELOPE_VERSION,
|
||||
API_VERSION,
|
||||
type ApiErrorPayload,
|
||||
type ApiMeta,
|
||||
parseApiErrorMessage,
|
||||
unwrapApiResponse,
|
||||
} from '../../packages/shared/src/http';
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
|
||||
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
|
||||
const REQUEST_ID_HEADER = 'x-request-id';
|
||||
const API_VERSION_HEADER = 'x-api-version';
|
||||
const ROUTE_VERSION_HEADER = 'x-route-version';
|
||||
const DEFAULT_RETRYABLE_STATUS_CODES = [408, 425, 429, 502, 503, 504];
|
||||
const DEFAULT_SAFE_RETRY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
export type ApiRetryOptions = {
|
||||
maxRetries?: number;
|
||||
baseDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
retryableStatusCodes?: number[];
|
||||
retryUnsafeMethods?: boolean;
|
||||
allowRetryMethods?: string[];
|
||||
};
|
||||
|
||||
export type ApiRequestOptions = {
|
||||
retry?: ApiRetryOptions;
|
||||
timeoutMs?: number;
|
||||
skipAuth?: boolean;
|
||||
omitEnvelopeHeader?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
// 会话探测类请求需要静默处理 401,避免 AuthGate 因自发广播再次触发 hydrate。
|
||||
notifyAuthStateChange?: boolean;
|
||||
};
|
||||
|
||||
type ResolvedRetryOptions = {
|
||||
maxRetries: number;
|
||||
baseDelayMs: number;
|
||||
maxDelayMs: number;
|
||||
retryableStatusCodes: Set<number>;
|
||||
retryUnsafeMethods: boolean;
|
||||
allowRetryMethods: Set<string>;
|
||||
method: string;
|
||||
};
|
||||
|
||||
type ParsedApiErrorShape = {
|
||||
code: string;
|
||||
details: Record<string, unknown> | null;
|
||||
meta: Partial<ApiMeta>;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers?: HeadersInit) {
|
||||
const nextHeaders: Record<string, string> = {};
|
||||
|
||||
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
nextHeaders[key] = value;
|
||||
});
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
nextHeaders[key] = value;
|
||||
}
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
if (headers) {
|
||||
Object.assign(nextHeaders, headers);
|
||||
}
|
||||
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
function coerceMeta(value: unknown): Partial<ApiMeta> {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
apiVersion:
|
||||
typeof value.apiVersion === 'string' && value.apiVersion.trim()
|
||||
? value.apiVersion.trim()
|
||||
: undefined,
|
||||
requestId:
|
||||
typeof value.requestId === 'string' && value.requestId.trim()
|
||||
? value.requestId.trim()
|
||||
: undefined,
|
||||
routeVersion:
|
||||
typeof value.routeVersion === 'string' && value.routeVersion.trim()
|
||||
? value.routeVersion.trim()
|
||||
: undefined,
|
||||
operation:
|
||||
typeof value.operation === 'string' && value.operation.trim()
|
||||
? value.operation.trim()
|
||||
: value.operation === null
|
||||
? null
|
||||
: undefined,
|
||||
latencyMs:
|
||||
typeof value.latencyMs === 'number' && Number.isFinite(value.latencyMs)
|
||||
? value.latencyMs
|
||||
: undefined,
|
||||
timestamp:
|
||||
typeof value.timestamp === 'string' && value.timestamp.trim()
|
||||
? value.timestamp.trim()
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseApiErrorShape(rawText: string): ParsedApiErrorShape | null {
|
||||
if (!rawText.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawText) as
|
||||
| {
|
||||
error?: ApiErrorPayload;
|
||||
meta?: Partial<ApiMeta>;
|
||||
code?: string;
|
||||
details?: Record<string, unknown> | null;
|
||||
}
|
||||
| Record<string, unknown>;
|
||||
|
||||
if (isRecord(parsed.error)) {
|
||||
return {
|
||||
code:
|
||||
typeof parsed.error.code === 'string' && parsed.error.code.trim()
|
||||
? parsed.error.code.trim()
|
||||
: 'HTTP_ERROR',
|
||||
details:
|
||||
isRecord(parsed.error.details) || parsed.error.details === null
|
||||
? (parsed.error.details as Record<string, unknown> | null)
|
||||
: null,
|
||||
meta: coerceMeta(parsed.meta),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof parsed.code === 'string' && parsed.code.trim()) {
|
||||
return {
|
||||
code: parsed.code.trim(),
|
||||
details:
|
||||
isRecord(parsed.details) || parsed.details === null
|
||||
? (parsed.details as Record<string, unknown> | null)
|
||||
: null,
|
||||
meta: coerceMeta(parsed.meta),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed json responses.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function createAbortError() {
|
||||
if (typeof DOMException !== 'undefined') {
|
||||
return new DOMException('The operation was aborted.', 'AbortError');
|
||||
}
|
||||
|
||||
const error = new Error('The operation was aborted.');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
function createTimeoutError(timeoutMs: number) {
|
||||
const error = new Error(`请求超时:${timeoutMs}ms`);
|
||||
error.name = 'TimeoutError';
|
||||
return error;
|
||||
}
|
||||
|
||||
function composeAbortSignal(
|
||||
signal: AbortSignal | undefined,
|
||||
timeoutMs: number | undefined,
|
||||
) {
|
||||
const shouldUseTimeout =
|
||||
typeof timeoutMs === 'number' &&
|
||||
Number.isFinite(timeoutMs) &&
|
||||
timeoutMs > 0;
|
||||
|
||||
if (!shouldUseTimeout) {
|
||||
return {
|
||||
signal,
|
||||
cleanup: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort(createTimeoutError(timeoutMs));
|
||||
}, timeoutMs);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeoutId);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
controller.abort(signal?.reason ?? createAbortError());
|
||||
};
|
||||
|
||||
if (signal?.aborted) {
|
||||
cleanup();
|
||||
controller.abort(signal.reason ?? createAbortError());
|
||||
return {
|
||||
signal: controller.signal,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForRetry(ms: number, signal?: AbortSignal) {
|
||||
if (ms <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve();
|
||||
}, ms);
|
||||
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(signal?.reason ?? createAbortError());
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeoutId);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
};
|
||||
|
||||
if (signal?.aborted) {
|
||||
cleanup();
|
||||
reject(signal.reason ?? createAbortError());
|
||||
return;
|
||||
}
|
||||
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function resolveRetryOptions(
|
||||
method: string,
|
||||
retry?: ApiRetryOptions,
|
||||
): ResolvedRetryOptions {
|
||||
const normalizedMethod = method.toUpperCase();
|
||||
const defaultMaxRetries = DEFAULT_SAFE_RETRY_METHODS.has(normalizedMethod)
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
return {
|
||||
maxRetries:
|
||||
typeof retry?.maxRetries === 'number' && retry.maxRetries >= 0
|
||||
? Math.floor(retry.maxRetries)
|
||||
: defaultMaxRetries,
|
||||
baseDelayMs:
|
||||
typeof retry?.baseDelayMs === 'number' && retry.baseDelayMs > 0
|
||||
? retry.baseDelayMs
|
||||
: 250,
|
||||
maxDelayMs:
|
||||
typeof retry?.maxDelayMs === 'number' && retry.maxDelayMs > 0
|
||||
? retry.maxDelayMs
|
||||
: 1500,
|
||||
retryableStatusCodes: new Set(
|
||||
retry?.retryableStatusCodes?.length
|
||||
? retry.retryableStatusCodes
|
||||
: DEFAULT_RETRYABLE_STATUS_CODES,
|
||||
),
|
||||
retryUnsafeMethods: retry?.retryUnsafeMethods === true,
|
||||
allowRetryMethods: new Set(
|
||||
(retry?.allowRetryMethods ?? []).map((value) => value.toUpperCase()),
|
||||
),
|
||||
method: normalizedMethod,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRetryResponse(
|
||||
status: number,
|
||||
attempt: number,
|
||||
retry: ResolvedRetryOptions,
|
||||
) {
|
||||
if (attempt >= retry.maxRetries) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!retry.retryableStatusCodes.has(status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
retry.retryUnsafeMethods ||
|
||||
DEFAULT_SAFE_RETRY_METHODS.has(retry.method) ||
|
||||
retry.allowRetryMethods.has(retry.method)
|
||||
);
|
||||
}
|
||||
|
||||
export function isAbortError(error: unknown) {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.name === 'AbortError' ||
|
||||
(typeof DOMException !== 'undefined' &&
|
||||
error instanceof DOMException &&
|
||||
error.name === 'AbortError'))
|
||||
);
|
||||
}
|
||||
|
||||
export function isTimeoutError(error: unknown) {
|
||||
return error instanceof Error && error.name === 'TimeoutError';
|
||||
}
|
||||
|
||||
function shouldRetryError(
|
||||
error: unknown,
|
||||
attempt: number,
|
||||
retry: ResolvedRetryOptions,
|
||||
) {
|
||||
if (attempt >= retry.maxRetries || isAbortError(error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return error instanceof TypeError;
|
||||
}
|
||||
|
||||
function buildRetryDelayMs(attempt: number, retry: ResolvedRetryOptions) {
|
||||
return Math.min(retry.maxDelayMs, retry.baseDelayMs * Math.max(1, attempt));
|
||||
}
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
details: Record<string, unknown> | null;
|
||||
meta: ApiMeta;
|
||||
responseText: string;
|
||||
|
||||
constructor(params: {
|
||||
message: string;
|
||||
status: number;
|
||||
code: string;
|
||||
details?: Record<string, unknown> | null;
|
||||
meta?: Partial<ApiMeta>;
|
||||
responseText?: string;
|
||||
}) {
|
||||
super(params.message);
|
||||
this.name = 'ApiClientError';
|
||||
this.status = params.status;
|
||||
this.code = params.code;
|
||||
this.details = params.details ?? null;
|
||||
this.meta = {
|
||||
apiVersion: params.meta?.apiVersion ?? API_VERSION,
|
||||
requestId: params.meta?.requestId,
|
||||
routeVersion: params.meta?.routeVersion,
|
||||
operation: params.meta?.operation,
|
||||
latencyMs: params.meta?.latencyMs,
|
||||
timestamp: params.meta?.timestamp,
|
||||
};
|
||||
this.responseText = params.responseText ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
function canUseLocalStorage() {
|
||||
return (
|
||||
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
|
||||
);
|
||||
}
|
||||
|
||||
export function emitAuthStateChange() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof CustomEvent === 'function') {
|
||||
window.dispatchEvent(new CustomEvent(AUTH_STATE_EVENT));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof Event === 'function') {
|
||||
window.dispatchEvent(new Event(AUTH_STATE_EVENT));
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredAccessToken() {
|
||||
if (!canUseLocalStorage()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return window.localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() || '';
|
||||
}
|
||||
|
||||
export function setStoredAccessToken(
|
||||
token: string,
|
||||
options: {
|
||||
emit?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextToken = token.trim();
|
||||
const previousToken = getStoredAccessToken();
|
||||
if (nextToken) {
|
||||
window.localStorage.setItem(ACCESS_TOKEN_KEY, nextToken);
|
||||
} else {
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
// 只有登录态令牌真的发生变化时才广播事件,避免无意义的鉴权重算。
|
||||
if (options.emit !== false && previousToken !== nextToken) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredAccessToken(
|
||||
options: {
|
||||
emit?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousToken = getStoredAccessToken();
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
|
||||
// 未登录态下重复清空 token 不应触发状态刷新,否则会放大 401 循环。
|
||||
if (options.emit !== false && previousToken) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
function withAuthorizationHeaders(
|
||||
headers?: HeadersInit,
|
||||
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
|
||||
) {
|
||||
const nextHeaders = normalizeHeaders(headers);
|
||||
const token = getStoredAccessToken();
|
||||
if (token && !options.skipAuth) {
|
||||
nextHeaders.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
if (!options.omitEnvelopeHeader) {
|
||||
nextHeaders[API_RESPONSE_ENVELOPE_HEADER] = API_RESPONSE_ENVELOPE_VERSION;
|
||||
}
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
let refreshAccessTokenPromise: Promise<string> | null = null;
|
||||
|
||||
async function refreshAccessToken() {
|
||||
if (refreshAccessTokenPromise) {
|
||||
return refreshAccessTokenPromise;
|
||||
}
|
||||
|
||||
refreshAccessTokenPromise = (async () => {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
[API_RESPONSE_ENVELOPE_HEADER]: API_RESPONSE_ENVELOPE_VERSION,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
throw await buildApiClientError(response, '刷新登录状态失败');
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
const payload = responseText
|
||||
? unwrapApiResponse<AuthRefreshResponse>(
|
||||
JSON.parse(responseText) as AuthRefreshResponse,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (payload?.ok !== true || !payload.token?.trim()) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
throw new Error('刷新登录状态失败');
|
||||
}
|
||||
|
||||
const nextToken = payload.token.trim();
|
||||
setStoredAccessToken(nextToken, { emit: false });
|
||||
return nextToken;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await refreshAccessTokenPromise;
|
||||
} finally {
|
||||
refreshAccessTokenPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureStoredAccessToken() {
|
||||
const currentToken = getStoredAccessToken();
|
||||
if (currentToken) {
|
||||
return currentToken;
|
||||
}
|
||||
|
||||
// AuthGate 恢复会话时可能只有 HttpOnly refresh cookie,本地尚无 access token。
|
||||
return refreshAccessToken();
|
||||
}
|
||||
|
||||
export async function fetchWithApiAuth(
|
||||
input: string,
|
||||
init: RequestInit = {},
|
||||
options: ApiRequestOptions = {},
|
||||
) {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry = resolveRetryOptions(method, options.retry);
|
||||
const shouldNotifyAuthStateChange = options.notifyAuthStateChange !== false;
|
||||
const requestSignal = init.signal ?? undefined;
|
||||
let attempt = 0;
|
||||
let refreshAttempted = false;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
let requestHeaders = withAuthorizationHeaders(init.headers, options);
|
||||
let hasAuthHeader = Boolean(
|
||||
requestHeaders.Authorization?.trim() ||
|
||||
requestHeaders.authorization?.trim(),
|
||||
);
|
||||
|
||||
if (!hasAuthHeader && !options.skipAuth && !options.skipRefresh) {
|
||||
try {
|
||||
// 受保护请求在本地 access token 缺失时,先尝试用 refresh cookie 静默补票,
|
||||
// 避免把后端原始 “缺少 Bearer Token” 直接暴露给业务 UI。
|
||||
await ensureStoredAccessToken();
|
||||
requestHeaders = withAuthorizationHeaders(init.headers, options);
|
||||
hasAuthHeader = Boolean(
|
||||
requestHeaders.Authorization?.trim() ||
|
||||
requestHeaders.authorization?.trim(),
|
||||
);
|
||||
} catch {
|
||||
// 补票失败时继续走原始请求,让调用方按真实 401 分支处理。
|
||||
}
|
||||
}
|
||||
|
||||
const timedRequest = composeAbortSignal(requestSignal, options.timeoutMs);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(input, {
|
||||
credentials: 'same-origin',
|
||||
...init,
|
||||
signal: timedRequest.signal,
|
||||
headers: requestHeaders,
|
||||
});
|
||||
} finally {
|
||||
timedRequest.cleanup();
|
||||
}
|
||||
|
||||
if (
|
||||
response.status === 401 &&
|
||||
hasAuthHeader &&
|
||||
!options.skipAuth &&
|
||||
!options.skipRefresh &&
|
||||
!refreshAttempted
|
||||
) {
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
refreshAttempted = true;
|
||||
// refresh 成功只代表 access token 已补票成功,
|
||||
// 不能把当前业务请求的首次 401 直接放大成全局鉴权变更,
|
||||
// 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。
|
||||
continue;
|
||||
} catch {
|
||||
if (hasAuthHeader) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
if (shouldNotifyAuthStateChange) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
response.status === 401 &&
|
||||
hasAuthHeader &&
|
||||
!options.skipAuth &&
|
||||
!refreshAttempted
|
||||
) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
if (shouldNotifyAuthStateChange) {
|
||||
emitAuthStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldRetryResponse(response.status, attempt, retry)) {
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!shouldRetryError(error, attempt, retry)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
attempt += 1;
|
||||
await waitForRetry(buildRetryDelayMs(attempt, retry), requestSignal);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildApiClientError(
|
||||
response: Response,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
const responseText = await response.text();
|
||||
const parsedError = parseApiErrorShape(responseText);
|
||||
|
||||
return new ApiClientError({
|
||||
message: parseApiErrorMessage(responseText, fallbackMessage),
|
||||
status: response.status,
|
||||
code: parsedError?.code ?? `HTTP_${response.status || 0}`,
|
||||
details: parsedError?.details ?? null,
|
||||
meta: {
|
||||
apiVersion:
|
||||
parsedError?.meta.apiVersion ??
|
||||
response.headers.get(API_VERSION_HEADER) ??
|
||||
API_VERSION,
|
||||
requestId:
|
||||
parsedError?.meta.requestId ??
|
||||
response.headers.get(REQUEST_ID_HEADER) ??
|
||||
undefined,
|
||||
routeVersion:
|
||||
parsedError?.meta.routeVersion ??
|
||||
response.headers.get(ROUTE_VERSION_HEADER) ??
|
||||
undefined,
|
||||
operation: parsedError?.meta.operation,
|
||||
latencyMs: parsedError?.meta.latencyMs,
|
||||
timestamp: parsedError?.meta.timestamp,
|
||||
},
|
||||
responseText,
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestJson<T>(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: ApiRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const response = await fetchWithApiAuth(url, init, options);
|
||||
|
||||
if (!response.ok) {
|
||||
throw await buildApiClientError(response, fallbackMessage);
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
return responseText
|
||||
? unwrapApiResponse<T>(JSON.parse(responseText) as T)
|
||||
: (null as T);
|
||||
}
|
||||
180
src/services/assetReadUrlService.test.ts
Normal file
180
src/services/assetReadUrlService.test.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { clearStoredAccessToken, setStoredAccessToken } from './apiClient';
|
||||
import {
|
||||
clearSignedAssetReadUrlCache,
|
||||
getSignedAssetReadUrl,
|
||||
resolveAssetReadUrl,
|
||||
} from './assetReadUrlService';
|
||||
|
||||
function createLocalStorageMock() {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return store.has(key) ? store.get(key)! : null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('assetReadUrlService', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createLocalStorageMock(),
|
||||
dispatchEvent: vi.fn(),
|
||||
});
|
||||
clearSignedAssetReadUrlCache();
|
||||
clearStoredAccessToken({ emit: false });
|
||||
setStoredAccessToken('test-access-token', { emit: false });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('resolveAssetReadUrl returns passthrough for absolute url', async () => {
|
||||
await expect(resolveAssetReadUrl('https://example.com/demo.png')).resolves.toBe(
|
||||
'https://example.com/demo.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveAssetReadUrl returns passthrough for data url', async () => {
|
||||
await expect(resolveAssetReadUrl('data:image/png;base64,abc')).resolves.toBe(
|
||||
'data:image/png;base64,abc',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveAssetReadUrl exchanges legacy generated path for signed url', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey: 'generated-characters/hero/visual/asset-01/master.png',
|
||||
signedUrl: 'https://signed.example.com/master.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resolveAssetReadUrl('/generated-characters/hero/visual/asset-01/master.png'),
|
||||
).resolves.toBe('https://signed.example.com/master.png');
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain(
|
||||
'/api/assets/read-url?',
|
||||
);
|
||||
});
|
||||
|
||||
test('getSignedAssetReadUrl reuses cached signed url before expiry', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2099-01-01T00:00:00Z'));
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data: {
|
||||
read: {
|
||||
objectKey: 'generated-custom-world-scenes/profile-1/landmark-1/scene.png',
|
||||
signedUrl: 'https://signed.example.com/scene.png',
|
||||
expiresAt: '2099-01-01T00:10:00Z',
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const first = await getSignedAssetReadUrl({
|
||||
legacyPublicPath: '/generated-custom-world-scenes/profile-1/landmark-1/scene.png',
|
||||
});
|
||||
const second = await getSignedAssetReadUrl({
|
||||
legacyPublicPath: '/generated-custom-world-scenes/profile-1/landmark-1/scene.png',
|
||||
});
|
||||
|
||||
expect(first).toBe('https://signed.example.com/scene.png');
|
||||
expect(second).toBe(first);
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('getSignedAssetReadUrl caches not-found failures for the same legacy path', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
data: null,
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: '对象不存在',
|
||||
},
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
routeVersion: '2026-04-08',
|
||||
latencyMs: 1,
|
||||
timestamp: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 404,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
getSignedAssetReadUrl({
|
||||
legacyPublicPath: '/generated-characters/hero/missing/master.png',
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
getSignedAssetReadUrl({
|
||||
legacyPublicPath: '/generated-characters/hero/missing/master.png',
|
||||
}),
|
||||
).rejects.toThrow('资源不存在或暂时不可读取');
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
232
src/services/assetReadUrlService.ts
Normal file
232
src/services/assetReadUrlService.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { ApiClientError, requestJson } from './apiClient';
|
||||
|
||||
export type AssetReadUrlRequest = {
|
||||
objectKey?: string;
|
||||
legacyPublicPath?: string;
|
||||
expireSeconds?: number;
|
||||
};
|
||||
|
||||
export type AssetReadUrlResponse = {
|
||||
read?: {
|
||||
objectKey?: string;
|
||||
signedUrl?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
signedUrl?: string;
|
||||
objectKey?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
|
||||
type CachedReadUrlEntry = {
|
||||
signedUrl: string;
|
||||
expiresAtMs: number;
|
||||
};
|
||||
|
||||
type CachedReadUrlFailureEntry = {
|
||||
expiresAtMs: number;
|
||||
};
|
||||
|
||||
const ASSET_READ_URL_API_PATH = '/api/assets/read-url';
|
||||
const DEFAULT_CACHE_SAFETY_WINDOW_MS = 30 * 1000;
|
||||
const DEFAULT_FAILURE_CACHE_WINDOW_MS = 60 * 1000;
|
||||
const signedReadUrlCache = new Map<string, CachedReadUrlEntry>();
|
||||
const signedReadUrlFailureCache = new Map<string, CachedReadUrlFailureEntry>();
|
||||
const pendingSignedReadUrlRequests = new Map<string, Promise<string>>();
|
||||
|
||||
export function isGeneratedLegacyPath(value: string) {
|
||||
return /^\/generated-[^/?#]+\/.+/u.test(value.trim());
|
||||
}
|
||||
|
||||
function normalizeLegacyPublicPath(value: string) {
|
||||
return `/${value.trim().replace(/^\/+/u, '')}`;
|
||||
}
|
||||
|
||||
function buildCacheKey(request: AssetReadUrlRequest) {
|
||||
if (request.objectKey?.trim()) {
|
||||
return `object:${request.objectKey.trim().replace(/^\/+/u, '')}`;
|
||||
}
|
||||
|
||||
if (request.legacyPublicPath?.trim()) {
|
||||
return `legacy:${normalizeLegacyPublicPath(request.legacyPublicPath)}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveSignedReadPayload(response: AssetReadUrlResponse) {
|
||||
const read = response.read ?? response;
|
||||
const signedUrl = typeof read.signedUrl === 'string' ? read.signedUrl.trim() : '';
|
||||
const expiresAt = typeof read.expiresAt === 'string' ? read.expiresAt.trim() : '';
|
||||
const objectKey = typeof read.objectKey === 'string' ? read.objectKey.trim() : '';
|
||||
|
||||
if (!signedUrl) {
|
||||
throw new Error('资源访问地址缺失');
|
||||
}
|
||||
|
||||
return {
|
||||
signedUrl,
|
||||
expiresAt,
|
||||
objectKey,
|
||||
};
|
||||
}
|
||||
|
||||
function parseExpiresAtMs(expiresAt: string) {
|
||||
if (!expiresAt) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(expiresAt);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function shouldReuseCachedReadUrl(entry: CachedReadUrlEntry | undefined) {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return entry.expiresAtMs - DEFAULT_CACHE_SAFETY_WINDOW_MS > Date.now();
|
||||
}
|
||||
|
||||
function shouldReuseCachedReadUrlFailure(
|
||||
entry: CachedReadUrlFailureEntry | undefined,
|
||||
) {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return entry.expiresAtMs > Date.now();
|
||||
}
|
||||
|
||||
export async function getSignedAssetReadUrl(
|
||||
request: AssetReadUrlRequest,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const cacheKey = buildCacheKey(request);
|
||||
const cached = cacheKey ? signedReadUrlCache.get(cacheKey) : undefined;
|
||||
if (cached && shouldReuseCachedReadUrl(cached)) {
|
||||
return cached.signedUrl;
|
||||
}
|
||||
|
||||
const cachedFailure = cacheKey
|
||||
? signedReadUrlFailureCache.get(cacheKey)
|
||||
: undefined;
|
||||
if (cachedFailure && shouldReuseCachedReadUrlFailure(cachedFailure)) {
|
||||
throw new Error('资源不存在或暂时不可读取');
|
||||
}
|
||||
|
||||
if (cacheKey) {
|
||||
const pendingRequest = pendingSignedReadUrlRequests.get(cacheKey);
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
}
|
||||
}
|
||||
|
||||
const requestPromise = (async () => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (request.objectKey?.trim()) {
|
||||
searchParams.set('objectKey', request.objectKey.trim().replace(/^\/+/u, ''));
|
||||
}
|
||||
if (request.legacyPublicPath?.trim()) {
|
||||
searchParams.set(
|
||||
'legacyPublicPath',
|
||||
normalizeLegacyPublicPath(request.legacyPublicPath),
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof request.expireSeconds === 'number' &&
|
||||
Number.isFinite(request.expireSeconds) &&
|
||||
request.expireSeconds > 0
|
||||
) {
|
||||
searchParams.set('expireSeconds', String(Math.floor(request.expireSeconds)));
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await requestJson<AssetReadUrlResponse>(
|
||||
`${ASSET_READ_URL_API_PATH}?${searchParams.toString()}`,
|
||||
{
|
||||
method: 'GET',
|
||||
signal,
|
||||
},
|
||||
'获取资源访问地址失败',
|
||||
);
|
||||
const payload = resolveSignedReadPayload(response);
|
||||
const expiresAtMs = parseExpiresAtMs(payload.expiresAt);
|
||||
|
||||
if (cacheKey) {
|
||||
signedReadUrlFailureCache.delete(cacheKey);
|
||||
}
|
||||
|
||||
if (cacheKey && expiresAtMs > 0) {
|
||||
signedReadUrlCache.set(cacheKey, {
|
||||
signedUrl: payload.signedUrl,
|
||||
expiresAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
return payload.signedUrl;
|
||||
} catch (error) {
|
||||
if (
|
||||
cacheKey &&
|
||||
error instanceof ApiClientError &&
|
||||
error.status === 404
|
||||
) {
|
||||
signedReadUrlFailureCache.set(cacheKey, {
|
||||
expiresAtMs: Date.now() + DEFAULT_FAILURE_CACHE_WINDOW_MS,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
if (cacheKey) {
|
||||
pendingSignedReadUrlRequests.set(cacheKey, requestPromise);
|
||||
}
|
||||
|
||||
try {
|
||||
return await requestPromise;
|
||||
} finally {
|
||||
if (cacheKey) {
|
||||
pendingSignedReadUrlRequests.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容层:普通 http(s)/data/blob 路径原样返回;历史 generated-* 路径自动换签名读 URL。
|
||||
export async function resolveAssetReadUrl(
|
||||
source: string | null | undefined,
|
||||
options: {
|
||||
signal?: AbortSignal;
|
||||
expireSeconds?: number;
|
||||
} = {},
|
||||
) {
|
||||
const value = source?.trim() ?? '';
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (
|
||||
/^(?:https?:)?\/\//u.test(value) ||
|
||||
value.startsWith('data:') ||
|
||||
value.startsWith('blob:')
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (isGeneratedLegacyPath(value)) {
|
||||
return getSignedAssetReadUrl(
|
||||
{
|
||||
legacyPublicPath: value,
|
||||
expireSeconds: options.expireSeconds,
|
||||
},
|
||||
options.signal,
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function clearSignedAssetReadUrlCache() {
|
||||
signedReadUrlCache.clear();
|
||||
signedReadUrlFailureCache.clear();
|
||||
pendingSignedReadUrlRequests.clear();
|
||||
}
|
||||
133
src/services/attributeSchemaGenerator.ts
Normal file
133
src/services/attributeSchemaGenerator.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import {validateWorldAttributeSchema} from '../data/attributeValidation';
|
||||
import {getTemplateWorldAttributeSchema} from '../data/worldAttributeSchemas';
|
||||
import type {
|
||||
AttributeSchemaGenerationInput,
|
||||
WorldAttributeSchema,
|
||||
WorldAttributeSlot,
|
||||
} from '../types';
|
||||
import {WorldType} from '../types';
|
||||
import {detectCustomWorldThemeMode} from './customWorldTheme';
|
||||
|
||||
function buildSchema(
|
||||
input: AttributeSchemaGenerationInput,
|
||||
schemaName: string,
|
||||
slots: WorldAttributeSlot[],
|
||||
): WorldAttributeSchema {
|
||||
return {
|
||||
id: `schema:${input.worldType.toLowerCase()}:${schemaName}`,
|
||||
worldId: input.worldType === WorldType.CUSTOM ? `custom:${input.worldName}` : input.worldType,
|
||||
schemaVersion: 1,
|
||||
schemaName,
|
||||
generatedFrom: {
|
||||
worldType: input.worldType,
|
||||
worldName: input.worldName,
|
||||
settingSummary: input.summary,
|
||||
tone: input.tone,
|
||||
conflictCore: input.coreConflicts[0] ?? input.playerGoal,
|
||||
},
|
||||
slots,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCustomThemeSlots(input: AttributeSchemaGenerationInput) {
|
||||
const themeMode = detectCustomWorldThemeMode({
|
||||
settingText: input.settingText,
|
||||
summary: input.summary,
|
||||
tone: input.tone,
|
||||
playerGoal: input.playerGoal,
|
||||
templateWorldType: /仙|灵|宗门|秘境|裂界/u.test(input.settingText) ? WorldType.XIANXIA : WorldType.WUXIA,
|
||||
});
|
||||
|
||||
if (themeMode === 'mythic') {
|
||||
return {
|
||||
schemaName: '叙境六维',
|
||||
slots: [
|
||||
{ slotId: 'axis_a', name: '体魄', definition: '承受正面压力与长期消耗的底子。', positiveSignals: ['稳固', '抗压'], negativeSignals: ['脆弱', '虚浮'], combatUseText: '扛住冲击、保持站位。', socialUseText: '给人可靠、能顶事的感觉。', explorationUseText: '在漫长旅途中维持可行动状态。' },
|
||||
{ slotId: 'axis_b', name: '身法', definition: '换位、腾挪、抢时机与穿行环境的能力。', positiveSignals: ['灵动', '迅捷'], negativeSignals: ['迟滞', '笨拙'], combatUseText: '变线、闪避、抢位和追击。', socialUseText: '反应快,懂得顺势调整说法。', explorationUseText: '穿越复杂地形与危险通路。' },
|
||||
{ slotId: 'axis_c', name: '识见', definition: '看清局势、拆解线索与判断轻重缓急的能力。', positiveSignals: ['洞察', '判断'], negativeSignals: ['误判', '迟钝'], combatUseText: '看穿敌方破绽与局势变化。', socialUseText: '识别真假、试探与隐藏立场。', explorationUseText: '整理线索、辨认路径与推断风险。' },
|
||||
{ slotId: 'axis_d', name: '胆魄', definition: '在高压局势里依然敢于推进和拍板的力量。', positiveSignals: ['果断', '压场'], negativeSignals: ['退缩', '犹疑'], combatUseText: '顶着压力推进战局。', socialUseText: '在僵局里定调并逼出回应。', explorationUseText: '面对未知异象仍敢继续前探。' },
|
||||
{ slotId: 'axis_e', name: '牵引', definition: '与人、物、线索和环境建立联动的能力。', positiveSignals: ['协同', '共鸣'], negativeSignals: ['脱节', '孤立'], combatUseText: '借协同和牵制形成连锁。', socialUseText: '建立合作、说服和互信。', explorationUseText: '从人情、物件和场景之间串起通路。' },
|
||||
{ slotId: 'axis_f', name: '定力', definition: '在变化与消耗中稳住节奏、拉回状态的能力。', positiveSignals: ['稳定', '续航'], negativeSignals: ['失衡', '崩乱'], combatUseText: '久战不乱,能重新控住节奏。', socialUseText: '情绪稳定,不轻易被带偏。', explorationUseText: '在长线推进中持续保持判断和行动力。' },
|
||||
] satisfies WorldAttributeSlot[],
|
||||
};
|
||||
}
|
||||
|
||||
if (themeMode === 'machina') {
|
||||
return {
|
||||
schemaName: '机潮六轴',
|
||||
slots: [
|
||||
{ slotId: 'axis_a', name: '机锋', definition: '承受硬碰撞与机械压力的结构强度。', positiveSignals: ['硬度', '结构'], negativeSignals: ['脆裂', '松散'], combatUseText: '扛住正面撞击与重压。', socialUseText: '给人可靠、稳固、难被撼动的感觉。', explorationUseText: '在高压、坍塌与工业险境中撑住阵脚。' },
|
||||
{ slotId: 'axis_b', name: '步准', definition: '换位、校准、抢时机与精准位移的能力。', positiveSignals: ['校准', '位移'], negativeSignals: ['迟滞', '失准'], combatUseText: '快速转位、抢射界、控节奏。', socialUseText: '反应精确,不轻易露怯。', explorationUseText: '穿越机关、轨道与复杂装置。' },
|
||||
{ slotId: 'axis_c', name: '算识', definition: '解析结构、演算路径、识别规律的能力。', positiveSignals: ['演算', '拆解'], negativeSignals: ['误算', '看不懂'], combatUseText: '读懂装置与敌方机制的薄弱点。', socialUseText: '判断局势、识别话术与利益结构。', explorationUseText: '解密、修复、校准与规划路径。' },
|
||||
{ slotId: 'axis_d', name: '潮压', definition: '在高噪高压中强行推进局势的能力。', positiveSignals: ['推进', '压迫'], negativeSignals: ['退缩', '失控'], combatUseText: '顶着火力和混乱继续施压。', socialUseText: '在混乱场合里定调并逼出表态。', explorationUseText: '面对失控装置时敢于推进关键步骤。' },
|
||||
{ slotId: 'axis_e', name: '协频', definition: '与同伴、器械、网络或环境建立协同的能力。', positiveSignals: ['协同', '接驳'], negativeSignals: ['脱节', '孤立'], combatUseText: '与队友和装置形成联动收益。', socialUseText: '建立合作、交换与稳定配合。', explorationUseText: '接驳系统、调和多方资源与线索。' },
|
||||
{ slotId: 'axis_f', name: '续载', definition: '维持负载、稳定输出、长线运转的能力。', positiveSignals: ['稳载', '续航'], negativeSignals: ['过热', '断载'], combatUseText: '稳住循环、维持持续输出与可操作状态。', socialUseText: '显得沉着、持重、不轻易失衡。', explorationUseText: '在长时间高负荷环境里持续工作。' },
|
||||
] satisfies WorldAttributeSlot[],
|
||||
};
|
||||
}
|
||||
|
||||
if (themeMode === 'tide') {
|
||||
return {
|
||||
schemaName: '潮境六脉',
|
||||
slots: [
|
||||
{ slotId: 'axis_a', name: '潮骨', definition: '扛住潮压与正面冲击的底子。', positiveSignals: ['承压', '稳'], negativeSignals: ['散', '弱'], combatUseText: '顶住正面浪涌与冲撞。', socialUseText: '给人能扛事的可靠感。', explorationUseText: '在风浪与湿重环境里稳住自己。' },
|
||||
{ slotId: 'axis_b', name: '浪步', definition: '顺潮借势、换位穿行的能力。', positiveSignals: ['借势', '轻快'], negativeSignals: ['笨拙', '慢'], combatUseText: '借势滑开、切线、拉开距离。', socialUseText: '谈吐灵活,懂得顺势而为。', explorationUseText: '穿越港口、水路、雾区与复杂地形。' },
|
||||
{ slotId: 'axis_c', name: '舟识', definition: '辨流向、识潮眼、看穿变化的能力。', positiveSignals: ['辨向', '识局'], negativeSignals: ['迷失', '误读'], combatUseText: '抓住潮势变化和敌人的失衡时机。', socialUseText: '看懂局势、试探真假与留白。', explorationUseText: '辨认水路、雾障、潮汐与遗留痕迹。' },
|
||||
{ slotId: 'axis_d', name: '潮魄', definition: '在剧烈变化中仍敢推进的胆气。', positiveSignals: ['胆气', '压前'], negativeSignals: ['畏缩', '犹疑'], combatUseText: '借高压局势硬推突破口。', socialUseText: '在谈判或冲突里顶住对方气势。', explorationUseText: '面对陌生水域与异变仍敢向前。' },
|
||||
{ slotId: 'axis_e', name: '契汐', definition: '与人、船、信物与约定形成牵引的能力。', positiveSignals: ['契合', '通人情'], negativeSignals: ['疏离', '难共鸣'], combatUseText: '借助协同与牵引打出连锁。', socialUseText: '善于结盟、安抚与做交换。', explorationUseText: '从航路、人情与旧约中打开局面。' },
|
||||
{ slotId: 'axis_f', name: '回澜', definition: '在漫长消耗中回稳状态、续住节奏的能力。', positiveSignals: ['回稳', '续航'], negativeSignals: ['紊乱', '断流'], combatUseText: '久战不乱,能把节奏重新拉回手里。', socialUseText: '遇事沉静,不易失态。', explorationUseText: '在漫长远行与恶劣天气里保有余力。' },
|
||||
] satisfies WorldAttributeSlot[],
|
||||
};
|
||||
}
|
||||
|
||||
if (themeMode === 'rift') {
|
||||
return {
|
||||
schemaName: '裂界六轴',
|
||||
slots: [
|
||||
{ slotId: 'axis_a', name: '界躯', definition: '承受裂界冲击与异压侵蚀的底子。', positiveSignals: ['承载', '抗压'], negativeSignals: ['脆弱', '崩裂'], combatUseText: '扛住高强度裂界冲击。', socialUseText: '让人感到能镇住危险局面。', explorationUseText: '在异压、失衡环境下维持完整。' },
|
||||
{ slotId: 'axis_b', name: '裂步', definition: '穿梭边界、抢位、转场的能力。', positiveSignals: ['转场', '抢位'], negativeSignals: ['迟滞', '卡顿'], combatUseText: '借裂隙切位、抢身位与节奏。', socialUseText: '对局势变化响应很快。', explorationUseText: '穿越裂缝、断层与高危通路。' },
|
||||
{ slotId: 'axis_c', name: '界识', definition: '识别边界规律、虚实与因果的能力。', positiveSignals: ['辨识', '推断'], negativeSignals: ['错判', '看不清'], combatUseText: '洞察异界规律和对手的真空点。', socialUseText: '看破隐藏立场与不完整真话。', explorationUseText: '解读旧迹、裂痕和禁域法则。' },
|
||||
{ slotId: 'axis_d', name: '界压', definition: '在失衡局势中强行立住意志与推进力。', positiveSignals: ['压上去', '定调'], negativeSignals: ['动摇', '失措'], combatUseText: '顶住异变推进攻势。', socialUseText: '在高压博弈中逼出答案。', explorationUseText: '面对危险异象仍敢推开下一层。' },
|
||||
{ slotId: 'axis_e', name: '缚契', definition: '与他者、异物、誓约建立束缚或联结的能力。', positiveSignals: ['联结', '束约'], negativeSignals: ['排斥', '难联动'], combatUseText: '借共鸣与束缚形成协同或压制。', socialUseText: '建立合作、誓约与安抚关系。', explorationUseText: '唤醒遗物、安抚异种、触发响应。' },
|
||||
{ slotId: 'axis_f', name: '回脉', definition: '在紊乱环境中把自身重新拉回稳态的能力。', positiveSignals: ['回稳', '续住'], negativeSignals: ['失衡', '崩坏'], combatUseText: '抗住异压后迅速回到可战状态。', socialUseText: '情绪与气势都更稳。', explorationUseText: '在裂界侵蚀与长线压力里保持在线。' },
|
||||
] satisfies WorldAttributeSlot[],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
schemaName: '叙境六维',
|
||||
slots: getTemplateWorldAttributeSchema(WorldType.WUXIA).slots,
|
||||
};
|
||||
}
|
||||
|
||||
export function generateWorldAttributeSchema(input: AttributeSchemaGenerationInput) {
|
||||
if (input.worldType === WorldType.WUXIA) {
|
||||
return getTemplateWorldAttributeSchema(WorldType.WUXIA);
|
||||
}
|
||||
|
||||
if (input.worldType === WorldType.XIANXIA) {
|
||||
return getTemplateWorldAttributeSchema(WorldType.XIANXIA);
|
||||
}
|
||||
|
||||
const generated = buildCustomThemeSlots(input);
|
||||
const schema = buildSchema(input, generated.schemaName, generated.slots);
|
||||
const issues = validateWorldAttributeSchema(schema);
|
||||
|
||||
if (issues.length > 0) {
|
||||
const fallbackWorldType = /仙|灵|宗门|秘境|裂界/u.test(input.settingText) ? WorldType.XIANXIA : WorldType.WUXIA;
|
||||
return {
|
||||
...getTemplateWorldAttributeSchema(fallbackWorldType),
|
||||
id: `schema:custom-fallback:${input.worldName}`,
|
||||
worldId: `custom:${input.worldName}`,
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: input.worldName,
|
||||
settingSummary: input.summary,
|
||||
tone: input.tone,
|
||||
conflictCore: input.coreConflicts[0] ?? input.playerGoal,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
479
src/services/authService.test.ts
Normal file
479
src/services/authService.test.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const apiClientMocks = vi.hoisted(() => ({
|
||||
emitAuthStateChange: vi.fn(),
|
||||
requestJson: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./apiClient', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||||
return {
|
||||
...actual,
|
||||
emitAuthStateChange: apiClientMocks.emitAuthStateChange,
|
||||
requestJson: apiClientMocks.requestJson,
|
||||
};
|
||||
});
|
||||
|
||||
import { ApiClientError } from './apiClient';
|
||||
import { clearStoredAccessToken, getStoredAccessToken } from './apiClient';
|
||||
import {
|
||||
authEntry,
|
||||
bindWechatPhone,
|
||||
changePhoneNumber,
|
||||
consumeAuthCallbackResult,
|
||||
getAuthAuditLogs,
|
||||
getAuthLoginOptions,
|
||||
getAuthRiskBlocks,
|
||||
getAuthSessions,
|
||||
getPublicAuthUserById,
|
||||
getCaptchaChallengeFromError,
|
||||
getCurrentAuthUser,
|
||||
liftAuthRiskBlock,
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
sendPhoneLoginCode,
|
||||
startWechatLogin,
|
||||
} from './authService';
|
||||
|
||||
function createLocalStorageMock() {
|
||||
const store = new Map<string, string>();
|
||||
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return store.has(key) ? store.get(key)! : null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWindowMock(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
dispatchEvent: vi.fn(),
|
||||
localStorage: createLocalStorageMock(),
|
||||
location: {
|
||||
pathname: '/',
|
||||
hash: '',
|
||||
search: '',
|
||||
assign: vi.fn(),
|
||||
},
|
||||
history: {
|
||||
replaceState: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('authService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('window', createWindowMock());
|
||||
clearStoredAccessToken({ emit: false });
|
||||
});
|
||||
|
||||
it('auth entry posts phone password credentials and 写入 access token', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
token: 'jwt-entry-token',
|
||||
user: {
|
||||
id: 'user_1',
|
||||
publicUserCode: 'SY-00000001',
|
||||
username: 'phone_00000001',
|
||||
displayName: '138****8000',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await authEntry(' 138 0013 8000 ', ' secret123 ');
|
||||
|
||||
expect(user.phoneNumberMasked).toBe('138****8000');
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/entry',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
password: 'secret123',
|
||||
}),
|
||||
}),
|
||||
'登录失败',
|
||||
{
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('jwt-entry-token');
|
||||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends phone login code through the auth endpoint', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
ok: true,
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
providerRequestId: 'mock-request-id',
|
||||
});
|
||||
|
||||
const result = await sendPhoneLoginCode(' 138 0013 8000 ');
|
||||
|
||||
expect(result.cooldownSeconds).toBe(60);
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/send-code',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
scene: 'login',
|
||||
}),
|
||||
}),
|
||||
'发送验证码失败',
|
||||
{
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts captcha challenge details from api errors', () => {
|
||||
expect(getCaptchaChallengeFromError(new Error('plain error'))).toBeNull();
|
||||
|
||||
const captchaError = new ApiClientError({
|
||||
message: '需要完成人机校验',
|
||||
status: 403,
|
||||
code: 'CAPTCHA_REQUIRED',
|
||||
details: {
|
||||
captchaChallenge: {
|
||||
challengeId: 'captcha_1',
|
||||
promptText: '请输入图中的验证码后再获取短信验证码',
|
||||
imageDataUrl: 'data:image/svg+xml;base64,abc',
|
||||
expiresInSeconds: 180,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getCaptchaChallengeFromError(captchaError)).toEqual({
|
||||
challengeId: 'captcha_1',
|
||||
promptText: '请输入图中的验证码后再获取短信验证码',
|
||||
imageDataUrl: 'data:image/svg+xml;base64,abc',
|
||||
expiresInSeconds: 180,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores renewed access token after phone login', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
token: 'jwt-phone-token',
|
||||
user: {
|
||||
id: 'user_phone',
|
||||
publicUserCode: 'SY-00000004',
|
||||
username: '138****8000',
|
||||
displayName: '138****8000',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await loginWithPhoneCode('13800138000', '123456');
|
||||
|
||||
expect(user.username).toBe('138****8000');
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/login',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
code: '123456',
|
||||
}),
|
||||
}),
|
||||
'登录失败',
|
||||
{
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('jwt-phone-token');
|
||||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stores renewed access token after wechat bind activation', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
token: 'jwt-wechat-bind-token',
|
||||
user: {
|
||||
id: 'user_wechat',
|
||||
publicUserCode: 'SY-00000005',
|
||||
username: '138****8000',
|
||||
displayName: '138****8000',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'wechat',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: true,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await bindWechatPhone('13800138000', '123456');
|
||||
|
||||
expect(user.wechatBound).toBe(true);
|
||||
expect(getStoredAccessToken()).toBe('jwt-wechat-bind-token');
|
||||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('changes phone number without emitting a global auth state refresh', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
user: {
|
||||
id: 'user_phone',
|
||||
publicUserCode: 'SY-00000006',
|
||||
username: '139****9000',
|
||||
displayName: '139****9000',
|
||||
phoneNumberMasked: '139****9000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await changePhoneNumber('13900139000', '123456');
|
||||
|
||||
expect(user.phoneNumberMasked).toBe('139****9000');
|
||||
expect(apiClientMocks.emitAuthStateChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts wechat login by navigating to backend authorization url', async () => {
|
||||
const assignMock = vi.fn();
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
createWindowMock({
|
||||
location: {
|
||||
pathname: '/',
|
||||
hash: '',
|
||||
search: '',
|
||||
assign: assignMock,
|
||||
},
|
||||
}),
|
||||
);
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
authorizationUrl:
|
||||
'/api/auth/wechat/callback?mock_code=wx-user&state=state123',
|
||||
});
|
||||
|
||||
await startWechatLogin();
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/wechat/start?redirectPath=%2F',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'微信登录暂不可用',
|
||||
{
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
expect(assignMock).toHaveBeenCalledWith(
|
||||
'/api/auth/wechat/callback?mock_code=wx-user&state=state123',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads available login methods for the unauthenticated login screen', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
availableLoginMethods: ['phone', 'wechat'],
|
||||
});
|
||||
|
||||
const result = await getAuthLoginOptions();
|
||||
|
||||
expect(result.availableLoginMethods).toEqual(['phone', 'wechat']);
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/login-options',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取登录方式失败',
|
||||
{
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('consumes auth callback hash and persists the returned access token', () => {
|
||||
const replaceStateMock = vi.fn();
|
||||
vi.stubGlobal(
|
||||
'window',
|
||||
createWindowMock({
|
||||
location: {
|
||||
pathname: '/',
|
||||
search: '',
|
||||
hash: '#auth_provider=wechat&auth_token=jwt-callback-token&auth_binding_status=pending_bind_phone',
|
||||
assign: vi.fn(),
|
||||
},
|
||||
history: {
|
||||
replaceState: replaceStateMock,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = consumeAuthCallbackResult();
|
||||
|
||||
expect(result).toEqual({
|
||||
provider: 'wechat',
|
||||
bindingStatus: 'pending_bind_phone',
|
||||
error: null,
|
||||
});
|
||||
expect(getStoredAccessToken()).toBe('jwt-callback-token');
|
||||
expect(window.dispatchEvent).not.toHaveBeenCalled();
|
||||
expect(replaceStateMock).toHaveBeenCalledWith(null, '', '/');
|
||||
});
|
||||
|
||||
it('gets current auth user with silent auth-state notification settings', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
user: null,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
const result = await getCurrentAuthUser();
|
||||
|
||||
expect(result).toEqual({
|
||||
user: null,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/me',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取当前用户失败',
|
||||
{
|
||||
notifyAuthStateChange: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('loads public user summary by internal user id', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
user: {
|
||||
id: 'user_00000001',
|
||||
publicUserCode: 'SY-00000001',
|
||||
displayName: '旅人一号',
|
||||
},
|
||||
});
|
||||
|
||||
const user = await getPublicAuthUserById(' user_00000001 ');
|
||||
|
||||
expect(user).toEqual({
|
||||
id: 'user_00000001',
|
||||
publicUserCode: 'SY-00000001',
|
||||
displayName: '旅人一号',
|
||||
});
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/public-users/by-id/user_00000001',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取用户信息失败',
|
||||
{
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('loads auth sessions from account center endpoint', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'usess_1',
|
||||
clientType: 'browser',
|
||||
clientLabel: '网页端浏览器',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
ipMasked: '127.0.*.*',
|
||||
isCurrent: true,
|
||||
createdAt: '2026-04-09T10:00:00.000Z',
|
||||
lastSeenAt: '2026-04-09T10:30:00.000Z',
|
||||
expiresAt: '2026-05-09T10:30:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const sessions = await getAuthSessions();
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('loads recent auth audit logs', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
logs: [
|
||||
{
|
||||
id: 'audit_1',
|
||||
eventType: 'phone_login',
|
||||
title: '手机号登录',
|
||||
detail: '使用手机号 138****8000 完成登录',
|
||||
ipMasked: '127.0.*.*',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
createdAt: '2026-04-09T10:30:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const logs = await getAuthAuditLogs();
|
||||
|
||||
expect(logs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('loads current risk blocks', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
blocks: [
|
||||
{
|
||||
scopeType: 'phone',
|
||||
title: '手机号保护中',
|
||||
detail: '该手机号因异常尝试已被临时保护,请约 30 分钟后再试',
|
||||
expiresAt: '2026-04-09T11:00:00.000Z',
|
||||
remainingSeconds: 1800,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const blocks = await getAuthRiskBlocks();
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('lifts a risk block by scope type', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
await liftAuthRiskBlock('phone');
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/risk-blocks/phone/lift',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'解除保护失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('emits auth change after logout all sessions', async () => {
|
||||
apiClientMocks.requestJson.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
await logoutAllAuthSessions();
|
||||
|
||||
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
|
||||
'/api/auth/logout-all',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'退出全部设备失败',
|
||||
);
|
||||
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
456
src/services/authService.ts
Normal file
456
src/services/authService.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
import type {
|
||||
AuthAuditLogEntry,
|
||||
AuthAuditLogsResponse,
|
||||
AuthCaptchaChallenge,
|
||||
AuthEntryResponse,
|
||||
AuthLiftRiskBlockResponse,
|
||||
AuthLoginMethod,
|
||||
AuthLoginOptionsResponse,
|
||||
AuthLogoutAllResponse,
|
||||
AuthMeResponse,
|
||||
AuthPasswordChangeResponse,
|
||||
AuthPasswordResetResponse,
|
||||
AuthPhoneChangeResponse,
|
||||
AuthPhoneLoginResponse,
|
||||
AuthPhoneSendCodeResponse,
|
||||
AuthRevokeSessionResponse,
|
||||
AuthRiskBlocksResponse,
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionsResponse,
|
||||
AuthSessionSummary,
|
||||
AuthUser,
|
||||
AuthWechatBindPhoneResponse,
|
||||
AuthWechatStartResponse,
|
||||
LogoutResponse,
|
||||
PublicUserSearchResponse,
|
||||
} from '../../packages/shared/src/contracts/auth';
|
||||
import {
|
||||
ApiClientError,
|
||||
type ApiRequestOptions,
|
||||
clearStoredAccessToken,
|
||||
emitAuthStateChange,
|
||||
requestJson,
|
||||
setStoredAccessToken,
|
||||
} from './apiClient';
|
||||
|
||||
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
|
||||
export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth';
|
||||
|
||||
export type AuthSessionSnapshot = {
|
||||
user: import('../../packages/shared/src/contracts/auth').AuthUser | null;
|
||||
availableLoginMethods: AuthLoginMethod[];
|
||||
};
|
||||
export type { AuthSessionSummary };
|
||||
export type { AuthCaptchaChallenge };
|
||||
export type { AuthAuditLogEntry };
|
||||
export type { AuthRiskBlockSummary };
|
||||
|
||||
export type ConsumedAuthCallback = {
|
||||
provider: 'wechat' | 'unknown';
|
||||
bindingStatus: string | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
// 登录前公开认证入口不能误带旧 token,也不能先触发 refresh 探测,
|
||||
// 否则无会话用户点击“获取验证码”时会先打出一条无意义的 /auth/refresh 401。
|
||||
const PUBLIC_AUTH_REQUEST_OPTIONS = {
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
} satisfies ApiRequestOptions;
|
||||
|
||||
const LAST_LOGIN_PHONE_STORAGE_KEY = 'genarrative:last-login-phone';
|
||||
|
||||
export function normalizePhoneInput(phoneInput: string) {
|
||||
return phoneInput.replace(/[^\d+]/gu, '').trim();
|
||||
}
|
||||
|
||||
export function getStoredLastLoginPhone() {
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return window.localStorage.getItem(LAST_LOGIN_PHONE_STORAGE_KEY) ?? '';
|
||||
}
|
||||
|
||||
export function setStoredLastLoginPhone(phone: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedPhone = normalizePhoneInput(phone);
|
||||
if (!normalizedPhone) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(LAST_LOGIN_PHONE_STORAGE_KEY, normalizedPhone);
|
||||
}
|
||||
|
||||
export function getCaptchaChallengeFromError(
|
||||
error: unknown,
|
||||
): AuthCaptchaChallenge | null {
|
||||
if (
|
||||
error instanceof ApiClientError &&
|
||||
error.code === 'CAPTCHA_REQUIRED' &&
|
||||
error.details &&
|
||||
typeof error.details === 'object' &&
|
||||
'captchaChallenge' in error.details
|
||||
) {
|
||||
const challenge = (error.details as { captchaChallenge?: unknown })
|
||||
.captchaChallenge;
|
||||
if (
|
||||
challenge &&
|
||||
typeof challenge === 'object' &&
|
||||
'challengeId' in challenge &&
|
||||
'promptText' in challenge &&
|
||||
'imageDataUrl' in challenge &&
|
||||
'expiresInSeconds' in challenge
|
||||
) {
|
||||
return challenge as AuthCaptchaChallenge;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function clearAuthSession() {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
emitAuthStateChange();
|
||||
}
|
||||
|
||||
export async function sendPhoneLoginCode(
|
||||
phone: string,
|
||||
scene: 'login' | 'bind_phone' | 'change_phone' | 'reset_password' = 'login',
|
||||
captcha?: {
|
||||
challengeId?: string;
|
||||
answer?: string;
|
||||
},
|
||||
) {
|
||||
const response = await requestJson<AuthPhoneSendCodeResponse>(
|
||||
'/api/auth/phone/send-code',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
scene,
|
||||
captchaChallengeId: captcha?.challengeId?.trim() || undefined,
|
||||
captchaAnswer: captcha?.answer?.trim() || undefined,
|
||||
}),
|
||||
},
|
||||
'发送验证码失败',
|
||||
PUBLIC_AUTH_REQUEST_OPTIONS,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function loginWithPhoneCode(phone: string, code: string) {
|
||||
const response = await requestJson<AuthPhoneLoginResponse>(
|
||||
'/api/auth/phone/login',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
}),
|
||||
},
|
||||
'登录失败',
|
||||
PUBLIC_AUTH_REQUEST_OPTIONS,
|
||||
);
|
||||
|
||||
setStoredAccessToken(response.token, { emit: false });
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function bindWechatPhone(phone: string, code: string) {
|
||||
const response = await requestJson<AuthWechatBindPhoneResponse>(
|
||||
'/api/auth/wechat/bind-phone',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
}),
|
||||
},
|
||||
'绑定手机号失败',
|
||||
);
|
||||
|
||||
setStoredAccessToken(response.token, { emit: false });
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function changePhoneNumber(phone: string, code: string) {
|
||||
const response = await requestJson<AuthPhoneChangeResponse>(
|
||||
'/api/auth/phone/change',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
}),
|
||||
},
|
||||
'更换手机号失败',
|
||||
);
|
||||
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function startWechatLogin() {
|
||||
const response = await requestJson<AuthWechatStartResponse>(
|
||||
`/api/auth/wechat/start?redirectPath=${encodeURIComponent(window.location.pathname)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'微信登录暂不可用',
|
||||
PUBLIC_AUTH_REQUEST_OPTIONS,
|
||||
);
|
||||
|
||||
window.location.assign(response.authorizationUrl);
|
||||
}
|
||||
|
||||
export async function getAuthLoginOptions() {
|
||||
return requestJson<AuthLoginOptionsResponse>(
|
||||
'/api/auth/login-options',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取登录方式失败',
|
||||
PUBLIC_AUTH_REQUEST_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
export async function authEntry(phone: string, password: string) {
|
||||
const response = await requestJson<AuthEntryResponse>(
|
||||
'/api/auth/entry',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
password: password.trim(),
|
||||
}),
|
||||
},
|
||||
'登录失败',
|
||||
PUBLIC_AUTH_REQUEST_OPTIONS,
|
||||
);
|
||||
|
||||
setStoredAccessToken(response.token, { emit: false });
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function changePassword(
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
) {
|
||||
const response = await requestJson<AuthPasswordChangeResponse>(
|
||||
'/api/auth/password/change',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
currentPassword: currentPassword.trim() || undefined,
|
||||
newPassword: newPassword.trim(),
|
||||
}),
|
||||
},
|
||||
'修改密码失败',
|
||||
);
|
||||
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function resetPassword(
|
||||
phone: string,
|
||||
code: string,
|
||||
newPassword: string,
|
||||
) {
|
||||
const response = await requestJson<AuthPasswordResetResponse>(
|
||||
'/api/auth/password/reset',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
newPassword: newPassword.trim(),
|
||||
}),
|
||||
},
|
||||
'重置密码失败',
|
||||
PUBLIC_AUTH_REQUEST_OPTIONS,
|
||||
);
|
||||
|
||||
setStoredAccessToken(response.token, { emit: false });
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export function consumeAuthCallbackResult(): ConsumedAuthCallback | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hash = window.location.hash.startsWith('#')
|
||||
? window.location.hash.slice(1)
|
||||
: window.location.hash;
|
||||
if (!hash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(hash);
|
||||
const authToken = params.get('auth_token');
|
||||
const authError = params.get('auth_error');
|
||||
const providerValue = params.get('auth_provider');
|
||||
const bindingStatus = params.get('auth_binding_status');
|
||||
|
||||
if (!authToken && !authError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authToken) {
|
||||
setStoredAccessToken(authToken, { emit: false });
|
||||
}
|
||||
|
||||
if (typeof window.history?.replaceState === 'function') {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`${window.location.pathname}${window.location.search}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: providerValue === 'wechat' ? 'wechat' : 'unknown',
|
||||
bindingStatus,
|
||||
error: authError,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCurrentAuthUser(): Promise<AuthSessionSnapshot> {
|
||||
const response = await requestJson<AuthMeResponse>(
|
||||
'/api/auth/me',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取当前用户失败',
|
||||
{
|
||||
// 会话恢复阶段允许 401 作为“未登录”信号,不应再广播一次全局鉴权事件。
|
||||
notifyAuthStateChange: false,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
user: response.user,
|
||||
availableLoginMethods: response.availableLoginMethods,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPublicAuthUserByCode(code: string) {
|
||||
const response = await requestJson<PublicUserSearchResponse>(
|
||||
`/api/auth/public-users/by-code/${encodeURIComponent(code.trim())}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取用户信息失败',
|
||||
PUBLIC_AUTH_REQUEST_OPTIONS,
|
||||
);
|
||||
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function getPublicAuthUserById(userId: string) {
|
||||
const response = await requestJson<PublicUserSearchResponse>(
|
||||
`/api/auth/public-users/by-id/${encodeURIComponent(userId.trim())}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取用户信息失败',
|
||||
PUBLIC_AUTH_REQUEST_OPTIONS,
|
||||
);
|
||||
|
||||
return response.user;
|
||||
}
|
||||
|
||||
export async function getAuthSessions() {
|
||||
const response = await requestJson<AuthSessionsResponse>(
|
||||
'/api/auth/sessions',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取登录设备失败',
|
||||
);
|
||||
|
||||
return response.sessions;
|
||||
}
|
||||
|
||||
export async function revokeAuthSession(sessionId: string) {
|
||||
await requestJson<AuthRevokeSessionResponse>(
|
||||
`/api/auth/sessions/${encodeURIComponent(sessionId)}/revoke`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'移除登录设备失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAuthAuditLogs() {
|
||||
const response = await requestJson<AuthAuditLogsResponse>(
|
||||
'/api/auth/audit-logs',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取账号操作记录失败',
|
||||
);
|
||||
|
||||
return response.logs;
|
||||
}
|
||||
|
||||
export async function getAuthRiskBlocks() {
|
||||
const response = await requestJson<AuthRiskBlocksResponse>(
|
||||
'/api/auth/risk-blocks',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取安全状态失败',
|
||||
);
|
||||
|
||||
return response.blocks;
|
||||
}
|
||||
|
||||
export async function liftAuthRiskBlock(scopeType: 'phone' | 'ip') {
|
||||
await requestJson<AuthLiftRiskBlockResponse>(
|
||||
`/api/auth/risk-blocks/${encodeURIComponent(scopeType)}/lift`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'解除保护失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function logoutAuthUser() {
|
||||
try {
|
||||
await requestJson<LogoutResponse>(
|
||||
'/api/auth/logout',
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'退出登录失败',
|
||||
);
|
||||
} finally {
|
||||
clearAuthSession();
|
||||
}
|
||||
}
|
||||
|
||||
export async function logoutAllAuthSessions() {
|
||||
try {
|
||||
await requestJson<AuthLogoutAllResponse>(
|
||||
'/api/auth/logout-all',
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'退出全部设备失败',
|
||||
);
|
||||
} finally {
|
||||
clearAuthSession();
|
||||
}
|
||||
}
|
||||
71
src/services/big-fish-creation/bigFishCreationClient.ts
Normal file
71
src/services/big-fish-creation/bigFishCreationClient.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type {
|
||||
BigFishActionResponse,
|
||||
BigFishSessionResponse,
|
||||
BigFishSessionSnapshotResponse,
|
||||
CreateBigFishSessionRequest,
|
||||
ExecuteBigFishActionRequest,
|
||||
SendBigFishMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
import { createCreationAgentClient } from '../creation-agent';
|
||||
|
||||
const BIG_FISH_AGENT_API_BASE = '/api/runtime/big-fish/agent/sessions';
|
||||
const bigFishAgentHttpClient = createCreationAgentClient<
|
||||
CreateBigFishSessionRequest,
|
||||
BigFishSessionResponse,
|
||||
BigFishSessionResponse,
|
||||
BigFishSessionSnapshotResponse,
|
||||
SendBigFishMessageRequest,
|
||||
BigFishSessionResponse,
|
||||
ExecuteBigFishActionRequest,
|
||||
BigFishActionResponse
|
||||
>({
|
||||
apiBase: BIG_FISH_AGENT_API_BASE,
|
||||
messages: {
|
||||
createSession: '创建大鱼吃小鱼共创会话失败',
|
||||
getSession: '读取大鱼吃小鱼共创会话失败',
|
||||
sendMessage: '发送大鱼吃小鱼共创消息失败',
|
||||
streamIncomplete: '大鱼吃小鱼共创消息流式结果不完整',
|
||||
executeAction: '执行大鱼吃小鱼共创操作失败',
|
||||
},
|
||||
});
|
||||
|
||||
export async function createBigFishCreationSession(
|
||||
payload: CreateBigFishSessionRequest = {},
|
||||
) {
|
||||
return bigFishAgentHttpClient.createSession(payload);
|
||||
}
|
||||
|
||||
export async function getBigFishCreationSession(sessionId: string) {
|
||||
return bigFishAgentHttpClient.getSession(sessionId);
|
||||
}
|
||||
|
||||
export async function sendBigFishCreationMessage(
|
||||
sessionId: string,
|
||||
payload: SendBigFishMessageRequest,
|
||||
) {
|
||||
return bigFishAgentHttpClient.sendMessage(sessionId, payload);
|
||||
}
|
||||
|
||||
export async function streamBigFishCreationMessage(
|
||||
sessionId: string,
|
||||
payload: SendBigFishMessageRequest,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
return bigFishAgentHttpClient.streamMessage(sessionId, payload, options);
|
||||
}
|
||||
|
||||
export async function executeBigFishCreationAction(
|
||||
sessionId: string,
|
||||
payload: ExecuteBigFishActionRequest,
|
||||
) {
|
||||
return bigFishAgentHttpClient.executeAction(sessionId, payload);
|
||||
}
|
||||
|
||||
export const bigFishCreationClient = {
|
||||
createSession: createBigFishCreationSession,
|
||||
getSession: getBigFishCreationSession,
|
||||
sendMessage: sendBigFishCreationMessage,
|
||||
streamMessage: streamBigFishCreationMessage,
|
||||
executeAction: executeBigFishCreationAction,
|
||||
};
|
||||
8
src/services/big-fish-creation/index.ts
Normal file
8
src/services/big-fish-creation/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
bigFishCreationClient,
|
||||
createBigFishCreationSession,
|
||||
executeBigFishCreationAction,
|
||||
getBigFishCreationSession,
|
||||
sendBigFishCreationMessage,
|
||||
streamBigFishCreationMessage,
|
||||
} from './bigFishCreationClient';
|
||||
68
src/services/big-fish-runtime/bigFishRuntimeClient.ts
Normal file
68
src/services/big-fish-runtime/bigFishRuntimeClient.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
BigFishRunResponse,
|
||||
SubmitBigFishInputRequest,
|
||||
} from '../../../packages/shared/src/contracts/bigFish';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const BIG_FISH_RUNTIME_API_BASE = '/api/runtime/big-fish';
|
||||
const BIG_FISH_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
};
|
||||
const BIG_FISH_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
export async function startBigFishRuntimeRun(sessionId: string) {
|
||||
return requestJson<BigFishRunResponse>(
|
||||
`${BIG_FISH_RUNTIME_API_BASE}/sessions/${encodeURIComponent(sessionId)}/runs`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'启动大鱼吃小鱼测试玩法失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getBigFishRuntimeRun(runId: string) {
|
||||
return requestJson<BigFishRunResponse>(
|
||||
`${BIG_FISH_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取大鱼吃小鱼运行快照失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function submitBigFishRuntimeInput(
|
||||
runId: string,
|
||||
payload: SubmitBigFishInputRequest,
|
||||
) {
|
||||
return requestJson<BigFishRunResponse>(
|
||||
`${BIG_FISH_RUNTIME_API_BASE}/runs/${encodeURIComponent(runId)}/input`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'提交大鱼吃小鱼移动输入失败',
|
||||
{
|
||||
retry: BIG_FISH_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const bigFishRuntimeClient = {
|
||||
startRun: startBigFishRuntimeRun,
|
||||
getRun: getBigFishRuntimeRun,
|
||||
submitInput: submitBigFishRuntimeInput,
|
||||
};
|
||||
6
src/services/big-fish-runtime/index.ts
Normal file
6
src/services/big-fish-runtime/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
bigFishRuntimeClient,
|
||||
getBigFishRuntimeRun,
|
||||
startBigFishRuntimeRun,
|
||||
submitBigFishRuntimeInput,
|
||||
} from './bigFishRuntimeClient';
|
||||
52
src/services/big-fish-works/bigFishWorksClient.ts
Normal file
52
src/services/big-fish-works/bigFishWorksClient.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { BigFishWorksResponse } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const BIG_FISH_WORKS_API_BASE = '/api/runtime/big-fish/works';
|
||||
const BIG_FISH_WORKS_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
};
|
||||
const BIG_FISH_WORKS_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取当前用户的大鱼吃小鱼创作作品列表。
|
||||
*/
|
||||
export async function listBigFishWorks() {
|
||||
return requestJson<BigFishWorksResponse>(
|
||||
BIG_FISH_WORKS_API_BASE,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取大鱼吃小鱼作品列表失败',
|
||||
{
|
||||
retry: BIG_FISH_WORKS_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除当前用户的大鱼吃小鱼作品,并返回删除后的作品列表。
|
||||
*/
|
||||
export async function deleteBigFishWork(sessionId: string) {
|
||||
return requestJson<BigFishWorksResponse>(
|
||||
`${BIG_FISH_WORKS_API_BASE}/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
'删除大鱼吃小鱼作品失败',
|
||||
{
|
||||
retry: BIG_FISH_WORKS_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const bigFishWorksClient = {
|
||||
delete: deleteBigFishWork,
|
||||
list: listBigFishWorks,
|
||||
};
|
||||
5
src/services/big-fish-works/index.ts
Normal file
5
src/services/big-fish-works/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
bigFishWorksClient,
|
||||
deleteBigFishWork,
|
||||
listBigFishWorks,
|
||||
} from './bigFishWorksClient';
|
||||
1
src/services/characterChatPrompt.ts
Normal file
1
src/services/characterChatPrompt.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '../prompts/characterChatPrompts';
|
||||
53
src/services/clipboard.ts
Normal file
53
src/services/clipboard.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export async function copyTextToClipboard(value: string) {
|
||||
const text = value.trim();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// 部分内嵌浏览器会暴露 Clipboard API,但会因权限上下文拒绝写入,继续走兼容路径。
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '0';
|
||||
document.body.appendChild(textarea);
|
||||
|
||||
const selection = document.getSelection();
|
||||
const selectedRange =
|
||||
selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
|
||||
let copied = false;
|
||||
try {
|
||||
copied =
|
||||
typeof document.execCommand === 'function' &&
|
||||
document.execCommand('copy');
|
||||
} catch {
|
||||
copied = false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
if (selectedRange) {
|
||||
selection.addRange(selectedRange);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return copied;
|
||||
}
|
||||
58
src/services/creation-agent/creationAgentChat.test.ts
Normal file
58
src/services/creation-agent/creationAgentChat.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
CREATION_AGENT_QUICK_FILL_MESSAGE,
|
||||
buildCreationAgentChatMessage,
|
||||
createCreationAgentChatQuickActions,
|
||||
resolveCreationAgentQuickActionMessage,
|
||||
} from './creationAgentChat';
|
||||
|
||||
test('creation agent chat exposes the unified summary and quick fill actions', () => {
|
||||
expect(createCreationAgentChatQuickActions()).toEqual([
|
||||
{
|
||||
key: 'summarize',
|
||||
label: '总结当前设定',
|
||||
},
|
||||
{
|
||||
key: 'quickFill',
|
||||
label: '补充剩余设定',
|
||||
minTurn: 2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('creation agent chat resolves quick actions through one message contract', () => {
|
||||
expect(
|
||||
resolveCreationAgentQuickActionMessage('quickFill', '请总结当前设定。'),
|
||||
).toEqual({
|
||||
text: CREATION_AGENT_QUICK_FILL_MESSAGE,
|
||||
quickFillRequested: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveCreationAgentQuickActionMessage('summarize', '请总结当前设定。'),
|
||||
).toEqual({
|
||||
text: '请总结当前设定。',
|
||||
quickFillRequested: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('creation agent chat builds shared message payload with genre extras', () => {
|
||||
expect(
|
||||
buildCreationAgentChatMessage({
|
||||
clientMessageId: 'message-1',
|
||||
text: '请补充剩余设定。',
|
||||
quickFillRequested: true,
|
||||
extraPayload: {
|
||||
focusCardId: null,
|
||||
selectedCardIds: [],
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
clientMessageId: 'message-1',
|
||||
text: '请补充剩余设定。',
|
||||
quickFillRequested: true,
|
||||
focusCardId: null,
|
||||
selectedCardIds: [],
|
||||
});
|
||||
});
|
||||
63
src/services/creation-agent/creationAgentChat.ts
Normal file
63
src/services/creation-agent/creationAgentChat.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export const CREATION_AGENT_SUMMARY_ACTION_KEY = 'summarize';
|
||||
export const CREATION_AGENT_QUICK_FILL_ACTION_KEY = 'quickFill';
|
||||
|
||||
export const CREATION_AGENT_SUMMARY_ACTION_LABEL = '总结当前设定';
|
||||
export const CREATION_AGENT_QUICK_FILL_ACTION_LABEL = '补充剩余设定';
|
||||
export const CREATION_AGENT_QUICK_FILL_MESSAGE = '请补充剩余设定。';
|
||||
|
||||
type CreationAgentChatQuickAction = {
|
||||
key: string;
|
||||
label: string;
|
||||
minTurn?: number;
|
||||
};
|
||||
|
||||
type CreationAgentChatMessageBase = {
|
||||
clientMessageId: string;
|
||||
text: string;
|
||||
quickFillRequested?: boolean;
|
||||
};
|
||||
|
||||
export function createCreationAgentChatQuickActions(): CreationAgentChatQuickAction[] {
|
||||
return [
|
||||
{
|
||||
key: CREATION_AGENT_SUMMARY_ACTION_KEY,
|
||||
label: CREATION_AGENT_SUMMARY_ACTION_LABEL,
|
||||
},
|
||||
{
|
||||
key: CREATION_AGENT_QUICK_FILL_ACTION_KEY,
|
||||
label: CREATION_AGENT_QUICK_FILL_ACTION_LABEL,
|
||||
minTurn: 2,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveCreationAgentQuickActionMessage(
|
||||
actionKey: string,
|
||||
summaryMessage: string,
|
||||
) {
|
||||
const quickFillRequested = actionKey === CREATION_AGENT_QUICK_FILL_ACTION_KEY;
|
||||
|
||||
return {
|
||||
text: quickFillRequested ? CREATION_AGENT_QUICK_FILL_MESSAGE : summaryMessage,
|
||||
quickFillRequested,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCreationAgentChatMessage<TExtraPayload extends object = Record<string, never>>({
|
||||
clientMessageId,
|
||||
text,
|
||||
quickFillRequested = false,
|
||||
extraPayload,
|
||||
}: {
|
||||
clientMessageId: string;
|
||||
text: string;
|
||||
quickFillRequested?: boolean;
|
||||
extraPayload?: TExtraPayload;
|
||||
}): CreationAgentChatMessageBase & TExtraPayload {
|
||||
return {
|
||||
...(extraPayload ?? ({} as TExtraPayload)),
|
||||
clientMessageId,
|
||||
text,
|
||||
quickFillRequested,
|
||||
};
|
||||
}
|
||||
165
src/services/creation-agent/creationAgentClientFactory.ts
Normal file
165
src/services/creation-agent/creationAgentClientFactory.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { parseApiErrorMessage } from '../../../packages/shared/src/http';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
import {
|
||||
type ApiRetryOptions,
|
||||
fetchWithApiAuth,
|
||||
requestJson,
|
||||
} from '../apiClient';
|
||||
import { readCreationAgentSessionFromSse } from './creationAgentSse';
|
||||
|
||||
type CreationAgentClientMessages = {
|
||||
createSession: string;
|
||||
getSession: string;
|
||||
sendMessage: string;
|
||||
streamIncomplete: string;
|
||||
executeAction: string;
|
||||
};
|
||||
|
||||
type CreationAgentClientOptions = {
|
||||
apiBase: string;
|
||||
messages: CreationAgentClientMessages;
|
||||
createSessionTimeoutMs?: number;
|
||||
readRetry?: ApiRetryOptions;
|
||||
writeRetry?: ApiRetryOptions;
|
||||
};
|
||||
|
||||
const DEFAULT_CREATION_AGENT_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 180,
|
||||
maxDelayMs: 480,
|
||||
};
|
||||
|
||||
const DEFAULT_CREATION_AGENT_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 240,
|
||||
maxDelayMs: 640,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
function buildJsonPostInit(payload: unknown): RequestInit {
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
};
|
||||
}
|
||||
|
||||
async function openCreationAgentSsePost(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
fallbackMessage: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const response = await fetchWithApiAuth(url, {
|
||||
...buildJsonPostInit(payload),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 三类作品创作 Agent 都遵循同一组 HTTP/SSE 端点形状。
|
||||
* 这里统一请求骨架,玩法 client 只保留路径、类型与中文错误文案差异。
|
||||
*/
|
||||
export function createCreationAgentClient<
|
||||
TCreateSessionPayload,
|
||||
TCreateSessionResponse,
|
||||
TGetSessionResponse,
|
||||
TSession,
|
||||
TSendMessagePayload,
|
||||
TSendMessageResponse,
|
||||
TExecuteActionPayload,
|
||||
TExecuteActionResponse,
|
||||
>({
|
||||
apiBase,
|
||||
messages,
|
||||
createSessionTimeoutMs = 15000,
|
||||
readRetry = DEFAULT_CREATION_AGENT_READ_RETRY,
|
||||
writeRetry = DEFAULT_CREATION_AGENT_WRITE_RETRY,
|
||||
}: CreationAgentClientOptions) {
|
||||
const createSession = (
|
||||
payload: TCreateSessionPayload,
|
||||
): Promise<TCreateSessionResponse> =>
|
||||
requestJson<TCreateSessionResponse>(
|
||||
apiBase,
|
||||
buildJsonPostInit(payload),
|
||||
messages.createSession,
|
||||
{
|
||||
retry: writeRetry,
|
||||
timeoutMs: createSessionTimeoutMs,
|
||||
},
|
||||
);
|
||||
|
||||
const getSession = (sessionId: string): Promise<TGetSessionResponse> =>
|
||||
requestJson<TGetSessionResponse>(
|
||||
`${apiBase}/${encodeURIComponent(sessionId)}`,
|
||||
{ method: 'GET' },
|
||||
messages.getSession,
|
||||
{
|
||||
retry: readRetry,
|
||||
},
|
||||
);
|
||||
|
||||
const sendMessage = (
|
||||
sessionId: string,
|
||||
payload: TSendMessagePayload,
|
||||
): Promise<TSendMessageResponse> =>
|
||||
requestJson<TSendMessageResponse>(
|
||||
`${apiBase}/${encodeURIComponent(sessionId)}/messages`,
|
||||
buildJsonPostInit(payload),
|
||||
messages.sendMessage,
|
||||
{
|
||||
retry: writeRetry,
|
||||
},
|
||||
);
|
||||
|
||||
const streamMessage = async (
|
||||
sessionId: string,
|
||||
payload: TSendMessagePayload,
|
||||
options: TextStreamOptions = {},
|
||||
): Promise<TSession> => {
|
||||
const response = await openCreationAgentSsePost(
|
||||
`${apiBase}/${encodeURIComponent(sessionId)}/messages/stream`,
|
||||
payload,
|
||||
messages.sendMessage,
|
||||
options.signal,
|
||||
);
|
||||
|
||||
return readCreationAgentSessionFromSse<TSession>(response, {
|
||||
...options,
|
||||
fallbackMessage: messages.sendMessage,
|
||||
incompleteMessage: messages.streamIncomplete,
|
||||
});
|
||||
};
|
||||
|
||||
const executeAction = (
|
||||
sessionId: string,
|
||||
payload: TExecuteActionPayload,
|
||||
): Promise<TExecuteActionResponse> =>
|
||||
requestJson<TExecuteActionResponse>(
|
||||
`${apiBase}/${encodeURIComponent(sessionId)}/actions`,
|
||||
buildJsonPostInit(payload),
|
||||
messages.executeAction,
|
||||
{
|
||||
retry: writeRetry,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
createSession,
|
||||
getSession,
|
||||
sendMessage,
|
||||
streamMessage,
|
||||
executeAction,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
parseCreationAgentDocumentInput,
|
||||
validateCreationAgentDocumentInputFile,
|
||||
} from './creationAgentDocumentInput';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('creation agent document input validation accepts supported text documents', () => {
|
||||
expect(() => {
|
||||
validateCreationAgentDocumentInputFile(
|
||||
new File(['世界设定'], '世界设定.MD', { type: 'text/markdown' }),
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test('creation agent document input validation rejects unsupported documents', () => {
|
||||
expect(() => {
|
||||
validateCreationAgentDocumentInputFile(
|
||||
new File(['binary'], '世界设定.docx', {
|
||||
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
}),
|
||||
);
|
||||
}).toThrow('暂时只支持 txt、md、csv、json 文本文档。');
|
||||
});
|
||||
|
||||
test('creation agent document input validation rejects oversized documents', () => {
|
||||
const oversizedContent = new Uint8Array(256 * 1024 + 1);
|
||||
|
||||
expect(() => {
|
||||
validateCreationAgentDocumentInputFile(
|
||||
new File([oversizedContent], '世界设定.txt', { type: 'text/plain' }),
|
||||
);
|
||||
}).toThrow('文档过大,请上传 256KB 以内的文本文件。');
|
||||
});
|
||||
|
||||
test('creation agent document input parse skips network for unsupported files', async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
await expect(
|
||||
parseCreationAgentDocumentInput(new File(['binary'], '世界设定.docx')),
|
||||
).rejects.toThrow('暂时只支持 txt、md、csv、json 文本文档。');
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
82
src/services/creation-agent/creationAgentDocumentInput.ts
Normal file
82
src/services/creation-agent/creationAgentDocumentInput.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
ParseCreationAgentDocumentInputRequest,
|
||||
ParseCreationAgentDocumentInputResponse,
|
||||
} from '../../../packages/shared/src/contracts/creationAgentDocumentInput';
|
||||
import { requestJson } from '../apiClient';
|
||||
|
||||
const DOCUMENT_INPUT_PARSE_ENDPOINT =
|
||||
'/api/runtime/creation-agent/document-inputs/parse';
|
||||
const MAX_DOCUMENT_INPUT_BYTES = 256 * 1024;
|
||||
const SUPPORTED_DOCUMENT_INPUT_EXTENSIONS = new Set([
|
||||
'txt',
|
||||
'md',
|
||||
'markdown',
|
||||
'csv',
|
||||
'json',
|
||||
]);
|
||||
|
||||
export async function parseCreationAgentDocumentInput(
|
||||
file: File,
|
||||
): Promise<ParseCreationAgentDocumentInputResponse> {
|
||||
validateCreationAgentDocumentInputFile(file);
|
||||
|
||||
const contentBase64 = await readFileAsBase64(file);
|
||||
const payload: ParseCreationAgentDocumentInputRequest = {
|
||||
fileName: file.name,
|
||||
contentType: file.type || null,
|
||||
contentBase64,
|
||||
};
|
||||
|
||||
return requestJson<ParseCreationAgentDocumentInputResponse>(
|
||||
DOCUMENT_INPUT_PARSE_ENDPOINT,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'解析文档失败',
|
||||
{
|
||||
retry: {
|
||||
maxRetries: 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function validateCreationAgentDocumentInputFile(file: File) {
|
||||
const fileName = file.name.trim();
|
||||
const extension = fileName.includes('.')
|
||||
? fileName.split('.').pop()?.trim().toLowerCase()
|
||||
: '';
|
||||
|
||||
if (!extension || !SUPPORTED_DOCUMENT_INPUT_EXTENSIONS.has(extension)) {
|
||||
throw new Error('暂时只支持 txt、md、csv、json 文本文档。');
|
||||
}
|
||||
|
||||
if (file.size <= 0) {
|
||||
throw new Error('文档内容为空,请选择有内容的文件。');
|
||||
}
|
||||
|
||||
if (file.size > MAX_DOCUMENT_INPUT_BYTES) {
|
||||
throw new Error('文档过大,请上传 256KB 以内的文本文件。');
|
||||
}
|
||||
}
|
||||
|
||||
function readFileAsBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onerror = () => {
|
||||
reject(new Error('读取文档失败,请重新选择文件。'));
|
||||
};
|
||||
reader.onload = () => {
|
||||
const result = typeof reader.result === 'string' ? reader.result : '';
|
||||
const commaIndex = result.indexOf(',');
|
||||
resolve(commaIndex >= 0 ? result.slice(commaIndex + 1) : result);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
77
src/services/creation-agent/creationAgentProgress.ts
Normal file
77
src/services/creation-agent/creationAgentProgress.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export type CreationAgentOperationLike = {
|
||||
status?: string | null;
|
||||
};
|
||||
|
||||
export type CreationAgentProgressCopy = {
|
||||
completed?: string;
|
||||
high?: string;
|
||||
medium?: string;
|
||||
low?: string;
|
||||
initial?: string;
|
||||
};
|
||||
|
||||
export function normalizeCreationAgentProgress(progressPercent: number) {
|
||||
if (!Number.isFinite(progressPercent)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(progressPercent)));
|
||||
}
|
||||
|
||||
export function isCreationAgentOperationBusy(
|
||||
operation: CreationAgentOperationLike | null | undefined,
|
||||
) {
|
||||
return operation?.status === 'queued' || operation?.status === 'running';
|
||||
}
|
||||
|
||||
export function resolveCreationAgentProgressHint(
|
||||
progressPercent: number,
|
||||
copy: CreationAgentProgressCopy = {},
|
||||
) {
|
||||
const normalizedProgress = normalizeCreationAgentProgress(progressPercent);
|
||||
|
||||
if (normalizedProgress >= 100) {
|
||||
return copy.completed || '当前设定已经收束完成,可以进入结果页生成';
|
||||
}
|
||||
|
||||
if (normalizedProgress >= 75) {
|
||||
return copy.high || '关键锚点基本成形,正在收束成可生成草稿的版本';
|
||||
}
|
||||
|
||||
if (normalizedProgress >= 45) {
|
||||
return copy.medium || '方向已经成形,继续补齐会影响体验的关键锚点';
|
||||
}
|
||||
|
||||
if (normalizedProgress >= 15) {
|
||||
return copy.low || '先把玩家一眼能感知的核心体验钉稳';
|
||||
}
|
||||
|
||||
return copy.initial || '先抓住这个创作品类最关键的方向';
|
||||
}
|
||||
|
||||
export function resolveCreationAnchorStatusLabel(status: string) {
|
||||
if (status === 'locked') {
|
||||
return '已锁定';
|
||||
}
|
||||
|
||||
if (status === 'confirmed') {
|
||||
return '已确认';
|
||||
}
|
||||
|
||||
if (status === 'inferred') {
|
||||
return '推断中';
|
||||
}
|
||||
|
||||
return '待补充';
|
||||
}
|
||||
|
||||
export function createCreationAgentClientMessageId(prefix: string) {
|
||||
if (
|
||||
typeof crypto !== 'undefined' &&
|
||||
typeof crypto.randomUUID === 'function'
|
||||
) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
return `${prefix}-client-message-${Date.now()}`;
|
||||
}
|
||||
53
src/services/creation-agent/creationAgentSse.test.ts
Normal file
53
src/services/creation-agent/creationAgentSse.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { readCreationAgentSessionFromSse } from './creationAgentSse';
|
||||
|
||||
function createChunkedStreamResponse(chunks: Uint8Array[]) {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(chunk);
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('readCreationAgentSessionFromSse flushes decoder tail and handles CRLF boundaries', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const prefix = encoder.encode('event: reply_delta\r\ndata: {"text":"');
|
||||
const replyTextBytes = encoder.encode('你好,潮雾列岛');
|
||||
const suffix = encoder.encode(
|
||||
'"}\r\n\r\nevent: session\r\ndata: {"session":{"sessionId":"session-1","title":"世界共创"}}\r\n\r\n',
|
||||
);
|
||||
const splitIndex = replyTextBytes.length - 1;
|
||||
|
||||
const chunks = [
|
||||
new Uint8Array([...prefix, ...replyTextBytes.slice(0, splitIndex)]),
|
||||
new Uint8Array([...replyTextBytes.slice(splitIndex), ...suffix]),
|
||||
];
|
||||
|
||||
const updates: string[] = [];
|
||||
const session = await readCreationAgentSessionFromSse<{
|
||||
sessionId: string;
|
||||
title: string;
|
||||
}>(createChunkedStreamResponse(chunks), {
|
||||
fallbackMessage: '发送失败',
|
||||
incompleteMessage: '结果不完整',
|
||||
onUpdate: (text) => {
|
||||
updates.push(text);
|
||||
},
|
||||
});
|
||||
|
||||
expect(updates).toEqual(['你好,潮雾列岛']);
|
||||
expect(session).toEqual({
|
||||
sessionId: 'session-1',
|
||||
title: '世界共创',
|
||||
});
|
||||
});
|
||||
178
src/services/creation-agent/creationAgentSse.ts
Normal file
178
src/services/creation-agent/creationAgentSse.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
|
||||
type CreationAgentSseOptions<TSession> = TextStreamOptions & {
|
||||
fallbackMessage: string;
|
||||
incompleteMessage: string;
|
||||
resolveSession?: (rawSession: unknown) => TSession | null;
|
||||
};
|
||||
|
||||
function findSseEventBoundary(buffer: string) {
|
||||
const lfBoundary = buffer.indexOf('\n\n');
|
||||
const crlfBoundary = buffer.indexOf('\r\n\r\n');
|
||||
|
||||
if (lfBoundary === -1 && crlfBoundary === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (lfBoundary === -1) {
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
if (crlfBoundary === -1 || lfBoundary < crlfBoundary) {
|
||||
return {
|
||||
index: lfBoundary,
|
||||
length: 2,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
index: crlfBoundary,
|
||||
length: 4,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSseEventBlock(eventBlock: string) {
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim() || 'message';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eventName,
|
||||
data: dataLines.join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonObject(data: string) {
|
||||
try {
|
||||
return JSON.parse(data) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readCreationAgentSessionFromSse<TSession>(
|
||||
response: Response,
|
||||
options: CreationAgentSseOptions<TSession>,
|
||||
) {
|
||||
const streamBody = response.body;
|
||||
if (!streamBody) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
const reader = streamBody.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const resolveSession =
|
||||
options.resolveSession ??
|
||||
((rawSession: unknown) => (rawSession as TSession | null) ?? null);
|
||||
let buffer = '';
|
||||
let finalSession: TSession | null = null;
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
for (;;) {
|
||||
const boundary = findSseEventBoundary(buffer);
|
||||
if (!boundary) {
|
||||
break;
|
||||
}
|
||||
|
||||
const eventBlock = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary.length);
|
||||
const { eventName, data } = parseSseEventBlock(eventBlock);
|
||||
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseJsonObject(data);
|
||||
|
||||
if (eventName === 'reply_delta' && parsed) {
|
||||
const text = parsed.text;
|
||||
if (typeof text === 'string') {
|
||||
options.onUpdate?.(text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'session' && parsed?.session) {
|
||||
finalSession = resolveSession(parsed.session);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'error' && parsed) {
|
||||
const message =
|
||||
typeof parsed.message === 'string' && parsed.message.trim()
|
||||
? parsed.message.trim()
|
||||
: options.fallbackMessage;
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 流结束后再 flush 一次解码器,避免 UTF-8 多字节字符残留在内部缓冲里。
|
||||
buffer += decoder.decode();
|
||||
|
||||
for (;;) {
|
||||
const boundary = findSseEventBoundary(buffer);
|
||||
if (!boundary) {
|
||||
break;
|
||||
}
|
||||
|
||||
const eventBlock = buffer.slice(0, boundary.index);
|
||||
buffer = buffer.slice(boundary.index + boundary.length);
|
||||
const { eventName, data } = parseSseEventBlock(eventBlock);
|
||||
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseJsonObject(data);
|
||||
|
||||
if (eventName === 'reply_delta' && parsed) {
|
||||
const text = parsed.text;
|
||||
if (typeof text === 'string') {
|
||||
options.onUpdate?.(text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'session' && parsed?.session) {
|
||||
finalSession = resolveSession(parsed.session);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (eventName === 'error' && parsed) {
|
||||
const message =
|
||||
typeof parsed.message === 'string' && parsed.message.trim()
|
||||
? parsed.message.trim()
|
||||
: options.fallbackMessage;
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalSession) {
|
||||
throw new Error(options.incompleteMessage);
|
||||
}
|
||||
|
||||
return finalSession;
|
||||
}
|
||||
5
src/services/creation-agent/index.ts
Normal file
5
src/services/creation-agent/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './creationAgentClientFactory';
|
||||
export * from './creationAgentChat';
|
||||
export * from './creationAgentDocumentInput';
|
||||
export * from './creationAgentProgress';
|
||||
export * from './creationAgentSse';
|
||||
281
src/services/customWorld.test.ts
Normal file
281
src/services/customWorld.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
|
||||
import { getCurrencyName } from '../data/economy';
|
||||
import { WorldType } from '../types';
|
||||
import { normalizeCustomWorldProfile } from './customWorld';
|
||||
|
||||
describe('normalizeCustomWorldProfile', () => {
|
||||
it('forces NPC backstory chapter thresholds to match shared affinity levels', () => {
|
||||
const rawChapterThresholds = [20, 40, 65, 85];
|
||||
const rawProfile = {
|
||||
name: '裂谷边城',
|
||||
playableNpcs: [
|
||||
{
|
||||
name: '沈砺',
|
||||
title: '灰炬向导',
|
||||
role: '向导',
|
||||
description: '常年带人穿过裂谷旧道。',
|
||||
backstory: '曾在塌桥夜里失去整支同行队伍。',
|
||||
personality: '谨慎寡言,却记得每一道风口。',
|
||||
motivation: '想查清旧道频繁异变的根源。',
|
||||
combatStyle: '短弓牵制后再逼近补刀。',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['带路', '旧案'],
|
||||
tags: ['裂谷', '向导'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '他只说自己熟悉旧道。',
|
||||
chapters: rawChapterThresholds.map((affinityRequired, index) => ({
|
||||
id: `playable-${index + 1}`,
|
||||
title: `章节${index + 1}`,
|
||||
affinityRequired,
|
||||
teaser: `提示${index + 1}`,
|
||||
content: `内容${index + 1}`,
|
||||
contextSnippet: `摘要${index + 1}`,
|
||||
})),
|
||||
},
|
||||
skills: [
|
||||
{ name: '灰炬起手', summary: '先以火光扰乱视线。', style: '起手压制' },
|
||||
{ name: '窄道游移', summary: '借地形不断换位牵制。', style: '机动周旋' },
|
||||
{ name: '崖风绝射', summary: '抓住破绽给出终结一箭。', style: '爆发终结' },
|
||||
],
|
||||
initialItems: [
|
||||
{ name: '旧道短弓', category: '武器', quantity: 1, rarity: 'rare', description: '磨损严重却极趁手。', tags: ['裂谷'] },
|
||||
{ name: '裂谷补给', category: '消耗品', quantity: 2, rarity: 'uncommon', description: '防风与止血一并备齐。', tags: ['补给'] },
|
||||
{ name: '断绳铜哨', category: '专属物品', quantity: 1, rarity: 'rare', description: '那场事故后仅存的信物。', tags: ['旧案'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
name: '裂谷巡哨蛛',
|
||||
title: '巡哨怪',
|
||||
role: '怪物哨兵',
|
||||
description: '伏在岩壁缝间监视往来活物。',
|
||||
backstory: '长期吞食矿脉异潮后逐渐拥有巡猎习性。',
|
||||
personality: '极度警觉,会反复试探猎物退路。',
|
||||
motivation: '守住巢穴上层不断扩大的裂口。',
|
||||
combatStyle: '吐丝封路,再借高处俯冲撕咬。',
|
||||
initialAffinity: -20,
|
||||
relationshipHooks: ['巢穴', '异潮'],
|
||||
tags: ['怪物', '裂谷'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '它始终盘踞在峭壁阴影里。',
|
||||
chapters: rawChapterThresholds.map((affinityRequired, index) => ({
|
||||
id: `story-${index + 1}`,
|
||||
title: `章节${index + 1}`,
|
||||
affinityRequired,
|
||||
teaser: `怪物提示${index + 1}`,
|
||||
content: `怪物内容${index + 1}`,
|
||||
contextSnippet: `怪物摘要${index + 1}`,
|
||||
})),
|
||||
},
|
||||
skills: [
|
||||
{ name: '蛛丝封步', summary: '先缠住脚步再逼近。', style: '起手压制' },
|
||||
{ name: '壁缝换位', summary: '沿岩壁快速转移位置。', style: '机动周旋' },
|
||||
{ name: '坠崖扑杀', summary: '从高处俯冲撕裂目标。', style: '爆发终结' },
|
||||
],
|
||||
initialItems: [
|
||||
{ name: '硬化毒牙', category: '材料', quantity: 1, rarity: 'rare', description: '可提炼出刺激性毒液。', tags: ['怪物'] },
|
||||
{ name: '粘稠丝囊', category: '材料', quantity: 2, rarity: 'uncommon', description: '能用于制作束缚陷阱。', tags: ['巢穴'] },
|
||||
{ name: '矿潮节壳', category: '稀有品', quantity: 1, rarity: 'rare', description: '受异潮侵染后的外壳碎片。', tags: ['异潮'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
name: '北侧塌桥',
|
||||
description: '横跨裂谷的旧桥只剩半截石拱。',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const profile = normalizeCustomWorldProfile(rawProfile, '玩家想要一个裂谷边城与怪物共存的世界。');
|
||||
|
||||
expect(
|
||||
profile.playableNpcs[0]?.backstoryReveal.chapters.map(
|
||||
(chapter) => chapter.affinityRequired,
|
||||
),
|
||||
).toEqual(AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS);
|
||||
expect(
|
||||
profile.storyNpcs[0]?.backstoryReveal.chapters.map(
|
||||
(chapter) => chapter.affinityRequired,
|
||||
),
|
||||
).toEqual(AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS);
|
||||
});
|
||||
|
||||
it('resolves landmark scene NPCs and relative connections into the final scene graph', () => {
|
||||
const rawProfile = {
|
||||
name: '裂界巡旅',
|
||||
playableNpcs: [
|
||||
{
|
||||
name: '岑舟',
|
||||
title: '裂界行脚',
|
||||
role: '引路人',
|
||||
description: '擅长在断层边缘辨路。',
|
||||
backstory: '长期在裂界边缘押送队伍。',
|
||||
personality: '稳重少言,但反应很快。',
|
||||
motivation: '想把几条旧通路重新串起来。',
|
||||
combatStyle: '短兵贴身后迅速换位。',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['带路', '断层'],
|
||||
tags: ['裂界', '向导'],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
name: '梁砺',
|
||||
title: '桥索修补匠',
|
||||
role: '修桥人',
|
||||
description: '守着断桥口修缮索道。',
|
||||
backstory: '曾在崩桥夜里救下半队人。',
|
||||
personality: '谨慎,习惯先看绳结再说话。',
|
||||
motivation: '想守住最后几条安全通路。',
|
||||
combatStyle: '铁钩牵制后贴近补击。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['断桥', '索道'],
|
||||
tags: ['桥', '工匠'],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
{
|
||||
name: '苏雾',
|
||||
title: '雾港采录者',
|
||||
role: '记录员',
|
||||
description: '在雾港整理各路来客口供。',
|
||||
backstory: '长期记录裂雾里消失的队伍名单。',
|
||||
personality: '敏感细致,总在核对细节。',
|
||||
motivation: '查清名单上重复出现的名字。',
|
||||
combatStyle: '保持距离,借器物扰乱节奏。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['雾港', '名单'],
|
||||
tags: ['港口', '记录'],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
{
|
||||
name: '顾岚',
|
||||
title: '界崖巡哨',
|
||||
role: '巡哨',
|
||||
description: '沿着崖线巡查异动和回声。',
|
||||
backstory: '常年住在界崖边的哨点里。',
|
||||
personality: '警觉直接,不喜欢绕弯。',
|
||||
motivation: '找出最近总在夜里响起的回声来源。',
|
||||
combatStyle: '长兵抢先压住身位。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['巡查', '崖线'],
|
||||
tags: ['哨点', '崖线'],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
{
|
||||
name: '闻砂',
|
||||
title: '砂塔守更人',
|
||||
role: '守更人',
|
||||
description: '夜里守着砂塔边的旧灯火。',
|
||||
backstory: '见过太多从塔下走失的人。',
|
||||
personality: '冷静克制,习惯留后手。',
|
||||
motivation: '想确认旧塔下方的回响是否重新苏醒。',
|
||||
combatStyle: '借高差压制后再收拢路线。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['守夜', '砂塔'],
|
||||
tags: ['砂塔', '旧灯'],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
name: '北侧塌桥',
|
||||
description: '断桥上方还残留着旧索道。',
|
||||
sceneNpcNames: ['梁砺'],
|
||||
connections: [
|
||||
{
|
||||
targetLandmarkName: '雾潮码头',
|
||||
relativePosition: 'south',
|
||||
summary: '顺着残桥往南下坡可到雾港。',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '雾潮码头',
|
||||
description: '潮雾会把来路和去路都遮住一半。',
|
||||
sceneNpcNames: ['苏雾', '顾岚'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const profile = normalizeCustomWorldProfile(
|
||||
rawProfile,
|
||||
'玩家想要一个围绕裂界断桥与雾港巡旅展开的世界。',
|
||||
);
|
||||
|
||||
expect(profile.landmarks).toHaveLength(2);
|
||||
expect(profile.landmarks[0]?.sceneNpcIds).toHaveLength(3);
|
||||
expect(profile.landmarks[1]?.sceneNpcIds).toHaveLength(3);
|
||||
expect(profile.landmarks[0]?.connections[0]?.targetLandmarkId).toBe(
|
||||
profile.landmarks[1]?.id,
|
||||
);
|
||||
expect(profile.landmarks[1]?.connections.some(
|
||||
(connection) => connection.targetLandmarkId === profile.landmarks[0]?.id,
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('compiles and preserves owned setting layers for runtime consumption', () => {
|
||||
const profile = normalizeCustomWorldProfile(
|
||||
{
|
||||
name: '雾潮港',
|
||||
summary: '被潮灾旧闻反复撕开的边港。',
|
||||
tone: '潮湿、迷雾、压抑',
|
||||
playerGoal: '查清港区失踪名单为何重复出现',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
ownedSettingLayers: {
|
||||
ruleProfile: {
|
||||
resourceLabels: {
|
||||
hp: '潮命',
|
||||
mp: '潮息',
|
||||
maxHp: '潮命上限',
|
||||
maxMp: '潮息上限',
|
||||
damage: '潮势',
|
||||
guard: '潮护',
|
||||
range: '潮距',
|
||||
cooldown: '回潮',
|
||||
manaCost: '潮息消耗',
|
||||
currency: '雾银',
|
||||
},
|
||||
economyProfile: {
|
||||
initialCurrency: 188,
|
||||
},
|
||||
},
|
||||
semanticAnchor: {
|
||||
genreSignals: ['海岸悬疑'],
|
||||
conflictForms: ['追查失踪'],
|
||||
institutionTypes: ['港务'],
|
||||
tabooTypes: ['回潮夜'],
|
||||
carrierTypes: ['航图'],
|
||||
forceSystemTypes: ['潮汐'],
|
||||
atmosphereTags: ['迷雾'],
|
||||
},
|
||||
},
|
||||
},
|
||||
'玩家想要一个围绕迷雾港区与潮灾旧闻展开的世界。',
|
||||
);
|
||||
|
||||
expect(profile.ownedSettingLayers?.ruleProfile.resourceLabels.currency).toBe(
|
||||
'雾银',
|
||||
);
|
||||
expect(profile.ownedSettingLayers?.ruleProfile.economyProfile.initialCurrency).toBe(
|
||||
188,
|
||||
);
|
||||
expect(getCurrencyName(WorldType.CUSTOM, profile)).toBe('雾银');
|
||||
expect(
|
||||
profile.ownedSettingLayers?.compatibilityProfile?.legacyTemplateWorldType,
|
||||
).toBe(WorldType.WUXIA);
|
||||
expect(
|
||||
profile.ownedSettingLayers?.referenceProfile.creatureArchetypes.length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
1538
src/services/customWorld.ts
Normal file
1538
src/services/customWorld.ts
Normal file
File diff suppressed because it is too large
Load Diff
231
src/services/customWorldAgentGenerationProgress.test.ts
Normal file
231
src/services/customWorldAgentGenerationProgress.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type {
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import {
|
||||
buildAgentDraftFoundationGenerationProgress,
|
||||
buildAgentDraftFoundationSettingText,
|
||||
isDraftFoundationOperationRunning,
|
||||
} from './customWorldAgentGenerationProgress';
|
||||
|
||||
const baseOperation: CustomWorldAgentOperationRecord = {
|
||||
operationId: 'operation-1',
|
||||
type: 'draft_foundation',
|
||||
status: 'running',
|
||||
phaseLabel: '生成场景角色',
|
||||
phaseDetail: '正在生成场景角色第 1 / 1 批,当前已完成 0/4。',
|
||||
progress: 38,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const baseSession: CustomWorldAgentSessionSnapshot = {
|
||||
sessionId: 'session-1',
|
||||
currentTurn: 8,
|
||||
anchorContent: {
|
||||
worldPromise:
|
||||
'海雾、旧灯塔和失控航路交织的边缘群岛,每次借路都要向海雾付出新的代价,体验压抑、悬疑、潮湿。',
|
||||
playerFantasy:
|
||||
'玩家刚回到群岛,准备调查父亲沉船的真相,追查沉船夜和禁航区异动的因果,风险是再失去唯一还敢接近真相的人。',
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: null,
|
||||
hiddenLines: null,
|
||||
iconicElements: '会移动的海雾、旧灯塔。',
|
||||
},
|
||||
progressPercent: 100,
|
||||
lastAssistantReply: '八锚点已经收束完成,可以进入游戏设定草稿生成。',
|
||||
stage: 'foundation_review',
|
||||
focusCardId: null,
|
||||
creatorIntent: {
|
||||
sourceMode: 'card',
|
||||
worldHook: '海雾、旧灯塔和失控航路交织的边缘群岛',
|
||||
themeKeywords: ['海雾', '灯塔', '旧航路'],
|
||||
toneDirectives: ['压抑', '悬疑'],
|
||||
playerPremise: '玩家刚回到群岛,准备调查父亲沉船的真相。',
|
||||
openingSituation: '首夜就有陌生船只在禁航区点灯。',
|
||||
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
|
||||
keyFactions: [],
|
||||
keyCharacters: [],
|
||||
keyLandmarks: [],
|
||||
iconicElements: ['会移动的海雾'],
|
||||
forbiddenDirectives: [],
|
||||
rawSettingText: '',
|
||||
},
|
||||
creatorIntentReadiness: {
|
||||
isReady: true,
|
||||
completedKeys: [],
|
||||
missingKeys: [],
|
||||
},
|
||||
anchorPack: null,
|
||||
lockState: null,
|
||||
draftProfile: null,
|
||||
messages: [
|
||||
{
|
||||
id: 'message-1',
|
||||
role: 'user',
|
||||
kind: 'chat',
|
||||
text: '我想做一个被海雾吞没的旧航路世界。',
|
||||
createdAt: '2026-04-14T10:00:00.000Z',
|
||||
relatedOperationId: null,
|
||||
},
|
||||
],
|
||||
draftCards: [],
|
||||
pendingClarifications: [],
|
||||
suggestedActions: [],
|
||||
recommendedReplies: [],
|
||||
qualityFindings: [],
|
||||
assetCoverage: {
|
||||
roleAssets: [],
|
||||
sceneAssets: [],
|
||||
allRoleAssetsReady: false,
|
||||
allSceneAssetsReady: false,
|
||||
},
|
||||
updatedAt: '2026-04-14T10:00:00.000Z',
|
||||
};
|
||||
|
||||
test('maps running draft_foundation operation to refined generation progress steps', () => {
|
||||
const progress = buildAgentDraftFoundationGenerationProgress(
|
||||
baseOperation,
|
||||
1_000,
|
||||
5_000,
|
||||
);
|
||||
|
||||
expect(progress).not.toBeNull();
|
||||
expect(progress?.phaseId).toBe('story-outline');
|
||||
expect(progress?.batchLabel).toBe('生成场景角色');
|
||||
expect(progress?.overallProgress).toBe(38);
|
||||
expect(progress?.elapsedMs).toBe(4_000);
|
||||
expect(progress?.estimatedRemainingMs).toBeGreaterThan(0);
|
||||
expect(progress?.steps).toHaveLength(13);
|
||||
expect(progress?.steps.map((step) => step.status)).toEqual([
|
||||
'completed',
|
||||
'completed',
|
||||
'completed',
|
||||
'active',
|
||||
'pending',
|
||||
'pending',
|
||||
'pending',
|
||||
'pending',
|
||||
'pending',
|
||||
'pending',
|
||||
'pending',
|
||||
'pending',
|
||||
'pending',
|
||||
]);
|
||||
expect(isDraftFoundationOperationRunning(baseOperation)).toBe(true);
|
||||
});
|
||||
|
||||
test('calculates elapsed time from operation startedAt before local fallback', () => {
|
||||
const progress = buildAgentDraftFoundationGenerationProgress(
|
||||
{
|
||||
...baseOperation,
|
||||
startedAt: '1970-01-01T00:00:01.000Z',
|
||||
},
|
||||
4_000,
|
||||
6_000,
|
||||
);
|
||||
|
||||
expect(progress?.elapsedMs).toBe(5_000);
|
||||
});
|
||||
|
||||
test('maps auto asset phases to refined generation progress steps', () => {
|
||||
const progress = buildAgentDraftFoundationGenerationProgress(
|
||||
{
|
||||
...baseOperation,
|
||||
phaseLabel: '生成幕背景图',
|
||||
phaseDetail: '正在生成幕背景图 3/6:潮汐码头 · 封锁加压。',
|
||||
progress: 99,
|
||||
},
|
||||
1_000,
|
||||
5_000,
|
||||
);
|
||||
|
||||
expect(progress?.phaseId).toBe('act-backgrounds');
|
||||
expect(progress?.batchLabel).toBe('生成幕背景图');
|
||||
expect(progress?.steps.filter((step) => step.status === 'completed')).toHaveLength(
|
||||
10,
|
||||
);
|
||||
expect(progress?.steps[10]?.status).toBe('active');
|
||||
});
|
||||
|
||||
test('marks all refined progress steps complete when draft foundation finishes', () => {
|
||||
const progress = buildAgentDraftFoundationGenerationProgress(
|
||||
{
|
||||
...baseOperation,
|
||||
status: 'completed',
|
||||
phaseLabel: '世界底稿已生成',
|
||||
phaseDetail: '第一版世界底稿和 6 张草稿卡已经整理完成。',
|
||||
progress: 100,
|
||||
},
|
||||
1_000,
|
||||
5_000,
|
||||
);
|
||||
|
||||
expect(progress?.phaseId).toBe('workspace');
|
||||
expect(progress?.estimatedRemainingMs).toBe(0);
|
||||
expect(progress?.steps.every((step) => step.status === 'completed')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps failed draft foundation progress on explicit failure state instead of pretending it is still compiling cards', () => {
|
||||
const progress = buildAgentDraftFoundationGenerationProgress(
|
||||
{
|
||||
...baseOperation,
|
||||
status: 'failed',
|
||||
phaseLabel: '底稿生成失败',
|
||||
phaseDetail: '角色主形象补齐失败,但世界底稿尚未完成写回。',
|
||||
progress: 100,
|
||||
error: 'dashscope timeout',
|
||||
},
|
||||
1_000,
|
||||
5_000,
|
||||
);
|
||||
|
||||
expect(progress?.phaseId).toBe('failed');
|
||||
expect(progress?.phaseLabel).toBe('底稿生成失败');
|
||||
expect(progress?.phaseDetail).toContain('角色主形象补齐失败');
|
||||
expect(progress?.overallProgress).toBeLessThan(100);
|
||||
expect(progress?.estimatedRemainingMs).toBeNull();
|
||||
expect(progress?.steps.some((step) => step.label === '编译草稿卡')).toBe(true);
|
||||
expect(progress?.steps.some((step) => step.status === 'active')).toBe(false);
|
||||
expect(progress?.steps.filter((step) => step.status === 'completed').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('estimates draft generation wait time from phase duration model instead of linear progress', () => {
|
||||
const progress = buildAgentDraftFoundationGenerationProgress(
|
||||
{
|
||||
...baseOperation,
|
||||
phaseLabel: '生成幕背景图',
|
||||
phaseDetail: '正在生成幕背景图 1/6:潮汐码头。',
|
||||
progress: 98,
|
||||
updatedAt: '1970-01-01T00:00:01.000Z',
|
||||
},
|
||||
1_000,
|
||||
6_000,
|
||||
);
|
||||
|
||||
expect(progress?.estimatedRemainingMs).toBeGreaterThan(80_000);
|
||||
expect(progress?.estimatedRemainingMs).toBeLessThan(140_000);
|
||||
});
|
||||
|
||||
test('builds readable draft setting text from creator intent first', () => {
|
||||
const settingText = buildAgentDraftFoundationSettingText(baseSession);
|
||||
|
||||
expect(settingText).toContain('世界一句话');
|
||||
expect(settingText).toContain('玩家开局');
|
||||
expect(settingText).toContain('标志元素');
|
||||
});
|
||||
|
||||
test('falls back to anchor content when creator intent is unavailable', () => {
|
||||
const settingText = buildAgentDraftFoundationSettingText({
|
||||
...baseSession,
|
||||
creatorIntent: null,
|
||||
});
|
||||
|
||||
expect(settingText).toContain('世界承诺');
|
||||
expect(settingText).toContain('玩家幻想');
|
||||
});
|
||||
502
src/services/customWorldAgentGenerationProgress.ts
Normal file
502
src/services/customWorldAgentGenerationProgress.ts
Normal file
@@ -0,0 +1,502 @@
|
||||
import type {
|
||||
CustomWorldAgentOperationRecord,
|
||||
CustomWorldAgentSessionSnapshot,
|
||||
EightAnchorContent,
|
||||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
CustomWorldGenerationStep,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
buildCustomWorldCreatorIntentFoundationText,
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
} from './customWorldCreatorIntent';
|
||||
|
||||
export type CustomWorldStructuredAnchorEntry = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
function normalizeAnchorText(value: string | null | undefined) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function buildEightAnchorFoundationText(anchorContent: EightAnchorContent) {
|
||||
return [
|
||||
['世界承诺', anchorContent.worldPromise],
|
||||
['玩家幻想', anchorContent.playerFantasy],
|
||||
['主题边界', anchorContent.themeBoundary],
|
||||
['玩家切入口', anchorContent.playerEntryPoint],
|
||||
['核心冲突', anchorContent.coreConflict],
|
||||
['关键关系', anchorContent.keyRelationships],
|
||||
['暗线与揭示', anchorContent.hiddenLines],
|
||||
['标志元素', anchorContent.iconicElements],
|
||||
]
|
||||
.map(([label, value]) => {
|
||||
const text = normalizeAnchorText(value);
|
||||
return text ? `${label}:${text}` : '';
|
||||
})
|
||||
.filter((line) => line)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildAgentDraftFoundationAnchorEntries(
|
||||
session: CustomWorldAgentSessionSnapshot | null | undefined,
|
||||
): CustomWorldStructuredAnchorEntry[] {
|
||||
if (!session) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const anchorContent = session.anchorContent;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'world-promise',
|
||||
label: '世界承诺',
|
||||
value: normalizeAnchorText(anchorContent.worldPromise),
|
||||
},
|
||||
{
|
||||
id: 'player-fantasy',
|
||||
label: '玩家幻想',
|
||||
value: normalizeAnchorText(anchorContent.playerFantasy),
|
||||
},
|
||||
{
|
||||
id: 'theme-boundary',
|
||||
label: '主题边界',
|
||||
value: normalizeAnchorText(anchorContent.themeBoundary),
|
||||
},
|
||||
{
|
||||
id: 'player-entry-point',
|
||||
label: '玩家切入口',
|
||||
value: normalizeAnchorText(anchorContent.playerEntryPoint),
|
||||
},
|
||||
{
|
||||
id: 'core-conflict',
|
||||
label: '核心冲突',
|
||||
value: normalizeAnchorText(anchorContent.coreConflict),
|
||||
},
|
||||
{
|
||||
id: 'key-relationships',
|
||||
label: '关键关系',
|
||||
value: normalizeAnchorText(anchorContent.keyRelationships),
|
||||
},
|
||||
{
|
||||
id: 'hidden-lines',
|
||||
label: '暗线与揭示',
|
||||
value: normalizeAnchorText(anchorContent.hiddenLines),
|
||||
},
|
||||
{
|
||||
id: 'iconic-elements',
|
||||
label: '标志元素',
|
||||
value: normalizeAnchorText(anchorContent.iconicElements),
|
||||
},
|
||||
].filter((entry) => entry.value.trim());
|
||||
}
|
||||
|
||||
type AgentDraftFoundationStepDefinition = {
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
matchers: string[];
|
||||
minProgress: number;
|
||||
expectedDurationMs: number;
|
||||
};
|
||||
|
||||
type AgentDraftFoundationFailedStep = {
|
||||
id: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
// 这里按真实服务端 phaseLabel 归并步骤,避免把草稿生成硬折成 4 个失真的阶段。
|
||||
const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
|
||||
{
|
||||
id: 'queue',
|
||||
label: '接收生成请求',
|
||||
detail: '正在校验当前锚点并准备底稿编译链路。',
|
||||
matchers: ['已接收请求'],
|
||||
minProgress: 0,
|
||||
expectedDurationMs: 3_000,
|
||||
},
|
||||
{
|
||||
id: 'framework',
|
||||
label: '整理世界骨架',
|
||||
detail: '正在生成第一版世界框架、主题与核心冲突。',
|
||||
matchers: ['整理世界骨架', '生成世界底稿'],
|
||||
minProgress: 12,
|
||||
expectedDurationMs: 25_000,
|
||||
},
|
||||
{
|
||||
id: 'playable-outline',
|
||||
label: '生成可扮演角色',
|
||||
detail: '正在补出玩家视角角色的首轮名单与定位。',
|
||||
matchers: ['生成可扮演角色'],
|
||||
minProgress: 16,
|
||||
expectedDurationMs: 18_000,
|
||||
},
|
||||
{
|
||||
id: 'story-outline',
|
||||
label: '生成场景角色',
|
||||
detail: '正在整理关键 NPC、势力接口人与关系入口。',
|
||||
matchers: ['生成场景角色'],
|
||||
minProgress: 30,
|
||||
expectedDurationMs: 45_000,
|
||||
},
|
||||
{
|
||||
id: 'landmark-seed',
|
||||
label: '生成关键场景',
|
||||
detail: '正在补出关键场景、幕 NPC 与地点连接。',
|
||||
matchers: ['生成关键场景'],
|
||||
minProgress: 44,
|
||||
expectedDurationMs: 36_000,
|
||||
},
|
||||
{
|
||||
id: 'playable-detail',
|
||||
label: '补全可扮演角色细节',
|
||||
detail: '正在补全可扮演角色的叙事基础与档案细节。',
|
||||
matchers: ['补全可扮演角色'],
|
||||
minProgress: 66,
|
||||
expectedDurationMs: 32_000,
|
||||
},
|
||||
{
|
||||
id: 'story-detail',
|
||||
label: '补全场景角色细节',
|
||||
detail: '正在补全场景角色的叙事基础与档案细节。',
|
||||
matchers: ['补全场景角色'],
|
||||
minProgress: 84,
|
||||
expectedDurationMs: 65_000,
|
||||
},
|
||||
{
|
||||
id: 'finalize',
|
||||
label: '编译世界底稿',
|
||||
detail: '正在把分批生成结果汇总成第一版可浏览的世界底稿。',
|
||||
matchers: ['编译世界底稿'],
|
||||
minProgress: 97,
|
||||
expectedDurationMs: 6_000,
|
||||
},
|
||||
{
|
||||
id: 'role-visuals',
|
||||
label: '生成角色主形象',
|
||||
detail: '正在为关键角色补主形象预览资源。',
|
||||
matchers: ['生成角色主形象'],
|
||||
minProgress: 97,
|
||||
expectedDurationMs: 85_000,
|
||||
},
|
||||
{
|
||||
id: 'act-backgrounds',
|
||||
label: '生成幕背景图',
|
||||
detail: '正在为场景章节的每一幕补背景图预览资源。',
|
||||
matchers: ['生成幕背景图'],
|
||||
minProgress: 98,
|
||||
expectedDurationMs: 85_000,
|
||||
},
|
||||
{
|
||||
id: 'cards',
|
||||
label: '编译草稿卡',
|
||||
detail: '正在整理世界卡、角色卡、地点卡与详情结构。',
|
||||
matchers: ['编译草稿卡'],
|
||||
minProgress: 99,
|
||||
expectedDurationMs: 15_000,
|
||||
},
|
||||
{
|
||||
id: 'workspace',
|
||||
label: '准备结果页',
|
||||
detail: '正在写回草稿数据,并打开可继续完善的结果页。',
|
||||
matchers: ['世界底稿已生成'],
|
||||
minProgress: 100,
|
||||
expectedDurationMs: 4_000,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<AgentDraftFoundationStepDefinition>;
|
||||
|
||||
const AGENT_DRAFT_FOUNDATION_FAILED_STEP = {
|
||||
id: 'failed',
|
||||
label: '生成失败',
|
||||
detail: '这一轮世界草稿没有编译完成,可以返回工作区补充设定后重试。',
|
||||
} as const satisfies AgentDraftFoundationFailedStep;
|
||||
|
||||
function clampProgress(progress: number | null | undefined) {
|
||||
if (typeof progress !== 'number' || Number.isNaN(progress)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(progress)));
|
||||
}
|
||||
|
||||
function resolveAgentDraftFoundationStepIndexByProgress(progress: number) {
|
||||
let matchedIndex = 0;
|
||||
|
||||
for (
|
||||
let index = 0;
|
||||
index < AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.length - 1;
|
||||
index += 1
|
||||
) {
|
||||
const step = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index];
|
||||
if (step && progress >= step.minProgress) {
|
||||
matchedIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
return matchedIndex;
|
||||
}
|
||||
|
||||
function resolveFailedProgress(
|
||||
operation: CustomWorldAgentOperationRecord,
|
||||
activeStepIndex: number,
|
||||
) {
|
||||
const progress = clampProgress(operation.progress);
|
||||
|
||||
if (operation.status !== 'failed') {
|
||||
return progress;
|
||||
}
|
||||
|
||||
if (progress < 100) {
|
||||
return progress;
|
||||
}
|
||||
|
||||
const activeStep =
|
||||
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[activeStepIndex] ??
|
||||
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[0];
|
||||
|
||||
return Math.max(0, Math.min(99, activeStep.minProgress));
|
||||
}
|
||||
|
||||
function parseOperationUpdatedAtMs(
|
||||
operation: CustomWorldAgentOperationRecord,
|
||||
) {
|
||||
const rawUpdatedAt = operation.updatedAt?.trim();
|
||||
if (!rawUpdatedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedMs = Date.parse(rawUpdatedAt);
|
||||
return Number.isFinite(parsedMs) ? parsedMs : null;
|
||||
}
|
||||
|
||||
function parseOperationStartedAtMs(
|
||||
operation: CustomWorldAgentOperationRecord,
|
||||
) {
|
||||
const rawStartedAt = operation.startedAt?.trim();
|
||||
if (!rawStartedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedMs = Date.parse(rawStartedAt);
|
||||
return Number.isFinite(parsedMs) ? parsedMs : null;
|
||||
}
|
||||
|
||||
function resolveAgentDraftFoundationStepIndex(
|
||||
operation: CustomWorldAgentOperationRecord,
|
||||
) {
|
||||
const progress = clampProgress(operation.progress);
|
||||
const phaseLabel = operation.phaseLabel.trim();
|
||||
|
||||
if (operation.status === 'completed' || phaseLabel.includes('世界底稿已生成')) {
|
||||
return AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.length - 1;
|
||||
}
|
||||
|
||||
for (
|
||||
let index = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.length - 2;
|
||||
index >= 0;
|
||||
index -= 1
|
||||
) {
|
||||
const step = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index];
|
||||
if (step?.matchers.some((matcher) => phaseLabel.includes(matcher))) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return resolveAgentDraftFoundationStepIndexByProgress(progress);
|
||||
}
|
||||
|
||||
function resolveAgentDraftFoundationFailedStep(
|
||||
operation: CustomWorldAgentOperationRecord,
|
||||
) {
|
||||
if (operation.status !== 'failed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const phaseLabel = operation.phaseLabel.trim();
|
||||
const phaseDetail = operation.phaseDetail.trim();
|
||||
const error = operation.error?.trim() ?? '';
|
||||
|
||||
return {
|
||||
id: AGENT_DRAFT_FOUNDATION_FAILED_STEP.id,
|
||||
label:
|
||||
phaseLabel ||
|
||||
error ||
|
||||
AGENT_DRAFT_FOUNDATION_FAILED_STEP.label,
|
||||
detail:
|
||||
phaseDetail ||
|
||||
error ||
|
||||
AGENT_DRAFT_FOUNDATION_FAILED_STEP.detail,
|
||||
} satisfies AgentDraftFoundationFailedStep;
|
||||
}
|
||||
|
||||
function buildAgentDraftFoundationSteps(
|
||||
operation: CustomWorldAgentOperationRecord,
|
||||
activeStepIndex: number,
|
||||
) {
|
||||
return AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.map((step, index) => {
|
||||
const isCompleted =
|
||||
operation.status === 'completed' ||
|
||||
(operation.status === 'failed'
|
||||
? index < activeStepIndex
|
||||
: index < activeStepIndex);
|
||||
const isActive =
|
||||
operation.status !== 'failed' && !isCompleted && index === activeStepIndex;
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
label: step.label,
|
||||
detail: step.detail,
|
||||
completed: isCompleted ? 1 : 0,
|
||||
total: 1,
|
||||
status: isCompleted ? 'completed' : isActive ? 'active' : 'pending',
|
||||
} satisfies CustomWorldGenerationStep;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEstimatedRemainingMs(
|
||||
progress: number,
|
||||
startedAtMs: number | null,
|
||||
nowMs: number,
|
||||
status: CustomWorldAgentOperationRecord['status'],
|
||||
activeStepIndex: number,
|
||||
operationUpdatedAtMs: number | null,
|
||||
) {
|
||||
if (status === 'completed') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (status === 'failed' || progress >= 100) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeStep =
|
||||
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[activeStepIndex] ??
|
||||
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[0];
|
||||
const nextStep =
|
||||
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[activeStepIndex + 1] ??
|
||||
activeStep;
|
||||
const phaseProgressRange = Math.max(
|
||||
1,
|
||||
nextStep.minProgress - activeStep.minProgress,
|
||||
);
|
||||
const phaseProgressRatio = Math.max(
|
||||
0,
|
||||
Math.min(0.95, (progress - activeStep.minProgress) / phaseProgressRange),
|
||||
);
|
||||
const phaseStartedAtMs = operationUpdatedAtMs ?? startedAtMs;
|
||||
const currentPhaseElapsedMs = phaseStartedAtMs
|
||||
? Math.max(0, nowMs - phaseStartedAtMs)
|
||||
: 0;
|
||||
const currentPhaseRemainingMs = Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
activeStep.expectedDurationMs * (1 - phaseProgressRatio) -
|
||||
currentPhaseElapsedMs,
|
||||
),
|
||||
);
|
||||
const followingStepsRemainingMs = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.slice(
|
||||
activeStepIndex + 1,
|
||||
).reduce((sum, step) => sum + step.expectedDurationMs, 0);
|
||||
|
||||
return currentPhaseRemainingMs + followingStepsRemainingMs;
|
||||
}
|
||||
|
||||
export function isDraftFoundationOperation(
|
||||
operation: CustomWorldAgentOperationRecord | null | undefined,
|
||||
): operation is CustomWorldAgentOperationRecord {
|
||||
return Boolean(operation && operation.type === 'draft_foundation');
|
||||
}
|
||||
|
||||
export function isDraftFoundationOperationRunning(
|
||||
operation: CustomWorldAgentOperationRecord | null | undefined,
|
||||
) {
|
||||
return (
|
||||
isDraftFoundationOperation(operation) &&
|
||||
(operation.status === 'queued' || operation.status === 'running')
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAgentDraftFoundationGenerationProgress(
|
||||
operation: CustomWorldAgentOperationRecord | null | undefined,
|
||||
fallbackStartedAtMs: number | null,
|
||||
nowMs = Date.now(),
|
||||
): CustomWorldGenerationProgress | null {
|
||||
if (!isDraftFoundationOperation(operation)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeStepIndex = resolveAgentDraftFoundationStepIndex(operation);
|
||||
const overallProgress = resolveFailedProgress(operation, activeStepIndex);
|
||||
// 中文注释:总耗时必须绑定服务端 operation 创建时间,避免刷新或前端重挂载后重新计时。
|
||||
const startedAtMs = parseOperationStartedAtMs(operation) ?? fallbackStartedAtMs;
|
||||
const elapsedMs = startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0;
|
||||
const estimatedRemainingMs = resolveEstimatedRemainingMs(
|
||||
overallProgress,
|
||||
startedAtMs,
|
||||
nowMs,
|
||||
operation.status,
|
||||
activeStepIndex,
|
||||
parseOperationUpdatedAtMs(operation),
|
||||
);
|
||||
const failedStep = resolveAgentDraftFoundationFailedStep(operation);
|
||||
const activeStep =
|
||||
failedStep ??
|
||||
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[activeStepIndex] ??
|
||||
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[0];
|
||||
|
||||
return {
|
||||
phaseId: activeStep.id,
|
||||
phaseLabel: operation.phaseLabel || activeStep.label,
|
||||
phaseDetail: operation.phaseDetail || activeStep.detail,
|
||||
batchLabel: activeStep.label,
|
||||
overallProgress,
|
||||
completedWeight: overallProgress,
|
||||
totalWeight: 100,
|
||||
elapsedMs,
|
||||
estimatedRemainingMs,
|
||||
activeStepIndex,
|
||||
steps: buildAgentDraftFoundationSteps(operation, activeStepIndex),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAgentDraftFoundationSettingText(
|
||||
session: CustomWorldAgentSessionSnapshot | null | undefined,
|
||||
) {
|
||||
if (!session) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const creatorIntent = normalizeCustomWorldCreatorIntent(
|
||||
session.creatorIntent,
|
||||
'freeform',
|
||||
);
|
||||
|
||||
if (creatorIntent) {
|
||||
const foundationText =
|
||||
buildCustomWorldCreatorIntentFoundationText(creatorIntent).trim();
|
||||
|
||||
if (foundationText) {
|
||||
return foundationText;
|
||||
}
|
||||
|
||||
if (creatorIntent.rawSettingText.trim()) {
|
||||
return creatorIntent.rawSettingText.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const latestUserMessage = [...session.messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === 'user' && message.text.trim());
|
||||
|
||||
const anchorSettingText = buildEightAnchorFoundationText(session.anchorContent);
|
||||
|
||||
return (
|
||||
anchorSettingText ||
|
||||
latestUserMessage?.text.trim() ||
|
||||
'正在整理当前共创设定。'
|
||||
);
|
||||
}
|
||||
75
src/services/customWorldAgentUiState.test.ts
Normal file
75
src/services/customWorldAgentUiState.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
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',
|
||||
customWorldGenerationSource: 'agent-draft-foundation',
|
||||
ownerUserId: 'user-1',
|
||||
},
|
||||
env,
|
||||
);
|
||||
|
||||
expect(currentUrl).toContain('customWorldSessionId=session-1');
|
||||
expect(currentUrl).toContain('customWorldOperationId=operation-1');
|
||||
expect(currentUrl).toContain(
|
||||
'customWorldGenerationSource=agent-draft-foundation',
|
||||
);
|
||||
expect(readCustomWorldAgentUiState(env)).toEqual({
|
||||
activeSessionId: 'session-1',
|
||||
activeOperationId: 'operation-1',
|
||||
customWorldGenerationSource: 'agent-draft-foundation',
|
||||
});
|
||||
|
||||
currentUrl = '/play';
|
||||
expect(readCustomWorldAgentUiState(env)).toEqual({
|
||||
activeSessionId: 'session-1',
|
||||
activeOperationId: 'operation-1',
|
||||
customWorldGenerationSource: 'agent-draft-foundation',
|
||||
ownerUserId: 'user-1',
|
||||
});
|
||||
|
||||
clearCustomWorldAgentUiState(env);
|
||||
expect(readCustomWorldAgentUiState(env)).toEqual({});
|
||||
});
|
||||
171
src/services/customWorldAgentUiState.ts
Normal file
171
src/services/customWorldAgentUiState.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
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_GENERATION_SOURCE_QUERY_KEY =
|
||||
'customWorldGenerationSource';
|
||||
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;
|
||||
}
|
||||
|
||||
function normalizeGenerationSource(value: unknown) {
|
||||
return value === 'agent-draft-foundation' ? value : 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),
|
||||
),
|
||||
customWorldGenerationSource: normalizeGenerationSource(
|
||||
params.get(CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY),
|
||||
),
|
||||
};
|
||||
|
||||
if (
|
||||
stateFromQuery.activeSessionId ||
|
||||
stateFromQuery.activeOperationId ||
|
||||
stateFromQuery.customWorldGenerationSource
|
||||
) {
|
||||
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),
|
||||
customWorldGenerationSource: normalizeGenerationSource(
|
||||
parsed.customWorldGenerationSource,
|
||||
),
|
||||
ownerUserId: normalizeValue(parsed.ownerUserId),
|
||||
};
|
||||
} 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 customWorldGenerationSource = normalizeGenerationSource(
|
||||
state.customWorldGenerationSource,
|
||||
);
|
||||
const ownerUserId = normalizeValue(state.ownerUserId);
|
||||
const nextState: CustomWorldAgentUiState = {
|
||||
activeSessionId,
|
||||
activeOperationId,
|
||||
customWorldGenerationSource,
|
||||
ownerUserId,
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (customWorldGenerationSource) {
|
||||
params.set(
|
||||
CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY,
|
||||
customWorldGenerationSource,
|
||||
);
|
||||
} else {
|
||||
params.delete(CUSTOM_WORLD_GENERATION_SOURCE_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 || customWorldGenerationSource) {
|
||||
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);
|
||||
}
|
||||
99
src/services/customWorldBuilder.test.ts
Normal file
99
src/services/customWorldBuilder.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildExpandedCustomWorldProfile } from './customWorldBuilder';
|
||||
|
||||
describe('buildExpandedCustomWorldProfile', () => {
|
||||
it('attaches theme pack, story graph, and narrative profiles', () => {
|
||||
const profile = buildExpandedCustomWorldProfile(
|
||||
{
|
||||
id: 'custom-world-test',
|
||||
name: '裂潮边城',
|
||||
subtitle: '风暴前夜',
|
||||
summary: '一座被裂潮与旧案同时牵动的边城。',
|
||||
tone: '紧张、克制、暗流涌动',
|
||||
playerGoal: '查清边城裂潮背后的真相',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['巡边司', '潮商会'],
|
||||
coreConflicts: ['裂潮反复冲垮旧防线', '旧案名单再次出现'],
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-1',
|
||||
name: '沈砺',
|
||||
title: '灰炬向导',
|
||||
role: '向导',
|
||||
description: '熟悉裂潮边路的灰炬向导。',
|
||||
backstory: '曾在旧撤离线里失去一整支同行队。',
|
||||
personality: '谨慎寡言,先看风向再开口。',
|
||||
motivation: '想查清旧撤离线为何再次失控。',
|
||||
combatStyle: '短弓牵制后贴近补刀。',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['旧撤离线', '名单'],
|
||||
tags: ['裂潮', '向导'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '他只说自己熟悉边路。',
|
||||
chapters: [
|
||||
{ id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他总盯着风向和路标。', content: '他先把注意力放在边路是否还能走。 ', contextSnippet: '他总先谈路和风。' },
|
||||
{ id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '旧撤离线像在他身上留下了什么。', content: '那次撤离失控后,他一直没再离开这片边路。', contextSnippet: '撤离旧事还没过去。' },
|
||||
{ id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '名单上的名字让他一直不肯放手。', content: '他一直在比对那份回响名单与旧撤离线。', contextSnippet: '他一直在查名单。' },
|
||||
{ id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '他知道裂潮里有人故意改过路标。', content: '他怀疑有人借裂潮重启旧案。', contextSnippet: '有人在利用裂潮。' },
|
||||
],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-1',
|
||||
name: '梁砺',
|
||||
title: '断桥巡守',
|
||||
role: '巡守',
|
||||
description: '守着断桥与旧哨火的巡守。',
|
||||
backstory: '旧案爆发时,他是最后一个封桥的人。',
|
||||
personality: '警觉直接,不喜欢绕弯。',
|
||||
motivation: '不想让旧案再次借裂潮翻上来。',
|
||||
combatStyle: '长兵先压,再卡住路口。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['封桥', '旧哨火'],
|
||||
tags: ['巡守', '断桥'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '他只承认自己还在守桥。',
|
||||
chapters: [
|
||||
{ id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他只说桥还不能放开。', content: '他总先谈桥和路。', contextSnippet: '桥还不能放开。' },
|
||||
{ id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '封桥那夜明显留下了后劲。', content: '他始终忘不了那夜桥上的名单。', contextSnippet: '封桥旧事还压着他。' },
|
||||
{ id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '他像还在替谁守着一个错误。', content: '他一直替旧命令继续守线。', contextSnippet: '他还在守旧命令。' },
|
||||
{ id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '有人逼他在封桥和救人之间选过。', content: '那夜真正下封桥令的人还没有露面。', contextSnippet: '封桥命令另有来头。' },
|
||||
],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '断桥旧哨',
|
||||
description: '旧哨火和断桥一起守着边城北口。',
|
||||
sceneNpcIds: ['story-1'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
'玩家想要一个裂潮边城与旧案回响交织的世界。',
|
||||
);
|
||||
|
||||
expect(profile.themePack?.displayName).toBeTruthy();
|
||||
expect(profile.storyGraph?.visibleThreads.length).toBeGreaterThan(0);
|
||||
expect(profile.storyGraph?.hiddenThreads.length).toBeGreaterThan(0);
|
||||
expect(profile.storyNpcs[0]?.narrativeProfile?.immediatePressure).toBeTruthy();
|
||||
expect(profile.playableNpcs[0]?.narrativeProfile?.relatedThreadIds.length).toBeGreaterThan(0);
|
||||
expect(profile.ownedSettingLayers?.expressionProfile.themePack.displayName).toBe(
|
||||
profile.themePack?.displayName,
|
||||
);
|
||||
expect(profile.ownedSettingLayers?.referenceProfile.roleArchetypes.length).toBeGreaterThan(0);
|
||||
expect(profile.ownedSettingLayers?.compatibilityProfile?.legacyTemplateWorldType).toBe(
|
||||
'WUXIA',
|
||||
);
|
||||
});
|
||||
});
|
||||
287
src/services/customWorldBuilder.ts
Normal file
287
src/services/customWorldBuilder.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import {
|
||||
buildCustomWorldPlayableNpcAttributeProfile,
|
||||
buildCustomWorldStoryNpcAttributeProfile,
|
||||
buildItemAttributeResonance,
|
||||
} from '../data/attributeProfileGenerator';
|
||||
import { mergeCustomWorldPlayableNpcTags } from '../data/customWorldBuildTags';
|
||||
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
|
||||
import { CustomWorldProfile, WorldType } from '../types';
|
||||
import { normalizeCustomWorldProfile } from './customWorld';
|
||||
import { normalizeCustomWorldOwnedSettingLayers } from './customWorldOwnedSettingLayers';
|
||||
import { resolveCustomWorldCompatibilityTemplateWorldType } from './customWorldTheme';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
} from './storyEngine/actorNarrativeProfile';
|
||||
import { compileCampaignFromWorldProfile } from './storyEngine/campaignPackCompiler';
|
||||
import { buildKnowledgeGraph } from './storyEngine/knowledgeGraph';
|
||||
import { registerScenarioPack } from './storyEngine/scenarioPackRegistry';
|
||||
import { buildSceneNarrativeResidues } from './storyEngine/sceneResidueCompiler';
|
||||
import {
|
||||
buildThemePackFromWorldProfile,
|
||||
normalizeThemePack,
|
||||
} from './storyEngine/themePack';
|
||||
import { buildThreadContractsFromProfile } from './storyEngine/threadContract';
|
||||
import {
|
||||
buildFallbackWorldStoryGraph,
|
||||
normalizeWorldStoryGraph,
|
||||
} from './storyEngine/worldStoryGraph';
|
||||
|
||||
const PLAYABLE_TEMPLATE_CHARACTER_IDS = [
|
||||
'sword-princess',
|
||||
'archer-hero',
|
||||
'girl-hero',
|
||||
'punch-hero',
|
||||
'fighter-4',
|
||||
] as const;
|
||||
|
||||
function pickCyclic<T>(items: readonly T[], index: number, label: string): T {
|
||||
const item = items[index % items.length];
|
||||
if (item === undefined) {
|
||||
throw new Error(`Missing ${label}`);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function getPlayableTemplateCharacterId(index: number) {
|
||||
return pickCyclic(
|
||||
PLAYABLE_TEMPLATE_CHARACTER_IDS,
|
||||
index,
|
||||
'playable template character id',
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeTags(tags: string[], fallbackTags: string[] = []) {
|
||||
return [
|
||||
...new Set(
|
||||
[...tags, ...fallbackTags].map((tag) => tag.trim()).filter(Boolean),
|
||||
),
|
||||
].slice(0, 5);
|
||||
}
|
||||
|
||||
function normalizeHooks(hooks: string[]) {
|
||||
const normalized = [
|
||||
...new Set(hooks.map((hook) => hook.trim()).filter(Boolean)),
|
||||
];
|
||||
if (normalized.length > 0) {
|
||||
return normalized.slice(0, 3);
|
||||
}
|
||||
return ['掌握关键线索'];
|
||||
}
|
||||
|
||||
function clampText(value: string, maxLength: number) {
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
const ascii = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
if (ascii) {
|
||||
return ascii.slice(0, 24);
|
||||
}
|
||||
|
||||
return 'entry';
|
||||
}
|
||||
|
||||
function createEntryId(prefix: string, label: string, index: number) {
|
||||
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
|
||||
}
|
||||
|
||||
function dedupeByName<T extends { name: string }>(items: T[]) {
|
||||
const seen = new Set<string>();
|
||||
return items.filter((item) => {
|
||||
const key = item.name.trim();
|
||||
if (!key || seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export interface CustomWorldBuilderOptions {}
|
||||
|
||||
export function buildExpandedCustomWorldProfile(
|
||||
raw: unknown,
|
||||
settingText: string,
|
||||
_options: CustomWorldBuilderOptions = {},
|
||||
): CustomWorldProfile {
|
||||
const profile = normalizeCustomWorldProfile(raw, settingText);
|
||||
const attributeSchema = profile.attributeSchema;
|
||||
const playableNpcs = dedupeByName(profile.playableNpcs)
|
||||
.slice(0, PLAYABLE_TEMPLATE_CHARACTER_IDS.length)
|
||||
.map((npc, index) => {
|
||||
const templateCharacterId = getPlayableTemplateCharacterId(index);
|
||||
return {
|
||||
...npc,
|
||||
id: npc.id || createEntryId('playable-npc', npc.name, index),
|
||||
tags: mergeCustomWorldPlayableNpcTags(profile, npc, {
|
||||
templateCharacterId,
|
||||
maxCount: 5,
|
||||
}),
|
||||
attributeProfile:
|
||||
npc.attributeProfile ??
|
||||
buildCustomWorldPlayableNpcAttributeProfile(npc, attributeSchema),
|
||||
};
|
||||
});
|
||||
const storyNpcs = dedupeByName(profile.storyNpcs).map((npc, index) => ({
|
||||
...npc,
|
||||
id: npc.id || createEntryId('story-npc', npc.name, index),
|
||||
description: clampText(npc.description, 72),
|
||||
motivation: clampText(npc.motivation, 72),
|
||||
relationshipHooks: normalizeHooks(npc.relationshipHooks),
|
||||
attributeProfile:
|
||||
npc.attributeProfile ??
|
||||
buildCustomWorldStoryNpcAttributeProfile(npc, attributeSchema),
|
||||
}));
|
||||
const storyNpcIdByReference = new Map<string, string>();
|
||||
storyNpcs.forEach((npc) => {
|
||||
storyNpcIdByReference.set(npc.id, npc.id);
|
||||
storyNpcIdByReference.set(npc.name, npc.id);
|
||||
});
|
||||
profile.storyNpcs.forEach((npc) => {
|
||||
const nextNpc = storyNpcs.find((entry) => entry.name === npc.name);
|
||||
if (!nextNpc) {
|
||||
return;
|
||||
}
|
||||
storyNpcIdByReference.set(npc.id, nextNpc.id);
|
||||
storyNpcIdByReference.set(npc.name, nextNpc.id);
|
||||
});
|
||||
const landmarkDrafts = dedupeByName(profile.landmarks).map((landmark, index) => ({
|
||||
...landmark,
|
||||
id: landmark.id || createEntryId('landmark', landmark.name, index),
|
||||
description: clampText(landmark.description, 96),
|
||||
}));
|
||||
const landmarkIdByReference = new Map<string, string>();
|
||||
landmarkDrafts.forEach((landmark) => {
|
||||
landmarkIdByReference.set(landmark.id, landmark.id);
|
||||
landmarkIdByReference.set(landmark.name, landmark.id);
|
||||
});
|
||||
profile.landmarks.forEach((landmark) => {
|
||||
const nextLandmark = landmarkDrafts.find(
|
||||
(entry) => entry.name === landmark.name,
|
||||
);
|
||||
if (!nextLandmark) {
|
||||
return;
|
||||
}
|
||||
landmarkIdByReference.set(landmark.id, nextLandmark.id);
|
||||
landmarkIdByReference.set(landmark.name, nextLandmark.id);
|
||||
});
|
||||
const landmarks = normalizeCustomWorldLandmarks({
|
||||
landmarks: landmarkDrafts.map((landmark) => ({
|
||||
...landmark,
|
||||
sceneNpcIds: landmark.sceneNpcIds.map(
|
||||
(npcId) => storyNpcIdByReference.get(npcId) ?? npcId,
|
||||
),
|
||||
connections: landmark.connections.map((connection) => ({
|
||||
targetLandmarkId:
|
||||
landmarkIdByReference.get(connection.targetLandmarkId) ??
|
||||
connection.targetLandmarkId,
|
||||
relativePosition: connection.relativePosition,
|
||||
summary: connection.summary,
|
||||
})),
|
||||
})),
|
||||
storyNpcs,
|
||||
});
|
||||
const items = dedupeByName(profile.items).map((item, index) => ({
|
||||
...item,
|
||||
id: item.id || createEntryId('item', item.name, index),
|
||||
description: clampText(item.description, 72),
|
||||
tags: normalizeTags(item.tags),
|
||||
attributeResonance:
|
||||
item.attributeResonance ?? buildItemAttributeResonance(item),
|
||||
}));
|
||||
const baseExpandedProfile = {
|
||||
...profile,
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
items,
|
||||
landmarks,
|
||||
} satisfies CustomWorldProfile;
|
||||
const themePack = normalizeThemePack(
|
||||
profile.themePack,
|
||||
buildThemePackFromWorldProfile(baseExpandedProfile),
|
||||
);
|
||||
const storyGraph = normalizeWorldStoryGraph(
|
||||
profile.storyGraph,
|
||||
buildFallbackWorldStoryGraph(baseExpandedProfile, themePack),
|
||||
);
|
||||
const enrichedPlayableNpcs = playableNpcs.map((npc) => ({
|
||||
...npc,
|
||||
narrativeProfile: normalizeActorNarrativeProfile(
|
||||
npc.narrativeProfile,
|
||||
buildFallbackActorNarrativeProfile(npc, storyGraph, themePack),
|
||||
),
|
||||
}));
|
||||
const enrichedStoryNpcs = storyNpcs.map((npc) => ({
|
||||
...npc,
|
||||
narrativeProfile: normalizeActorNarrativeProfile(
|
||||
npc.narrativeProfile,
|
||||
buildFallbackActorNarrativeProfile(npc, storyGraph, themePack),
|
||||
),
|
||||
}));
|
||||
const landmarksWithResidues = landmarks.map((landmark) => ({
|
||||
...landmark,
|
||||
narrativeResidues:
|
||||
landmark.narrativeResidues && landmark.narrativeResidues.length > 0
|
||||
? landmark.narrativeResidues
|
||||
: buildSceneNarrativeResidues({
|
||||
sceneId: landmark.id,
|
||||
sceneName: landmark.name,
|
||||
profile: {
|
||||
...baseExpandedProfile,
|
||||
playableNpcs: enrichedPlayableNpcs,
|
||||
storyNpcs: enrichedStoryNpcs,
|
||||
storyGraph,
|
||||
themePack,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
const profileWithNarrative = {
|
||||
...baseExpandedProfile,
|
||||
playableNpcs: enrichedPlayableNpcs,
|
||||
storyNpcs: enrichedStoryNpcs,
|
||||
themePack,
|
||||
storyGraph,
|
||||
landmarks: landmarksWithResidues,
|
||||
} satisfies CustomWorldProfile;
|
||||
const knowledgeFacts =
|
||||
profile.knowledgeFacts && profile.knowledgeFacts.length > 0
|
||||
? profile.knowledgeFacts
|
||||
: buildKnowledgeGraph(profileWithNarrative);
|
||||
const threadContracts =
|
||||
profile.threadContracts && profile.threadContracts.length > 0
|
||||
? profile.threadContracts
|
||||
: buildThreadContractsFromProfile(profileWithNarrative);
|
||||
const compiledPacks = compileCampaignFromWorldProfile({
|
||||
profile: {
|
||||
...profileWithNarrative,
|
||||
knowledgeFacts,
|
||||
threadContracts,
|
||||
},
|
||||
});
|
||||
registerScenarioPack(compiledPacks.scenarioPack);
|
||||
|
||||
const finalizedProfile = {
|
||||
...profileWithNarrative,
|
||||
knowledgeFacts,
|
||||
threadContracts,
|
||||
scenarioPackId: profile.scenarioPackId ?? compiledPacks.scenarioPack.id,
|
||||
campaignPackId: profile.campaignPackId ?? compiledPacks.campaignPack.id,
|
||||
} satisfies CustomWorldProfile;
|
||||
|
||||
return {
|
||||
...finalizedProfile,
|
||||
ownedSettingLayers: normalizeCustomWorldOwnedSettingLayers(
|
||||
finalizedProfile.ownedSettingLayers,
|
||||
finalizedProfile,
|
||||
),
|
||||
};
|
||||
}
|
||||
120
src/services/customWorldCamp.ts
Normal file
120
src/services/customWorldCamp.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
type CustomWorldCampScene,
|
||||
type CustomWorldProfile,
|
||||
} from '../types';
|
||||
import { detectCustomWorldThemeMode } from './customWorldTheme';
|
||||
|
||||
type CampProfileSeed = Pick<
|
||||
CustomWorldProfile,
|
||||
'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
|
||||
> & {
|
||||
camp?: Pick<
|
||||
CustomWorldCampScene,
|
||||
| 'id'
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'visualDescription'
|
||||
| 'imageSrc'
|
||||
| 'sceneNpcIds'
|
||||
| 'connections'
|
||||
| 'narrativeResidues'
|
||||
> | null;
|
||||
};
|
||||
|
||||
function clampText(value: string, maxLength: number) {
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
|
||||
}
|
||||
|
||||
function sanitizeCampSeed(name: string) {
|
||||
const normalized = name.trim().replace(/\s+/g, '');
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const stripped = normalized.replace(
|
||||
/(世界|江湖|边城|仙洲|仙境|灵境|界|录|域|境)$/u,
|
||||
'',
|
||||
);
|
||||
const seed = stripped || normalized;
|
||||
|
||||
return seed.slice(0, Math.min(seed.length, 4));
|
||||
}
|
||||
|
||||
function buildFallbackCampName(profile: CampProfileSeed) {
|
||||
const seed =
|
||||
sanitizeCampSeed(profile.name) ||
|
||||
'归途';
|
||||
const themeMode = detectCustomWorldThemeMode(profile);
|
||||
|
||||
const suffixByMode = {
|
||||
mythic: '归舍',
|
||||
martial: '归舍',
|
||||
arcane: '栖居',
|
||||
machina: '整备居',
|
||||
tide: '潮居',
|
||||
rift: '界隙居所',
|
||||
} as const;
|
||||
|
||||
return `${seed}${suffixByMode[themeMode]}`;
|
||||
}
|
||||
|
||||
function buildFallbackCampDescription(profile: CampProfileSeed, campName: string) {
|
||||
const summaryLead = clampText(profile.summary, 24) || '前路尚未明朗';
|
||||
const goalLead = clampText(profile.playerGoal, 24) || '继续整理线索';
|
||||
const themeMode = detectCustomWorldThemeMode(profile);
|
||||
|
||||
const descriptionByMode = {
|
||||
mythic: `${campName}是你在${profile.name}暂时安顿的归处,能整备行装、交换判断,并围绕“${goalLead}”继续启程。`,
|
||||
martial: `${campName}是你在${profile.name}暂时落脚的归处,能整备行装、收拢同伴,并朝“${goalLead}”继续动身。`,
|
||||
arcane: `${campName}是你在${profile.name}暂时栖身的居所,适合调息、整理法器,并围绕“${summaryLead}”交换判断。`,
|
||||
machina: `${campName}是你在${profile.name}的临时居所,能检修装备、补齐物资,并为下一段行动重新校准节奏。`,
|
||||
tide: `${campName}是你在${profile.name}靠潮歇息的住处,能安顿队伍、整理补给,并顺着“${goalLead}”继续前探。`,
|
||||
rift: `${campName}是你在${profile.name}勉强守住的一处居所,既能短暂停步,也能围绕“${summaryLead}”商量下一步去向。`,
|
||||
} as const;
|
||||
|
||||
return descriptionByMode[themeMode];
|
||||
}
|
||||
|
||||
export function buildFallbackCustomWorldCampScene(
|
||||
profile: CampProfileSeed,
|
||||
): CustomWorldCampScene {
|
||||
const fallbackName = buildFallbackCampName(profile);
|
||||
|
||||
return {
|
||||
id: 'custom-scene-camp',
|
||||
name: fallbackName,
|
||||
description: buildFallbackCampDescription(profile, fallbackName),
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
narrativeResidues: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCustomWorldCampScene(
|
||||
profile: CampProfileSeed,
|
||||
): CustomWorldCampScene {
|
||||
const fallback = buildFallbackCustomWorldCampScene(profile);
|
||||
const camp = profile.camp;
|
||||
|
||||
return {
|
||||
id: camp?.id?.trim() || fallback.id,
|
||||
name: camp?.name?.trim() || fallback.name,
|
||||
description: camp?.description?.trim() || fallback.description,
|
||||
visualDescription: camp?.visualDescription?.trim() || undefined,
|
||||
imageSrc: camp?.imageSrc?.trim() || undefined,
|
||||
sceneNpcIds: Array.isArray(camp?.sceneNpcIds)
|
||||
? [...new Set(camp.sceneNpcIds.map((entry) => entry.trim()).filter(Boolean))]
|
||||
: fallback.sceneNpcIds,
|
||||
connections: Array.isArray(camp?.connections)
|
||||
? camp.connections
|
||||
: fallback.connections,
|
||||
narrativeResidues: camp?.narrativeResidues ?? fallback.narrativeResidues,
|
||||
};
|
||||
}
|
||||
150
src/services/customWorldCover.test.ts
Normal file
150
src/services/customWorldCover.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { WorldType, type CustomWorldProfile } from '../types';
|
||||
import { resolveCustomWorldCoverPresentation } from './customWorldCover';
|
||||
|
||||
function createBaseProfile(): CustomWorldProfile {
|
||||
return {
|
||||
id: 'custom-world-cover-test',
|
||||
settingText: '潮雾群岛',
|
||||
name: '潮雾群岛',
|
||||
subtitle: '封面规则测试',
|
||||
summary: '用于验证默认封面优先级。',
|
||||
tone: '潮湿、压抑',
|
||||
playerGoal: '查明旧航道真相。',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
compatibilityTemplateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
id: 'schema:test',
|
||||
worldId: 'CUSTOM',
|
||||
schemaVersion: 1,
|
||||
schemaName: '测试属性',
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '潮雾群岛',
|
||||
settingSummary: '封面规则测试',
|
||||
tone: '潮湿、压抑',
|
||||
conflictCore: '旧航道真相',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-1',
|
||||
name: '林潮',
|
||||
title: '守潮人',
|
||||
role: '可扮演角色',
|
||||
description: '负责守住第一道进港口。',
|
||||
backstory: '他在港口旧案里失去过同伴。',
|
||||
personality: '谨慎克制。',
|
||||
motivation: '想查清货船去向。',
|
||||
combatStyle: '借地形换位。',
|
||||
initialAffinity: 20,
|
||||
relationshipHooks: ['旧案'],
|
||||
tags: ['港口'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '他对港口格外熟悉。',
|
||||
chapters: [],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
imageSrc: '/images/roles/linchao.webp',
|
||||
},
|
||||
],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
camp: {
|
||||
id: 'camp-1',
|
||||
name: '守夜营地',
|
||||
description: '潮线后的临时据点。',
|
||||
imageSrc: '/images/camp/camp.webp',
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
},
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '潮汐码头',
|
||||
description: '涨潮时会吞掉半截栈桥。',
|
||||
imageSrc: '/images/landmark/docks.webp',
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-chapter-1',
|
||||
sceneId: 'landmark-1',
|
||||
title: '潮汐码头',
|
||||
summary: '第一章开局场景。',
|
||||
sceneTaskDescription: '追查潮汐码头失踪案的第一条线索。',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: ['landmark-1'],
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
sceneId: 'landmark-1',
|
||||
title: '雾里靠岸',
|
||||
summary: '玩家第一次进入港口。',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc: '/images/scene/act-1.webp',
|
||||
backgroundAssetId: 'asset-scene-act-1',
|
||||
encounterNpcIds: [],
|
||||
primaryNpcId: 'playable-1',
|
||||
oppositeNpcId: 'playable-1',
|
||||
eventDescription: '玩家第一次进入港口,发现涨潮后的异常痕迹。',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '拿到第一句真话。',
|
||||
transitionHook: '下一幕将进入封锁区。',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveCustomWorldCoverPresentation', () => {
|
||||
it('优先使用开局场景第一幕图片作为默认封面底图', () => {
|
||||
const profile = createBaseProfile();
|
||||
|
||||
const result = resolveCustomWorldCoverPresentation(profile);
|
||||
|
||||
expect(result.imageSrc).toBe('/images/scene/act-1.webp');
|
||||
expect(result.renderMode).toBe('scene_with_roles');
|
||||
expect(result.characterImageSrcs).toEqual(['/images/roles/linchao.webp']);
|
||||
});
|
||||
|
||||
it('当第一幕图片缺失时按营地图与地标图顺序回退', () => {
|
||||
const profile = createBaseProfile();
|
||||
const firstSceneChapter = profile.sceneChapterBlueprints?.[0];
|
||||
const firstSceneAct = firstSceneChapter?.acts[0];
|
||||
if (!firstSceneChapter || !firstSceneAct) {
|
||||
throw new Error('expected base profile to provide an opening scene chapter');
|
||||
}
|
||||
profile.sceneChapterBlueprints = [
|
||||
{
|
||||
...firstSceneChapter,
|
||||
acts: [
|
||||
{
|
||||
...firstSceneAct,
|
||||
backgroundImageSrc: null,
|
||||
backgroundAssetId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const fallbackToCamp = resolveCustomWorldCoverPresentation(profile);
|
||||
expect(fallbackToCamp.imageSrc).toBe('/images/camp/camp.webp');
|
||||
|
||||
profile.camp = {
|
||||
...profile.camp!,
|
||||
imageSrc: '',
|
||||
};
|
||||
const fallbackToLandmark = resolveCustomWorldCoverPresentation(profile);
|
||||
expect(fallbackToLandmark.imageSrc).toBe('/images/landmark/docks.webp');
|
||||
});
|
||||
});
|
||||
132
src/services/customWorldCover.ts
Normal file
132
src/services/customWorldCover.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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 resolveOpeningSceneFirstActImageSrc(profile: CustomWorldProfile) {
|
||||
return profile.sceneChapterBlueprints?.[0]?.acts?.[0]?.backgroundImageSrc?.trim() || null;
|
||||
}
|
||||
|
||||
function resolveOpeningSceneImageSrc(profile: CustomWorldProfile) {
|
||||
// 默认封面优先取开局场景第一幕图,避免草稿页与作品库继续沿用旧的营地兜底策略。
|
||||
const firstActImageSrc = resolveOpeningSceneFirstActImageSrc(profile);
|
||||
if (firstActImageSrc) {
|
||||
return firstActImageSrc;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return 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',
|
||||
};
|
||||
}
|
||||
58
src/services/customWorldCoverAssetService.ts
Normal file
58
src/services/customWorldCoverAssetService.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { requestJson } from './apiClient';
|
||||
import type { CustomWorldCoverCropRect, 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;
|
||||
cropRect: CustomWorldCoverCropRect;
|
||||
}
|
||||
|
||||
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),
|
||||
},
|
||||
'上传作品封面失败',
|
||||
);
|
||||
}
|
||||
202
src/services/customWorldCreatorIntent.test.ts
Normal file
202
src/services/customWorldCreatorIntent.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCustomWorldAnchorPackFromIntent,
|
||||
buildCustomWorldCreatorIntentDisplayText,
|
||||
buildCustomWorldCreatorIntentFoundationText,
|
||||
buildPendingClarifications,
|
||||
createEmptyCustomWorldCreatorIntent,
|
||||
evaluateCustomWorldCreatorIntentReadiness,
|
||||
mergeCustomWorldCreatorIntent,
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
} from './customWorldCreatorIntent';
|
||||
|
||||
describe('customWorldCreatorIntent', () => {
|
||||
it('builds a readable summary from creator intent cards', () => {
|
||||
const intent = {
|
||||
...createEmptyCustomWorldCreatorIntent('card'),
|
||||
worldHook: '一个会被灵潮反复改写地形的边境世界。',
|
||||
themeKeywords: ['边境', '灵潮'],
|
||||
toneDirectives: ['紧张', '潮湿'],
|
||||
playerPremise: '玩家是带着旧名单回来的前巡夜人。',
|
||||
coreConflicts: ['旧案名单再次出现'],
|
||||
keyCharacters: [
|
||||
{
|
||||
id: 'character-1',
|
||||
name: '沈砺',
|
||||
role: '灰炬向导',
|
||||
publicMask: '看起来只是熟路的带路人',
|
||||
hiddenHook: '他一直在追查撤离线失控真相',
|
||||
relationToPlayer: '会先怀疑玩家身份',
|
||||
notes: '',
|
||||
locked: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const summary = buildCustomWorldCreatorIntentDisplayText(intent);
|
||||
|
||||
expect(summary).toContain(
|
||||
'世界一句话:一个会被灵潮反复改写地形的边境世界。',
|
||||
);
|
||||
expect(summary).toContain('主题关键词:边境、灵潮');
|
||||
expect(summary).toContain('关键角色:沈砺 / 灰炬向导');
|
||||
});
|
||||
|
||||
it('builds six-anchor foundation text from structured creator intent', () => {
|
||||
const intent = {
|
||||
...createEmptyCustomWorldCreatorIntent('card'),
|
||||
worldHook: '一个会被灵潮反复改写地形的边境世界。',
|
||||
themeKeywords: ['边境', '灵潮'],
|
||||
toneDirectives: ['紧张', '潮湿'],
|
||||
playerPremise: '玩家是带着旧名单回来的前巡夜人。',
|
||||
openingSituation: '返乡第一夜,封锁线外出现了本不该存在的灯火。',
|
||||
coreConflicts: ['旧案名单再次出现'],
|
||||
keyCharacters: [
|
||||
{
|
||||
id: 'character-1',
|
||||
name: '沈砺',
|
||||
role: '灰炬向导',
|
||||
publicMask: '看起来只是熟路的带路人',
|
||||
hiddenHook: '他一直在追查撤离线失控真相',
|
||||
relationToPlayer: '会先怀疑玩家身份',
|
||||
notes: '',
|
||||
locked: true,
|
||||
},
|
||||
],
|
||||
iconicElements: ['会逆向蔓延的潮雾'],
|
||||
};
|
||||
|
||||
const foundationText = buildCustomWorldCreatorIntentFoundationText(intent);
|
||||
|
||||
expect(foundationText).toContain(
|
||||
'世界一句话:一个会被灵潮反复改写地形的边境世界。',
|
||||
);
|
||||
expect(foundationText).toContain('玩家开局:玩家是带着旧名单回来的前巡夜人。');
|
||||
expect(foundationText).toContain('主题气质:边境、灵潮 / 紧张、潮湿');
|
||||
expect(foundationText).toContain('关键关系:沈砺 · 灰炬向导');
|
||||
expect(foundationText).toContain('标志元素:会逆向蔓延的潮雾');
|
||||
});
|
||||
|
||||
it('builds anchor pack from creator intent and keeps locked ids', () => {
|
||||
const intent = {
|
||||
...createEmptyCustomWorldCreatorIntent('card'),
|
||||
worldHook: '边境世界',
|
||||
coreConflicts: ['裂潮失控'],
|
||||
keyFactions: [
|
||||
{
|
||||
id: 'faction-1',
|
||||
name: '巡边司',
|
||||
publicGoal: '维持边境秩序',
|
||||
tension: '正在被旧案拖入裂潮',
|
||||
notes: '',
|
||||
locked: true,
|
||||
},
|
||||
],
|
||||
keyLandmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '断桥旧哨',
|
||||
purpose: '边境咽喉',
|
||||
mood: '压迫',
|
||||
secret: '封桥旧令来源不明',
|
||||
locked: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const anchorPack = buildCustomWorldAnchorPackFromIntent(intent);
|
||||
|
||||
expect(anchorPack?.keyConflictSummaries).toEqual(['裂潮失控']);
|
||||
expect(anchorPack?.keyFactionSummaries[0]).toContain('巡边司');
|
||||
expect(anchorPack?.lockedAnchorIds).toEqual(
|
||||
expect.arrayContaining(['faction-1', 'landmark-1']),
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes sparse creator intent payloads', () => {
|
||||
const intent = normalizeCustomWorldCreatorIntent({
|
||||
sourceMode: 'card',
|
||||
worldHook: '雾海边城',
|
||||
themeKeywords: ['雾海', '旧案'],
|
||||
keyCharacters: [
|
||||
{
|
||||
name: '梁砺',
|
||||
role: '断桥巡守',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(intent?.sourceMode).toBe('card');
|
||||
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');
|
||||
});
|
||||
});
|
||||
975
src/services/customWorldCreatorIntent.ts
Normal file
975
src/services/customWorldCreatorIntent.ts
Normal file
@@ -0,0 +1,975 @@
|
||||
import type {
|
||||
CreatorIntentReadiness,
|
||||
CustomWorldPendingClarification,
|
||||
} from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type {
|
||||
ActorAnchor,
|
||||
CreatorCharacterSeed,
|
||||
CreatorFactionSeed,
|
||||
CreatorLandmarkSeed,
|
||||
CustomWorldAnchorPack,
|
||||
CustomWorldCreatorInputMode,
|
||||
CustomWorldCreatorIntent,
|
||||
CustomWorldLockState,
|
||||
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() : '';
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown, maxCount = 8) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice(
|
||||
0,
|
||||
maxCount,
|
||||
);
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return normalized || 'entry';
|
||||
}
|
||||
|
||||
function createSeedId(prefix: string, label: string, index: number) {
|
||||
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
|
||||
}
|
||||
|
||||
function clampText(value: string, maxLength: number) {
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
|
||||
}
|
||||
|
||||
function normalizeCreatorFactionSeed(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): CreatorFactionSeed | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const name = toText(item.name);
|
||||
const publicGoal = toText(item.publicGoal);
|
||||
const tension = toText(item.tension);
|
||||
const notes = toText(item.notes);
|
||||
|
||||
if (!name && !publicGoal && !tension && !notes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id:
|
||||
toText(item.id) ||
|
||||
createSeedId('creator-faction', name || publicGoal, index),
|
||||
name,
|
||||
publicGoal,
|
||||
tension,
|
||||
notes,
|
||||
locked: Boolean(item.locked),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCreatorCharacterSeed(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): CreatorCharacterSeed | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const name = toText(item.name);
|
||||
const role = toText(item.role);
|
||||
const publicMask = toText(item.publicMask);
|
||||
const hiddenHook = toText(item.hiddenHook);
|
||||
const relationToPlayer = toText(item.relationToPlayer);
|
||||
const notes = toText(item.notes);
|
||||
|
||||
if (
|
||||
!name &&
|
||||
!role &&
|
||||
!publicMask &&
|
||||
!hiddenHook &&
|
||||
!relationToPlayer &&
|
||||
!notes
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id:
|
||||
toText(item.id) ||
|
||||
createSeedId('creator-character', name || role || publicMask, index),
|
||||
name,
|
||||
role,
|
||||
publicMask,
|
||||
hiddenHook,
|
||||
relationToPlayer,
|
||||
notes,
|
||||
locked: Boolean(item.locked),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCreatorLandmarkSeed(
|
||||
value: unknown,
|
||||
index: number,
|
||||
): CreatorLandmarkSeed | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const name = toText(item.name);
|
||||
const purpose = toText(item.purpose);
|
||||
const mood = toText(item.mood);
|
||||
const secret = toText(item.secret);
|
||||
|
||||
if (!name && !purpose && !mood && !secret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id:
|
||||
toText(item.id) ||
|
||||
createSeedId('creator-landmark', name || purpose || mood, index),
|
||||
name,
|
||||
purpose,
|
||||
mood,
|
||||
secret,
|
||||
locked: Boolean(item.locked),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAnchorArray<T>(
|
||||
value: unknown,
|
||||
normalizer: (value: unknown, index: number) => T | null,
|
||||
maxCount: number,
|
||||
) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item, index) => normalizer(item, index))
|
||||
.filter((item): item is T => Boolean(item))
|
||||
.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 {
|
||||
return {
|
||||
sourceMode,
|
||||
rawSettingText: '',
|
||||
worldHook: '',
|
||||
themeKeywords: [],
|
||||
toneDirectives: [],
|
||||
playerPremise: '',
|
||||
openingSituation: '',
|
||||
coreConflicts: [],
|
||||
keyFactions: [],
|
||||
keyCharacters: [],
|
||||
keyLandmarks: [],
|
||||
iconicElements: [],
|
||||
forbiddenDirectives: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldCreatorIntent(
|
||||
value: unknown,
|
||||
fallbackMode: CustomWorldCreatorInputMode = 'freeform',
|
||||
): CustomWorldCreatorIntent | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const sourceMode =
|
||||
item.sourceMode === 'card' || item.sourceMode === 'freeform'
|
||||
? item.sourceMode
|
||||
: fallbackMode;
|
||||
const rawSettingText = toText(item.rawSettingText);
|
||||
const worldHook = toText(item.worldHook);
|
||||
const playerPremise = toText(item.playerPremise);
|
||||
const openingSituation = toText(item.openingSituation);
|
||||
const themeKeywords = toStringArray(item.themeKeywords, 8);
|
||||
const toneDirectives = toStringArray(item.toneDirectives, 8);
|
||||
const coreConflicts = toStringArray(item.coreConflicts, 6);
|
||||
const iconicElements = toStringArray(item.iconicElements, 8);
|
||||
const forbiddenDirectives = toStringArray(item.forbiddenDirectives, 8);
|
||||
const keyFactions = normalizeAnchorArray(
|
||||
item.keyFactions,
|
||||
normalizeCreatorFactionSeed,
|
||||
6,
|
||||
);
|
||||
const keyCharacters = normalizeAnchorArray(
|
||||
item.keyCharacters,
|
||||
normalizeCreatorCharacterSeed,
|
||||
8,
|
||||
);
|
||||
const keyLandmarks = normalizeAnchorArray(
|
||||
item.keyLandmarks,
|
||||
normalizeCreatorLandmarkSeed,
|
||||
8,
|
||||
);
|
||||
|
||||
if (
|
||||
!rawSettingText &&
|
||||
!worldHook &&
|
||||
themeKeywords.length === 0 &&
|
||||
toneDirectives.length === 0 &&
|
||||
!playerPremise &&
|
||||
!openingSituation &&
|
||||
coreConflicts.length === 0 &&
|
||||
keyFactions.length === 0 &&
|
||||
keyCharacters.length === 0 &&
|
||||
keyLandmarks.length === 0 &&
|
||||
iconicElements.length === 0 &&
|
||||
forbiddenDirectives.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sourceMode,
|
||||
rawSettingText,
|
||||
worldHook,
|
||||
themeKeywords,
|
||||
toneDirectives,
|
||||
playerPremise,
|
||||
openingSituation,
|
||||
coreConflicts,
|
||||
keyFactions,
|
||||
keyCharacters,
|
||||
keyLandmarks,
|
||||
iconicElements,
|
||||
forbiddenDirectives,
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {
|
||||
worldLockedFields: [],
|
||||
lockedCharacterIds: [],
|
||||
lockedLandmarkIds: [],
|
||||
lockedConflictIds: [],
|
||||
lockedFactionIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
return {
|
||||
worldLockedFields: toStringArray(item.worldLockedFields, 12),
|
||||
lockedCharacterIds: toStringArray(item.lockedCharacterIds, 20),
|
||||
lockedLandmarkIds: toStringArray(item.lockedLandmarkIds, 20),
|
||||
lockedConflictIds: toStringArray(item.lockedConflictIds, 20),
|
||||
lockedFactionIds: toStringArray(item.lockedFactionIds, 20),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveCustomWorldLockStateFromIntent(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
): CustomWorldLockState {
|
||||
return {
|
||||
worldLockedFields: [],
|
||||
lockedCharacterIds:
|
||||
intent?.keyCharacters
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.id) ?? [],
|
||||
lockedLandmarkIds:
|
||||
intent?.keyLandmarks
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.id) ?? [],
|
||||
lockedConflictIds: [],
|
||||
lockedFactionIds:
|
||||
intent?.keyFactions
|
||||
.filter((entry) => entry.locked)
|
||||
.map((entry) => entry.id) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function hasMeaningfulCustomWorldCreatorIntent(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
) {
|
||||
return Boolean(
|
||||
intent &&
|
||||
(intent.rawSettingText ||
|
||||
intent.worldHook ||
|
||||
intent.themeKeywords.length > 0 ||
|
||||
intent.toneDirectives.length > 0 ||
|
||||
intent.playerPremise ||
|
||||
intent.openingSituation ||
|
||||
intent.coreConflicts.length > 0 ||
|
||||
intent.keyFactions.length > 0 ||
|
||||
intent.keyCharacters.length > 0 ||
|
||||
intent.keyLandmarks.length > 0 ||
|
||||
intent.iconicElements.length > 0 ||
|
||||
intent.forbiddenDirectives.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
function buildAnchorLine(label: string, content: string) {
|
||||
return content ? `${label}:${content}` : '';
|
||||
}
|
||||
|
||||
export function buildCustomWorldCreatorIntentFoundationText(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
) {
|
||||
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const relationshipSeed = intent?.keyCharacters[0];
|
||||
const relationshipText = relationshipSeed
|
||||
? [
|
||||
relationshipSeed.name,
|
||||
relationshipSeed.role,
|
||||
relationshipSeed.relationToPlayer
|
||||
? `与玩家 ${relationshipSeed.relationToPlayer}`
|
||||
: '',
|
||||
relationshipSeed.hiddenHook ? `暗线 ${relationshipSeed.hiddenHook}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: '';
|
||||
const playerOpeningText = [intent?.playerPremise || '', intent?.openingSituation || '']
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
const themeToneText = [
|
||||
intent?.themeKeywords.join('、') || '',
|
||||
intent?.toneDirectives.join('、') || '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
|
||||
return [
|
||||
buildAnchorLine('世界一句话', intent?.worldHook || ''),
|
||||
buildAnchorLine('玩家开局', playerOpeningText),
|
||||
buildAnchorLine('主题气质', themeToneText),
|
||||
buildAnchorLine('核心冲突', intent?.coreConflicts.join(';') || ''),
|
||||
buildAnchorLine('关键关系', relationshipText),
|
||||
buildAnchorLine('标志元素', intent?.iconicElements.join('、') || ''),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldCreatorIntentDisplayText(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
) {
|
||||
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lines = [
|
||||
intent?.worldHook ? `世界一句话:${intent.worldHook}` : '',
|
||||
intent?.rawSettingText ? `补充设定:${intent.rawSettingText}` : '',
|
||||
buildAnchorLine('主题关键词', intent?.themeKeywords.join('、') || ''),
|
||||
buildAnchorLine('世界气质', intent?.toneDirectives.join('、') || ''),
|
||||
buildAnchorLine('玩家是谁', intent?.playerPremise || ''),
|
||||
buildAnchorLine('开局处境', intent?.openingSituation || ''),
|
||||
buildAnchorLine('核心冲突', intent?.coreConflicts.join('、') || ''),
|
||||
buildAnchorLine(
|
||||
'关键势力',
|
||||
intent?.keyFactions
|
||||
.map((entry) =>
|
||||
[entry.name, entry.publicGoal, entry.tension]
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';') || '',
|
||||
),
|
||||
buildAnchorLine(
|
||||
'关键角色',
|
||||
intent?.keyCharacters
|
||||
.map((entry) =>
|
||||
[
|
||||
entry.name,
|
||||
entry.role,
|
||||
entry.publicMask,
|
||||
entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';') || '',
|
||||
),
|
||||
buildAnchorLine(
|
||||
'关键地点',
|
||||
intent?.keyLandmarks
|
||||
.map((entry) =>
|
||||
[entry.name, entry.purpose, entry.mood].filter(Boolean).join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';') || '',
|
||||
),
|
||||
buildAnchorLine('标志性要素', intent?.iconicElements.join('、') || ''),
|
||||
buildAnchorLine('禁止事项', intent?.forbiddenDirectives.join('、') || ''),
|
||||
].filter(Boolean);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function buildCustomWorldCreatorIntentGenerationText(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
) {
|
||||
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const sections = [
|
||||
buildAnchorLine('世界核心命题', intent?.worldHook || ''),
|
||||
buildAnchorLine('补充设定原文', intent?.rawSettingText || ''),
|
||||
buildAnchorLine('主题关键词', intent?.themeKeywords.join('、') || ''),
|
||||
buildAnchorLine('情绪与气质', intent?.toneDirectives.join('、') || ''),
|
||||
buildAnchorLine('玩家身份', intent?.playerPremise || ''),
|
||||
buildAnchorLine('开局处境', intent?.openingSituation || ''),
|
||||
buildAnchorLine('核心冲突', intent?.coreConflicts.join('、') || ''),
|
||||
buildAnchorLine(
|
||||
'关键势力锚点',
|
||||
intent?.keyFactions
|
||||
.map((entry) =>
|
||||
[
|
||||
entry.name,
|
||||
entry.publicGoal ? `目标 ${entry.publicGoal}` : '',
|
||||
entry.tension ? `张力 ${entry.tension}` : '',
|
||||
entry.notes ? `补充 ${entry.notes}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('\n') || '',
|
||||
),
|
||||
buildAnchorLine(
|
||||
'关键角色锚点',
|
||||
intent?.keyCharacters
|
||||
.map((entry) =>
|
||||
[
|
||||
entry.name,
|
||||
entry.role ? `身份 ${entry.role}` : '',
|
||||
entry.publicMask ? `表面 ${entry.publicMask}` : '',
|
||||
entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '',
|
||||
entry.relationToPlayer ? `与玩家 ${entry.relationToPlayer}` : '',
|
||||
entry.notes ? `补充 ${entry.notes}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('\n') || '',
|
||||
),
|
||||
buildAnchorLine(
|
||||
'关键地点锚点',
|
||||
intent?.keyLandmarks
|
||||
.map((entry) =>
|
||||
[
|
||||
entry.name,
|
||||
entry.purpose ? `作用 ${entry.purpose}` : '',
|
||||
entry.mood ? `氛围 ${entry.mood}` : '',
|
||||
entry.secret ? `秘密 ${entry.secret}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join('\n') || '',
|
||||
),
|
||||
buildAnchorLine('标志性要素', intent?.iconicElements.join('、') || ''),
|
||||
buildAnchorLine('禁止事项', intent?.forbiddenDirectives.join('、') || ''),
|
||||
].filter(Boolean);
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
function buildCharacterAnchorSummary(entry: CreatorCharacterSeed): ActorAnchor {
|
||||
const summary = clampText(
|
||||
[
|
||||
entry.role,
|
||||
entry.publicMask,
|
||||
entry.hiddenHook ? `暗线 ${entry.hiddenHook}` : '',
|
||||
entry.relationToPlayer ? `与玩家 ${entry.relationToPlayer}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
72,
|
||||
);
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name || '未命名关键角色',
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLandmarkAnchorSummary(
|
||||
entry: CreatorLandmarkSeed,
|
||||
): LandmarkAnchor {
|
||||
const summary = clampText(
|
||||
[entry.purpose, entry.mood, entry.secret ? `秘密 ${entry.secret}` : '']
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
72,
|
||||
);
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name || '未命名关键地点',
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCustomWorldAnchorPackFromIntent(
|
||||
intent: CustomWorldCreatorIntent | null | undefined,
|
||||
): CustomWorldAnchorPack | null {
|
||||
if (!hasMeaningfulCustomWorldCreatorIntent(intent)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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) ?? []),
|
||||
];
|
||||
|
||||
return {
|
||||
worldSummary: clampText(
|
||||
intent?.worldHook || intent?.rawSettingText || '',
|
||||
96,
|
||||
),
|
||||
creatorIntentSummary: clampText(
|
||||
buildCustomWorldCreatorIntentDisplayText(intent),
|
||||
240,
|
||||
),
|
||||
lockedAnchorIds,
|
||||
keyConflictSummaries:
|
||||
intent?.coreConflicts.map((entry) => clampText(entry, 48)) ?? [],
|
||||
keyFactionSummaries:
|
||||
intent?.keyFactions.map((entry) =>
|
||||
clampText(
|
||||
[entry.name, entry.publicGoal, entry.tension]
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
72,
|
||||
),
|
||||
) ?? [],
|
||||
keyCharacterAnchors:
|
||||
intent?.keyCharacters.map((entry) =>
|
||||
buildCharacterAnchorSummary(entry),
|
||||
) ?? [],
|
||||
keyLandmarkAnchors:
|
||||
intent?.keyLandmarks.map((entry) => buildLandmarkAnchorSummary(entry)) ??
|
||||
[],
|
||||
motifDirectives: [
|
||||
...(intent?.themeKeywords ?? []),
|
||||
...(intent?.toneDirectives ?? []),
|
||||
...(intent?.iconicElements ?? []),
|
||||
].slice(0, 12),
|
||||
};
|
||||
}
|
||||
229
src/services/customWorldFoundationEntries.ts
Normal file
229
src/services/customWorldFoundationEntries.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import type { EightAnchorContent } from '../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { normalizeCustomWorldCreatorIntent } from './customWorldCreatorIntent';
|
||||
import type { CustomWorldProfile } from '../types';
|
||||
|
||||
export type CustomWorldFoundationEntryId =
|
||||
| 'world-promise'
|
||||
| 'player-fantasy'
|
||||
| 'theme-boundary'
|
||||
| 'player-entry-point'
|
||||
| 'core-conflict'
|
||||
| 'key-relationships'
|
||||
| 'hidden-lines'
|
||||
| 'iconic-elements';
|
||||
|
||||
export type CustomWorldFoundationEntry = {
|
||||
id: CustomWorldFoundationEntryId;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export function compactFoundationTextList(
|
||||
values: Array<string | null | undefined>,
|
||||
) {
|
||||
return values.map((value) => value?.trim()).filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
export function parseFoundationTagText(value: string) {
|
||||
return value
|
||||
.split(/[;;]/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toRecord(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function compactAnchorValue(value: unknown): string | null {
|
||||
const text = toText(value);
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const compacted = compactFoundationTextList(
|
||||
value.map((item) => compactAnchorValue(item)),
|
||||
).join(';');
|
||||
return compacted || null;
|
||||
}
|
||||
const record = toRecord(value);
|
||||
if (record) {
|
||||
const compacted = compactFoundationTextList(
|
||||
Object.values(record).map((item) => compactAnchorValue(item)),
|
||||
).join(';');
|
||||
return compacted || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildRelationshipSeedText(value: unknown) {
|
||||
const record = toRecord(value);
|
||||
if (!record) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return compactFoundationTextList([
|
||||
toText(record.name),
|
||||
toText(record.role),
|
||||
toText(record.relationToPlayer)
|
||||
? `与玩家:${toText(record.relationToPlayer)}`
|
||||
: '',
|
||||
toText(record.hiddenHook) ? `代价/暗线:${toText(record.hiddenHook)}` : '',
|
||||
]).join(';');
|
||||
}
|
||||
|
||||
function buildAnchorContentFromProfileFallback(
|
||||
profile: CustomWorldProfile,
|
||||
): EightAnchorContent {
|
||||
const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent);
|
||||
const relationshipSeed = creatorIntent?.keyCharacters[0] ?? null;
|
||||
|
||||
return {
|
||||
worldPromise: compactFoundationTextList([
|
||||
creatorIntent?.worldHook ||
|
||||
profile.anchorPack?.worldSummary ||
|
||||
profile.summary,
|
||||
profile.subtitle || profile.settingText,
|
||||
creatorIntent?.toneDirectives.join('、') || profile.tone,
|
||||
]).join(';') || null,
|
||||
playerFantasy: compactFoundationTextList([
|
||||
creatorIntent?.playerPremise || profile.playerGoal,
|
||||
profile.playerGoal,
|
||||
relationshipSeed?.hiddenHook ||
|
||||
creatorIntent?.coreConflicts[0] ||
|
||||
profile.coreConflicts[0] ||
|
||||
'',
|
||||
]).join(';') || null,
|
||||
themeBoundary: compactFoundationTextList([
|
||||
creatorIntent?.themeKeywords.join('、') || '',
|
||||
creatorIntent?.toneDirectives.join('、') || '',
|
||||
profile.tone,
|
||||
profile.subtitle,
|
||||
creatorIntent?.forbiddenDirectives.length
|
||||
? `避免:${creatorIntent.forbiddenDirectives.join('、')}`
|
||||
: '',
|
||||
]).join(';') || null,
|
||||
playerEntryPoint: compactFoundationTextList([
|
||||
creatorIntent?.playerPremise || '',
|
||||
creatorIntent?.openingSituation || profile.coreConflicts[0] || '',
|
||||
profile.playerGoal,
|
||||
]).join(';') || null,
|
||||
coreConflict: compactFoundationTextList([
|
||||
(creatorIntent?.coreConflicts.length
|
||||
? creatorIntent.coreConflicts
|
||||
: profile.coreConflicts
|
||||
).join('、'),
|
||||
relationshipSeed?.hiddenHook || profile.summary || profile.settingText,
|
||||
creatorIntent?.openingSituation || profile.coreConflicts[0] || profile.playerGoal,
|
||||
]).join(';') || null,
|
||||
keyRelationships: relationshipSeed
|
||||
? compactFoundationTextList([
|
||||
relationshipSeed.name,
|
||||
relationshipSeed.role,
|
||||
relationshipSeed.relationToPlayer,
|
||||
relationshipSeed.hiddenHook ? `代价/秘密:${relationshipSeed.hiddenHook}` : '',
|
||||
]).join(';')
|
||||
: null,
|
||||
hiddenLines: compactFoundationTextList([
|
||||
relationshipSeed?.hiddenHook || '',
|
||||
profile.summary,
|
||||
profile.subtitle,
|
||||
profile.majorFactions[0] || '',
|
||||
creatorIntent?.openingSituation || profile.coreConflicts[0] || profile.playerGoal,
|
||||
]).join(';') || null,
|
||||
iconicElements: compactFoundationTextList([
|
||||
(creatorIntent?.iconicElements.length
|
||||
? creatorIntent.iconicElements
|
||||
: [
|
||||
profile.anchorPack?.motifDirectives.join('、') || '',
|
||||
profile.landmarks[0]?.name || '',
|
||||
]
|
||||
).join('、'),
|
||||
profile.camp?.name || '',
|
||||
profile.majorFactions[0] || '',
|
||||
profile.playerGoal,
|
||||
profile.coreConflicts[0] || '',
|
||||
]).join(';') || null,
|
||||
} satisfies EightAnchorContent;
|
||||
}
|
||||
|
||||
export function getCustomWorldFoundationAnchorContent(
|
||||
profile: CustomWorldProfile,
|
||||
) {
|
||||
const anchorContentRecord = profile.anchorContent;
|
||||
if (!anchorContentRecord) {
|
||||
return buildAnchorContentFromProfileFallback(profile);
|
||||
}
|
||||
|
||||
return {
|
||||
worldPromise: compactAnchorValue(anchorContentRecord.worldPromise),
|
||||
playerFantasy: compactAnchorValue(anchorContentRecord.playerFantasy),
|
||||
themeBoundary: compactAnchorValue(anchorContentRecord.themeBoundary),
|
||||
playerEntryPoint: compactAnchorValue(anchorContentRecord.playerEntryPoint),
|
||||
coreConflict: compactAnchorValue(anchorContentRecord.coreConflict),
|
||||
keyRelationships: compactAnchorValue(anchorContentRecord.keyRelationships),
|
||||
hiddenLines: compactAnchorValue(anchorContentRecord.hiddenLines),
|
||||
iconicElements: compactAnchorValue(anchorContentRecord.iconicElements),
|
||||
} satisfies EightAnchorContent;
|
||||
}
|
||||
|
||||
export function buildCustomWorldFoundationEntries(
|
||||
profile: CustomWorldProfile,
|
||||
): CustomWorldFoundationEntry[] {
|
||||
const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent);
|
||||
const anchorContent = getCustomWorldFoundationAnchorContent(profile);
|
||||
const fallbackRelationshipText =
|
||||
buildRelationshipSeedText(creatorIntent?.keyCharacters[0]) ||
|
||||
profile.playableNpcs[0]?.relationshipHooks.join(';') ||
|
||||
profile.storyNpcs[0]?.relationshipHooks.join(';') ||
|
||||
'';
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'world-promise',
|
||||
label: '世界承诺',
|
||||
value: anchorContent.worldPromise || '',
|
||||
},
|
||||
{
|
||||
id: 'player-fantasy',
|
||||
label: '玩家幻想',
|
||||
value: anchorContent.playerFantasy || '',
|
||||
},
|
||||
{
|
||||
id: 'theme-boundary',
|
||||
label: '主题边界',
|
||||
value: anchorContent.themeBoundary || '',
|
||||
},
|
||||
{
|
||||
id: 'player-entry-point',
|
||||
label: '玩家切入口',
|
||||
value: anchorContent.playerEntryPoint || '',
|
||||
},
|
||||
{
|
||||
id: 'core-conflict',
|
||||
label: '核心冲突',
|
||||
value: anchorContent.coreConflict || '',
|
||||
},
|
||||
{
|
||||
id: 'key-relationships',
|
||||
label: '关键关系',
|
||||
value: anchorContent.keyRelationships || fallbackRelationshipText,
|
||||
},
|
||||
{
|
||||
id: 'hidden-lines',
|
||||
label: '暗线与揭示',
|
||||
value: anchorContent.hiddenLines || '',
|
||||
},
|
||||
{
|
||||
id: 'iconic-elements',
|
||||
label: '标志元素',
|
||||
value: anchorContent.iconicElements || '',
|
||||
},
|
||||
];
|
||||
}
|
||||
981
src/services/customWorldOwnedSettingLayers.ts
Normal file
981
src/services/customWorldOwnedSettingLayers.ts
Normal file
@@ -0,0 +1,981 @@
|
||||
import { coerceWorldAttributeSchema } from '../data/attributeValidation';
|
||||
import {
|
||||
type CreatureArchetypeProfile,
|
||||
type CustomWorldCompatibilityProfile,
|
||||
type CustomWorldExpressionProfile,
|
||||
type CustomWorldOwnedSettingLayers,
|
||||
type CustomWorldProfile,
|
||||
type CustomWorldReferenceProfile,
|
||||
type CustomWorldRuleProfile,
|
||||
type CustomWorldSemanticAnchor,
|
||||
type RoleArchetypeProfile,
|
||||
type SceneArchetypeBucket,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
type CustomWorldThemeMode,
|
||||
detectCustomWorldThemeMode,
|
||||
resolveCustomWorldCompatibilityTemplateWorldType,
|
||||
} from './customWorldTheme';
|
||||
import {
|
||||
buildThemePackFromWorldProfile,
|
||||
normalizeThemePack,
|
||||
} from './storyEngine/themePack';
|
||||
|
||||
const OWNED_SETTING_LAYER_MIGRATION_VERSION =
|
||||
'2026-04-08-owned-setting-layers-v1';
|
||||
|
||||
const RESOURCE_LABEL_PRESETS: Record<
|
||||
CustomWorldThemeMode,
|
||||
CustomWorldRuleProfile['resourceLabels']
|
||||
> = {
|
||||
mythic: {
|
||||
hp: '生命',
|
||||
mp: '心流',
|
||||
maxHp: '生命上限',
|
||||
maxMp: '心流上限',
|
||||
damage: '势能',
|
||||
guard: '防护',
|
||||
range: '距离',
|
||||
cooldown: '回整',
|
||||
manaCost: '心流消耗',
|
||||
currency: '旅券',
|
||||
},
|
||||
martial: {
|
||||
hp: '气血',
|
||||
mp: '内力',
|
||||
maxHp: '气血上限',
|
||||
maxMp: '内力上限',
|
||||
damage: '招式',
|
||||
guard: '防御',
|
||||
range: '招距',
|
||||
cooldown: '调息',
|
||||
manaCost: '内力消耗',
|
||||
currency: '铜钱',
|
||||
},
|
||||
arcane: {
|
||||
hp: '元命',
|
||||
mp: '灵韵',
|
||||
maxHp: '元命上限',
|
||||
maxMp: '灵韵上限',
|
||||
damage: '术法',
|
||||
guard: '护盾',
|
||||
range: '术距',
|
||||
cooldown: '回息',
|
||||
manaCost: '灵韵消耗',
|
||||
currency: '灵石',
|
||||
},
|
||||
machina: {
|
||||
hp: '耐久',
|
||||
mp: '能量',
|
||||
maxHp: '耐久上限',
|
||||
maxMp: '能量上限',
|
||||
damage: '火力',
|
||||
guard: '护盾',
|
||||
range: '射程',
|
||||
cooldown: '充能',
|
||||
manaCost: '能量消耗',
|
||||
currency: '配给券',
|
||||
},
|
||||
tide: {
|
||||
hp: '潮命',
|
||||
mp: '潮息',
|
||||
maxHp: '潮命上限',
|
||||
maxMp: '潮息上限',
|
||||
damage: '潮势',
|
||||
guard: '潮护',
|
||||
range: '潮距',
|
||||
cooldown: '回潮',
|
||||
manaCost: '潮息消耗',
|
||||
currency: '潮银',
|
||||
},
|
||||
rift: {
|
||||
hp: '界命',
|
||||
mp: '裂能',
|
||||
maxHp: '界命上限',
|
||||
maxMp: '裂能上限',
|
||||
damage: '界势',
|
||||
guard: '稳界',
|
||||
range: '界距',
|
||||
cooldown: '复界',
|
||||
manaCost: '裂能消耗',
|
||||
currency: '边贸券',
|
||||
},
|
||||
};
|
||||
|
||||
const INITIAL_CURRENCY_PRESETS: Record<CustomWorldThemeMode, number> = {
|
||||
mythic: 160,
|
||||
martial: 160,
|
||||
arcane: 140,
|
||||
machina: 160,
|
||||
tide: 160,
|
||||
rift: 160,
|
||||
};
|
||||
|
||||
const SEMANTIC_ANCHOR_PRESETS: Record<
|
||||
CustomWorldThemeMode,
|
||||
Omit<CustomWorldSemanticAnchor, 'atmosphereTags'>
|
||||
> = {
|
||||
mythic: {
|
||||
genreSignals: ['跨题材冒险', '未知旅境'],
|
||||
conflictForms: ['追查', '护送', '回收', '失踪追索'],
|
||||
institutionTypes: ['据点', '旅团', '档案室', '归舍'],
|
||||
tabooTypes: ['越界', '封存', '失约', '旧痕'],
|
||||
carrierTypes: ['信物', '残页', '样本', '旧钥'],
|
||||
forceSystemTypes: ['回响', '誓约', '遗物', '余波'],
|
||||
},
|
||||
martial: {
|
||||
genreSignals: ['江湖纷争', '旧案追索'],
|
||||
conflictForms: ['寻仇', '围剿', '护送', '失踪追查'],
|
||||
institutionTypes: ['门派', '镖局', '巡司', '商号'],
|
||||
tabooTypes: ['旧案', '断誓', '禁脉', '失契'],
|
||||
carrierTypes: ['遗兵', '令牌', '残卷', '旧佩'],
|
||||
forceSystemTypes: ['心法', '招式', '经脉', '誓约'],
|
||||
},
|
||||
arcane: {
|
||||
genreSignals: ['灵异修行', '秘境因果'],
|
||||
conflictForms: ['夺脉', '封印失衡', '宗门旧案', '秘境争夺'],
|
||||
institutionTypes: ['宗门', '法坛', '巡守司', '灵舟会'],
|
||||
tabooTypes: ['封印', '禁术', '残魂', '逆脉'],
|
||||
carrierTypes: ['法器', '灵符', '玉简', '阵核'],
|
||||
forceSystemTypes: ['灵脉', '术式', '契约', '神识'],
|
||||
},
|
||||
machina: {
|
||||
genreSignals: ['工业前线', '失控科技'],
|
||||
conflictForms: ['封锁', '回收', '追查事故', '前线失守'],
|
||||
institutionTypes: ['财团', '工坊', '舰队', '调查局'],
|
||||
tabooTypes: ['过载', '失控协议', '封存日志', '污染区'],
|
||||
carrierTypes: ['芯片', '驱动核', '记录模组', '封存匣'],
|
||||
forceSystemTypes: ['科技', '协议', '驱动', '能量网'],
|
||||
},
|
||||
tide: {
|
||||
genreSignals: ['海岸悬疑', '潮灾余波'],
|
||||
conflictForms: ['封港', '海路争夺', '追查失踪', '护送穿渡'],
|
||||
institutionTypes: ['港务', '巡海司', '渡船会', '潮站'],
|
||||
tabooTypes: ['沉船', '禁海区', '回潮夜', '失契'],
|
||||
carrierTypes: ['航图', '潮印', '信标', '封潮匣'],
|
||||
forceSystemTypes: ['潮汐', '雾潮', '海誓', '异流'],
|
||||
},
|
||||
rift: {
|
||||
genreSignals: ['裂界边境', '战线余烬'],
|
||||
conflictForms: ['守线', '撤离', '回收异常', '追查失线'],
|
||||
institutionTypes: ['前哨', '巡边队', '断层站', '回收组'],
|
||||
tabooTypes: ['断层失守', '界外污染', '封桥令', '旧撤离线'],
|
||||
carrierTypes: ['界核', '锚印', '样本', '回响记录'],
|
||||
forceSystemTypes: ['裂界', '界压', '污染', '锚定'],
|
||||
},
|
||||
};
|
||||
|
||||
const CREATURE_ARCHETYPE_PRESETS: Record<
|
||||
CustomWorldThemeMode,
|
||||
Array<Omit<CreatureArchetypeProfile, 'id'>>
|
||||
> = {
|
||||
mythic: [
|
||||
{
|
||||
label: '潜伏袭击者',
|
||||
threatStyle: '借地形潜伏后突然贴身施压。',
|
||||
keywords: ['潜伏', '伏击', '前探阻断'],
|
||||
},
|
||||
{
|
||||
label: '群居骚扰者',
|
||||
threatStyle: '依靠数量与机动性反复撕扯阵线。',
|
||||
keywords: ['群居', '扰动', '消耗'],
|
||||
},
|
||||
{
|
||||
label: '回响追猎者',
|
||||
threatStyle: '会追着异常痕迹与关键目标持续压迫。',
|
||||
keywords: ['回响', '追索', '持续压迫'],
|
||||
},
|
||||
],
|
||||
martial: [
|
||||
{
|
||||
label: '潜伏袭击者',
|
||||
threatStyle: '先藏身再借速度打出首轮杀招。',
|
||||
keywords: ['潜袭', '伏击', '贴身爆发'],
|
||||
},
|
||||
{
|
||||
label: '重甲承压者',
|
||||
threatStyle: '站住正面、顶着伤害强行换血。',
|
||||
keywords: ['承压', '守线', '正面对撞'],
|
||||
},
|
||||
{
|
||||
label: '远程威胁者',
|
||||
threatStyle: '依靠暗器、弓弩或投掷不断压制走位。',
|
||||
keywords: ['远程', '压制', '封走位'],
|
||||
},
|
||||
],
|
||||
arcane: [
|
||||
{
|
||||
label: '灵体回响体',
|
||||
threatStyle: '借余波与残识干扰节奏并持续追逼。',
|
||||
keywords: ['灵体', '回响', '术式残留'],
|
||||
},
|
||||
{
|
||||
label: '异化污染体',
|
||||
threatStyle: '被灵潮扭曲后具备高压近身威胁。',
|
||||
keywords: ['异化', '污染', '近身撕咬'],
|
||||
},
|
||||
{
|
||||
label: '机关守卫体',
|
||||
threatStyle: '围绕阵核或封印节点进行固守打击。',
|
||||
keywords: ['机关', '守卫', '节点压制'],
|
||||
},
|
||||
],
|
||||
machina: [
|
||||
{
|
||||
label: '远程威胁者',
|
||||
threatStyle: '依靠火力、脉冲或投射装置封锁空间。',
|
||||
keywords: ['火力', '远程', '封锁'],
|
||||
},
|
||||
{
|
||||
label: '重装阻断者',
|
||||
threatStyle: '借重甲和装置正面堵截推进线路。',
|
||||
keywords: ['重装', '阻断', '压线'],
|
||||
},
|
||||
{
|
||||
label: '失控追击者',
|
||||
threatStyle: '高频位移并持续追杀被标记目标。',
|
||||
keywords: ['失控', '追击', '高机动'],
|
||||
},
|
||||
],
|
||||
tide: [
|
||||
{
|
||||
label: '群居骚扰者',
|
||||
threatStyle: '借潮湿地形和数量优势消耗行进队伍。',
|
||||
keywords: ['群居', '潮湿', '消耗'],
|
||||
},
|
||||
{
|
||||
label: '潜伏袭击者',
|
||||
threatStyle: '利用雾潮与死角打出突袭。',
|
||||
keywords: ['迷雾', '潜伏', '突袭'],
|
||||
},
|
||||
{
|
||||
label: '异化污染体',
|
||||
threatStyle: '会沿潮灾痕迹持续扩散压迫。',
|
||||
keywords: ['潮灾', '异化', '扩散'],
|
||||
},
|
||||
],
|
||||
rift: [
|
||||
{
|
||||
label: '异化污染体',
|
||||
threatStyle: '长期暴露在裂界环境后具备高压侵蚀性。',
|
||||
keywords: ['污染', '侵蚀', '裂界'],
|
||||
},
|
||||
{
|
||||
label: '远程威胁者',
|
||||
threatStyle: '依靠界压残波或碎片投射逼迫走位。',
|
||||
keywords: ['界压', '残波', '远程'],
|
||||
},
|
||||
{
|
||||
label: '机关守卫体',
|
||||
threatStyle: '围绕前哨节点和封桥设施持续守线。',
|
||||
keywords: ['前哨', '守线', '节点'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown, max = 8) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => toText(item))
|
||||
.filter(Boolean)
|
||||
.slice(0, max);
|
||||
}
|
||||
|
||||
function dedupeStrings(
|
||||
values: Array<string | null | undefined>,
|
||||
max = 8,
|
||||
) {
|
||||
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
|
||||
.slice(0, max);
|
||||
}
|
||||
|
||||
function splitToneTags(tone: string) {
|
||||
return dedupeStrings(tone.split(/[,、,/\s]+/u), 6);
|
||||
}
|
||||
|
||||
function inferInstitutionType(labels: string[]) {
|
||||
return dedupeStrings(
|
||||
labels.map((label) => {
|
||||
if (/[宗门宫阁派]/u.test(label)) return '宗门';
|
||||
if (/[司局署院]/u.test(label)) return '调查局';
|
||||
if (/[团盟会]/u.test(label)) return '同盟';
|
||||
if (/[公司财团]/u.test(label)) return '公司';
|
||||
if (/[港坞渡站哨]/u.test(label)) return '前哨';
|
||||
if (/[学园学院书院]/u.test(label)) return '学园';
|
||||
if (/[舰船坞航]/u.test(label)) return '舰队';
|
||||
if (/[部族寨群落]/u.test(label)) return '部族';
|
||||
return label.length <= 8 ? label : '';
|
||||
}),
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
function inferForceSystemTypes(profile: CustomWorldProfile) {
|
||||
const source = `${profile.settingText} ${profile.summary} ${profile.tone} ${profile.playerGoal}`;
|
||||
const detected = [
|
||||
/科技|机巧|协议|工坊|脉冲|驱动/u.test(source) ? '科技' : null,
|
||||
/海潮|潮汐|海雾|港湾/u.test(source) ? '潮汐' : null,
|
||||
/裂界|断层|界桥|灰域|污染/u.test(source) ? '裂界' : null,
|
||||
/仙|灵|术|阵|符|秘境|宗门/u.test(source) ? '灵脉' : null,
|
||||
/契约|誓约|旧誓/u.test(source) ? '契约' : null,
|
||||
/回响|残响|余波/u.test(source) ? '回响' : null,
|
||||
];
|
||||
|
||||
return dedupeStrings(detected, 4);
|
||||
}
|
||||
|
||||
function inferRoleArchetypeLabel(
|
||||
role: Pick<CustomWorldProfile['playableNpcs'][number], 'combatStyle' | 'role' | 'tags'>,
|
||||
) {
|
||||
const source = `${role.role} ${role.combatStyle} ${role.tags.join(' ')}`;
|
||||
|
||||
if (/[弓|炮|射|远程|投掷|火力]/u.test(source)) {
|
||||
return '远程压制型';
|
||||
}
|
||||
if (/[盾|守|承压|阵线|重甲|护]/u.test(source)) {
|
||||
return '续航承压型';
|
||||
}
|
||||
if (/[影|潜|刺|闪|遁|爆发]/u.test(source)) {
|
||||
return '潜行爆发型';
|
||||
}
|
||||
if (/[法|术|控|阵|咒|机关|干扰|牵制]/u.test(source)) {
|
||||
return '控场解构型';
|
||||
}
|
||||
return '正面推进型';
|
||||
}
|
||||
|
||||
function inferSceneBucketLabel(
|
||||
landmark: Pick<CustomWorldProfile['landmarks'][number], 'name' | 'description'>,
|
||||
) {
|
||||
const source = `${landmark.name} ${landmark.description}`;
|
||||
|
||||
if (/[港|湾|渡口|码头|潮]/u.test(source)) return '临水渡口区';
|
||||
if (/[桥|关|哨|门|入口]/u.test(source)) return '高压入口区';
|
||||
if (/[殿|坛|仪式|祭]/u.test(source)) return '仪式神殿区';
|
||||
if (/[塔|空桥|高空|云|崖]/u.test(source)) return '高空通路区';
|
||||
if (/[厂|工坊|机库|轨|站]/u.test(source)) return '工业热区';
|
||||
if (/[洞|穴|地下|遗迹|墓]/u.test(source)) return '地底遗迹区';
|
||||
if (/[街|巷|城|镇|居]/u.test(source)) return '群落聚居区';
|
||||
|
||||
return '叙事缓冲区';
|
||||
}
|
||||
|
||||
function buildRoleArchetypes(profile: CustomWorldProfile) {
|
||||
return profile.playableNpcs.slice(0, 6).map((role, index) => ({
|
||||
id: `role-archetype-${index + 1}`,
|
||||
label: inferRoleArchetypeLabel(role),
|
||||
combatFocus: role.combatStyle.trim() || role.role.trim() || '围绕核心职责推进战局。',
|
||||
narrativeFunction:
|
||||
role.role.trim() || role.description.trim() || '在主线推进中提供关键响应。',
|
||||
sourceRoleIds: [role.id],
|
||||
sourceTemplateCharacterIds: [],
|
||||
tags: dedupeStrings(role.tags, 5),
|
||||
})) satisfies RoleArchetypeProfile[];
|
||||
}
|
||||
|
||||
function buildSceneBuckets(profile: CustomWorldProfile) {
|
||||
return profile.landmarks.slice(0, 8).map((landmark, index) => ({
|
||||
id: `scene-bucket-${index + 1}`,
|
||||
label: inferSceneBucketLabel(landmark),
|
||||
moodTags: dedupeStrings(
|
||||
splitToneTags(profile.tone),
|
||||
4,
|
||||
),
|
||||
keywords: dedupeStrings([landmark.name, landmark.description], 4),
|
||||
referenceLandmarkIds: [landmark.id],
|
||||
})) satisfies SceneArchetypeBucket[];
|
||||
}
|
||||
|
||||
function buildCreatureArchetypes(mode: CustomWorldThemeMode) {
|
||||
return CREATURE_ARCHETYPE_PRESETS[mode].map((creature, index) => ({
|
||||
id: `creature-archetype-${index + 1}`,
|
||||
...creature,
|
||||
})) satisfies CreatureArchetypeProfile[];
|
||||
}
|
||||
|
||||
function buildThemePackSeed(profile: CustomWorldProfile) {
|
||||
return buildThemePackFromWorldProfile({
|
||||
settingText: profile.settingText,
|
||||
summary: profile.summary,
|
||||
tone: profile.tone,
|
||||
playerGoal: profile.playerGoal,
|
||||
templateWorldType: resolveCustomWorldCompatibilityTemplateWorldType(profile),
|
||||
majorFactions: profile.majorFactions,
|
||||
coreConflicts: profile.coreConflicts,
|
||||
ownedSettingLayers: null,
|
||||
});
|
||||
}
|
||||
|
||||
function compileSemanticAnchor(
|
||||
profile: CustomWorldProfile,
|
||||
mode: CustomWorldThemeMode,
|
||||
) {
|
||||
const preset = SEMANTIC_ANCHOR_PRESETS[mode];
|
||||
const creatorIntent = profile.creatorIntent;
|
||||
const institutionHints = inferInstitutionType([
|
||||
...profile.majorFactions,
|
||||
...(creatorIntent?.keyFactions.map((seed) => seed.name) ?? []),
|
||||
]);
|
||||
const forceSystemTypes = inferForceSystemTypes(profile);
|
||||
|
||||
return {
|
||||
genreSignals: dedupeStrings(
|
||||
[...(creatorIntent?.themeKeywords ?? []), ...preset.genreSignals],
|
||||
6,
|
||||
),
|
||||
conflictForms: dedupeStrings(
|
||||
[...profile.coreConflicts, ...preset.conflictForms],
|
||||
6,
|
||||
),
|
||||
institutionTypes: dedupeStrings(
|
||||
[...institutionHints, ...preset.institutionTypes],
|
||||
6,
|
||||
),
|
||||
tabooTypes: dedupeStrings(
|
||||
[...profile.coreConflicts, ...preset.tabooTypes],
|
||||
6,
|
||||
),
|
||||
carrierTypes: dedupeStrings(
|
||||
[...(creatorIntent?.iconicElements ?? []), ...preset.carrierTypes],
|
||||
6,
|
||||
),
|
||||
forceSystemTypes: dedupeStrings(
|
||||
[...forceSystemTypes, ...preset.forceSystemTypes],
|
||||
6,
|
||||
),
|
||||
atmosphereTags: dedupeStrings(
|
||||
[...splitToneTags(profile.tone), ...preset.genreSignals],
|
||||
6,
|
||||
),
|
||||
} satisfies CustomWorldSemanticAnchor;
|
||||
}
|
||||
|
||||
function compileRuleProfile(
|
||||
profile: CustomWorldProfile,
|
||||
mode: CustomWorldThemeMode,
|
||||
) {
|
||||
return {
|
||||
attributeSchema: profile.attributeSchema,
|
||||
resourceLabels: RESOURCE_LABEL_PRESETS[mode],
|
||||
economyProfile: {
|
||||
initialCurrency: INITIAL_CURRENCY_PRESETS[mode],
|
||||
},
|
||||
} satisfies CustomWorldRuleProfile;
|
||||
}
|
||||
|
||||
function compileExpressionProfile(
|
||||
profile: CustomWorldProfile,
|
||||
semanticAnchor: CustomWorldSemanticAnchor,
|
||||
) {
|
||||
const fallbackThemePack = buildThemePackSeed(profile);
|
||||
const themePack = normalizeThemePack(profile.themePack, fallbackThemePack);
|
||||
|
||||
return {
|
||||
themePack,
|
||||
presentationTone: dedupeStrings(
|
||||
[profile.tone, ...semanticAnchor.atmosphereTags, ...themePack.toneRange],
|
||||
8,
|
||||
),
|
||||
namingDirectives: dedupeStrings(themePack.namingPatterns, 6),
|
||||
clueDirectives: dedupeStrings(themePack.clueForms, 6),
|
||||
revealDirectives: dedupeStrings(themePack.revealStyles, 6),
|
||||
} satisfies CustomWorldExpressionProfile;
|
||||
}
|
||||
|
||||
function compileReferenceProfile(
|
||||
profile: CustomWorldProfile,
|
||||
mode: CustomWorldThemeMode,
|
||||
) {
|
||||
return {
|
||||
roleArchetypes: buildRoleArchetypes(profile),
|
||||
sceneBuckets: buildSceneBuckets(profile),
|
||||
creatureArchetypes: buildCreatureArchetypes(mode),
|
||||
} satisfies CustomWorldReferenceProfile;
|
||||
}
|
||||
|
||||
function compileCompatibilityProfile(profile: CustomWorldProfile) {
|
||||
const compatibilityTemplateWorldType =
|
||||
resolveCustomWorldCompatibilityTemplateWorldType(profile);
|
||||
|
||||
return {
|
||||
compatibilityTemplateWorldType,
|
||||
legacyTemplateWorldType: compatibilityTemplateWorldType,
|
||||
migrationVersion: OWNED_SETTING_LAYER_MIGRATION_VERSION,
|
||||
} satisfies CustomWorldCompatibilityProfile;
|
||||
}
|
||||
|
||||
function normalizeRoleArchetypes(
|
||||
value: unknown,
|
||||
fallback: RoleArchetypeProfile[],
|
||||
) {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.map((entry, index) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = entry as Record<string, unknown>;
|
||||
const label = toText(item.label);
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: toText(item.id) || `role-archetype-${index + 1}`,
|
||||
label,
|
||||
combatFocus:
|
||||
toText(item.combatFocus) ||
|
||||
fallback[index]?.combatFocus ||
|
||||
'围绕核心职责推进战局。',
|
||||
narrativeFunction:
|
||||
toText(item.narrativeFunction) ||
|
||||
fallback[index]?.narrativeFunction ||
|
||||
'在主线推进中提供关键响应。',
|
||||
sourceRoleIds: toStringArray(item.sourceRoleIds, 4),
|
||||
sourceTemplateCharacterIds: toStringArray(
|
||||
item.sourceTemplateCharacterIds,
|
||||
4,
|
||||
),
|
||||
tags: dedupeStrings(toStringArray(item.tags, 5), 5),
|
||||
} satisfies RoleArchetypeProfile;
|
||||
})
|
||||
.filter((entry): entry is RoleArchetypeProfile => Boolean(entry));
|
||||
|
||||
return normalized.length > 0 ? normalized : fallback;
|
||||
}
|
||||
|
||||
function normalizeSceneBuckets(
|
||||
value: unknown,
|
||||
fallback: SceneArchetypeBucket[],
|
||||
) {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.map((entry, index) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = entry as Record<string, unknown>;
|
||||
const label = toText(item.label);
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: toText(item.id) || `scene-bucket-${index + 1}`,
|
||||
label,
|
||||
moodTags: dedupeStrings(toStringArray(item.moodTags, 4), 4),
|
||||
keywords: dedupeStrings(toStringArray(item.keywords, 4), 4),
|
||||
referenceLandmarkIds: toStringArray(item.referenceLandmarkIds, 4),
|
||||
} satisfies SceneArchetypeBucket;
|
||||
})
|
||||
.filter((entry): entry is SceneArchetypeBucket => Boolean(entry));
|
||||
|
||||
return normalized.length > 0 ? normalized : fallback;
|
||||
}
|
||||
|
||||
function normalizeCreatureArchetypes(
|
||||
value: unknown,
|
||||
fallback: CreatureArchetypeProfile[],
|
||||
) {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.map((entry, index) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = entry as Record<string, unknown>;
|
||||
const label = toText(item.label);
|
||||
if (!label) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: toText(item.id) || `creature-archetype-${index + 1}`,
|
||||
label,
|
||||
threatStyle:
|
||||
toText(item.threatStyle) ||
|
||||
fallback[index]?.threatStyle ||
|
||||
'围绕核心威胁方式持续施压。',
|
||||
keywords: dedupeStrings(toStringArray(item.keywords, 4), 4),
|
||||
} satisfies CreatureArchetypeProfile;
|
||||
})
|
||||
.filter((entry): entry is CreatureArchetypeProfile => Boolean(entry));
|
||||
|
||||
return normalized.length > 0 ? normalized : fallback;
|
||||
}
|
||||
|
||||
export function compileOwnedSettingLayersFromLegacyTemplate(
|
||||
profile: CustomWorldProfile,
|
||||
) {
|
||||
const mode = detectCustomWorldThemeMode({
|
||||
settingText: profile.settingText,
|
||||
summary: profile.summary,
|
||||
tone: profile.tone,
|
||||
playerGoal: profile.playerGoal,
|
||||
templateWorldType: profile.templateWorldType,
|
||||
compatibilityTemplateWorldType:
|
||||
profile.compatibilityTemplateWorldType ?? profile.templateWorldType,
|
||||
ownedSettingLayers: null,
|
||||
});
|
||||
const semanticAnchor = compileSemanticAnchor(profile, mode);
|
||||
|
||||
return {
|
||||
semanticAnchor,
|
||||
ruleProfile: compileRuleProfile(profile, mode),
|
||||
expressionProfile: compileExpressionProfile(profile, semanticAnchor),
|
||||
referenceProfile: compileReferenceProfile(profile, mode),
|
||||
compatibilityProfile: compileCompatibilityProfile(profile),
|
||||
} satisfies CustomWorldOwnedSettingLayers;
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldOwnedSettingLayers(
|
||||
value: unknown,
|
||||
profile: CustomWorldProfile,
|
||||
) {
|
||||
const fallback = compileOwnedSettingLayersFromLegacyTemplate(profile);
|
||||
if (!value || typeof value !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const semanticAnchorItem =
|
||||
item.semanticAnchor && typeof item.semanticAnchor === 'object'
|
||||
? (item.semanticAnchor as Record<string, unknown>)
|
||||
: {};
|
||||
const ruleProfileItem =
|
||||
item.ruleProfile && typeof item.ruleProfile === 'object'
|
||||
? (item.ruleProfile as Record<string, unknown>)
|
||||
: {};
|
||||
const resourceLabelsItem =
|
||||
ruleProfileItem.resourceLabels &&
|
||||
typeof ruleProfileItem.resourceLabels === 'object'
|
||||
? (ruleProfileItem.resourceLabels as Record<string, unknown>)
|
||||
: {};
|
||||
const expressionProfileItem =
|
||||
item.expressionProfile && typeof item.expressionProfile === 'object'
|
||||
? (item.expressionProfile as Record<string, unknown>)
|
||||
: {};
|
||||
const referenceProfileItem =
|
||||
item.referenceProfile && typeof item.referenceProfile === 'object'
|
||||
? (item.referenceProfile as Record<string, unknown>)
|
||||
: {};
|
||||
const compatibilityProfileItem =
|
||||
item.compatibilityProfile && typeof item.compatibilityProfile === 'object'
|
||||
? (item.compatibilityProfile as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
semanticAnchor: {
|
||||
genreSignals: dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.genreSignals,
|
||||
fallback.semanticAnchor.genreSignals.length,
|
||||
),
|
||||
fallback.semanticAnchor.genreSignals.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.genreSignals,
|
||||
fallback.semanticAnchor.genreSignals.length,
|
||||
),
|
||||
fallback.semanticAnchor.genreSignals.length,
|
||||
)
|
||||
: fallback.semanticAnchor.genreSignals,
|
||||
conflictForms: dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.conflictForms,
|
||||
fallback.semanticAnchor.conflictForms.length,
|
||||
),
|
||||
fallback.semanticAnchor.conflictForms.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.conflictForms,
|
||||
fallback.semanticAnchor.conflictForms.length,
|
||||
),
|
||||
fallback.semanticAnchor.conflictForms.length,
|
||||
)
|
||||
: fallback.semanticAnchor.conflictForms,
|
||||
institutionTypes: dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.institutionTypes,
|
||||
fallback.semanticAnchor.institutionTypes.length,
|
||||
),
|
||||
fallback.semanticAnchor.institutionTypes.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.institutionTypes,
|
||||
fallback.semanticAnchor.institutionTypes.length,
|
||||
),
|
||||
fallback.semanticAnchor.institutionTypes.length,
|
||||
)
|
||||
: fallback.semanticAnchor.institutionTypes,
|
||||
tabooTypes: dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.tabooTypes,
|
||||
fallback.semanticAnchor.tabooTypes.length,
|
||||
),
|
||||
fallback.semanticAnchor.tabooTypes.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.tabooTypes,
|
||||
fallback.semanticAnchor.tabooTypes.length,
|
||||
),
|
||||
fallback.semanticAnchor.tabooTypes.length,
|
||||
)
|
||||
: fallback.semanticAnchor.tabooTypes,
|
||||
carrierTypes: dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.carrierTypes,
|
||||
fallback.semanticAnchor.carrierTypes.length,
|
||||
),
|
||||
fallback.semanticAnchor.carrierTypes.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.carrierTypes,
|
||||
fallback.semanticAnchor.carrierTypes.length,
|
||||
),
|
||||
fallback.semanticAnchor.carrierTypes.length,
|
||||
)
|
||||
: fallback.semanticAnchor.carrierTypes,
|
||||
forceSystemTypes: dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.forceSystemTypes,
|
||||
fallback.semanticAnchor.forceSystemTypes.length,
|
||||
),
|
||||
fallback.semanticAnchor.forceSystemTypes.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.forceSystemTypes,
|
||||
fallback.semanticAnchor.forceSystemTypes.length,
|
||||
),
|
||||
fallback.semanticAnchor.forceSystemTypes.length,
|
||||
)
|
||||
: fallback.semanticAnchor.forceSystemTypes,
|
||||
atmosphereTags: dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.atmosphereTags,
|
||||
fallback.semanticAnchor.atmosphereTags.length,
|
||||
),
|
||||
fallback.semanticAnchor.atmosphereTags.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
semanticAnchorItem.atmosphereTags,
|
||||
fallback.semanticAnchor.atmosphereTags.length,
|
||||
),
|
||||
fallback.semanticAnchor.atmosphereTags.length,
|
||||
)
|
||||
: fallback.semanticAnchor.atmosphereTags,
|
||||
},
|
||||
ruleProfile: {
|
||||
attributeSchema: coerceWorldAttributeSchema(
|
||||
ruleProfileItem.attributeSchema,
|
||||
fallback.ruleProfile.attributeSchema,
|
||||
),
|
||||
resourceLabels: {
|
||||
hp: toText(resourceLabelsItem.hp) || fallback.ruleProfile.resourceLabels.hp,
|
||||
mp: toText(resourceLabelsItem.mp) || fallback.ruleProfile.resourceLabels.mp,
|
||||
maxHp:
|
||||
toText(resourceLabelsItem.maxHp) ||
|
||||
fallback.ruleProfile.resourceLabels.maxHp,
|
||||
maxMp:
|
||||
toText(resourceLabelsItem.maxMp) ||
|
||||
fallback.ruleProfile.resourceLabels.maxMp,
|
||||
damage:
|
||||
toText(resourceLabelsItem.damage) ||
|
||||
fallback.ruleProfile.resourceLabels.damage,
|
||||
guard:
|
||||
toText(resourceLabelsItem.guard) ||
|
||||
fallback.ruleProfile.resourceLabels.guard,
|
||||
range:
|
||||
toText(resourceLabelsItem.range) ||
|
||||
fallback.ruleProfile.resourceLabels.range,
|
||||
cooldown:
|
||||
toText(resourceLabelsItem.cooldown) ||
|
||||
fallback.ruleProfile.resourceLabels.cooldown,
|
||||
manaCost:
|
||||
toText(resourceLabelsItem.manaCost) ||
|
||||
fallback.ruleProfile.resourceLabels.manaCost,
|
||||
currency:
|
||||
toText(resourceLabelsItem.currency) ||
|
||||
fallback.ruleProfile.resourceLabels.currency,
|
||||
},
|
||||
economyProfile: {
|
||||
initialCurrency:
|
||||
typeof ruleProfileItem.economyProfile === 'object' &&
|
||||
ruleProfileItem.economyProfile &&
|
||||
typeof (ruleProfileItem.economyProfile as Record<string, unknown>)
|
||||
.initialCurrency === 'number' &&
|
||||
Number.isFinite(
|
||||
(ruleProfileItem.economyProfile as Record<string, unknown>)
|
||||
.initialCurrency,
|
||||
)
|
||||
? Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
(ruleProfileItem.economyProfile as Record<string, unknown>)
|
||||
.initialCurrency as number,
|
||||
),
|
||||
)
|
||||
: fallback.ruleProfile.economyProfile.initialCurrency,
|
||||
},
|
||||
},
|
||||
expressionProfile: {
|
||||
themePack: normalizeThemePack(
|
||||
expressionProfileItem.themePack,
|
||||
fallback.expressionProfile.themePack,
|
||||
),
|
||||
presentationTone: dedupeStrings(
|
||||
toStringArray(
|
||||
expressionProfileItem.presentationTone,
|
||||
fallback.expressionProfile.presentationTone.length,
|
||||
),
|
||||
fallback.expressionProfile.presentationTone.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
expressionProfileItem.presentationTone,
|
||||
fallback.expressionProfile.presentationTone.length,
|
||||
),
|
||||
fallback.expressionProfile.presentationTone.length,
|
||||
)
|
||||
: fallback.expressionProfile.presentationTone,
|
||||
namingDirectives: dedupeStrings(
|
||||
toStringArray(
|
||||
expressionProfileItem.namingDirectives,
|
||||
fallback.expressionProfile.namingDirectives.length,
|
||||
),
|
||||
fallback.expressionProfile.namingDirectives.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
expressionProfileItem.namingDirectives,
|
||||
fallback.expressionProfile.namingDirectives.length,
|
||||
),
|
||||
fallback.expressionProfile.namingDirectives.length,
|
||||
)
|
||||
: fallback.expressionProfile.namingDirectives,
|
||||
clueDirectives: dedupeStrings(
|
||||
toStringArray(
|
||||
expressionProfileItem.clueDirectives,
|
||||
fallback.expressionProfile.clueDirectives.length,
|
||||
),
|
||||
fallback.expressionProfile.clueDirectives.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
expressionProfileItem.clueDirectives,
|
||||
fallback.expressionProfile.clueDirectives.length,
|
||||
),
|
||||
fallback.expressionProfile.clueDirectives.length,
|
||||
)
|
||||
: fallback.expressionProfile.clueDirectives,
|
||||
revealDirectives: dedupeStrings(
|
||||
toStringArray(
|
||||
expressionProfileItem.revealDirectives,
|
||||
fallback.expressionProfile.revealDirectives.length,
|
||||
),
|
||||
fallback.expressionProfile.revealDirectives.length,
|
||||
).length
|
||||
? dedupeStrings(
|
||||
toStringArray(
|
||||
expressionProfileItem.revealDirectives,
|
||||
fallback.expressionProfile.revealDirectives.length,
|
||||
),
|
||||
fallback.expressionProfile.revealDirectives.length,
|
||||
)
|
||||
: fallback.expressionProfile.revealDirectives,
|
||||
},
|
||||
referenceProfile: {
|
||||
roleArchetypes: normalizeRoleArchetypes(
|
||||
referenceProfileItem.roleArchetypes,
|
||||
fallback.referenceProfile.roleArchetypes,
|
||||
),
|
||||
sceneBuckets: normalizeSceneBuckets(
|
||||
referenceProfileItem.sceneBuckets,
|
||||
fallback.referenceProfile.sceneBuckets,
|
||||
),
|
||||
creatureArchetypes: normalizeCreatureArchetypes(
|
||||
referenceProfileItem.creatureArchetypes,
|
||||
fallback.referenceProfile.creatureArchetypes,
|
||||
),
|
||||
},
|
||||
compatibilityProfile: {
|
||||
compatibilityTemplateWorldType:
|
||||
compatibilityProfileItem.compatibilityTemplateWorldType === WorldType.XIANXIA
|
||||
? WorldType.XIANXIA
|
||||
: compatibilityProfileItem.compatibilityTemplateWorldType === WorldType.WUXIA
|
||||
? WorldType.WUXIA
|
||||
: fallback.compatibilityProfile?.compatibilityTemplateWorldType ?? null,
|
||||
legacyTemplateWorldType:
|
||||
compatibilityProfileItem.legacyTemplateWorldType === WorldType.XIANXIA
|
||||
? WorldType.XIANXIA
|
||||
: compatibilityProfileItem.legacyTemplateWorldType === WorldType.WUXIA
|
||||
? WorldType.WUXIA
|
||||
: fallback.compatibilityProfile?.legacyTemplateWorldType ?? null,
|
||||
migrationVersion:
|
||||
toText(compatibilityProfileItem.migrationVersion) ||
|
||||
fallback.compatibilityProfile?.migrationVersion ||
|
||||
OWNED_SETTING_LAYER_MIGRATION_VERSION,
|
||||
},
|
||||
} satisfies CustomWorldOwnedSettingLayers;
|
||||
}
|
||||
|
||||
export function resolveCustomWorldOwnedSettingLayers(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
) {
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return profile.ownedSettingLayers ?? compileOwnedSettingLayersFromLegacyTemplate(profile);
|
||||
}
|
||||
|
||||
export function resolveCustomWorldRuleProfile(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
) {
|
||||
return resolveCustomWorldOwnedSettingLayers(profile)?.ruleProfile ?? null;
|
||||
}
|
||||
|
||||
export function resolveCustomWorldExpressionProfile(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
) {
|
||||
return resolveCustomWorldOwnedSettingLayers(profile)?.expressionProfile ?? null;
|
||||
}
|
||||
|
||||
export function resolveCustomWorldSemanticAnchor(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
) {
|
||||
return resolveCustomWorldOwnedSettingLayers(profile)?.semanticAnchor ?? null;
|
||||
}
|
||||
|
||||
export function resolveCustomWorldCompatibilityProfile(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
) {
|
||||
return resolveCustomWorldOwnedSettingLayers(profile)?.compatibilityProfile ?? null;
|
||||
}
|
||||
539
src/services/customWorldPresentation.ts
Normal file
539
src/services/customWorldPresentation.ts
Normal file
@@ -0,0 +1,539 @@
|
||||
import { getRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
|
||||
import { ITEM_CATEGORY_OPTIONS } from '../data/itemCatalog';
|
||||
import {
|
||||
Character,
|
||||
CharacterSkillDefinition,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
EquipmentSlotId,
|
||||
ItemRarity,
|
||||
ItemStatProfile,
|
||||
ItemUseProfile,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { resolveCustomWorldCampScene } from './customWorldCamp';
|
||||
import { resolveCustomWorldRuleProfile } from './customWorldOwnedSettingLayers';
|
||||
import {
|
||||
type CustomWorldThemeMode,
|
||||
detectCustomWorldThemeMode,
|
||||
} from './customWorldTheme';
|
||||
|
||||
type ThemeMode = CustomWorldThemeMode;
|
||||
type AttributeLabelMap = Record<keyof Character['attributes'], string>;
|
||||
|
||||
const [
|
||||
CATEGORY_WEAPON,
|
||||
CATEGORY_ARMOR,
|
||||
CATEGORY_RELIC,
|
||||
CATEGORY_CONSUMABLE,
|
||||
CATEGORY_MATERIAL,
|
||||
CATEGORY_RARE,
|
||||
CATEGORY_EXCLUSIVE,
|
||||
] = ITEM_CATEGORY_OPTIONS;
|
||||
|
||||
type WorldPresentation = {
|
||||
mode: ThemeMode;
|
||||
attributeLabels: AttributeLabelMap;
|
||||
hpLabel: string;
|
||||
mpLabel: string;
|
||||
maxHpLabel: string;
|
||||
maxMpLabel: string;
|
||||
damageLabel: string;
|
||||
guardLabel: string;
|
||||
rangeLabel: string;
|
||||
cooldownLabel: string;
|
||||
manaCostLabel: string;
|
||||
campSuffix: string;
|
||||
itemPrefixes: string[];
|
||||
itemInfixes: string[];
|
||||
skillPrefixes: string[];
|
||||
skillSuffixByStyle: Record<CharacterSkillDefinition['style'], string[]>;
|
||||
};
|
||||
|
||||
const WORLD_PRESENTATIONS: Record<ThemeMode, WorldPresentation> = {
|
||||
mythic: {
|
||||
mode: 'mythic',
|
||||
attributeLabels: { strength: '体魄', agility: '身法', intelligence: '识见', spirit: '心魂' },
|
||||
hpLabel: '生命',
|
||||
mpLabel: '心流',
|
||||
maxHpLabel: '生命上限',
|
||||
maxMpLabel: '心流上限',
|
||||
damageLabel: '势能',
|
||||
guardLabel: '防护',
|
||||
rangeLabel: '距离',
|
||||
cooldownLabel: '回整',
|
||||
manaCostLabel: '心流消耗',
|
||||
campSuffix: '归舍',
|
||||
itemPrefixes: ['远岸', '回声', '曙迹', '长旅', '微光', '新铭'],
|
||||
itemInfixes: ['印', '纹', '辉', '迹', '息', '铭'],
|
||||
skillPrefixes: ['映', '折', '回', '逐', '临', '流'],
|
||||
skillSuffixByStyle: {
|
||||
burst: ['震', '断', '破', '坠'],
|
||||
steady: ['守', '定', '护', '镇'],
|
||||
mobility: ['跃', '移', '转', '行'],
|
||||
finisher: ['终', '决', '落', '尽'],
|
||||
projectile: ['矢', '刃', '波', '纹'],
|
||||
},
|
||||
},
|
||||
martial: {
|
||||
mode: 'martial',
|
||||
attributeLabels: { strength: '力量', agility: '敏捷', intelligence: '智力', spirit: '精神' },
|
||||
hpLabel: '气血',
|
||||
mpLabel: '内力',
|
||||
maxHpLabel: '气血上限',
|
||||
maxMpLabel: '内力上限',
|
||||
damageLabel: '招式',
|
||||
guardLabel: '防御',
|
||||
rangeLabel: '招距',
|
||||
cooldownLabel: '调息',
|
||||
manaCostLabel: '内力消耗',
|
||||
campSuffix: '归舍',
|
||||
itemPrefixes: ['风雨', '青锋', '断桥', '冷铁', '旧案', '残影'],
|
||||
itemInfixes: ['刃','锋','魂','诀','式','影'],
|
||||
skillPrefixes: ['破','斩','击','御','飞','隐'],
|
||||
skillSuffixByStyle: {
|
||||
burst: ['杀','灭','破','击'],
|
||||
steady: ['守','御','护','镇'],
|
||||
mobility: ['闪','移','跃','遁'],
|
||||
finisher: ['决','断','灭','终'],
|
||||
projectile: ['飞','射','投','掷'],
|
||||
},
|
||||
},
|
||||
arcane: {
|
||||
mode: 'arcane',
|
||||
attributeLabels: { strength: '道骨', agility: '身法', intelligence: '神识', spirit: '灵韵' },
|
||||
hpLabel: '元命',
|
||||
mpLabel: '灵韵',
|
||||
maxHpLabel: '元命上限',
|
||||
maxMpLabel: '灵韵上限',
|
||||
damageLabel: '术法',
|
||||
guardLabel: '护盾',
|
||||
rangeLabel: '术距',
|
||||
cooldownLabel: '回息',
|
||||
manaCostLabel: '灵韵消耗',
|
||||
campSuffix: '栖居',
|
||||
itemPrefixes: ['灵韵', '道纹', '云篆', '星芒', '界辉', '道痕'],
|
||||
itemInfixes: ['灵','道','法','术','诀','印'],
|
||||
skillPrefixes: ['灵','道','法','界','星','印'],
|
||||
skillSuffixByStyle: {
|
||||
burst: ['破','灭','毁','绝'],
|
||||
steady: ['守','御','护','镇'],
|
||||
mobility: ['闪','移','跃','遁'],
|
||||
finisher: ['决','断','灭','终'],
|
||||
projectile: ['飞','射','投','掷'],
|
||||
},
|
||||
},
|
||||
machina: {
|
||||
mode: 'machina',
|
||||
attributeLabels: { strength: '动力', agility: '精度', intelligence: '逻辑', spirit: '核心' },
|
||||
hpLabel: '耐久',
|
||||
mpLabel: '能量',
|
||||
maxHpLabel: '耐久上限',
|
||||
maxMpLabel: '能量上限',
|
||||
damageLabel: '火力',
|
||||
guardLabel: '护盾',
|
||||
rangeLabel: '射程',
|
||||
cooldownLabel: '充能',
|
||||
manaCostLabel: '能量消耗',
|
||||
campSuffix: '整备居',
|
||||
itemPrefixes: ['铁脊', '钢律', '脉冲', '核列', '新星', '等离'],
|
||||
itemInfixes: ['芯', '驱', '链', '阵', '节', '机'],
|
||||
skillPrefixes: ['超载', '脉冲', '聚核', '磁轨', '新星', '裂火'],
|
||||
skillSuffixByStyle: {
|
||||
burst: ['爆裂', '齐射', '连发', '倾泻'],
|
||||
steady: ['稳压', '固守', '护持', '锚定'],
|
||||
mobility: ['疾冲', '推进', '跃迁', '闪移'],
|
||||
finisher: ['终断', '歼灭', '过载', '坠落'],
|
||||
projectile: ['弹', '束', '矢', '炮'],
|
||||
},
|
||||
},
|
||||
tide: {
|
||||
mode: 'tide',
|
||||
attributeLabels: { strength: '潮力', agility: '浪步', intelligence: '潮识', spirit: '潮魄' },
|
||||
hpLabel: '潮命',
|
||||
mpLabel: '潮息',
|
||||
maxHpLabel: '潮命上限',
|
||||
maxMpLabel: '潮息上限',
|
||||
damageLabel: '潮势',
|
||||
guardLabel: '潮护',
|
||||
rangeLabel: '潮距',
|
||||
cooldownLabel: '回潮',
|
||||
manaCostLabel: '潮息消耗',
|
||||
campSuffix: '潮居',
|
||||
itemPrefixes: ['潮纹', '海晕', '霜浪', '天澜', '潮歌', '沧流'],
|
||||
itemInfixes: ['潮', '浪', '汐', '海', '涛', '澜'],
|
||||
skillPrefixes: ['潮', '浪', '汐', '海', '澜', '涌'],
|
||||
skillSuffixByStyle: {
|
||||
burst: ['裂潮', '怒涌', '连浪', '奔潮'],
|
||||
steady: ['守潮', '潮护', '定澜', '镇流'],
|
||||
mobility: ['踏浪', '游潮', '跃汐', '逐流'],
|
||||
finisher: ['断潮', '覆海', '终汐', '沉落'],
|
||||
projectile: ['潮矢', '水矛', '浪刃', '飞涌'],
|
||||
},
|
||||
},
|
||||
rift: {
|
||||
mode: 'rift',
|
||||
attributeLabels: { strength: '界劲', agility: '裂步', intelligence: '界识', spirit: '界压' },
|
||||
hpLabel: '界命',
|
||||
mpLabel: '裂能',
|
||||
maxHpLabel: '界命上限',
|
||||
maxMpLabel: '裂能上限',
|
||||
damageLabel: '界势',
|
||||
guardLabel: '稳界',
|
||||
rangeLabel: '界距',
|
||||
cooldownLabel: '复界',
|
||||
manaCostLabel: '裂能消耗',
|
||||
campSuffix: '界隙居所',
|
||||
itemPrefixes: ['裂界', '断层', '边潮', '灰域', '界桥', '前哨'],
|
||||
itemInfixes: ['锋', '隙', '锚', '印', '界', '核'],
|
||||
skillPrefixes: ['裂', '断', '界', '相', '折', '迁'],
|
||||
skillSuffixByStyle: {
|
||||
burst: ['崩断', '碎坠', '裂爆', '界崩'],
|
||||
steady: ['守界', '固相', '帷障', '界卫'],
|
||||
mobility: ['裂步', '转相', '闪迁', '漂移'],
|
||||
finisher: ['终坠', '断灭', '裂终', '界燃'],
|
||||
projectile: ['界刺', '裂矢', '碎片', '裂波'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CATEGORY_NOUNS: Record<string, string[]> = Object.fromEntries([
|
||||
[CATEGORY_WEAPON, ['剑', '刃', '弓', '杖', '枪', '盾']],
|
||||
[CATEGORY_ARMOR, ['甲', '袍', '披风', '护具', '肩甲', '护腕']],
|
||||
[CATEGORY_RELIC, ['戒', '印', '徽', '玉', '符', '珠']],
|
||||
[CATEGORY_CONSUMABLE, ['药', '散', '剂', '露', '油', '卷']],
|
||||
[CATEGORY_MATERIAL, ['矿', '晶', '骨', '草', '核', '丝']],
|
||||
[CATEGORY_RARE, ['符', '遗物', '残页', '图', '钥', '像']],
|
||||
[CATEGORY_EXCLUSIVE, ['核心', '封印', '主钥', '源匣', '真印', '界核']],
|
||||
]);
|
||||
const DEFAULT_CATEGORY_NOUNS = ['符', '印', '信物', '匣', '核', '铭片'];
|
||||
|
||||
const ROLE_SKILL_ROOTS: Record<string, string[]> = {
|
||||
'sword-princess': ['王剑', '锋式', '裁锋', '裂锋'],
|
||||
'archer-hero': ['弦诀', '远袭', '追风', '贯矢'],
|
||||
'girl-hero': ['双刃', '影袭', '疾斩', '掠影'],
|
||||
'punch-hero': ['拳势', '震击', '裂拳', '崩步'],
|
||||
'fighter-4': ['重锋', '盾阵', '镇线', '压城'],
|
||||
};
|
||||
|
||||
const SKILL_ROOT_STOP_WORDS = new Set([
|
||||
'世界',
|
||||
'设定',
|
||||
'基调',
|
||||
'目标',
|
||||
'角色',
|
||||
'战斗',
|
||||
'风格',
|
||||
'背景',
|
||||
'性格',
|
||||
'故事',
|
||||
'custom-world',
|
||||
'playable-role',
|
||||
]);
|
||||
|
||||
|
||||
function hashText(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function pickCyclic<T>(items: readonly T[], index: number, label: string): T {
|
||||
const item = items[index % items.length];
|
||||
if (item === undefined) {
|
||||
throw new Error(`Missing ${label}`);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
function getWorldPresentation(profile: CustomWorldProfile) {
|
||||
return WORLD_PRESENTATIONS[detectCustomWorldThemeMode(profile)];
|
||||
}
|
||||
|
||||
function dedupeStrings(values: string[], max = 12) {
|
||||
return [...new Set(values.map(value => value.trim()).filter(Boolean))].slice(0, max);
|
||||
}
|
||||
|
||||
function collectSkillRootFragments(value: string, max = 8) {
|
||||
if (!value.trim()) return [] as string[];
|
||||
|
||||
const directSegments = value
|
||||
.split(/[ \t\r\n,!?:"()|/\\[\]-]+/u)
|
||||
.map(segment => segment.trim())
|
||||
.filter(segment => segment.length >= 2 && segment.length <= 6)
|
||||
.filter(segment => !SKILL_ROOT_STOP_WORDS.has(segment));
|
||||
|
||||
const chineseSource = value.replace(/[^\u4e00-\u9fa5]/gu, '');
|
||||
const ngrams: string[] = [];
|
||||
|
||||
for (let size = 2; size <= 4; size += 1) {
|
||||
for (let index = 0; index <= chineseSource.length - size; index += 1) {
|
||||
const fragment = chineseSource.slice(index, index + size);
|
||||
if (SKILL_ROOT_STOP_WORDS.has(fragment)) {
|
||||
continue;
|
||||
}
|
||||
ngrams.push(fragment);
|
||||
if (ngrams.length >= max) {
|
||||
return dedupeStrings([...directSegments, ...ngrams], max);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dedupeStrings([...directSegments, ...ngrams], max);
|
||||
}
|
||||
|
||||
function buildSkillThemeSeedSource(
|
||||
profile: CustomWorldProfile,
|
||||
character: Character,
|
||||
skill: CharacterSkillDefinition,
|
||||
index: number,
|
||||
role?: Pick<CustomWorldPlayableNpc, 'title' | 'combatStyle' | 'tags'> | null,
|
||||
) {
|
||||
return [
|
||||
profile.name,
|
||||
profile.settingText,
|
||||
profile.summary,
|
||||
profile.tone,
|
||||
profile.playerGoal,
|
||||
role?.title ?? '',
|
||||
role?.combatStyle ?? '',
|
||||
role?.tags.join('|') ?? '',
|
||||
character.id,
|
||||
skill.id,
|
||||
skill.style,
|
||||
skill.delivery ?? '',
|
||||
index,
|
||||
].join('::');
|
||||
}
|
||||
|
||||
function buildSkillRootOptions(
|
||||
profile: CustomWorldProfile,
|
||||
character: Character,
|
||||
role?: Pick<CustomWorldPlayableNpc, 'title' | 'combatStyle' | 'tags'> | null,
|
||||
) {
|
||||
const fallbackRoots = ROLE_SKILL_ROOTS[character.id] ?? ['界式', '行诀', '裂锋', '潮印'];
|
||||
const derivedRoots = dedupeStrings([
|
||||
...collectSkillRootFragments(role?.title ?? '', 4),
|
||||
...collectSkillRootFragments(role?.combatStyle ?? '', 6),
|
||||
...(role?.tags ?? []).flatMap(tag => collectSkillRootFragments(tag, 2)),
|
||||
...collectSkillRootFragments(profile.name, 4),
|
||||
...collectSkillRootFragments(profile.playerGoal, 6),
|
||||
], 8);
|
||||
|
||||
return derivedRoots.length > 0 ? dedupeStrings([...derivedRoots, ...fallbackRoots], 8) : fallbackRoots;
|
||||
}
|
||||
|
||||
export function getCustomWorldProfileForDisplay(worldType: WorldType | null | undefined, explicitProfile?: CustomWorldProfile | null) {
|
||||
if (explicitProfile) return explicitProfile;
|
||||
if (worldType === WorldType.CUSTOM) {
|
||||
return getRuntimeCustomWorldProfile();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getAttributeLabelsForWorld(worldType: WorldType | null | undefined, explicitProfile?: CustomWorldProfile | null): AttributeLabelMap {
|
||||
const profile = getCustomWorldProfileForDisplay(worldType, explicitProfile);
|
||||
if (!profile) {
|
||||
if (worldType === WorldType.XIANXIA) {
|
||||
return { strength: '道骨', agility: '身法', intelligence: '神识', spirit: '灵韵' };
|
||||
}
|
||||
return { strength: '力量', agility: '敏捷', intelligence: '智力', spirit: '精神' };
|
||||
}
|
||||
|
||||
return getWorldPresentation(profile).attributeLabels;
|
||||
}
|
||||
|
||||
export function getResourceLabelsForWorld(worldType: WorldType | null | undefined, explicitProfile?: CustomWorldProfile | null) {
|
||||
const profile = getCustomWorldProfileForDisplay(worldType, explicitProfile);
|
||||
if (!profile) {
|
||||
if (worldType === WorldType.XIANXIA) {
|
||||
return {
|
||||
hp: '命元',
|
||||
mp: '灵韵',
|
||||
maxHp: '命元上限',
|
||||
maxMp: '灵韵上限',
|
||||
damage: '术势',
|
||||
guard: '护元',
|
||||
range: '术距',
|
||||
cooldown: '回息',
|
||||
manaCost: '灵韵消耗',
|
||||
};
|
||||
}
|
||||
return {
|
||||
hp: '生命',
|
||||
mp: '灵力',
|
||||
maxHp: '生命上限',
|
||||
maxMp: '灵力上限',
|
||||
damage: '伤害',
|
||||
guard: '防护',
|
||||
range: '距离',
|
||||
cooldown: '冷却',
|
||||
manaCost: '消耗',
|
||||
};
|
||||
}
|
||||
|
||||
const ruleProfile = resolveCustomWorldRuleProfile(profile);
|
||||
if (ruleProfile) {
|
||||
return {
|
||||
hp: ruleProfile.resourceLabels.hp,
|
||||
mp: ruleProfile.resourceLabels.mp,
|
||||
maxHp: ruleProfile.resourceLabels.maxHp,
|
||||
maxMp: ruleProfile.resourceLabels.maxMp,
|
||||
damage: ruleProfile.resourceLabels.damage,
|
||||
guard: ruleProfile.resourceLabels.guard,
|
||||
range: ruleProfile.resourceLabels.range,
|
||||
cooldown: ruleProfile.resourceLabels.cooldown,
|
||||
manaCost: ruleProfile.resourceLabels.manaCost,
|
||||
};
|
||||
}
|
||||
|
||||
const presentation = getWorldPresentation(profile);
|
||||
return {
|
||||
hp: presentation.hpLabel,
|
||||
mp: presentation.mpLabel,
|
||||
maxHp: presentation.maxHpLabel,
|
||||
maxMp: presentation.maxMpLabel,
|
||||
damage: presentation.damageLabel,
|
||||
guard: presentation.guardLabel,
|
||||
range: presentation.rangeLabel,
|
||||
cooldown: presentation.cooldownLabel,
|
||||
manaCost: presentation.manaCostLabel,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function buildCustomCampSceneName(profile: CustomWorldProfile) {
|
||||
return resolveCustomWorldCampScene(profile).name;
|
||||
}
|
||||
|
||||
export function buildThemedSkillName(
|
||||
profile: CustomWorldProfile,
|
||||
character: Character,
|
||||
skill: CharacterSkillDefinition,
|
||||
index: number,
|
||||
role?: Pick<CustomWorldPlayableNpc, 'title' | 'combatStyle' | 'tags'> | null,
|
||||
) {
|
||||
const presentation = getWorldPresentation(profile);
|
||||
const seed = hashText(buildSkillThemeSeedSource(profile, character, skill, index, role));
|
||||
const rootOptions = buildSkillRootOptions(profile, character, role);
|
||||
const prefix = presentation.skillPrefixes[seed % presentation.skillPrefixes.length];
|
||||
const root = rootOptions[(seed >>> 3) % rootOptions.length];
|
||||
const suffix = presentation.skillSuffixByStyle[skill.style][(seed >>> 5) % presentation.skillSuffixByStyle[skill.style].length];
|
||||
return `${prefix}${root}${suffix}`;
|
||||
}
|
||||
|
||||
function getCategoryNouns(category: string) {
|
||||
return CATEGORY_NOUNS[category] ?? CATEGORY_NOUNS[CATEGORY_RARE];
|
||||
}
|
||||
|
||||
function getResolvedCategoryNouns(category: string): string[] {
|
||||
return getCategoryNouns(category) ?? DEFAULT_CATEGORY_NOUNS;
|
||||
}
|
||||
|
||||
export function buildThemedItemName(
|
||||
profile: CustomWorldProfile,
|
||||
category: string,
|
||||
sourceKey: string,
|
||||
index: number,
|
||||
) {
|
||||
const presentation = getWorldPresentation(profile);
|
||||
const seed = hashText(`${profile.id}:${sourceKey}:${category}:${index}`);
|
||||
const prefix = presentation.itemPrefixes[seed % presentation.itemPrefixes.length];
|
||||
const infix = presentation.itemInfixes[(seed >>> 3) % presentation.itemInfixes.length];
|
||||
const nouns = getResolvedCategoryNouns(category);
|
||||
const noun = pickCyclic(nouns, seed >>> 5, `item noun for category "${category}"`);
|
||||
return `${prefix}${infix}${noun}${index + 1}`;
|
||||
}
|
||||
|
||||
export function buildThemedItemDescription(
|
||||
profile: CustomWorldProfile,
|
||||
category: string,
|
||||
rarity: ItemRarity,
|
||||
seedKey: string,
|
||||
) {
|
||||
const seed = hashText(`${profile.id}:${category}:${rarity}:${seedKey}`);
|
||||
const hooks = [
|
||||
`适合围绕“${profile.playerGoal}”继续推进。`,
|
||||
`它的气质和“${profile.tone}”这条世界基调很贴近。`,
|
||||
'很可能会出现在这个世界的关键冲突里。',
|
||||
'能明显牵出这个世界正在扩大的主要矛盾。',
|
||||
];
|
||||
const rarityText = {
|
||||
common: '常见',
|
||||
uncommon: '进阶',
|
||||
rare: '稀有',
|
||||
epic: '核心',
|
||||
legendary: '关键',
|
||||
}[rarity];
|
||||
|
||||
return `${rarityText}${category}。${hooks[seed % hooks.length]}`;
|
||||
}
|
||||
|
||||
export function inferCustomItemMechanics(
|
||||
category: string,
|
||||
rarity: ItemRarity,
|
||||
tags: string[],
|
||||
seedKey: string,
|
||||
): {
|
||||
equipmentSlotId?: EquipmentSlotId | null;
|
||||
statProfile?: ItemStatProfile | null;
|
||||
useProfile?: ItemUseProfile | null;
|
||||
value: number;
|
||||
} {
|
||||
const seed = hashText(`${category}:${rarity}:${seedKey}:${tags.join('|')}`);
|
||||
const rarityTier = {
|
||||
common: 1,
|
||||
uncommon: 2,
|
||||
rare: 3,
|
||||
epic: 4,
|
||||
legendary: 5,
|
||||
}[rarity];
|
||||
|
||||
if (category === CATEGORY_WEAPON) {
|
||||
return {
|
||||
equipmentSlotId: 'weapon',
|
||||
statProfile: {
|
||||
outgoingDamageBonus: 2 * rarityTier + (seed % 3),
|
||||
},
|
||||
value: 28 * rarityTier,
|
||||
};
|
||||
}
|
||||
|
||||
if (category === CATEGORY_ARMOR) {
|
||||
return {
|
||||
equipmentSlotId: 'armor',
|
||||
statProfile: {
|
||||
maxHpBonus: 10 * rarityTier + (seed % 8),
|
||||
incomingDamageMultiplier: Math.max(0.72, Number((1 - rarityTier * 0.04).toFixed(2))),
|
||||
},
|
||||
value: 26 * rarityTier,
|
||||
};
|
||||
}
|
||||
|
||||
if (category === CATEGORY_RELIC || category === CATEGORY_RARE || category === CATEGORY_EXCLUSIVE) {
|
||||
return {
|
||||
equipmentSlotId: 'relic',
|
||||
statProfile: {
|
||||
maxManaBonus: 8 * rarityTier + (seed % 7),
|
||||
outgoingDamageBonus: rarityTier >= 3 ? rarityTier : undefined,
|
||||
},
|
||||
value: 32 * rarityTier,
|
||||
};
|
||||
}
|
||||
|
||||
if (category === CATEGORY_CONSUMABLE) {
|
||||
const heals = tags.includes('healing') || seed % 2 === 0;
|
||||
return {
|
||||
useProfile: heals
|
||||
? { hpRestore: 16 * rarityTier }
|
||||
: { manaRestore: 12 * rarityTier, cooldownReduction: rarityTier >= 3 ? 1 : 0 },
|
||||
value: 18 * rarityTier,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
value: 10 * rarityTier,
|
||||
};
|
||||
}
|
||||
209
src/services/customWorldReferenceSignals.test.ts
Normal file
209
src/services/customWorldReferenceSignals.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { type CustomWorldProfile, WorldType } from '../types';
|
||||
import {
|
||||
collectCreatureArchetypeSignals,
|
||||
collectSceneBucketSignalKeywords,
|
||||
resolveCreatureArchetypeForSource,
|
||||
resolveRoleTemplateCharacterIdFromReferenceProfile,
|
||||
resolveSceneBucketForLandmark,
|
||||
} from './customWorldReferenceSignals';
|
||||
|
||||
function buildReferenceProfileHarness() {
|
||||
return {
|
||||
id: 'reference-harness',
|
||||
settingText: '围绕裂界港区、断桥前线与工业旧站展开的世界。',
|
||||
name: '裂桥港区',
|
||||
subtitle: '前线潮压',
|
||||
summary: '断桥、港区和旧站之间的战线不断回响。',
|
||||
tone: '高压、潮湿、迟滞',
|
||||
playerGoal: '查清断桥封锁与旧站事故背后的真相',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
id: 'schema:test',
|
||||
worldId: 'custom:test',
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '裂桥港区',
|
||||
settingSummary: '断桥前线',
|
||||
tone: '高压',
|
||||
conflictCore: '旧站事故',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
ownedSettingLayers: {
|
||||
semanticAnchor: {
|
||||
genreSignals: ['裂界边境'],
|
||||
conflictForms: ['追查失线'],
|
||||
institutionTypes: ['前哨'],
|
||||
tabooTypes: ['封桥令'],
|
||||
carrierTypes: ['界核'],
|
||||
forceSystemTypes: ['裂界'],
|
||||
atmosphereTags: ['高压'],
|
||||
},
|
||||
ruleProfile: {
|
||||
attributeSchema: {
|
||||
id: 'schema:test',
|
||||
worldId: 'custom:test',
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '裂桥港区',
|
||||
settingSummary: '断桥前线',
|
||||
tone: '高压',
|
||||
conflictCore: '旧站事故',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
resourceLabels: {
|
||||
hp: '界命',
|
||||
mp: '裂能',
|
||||
maxHp: '界命上限',
|
||||
maxMp: '裂能上限',
|
||||
damage: '界势',
|
||||
guard: '稳界',
|
||||
range: '界距',
|
||||
cooldown: '复界',
|
||||
manaCost: '裂能消耗',
|
||||
currency: '边贸券',
|
||||
},
|
||||
economyProfile: {
|
||||
initialCurrency: 160,
|
||||
},
|
||||
},
|
||||
expressionProfile: {
|
||||
themePack: {
|
||||
id: 'theme:test',
|
||||
displayName: '裂桥前线',
|
||||
toneRange: ['高压'],
|
||||
institutionLexicon: ['前哨'],
|
||||
tabooLexicon: ['封桥令'],
|
||||
artifactClasses: ['界核'],
|
||||
actorArchetypes: ['边巡者'],
|
||||
conflictForms: ['追查失线'],
|
||||
clueForms: ['裂痕'],
|
||||
namingPatterns: ['前哨+旧痕+器类'],
|
||||
revealStyles: ['证词错位'],
|
||||
},
|
||||
presentationTone: ['高压'],
|
||||
namingDirectives: ['前哨+旧痕+器类'],
|
||||
clueDirectives: ['裂痕'],
|
||||
revealDirectives: ['证词错位'],
|
||||
},
|
||||
referenceProfile: {
|
||||
roleArchetypes: [
|
||||
{
|
||||
id: 'role-1',
|
||||
label: '远程压制型',
|
||||
combatFocus: '依靠弓与远程火力持续压制。',
|
||||
narrativeFunction: '为队伍提供远程压制与侦查。',
|
||||
sourceRoleIds: [],
|
||||
sourceTemplateCharacterIds: [],
|
||||
tags: ['远程', '射击'],
|
||||
},
|
||||
],
|
||||
sceneBuckets: [
|
||||
{
|
||||
id: 'scene-1',
|
||||
label: '工业热区',
|
||||
moodTags: ['高压'],
|
||||
keywords: ['旧站', '工坊'],
|
||||
referenceLandmarkIds: ['landmark-industrial'],
|
||||
},
|
||||
{
|
||||
id: 'scene-2',
|
||||
label: '临水渡口区',
|
||||
moodTags: ['潮湿'],
|
||||
keywords: ['港区', '渡桥'],
|
||||
referenceLandmarkIds: ['landmark-harbor'],
|
||||
},
|
||||
],
|
||||
creatureArchetypes: [
|
||||
{
|
||||
id: 'creature-1',
|
||||
label: '机关守卫体',
|
||||
threatStyle: '围绕节点和装置进行守线压制。',
|
||||
keywords: ['机关', '守卫', '旧站'],
|
||||
},
|
||||
{
|
||||
id: 'creature-2',
|
||||
label: '远程威胁者',
|
||||
threatStyle: '依靠远程投射和凝视压制走位。',
|
||||
keywords: ['远程', '压制', '索敌'],
|
||||
},
|
||||
],
|
||||
},
|
||||
compatibilityProfile: {
|
||||
legacyTemplateWorldType: WorldType.WUXIA,
|
||||
migrationVersion: 'test',
|
||||
},
|
||||
},
|
||||
themePack: null,
|
||||
storyGraph: null,
|
||||
creatorIntent: null,
|
||||
anchorPack: null,
|
||||
lockState: null,
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
describe('customWorldReferenceSignals', () => {
|
||||
it('resolves scene buckets by explicit landmark ownership', () => {
|
||||
const profile = buildReferenceProfileHarness();
|
||||
|
||||
const bucket = resolveSceneBucketForLandmark(profile, {
|
||||
id: 'landmark-industrial',
|
||||
name: '旧站锅炉层',
|
||||
description: '轨道和锅炉残响仍卡在热区深处。',
|
||||
});
|
||||
|
||||
expect(bucket?.label).toBe('工业热区');
|
||||
expect(collectSceneBucketSignalKeywords(bucket!).includes('工坊')).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves creature archetypes and exposes combat/habitat signal tags', () => {
|
||||
const profile = buildReferenceProfileHarness();
|
||||
|
||||
const archetype = resolveCreatureArchetypeForSource(profile, {
|
||||
name: '旧站守卫傀',
|
||||
role: '节点守卫',
|
||||
description: '围绕工坊旧站守线,遇敌后会启动压制炮座。',
|
||||
combatStyle: '守住节点后用远程火力封锁通路。',
|
||||
tags: ['机关', '旧站', '守卫'],
|
||||
});
|
||||
|
||||
const signals = collectCreatureArchetypeSignals(archetype!);
|
||||
|
||||
expect(archetype?.label).toBe('机关守卫体');
|
||||
expect(signals.combatTags).toContain('守御');
|
||||
expect(signals.habitatTags).toContain('工场');
|
||||
});
|
||||
|
||||
it('maps role archetypes back to suitable preset character templates', () => {
|
||||
const profile = buildReferenceProfileHarness();
|
||||
|
||||
const templateCharacterId = resolveRoleTemplateCharacterIdFromReferenceProfile(
|
||||
profile,
|
||||
{
|
||||
id: 'story-role-1',
|
||||
name: '雾港狙巡手',
|
||||
title: '岸线压制者',
|
||||
role: '远程巡手',
|
||||
description: '负责在港区高点做远程掩护与索敌压制。',
|
||||
personality: '冷静,开火前总先确认潮向。',
|
||||
combatStyle: '高点远程压制,必要时转为游击拉扯。',
|
||||
tags: ['远程', '射击', '港区'],
|
||||
},
|
||||
);
|
||||
|
||||
expect(templateCharacterId).toBe('archer-hero');
|
||||
});
|
||||
});
|
||||
369
src/services/customWorldReferenceSignals.ts
Normal file
369
src/services/customWorldReferenceSignals.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import type {
|
||||
CreatureArchetypeProfile,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
RoleArchetypeProfile,
|
||||
SceneArchetypeBucket,
|
||||
} from '../types';
|
||||
|
||||
type SceneBucketSignalPreset = {
|
||||
keywords: string[];
|
||||
};
|
||||
|
||||
type CreatureArchetypeSignalPreset = {
|
||||
keywords: string[];
|
||||
combatTags: string[];
|
||||
habitatTags: string[];
|
||||
};
|
||||
|
||||
type RoleArchetypeSignalPreset = {
|
||||
keywords: string[];
|
||||
templateCharacterIds: string[];
|
||||
};
|
||||
|
||||
const SCENE_BUCKET_SIGNAL_PRESETS: Record<string, SceneBucketSignalPreset> = {
|
||||
高压入口区: {
|
||||
keywords: ['入口', '关口', '哨站', '桥口', '门廊', '边关'],
|
||||
},
|
||||
临水渡口区: {
|
||||
keywords: ['渡口', '码头', '港口', '岸线', '船坞', '水路'],
|
||||
},
|
||||
仪式神殿区: {
|
||||
keywords: ['祭坛', '神殿', '仪式', '法坛', '庙宇', '圣所'],
|
||||
},
|
||||
高空通路区: {
|
||||
keywords: ['高空', '悬桥', '云阶', '塔顶', '崖道', '飞桥'],
|
||||
},
|
||||
工业热区: {
|
||||
keywords: ['工坊', '轨道', '机库', '熔炉', '工场', '锅炉'],
|
||||
},
|
||||
地底遗迹区: {
|
||||
keywords: ['地宫', '矿道', '遗迹', '洞窟', '墓道', '地底'],
|
||||
},
|
||||
群落聚居区: {
|
||||
keywords: ['街巷', '聚落', '城镇', '营地', '居所', '市集'],
|
||||
},
|
||||
高压交汇区: {
|
||||
keywords: ['险地', '封锁', '交汇', '前线', '险关', '断层'],
|
||||
},
|
||||
叙事缓冲区: {
|
||||
keywords: ['归处', '栖居', '缓冲', '休整', '据点', '落脚'],
|
||||
},
|
||||
};
|
||||
|
||||
const CREATURE_ARCHETYPE_SIGNAL_PRESETS: Record<
|
||||
string,
|
||||
CreatureArchetypeSignalPreset
|
||||
> = {
|
||||
潜伏袭击者: {
|
||||
keywords: ['潜伏', '伏击', '突袭', '暗影', '贴身'],
|
||||
combatTags: ['快袭', '突进', '机动'],
|
||||
habitatTags: ['雾林', '断垣', '妖雾', '崖壁'],
|
||||
},
|
||||
重甲承压者: {
|
||||
keywords: ['重甲', '承压', '守线', '堵截', '厚重'],
|
||||
combatTags: ['重甲', '守御', '护体', '堡垒'],
|
||||
habitatTags: ['矿道', '废城', '边关', '地宫'],
|
||||
},
|
||||
群居骚扰者: {
|
||||
keywords: ['群居', '骚扰', '游窜', '围猎', '消耗'],
|
||||
combatTags: ['机动', '追击', '控场'],
|
||||
habitatTags: ['竹林', '雾林', '荒野', '月湖'],
|
||||
},
|
||||
远程威胁者: {
|
||||
keywords: ['远程', '投射', '压制', '炮击', '凝视'],
|
||||
combatTags: ['远射', '法修', '雷法'],
|
||||
habitatTags: ['长街', '仙门', '星舟', '祭坛'],
|
||||
},
|
||||
异化污染体: {
|
||||
keywords: ['异化', '污染', '腐化', '潮灾', '侵蚀'],
|
||||
combatTags: ['法力', '回复', '重甲'],
|
||||
habitatTags: ['洞天', '谷地', '秘境', '灵泉'],
|
||||
},
|
||||
灵体回响体: {
|
||||
keywords: ['灵体', '回响', '残魂', '旧痕', '幽灵'],
|
||||
combatTags: ['镇邪', '控场', '法修'],
|
||||
habitatTags: ['遗迹', '祭坛', '古迹', '废寺'],
|
||||
},
|
||||
机关守卫体: {
|
||||
keywords: ['机关', '守卫', '节点', '封印', '装置'],
|
||||
combatTags: ['守御', '压制', '符阵'],
|
||||
habitatTags: ['铸坊', '工场', '前哨', '长廊'],
|
||||
},
|
||||
回响追猎者: {
|
||||
keywords: ['追猎', '回响', '索敌', '追索', '名单'],
|
||||
combatTags: ['追击', '压制', '机动'],
|
||||
habitatTags: ['前线', '断层', '渡口', '雾港'],
|
||||
},
|
||||
};
|
||||
|
||||
const ROLE_ARCHETYPE_SIGNAL_PRESETS: Record<string, RoleArchetypeSignalPreset> = {
|
||||
正面推进型: {
|
||||
keywords: ['推进', '压前', '正面', '先锋', '破阵'],
|
||||
templateCharacterIds: ['sword-princess', 'punch-hero'],
|
||||
},
|
||||
远程压制型: {
|
||||
keywords: ['远程', '弓', '射击', '投掷', '炮击'],
|
||||
templateCharacterIds: ['archer-hero'],
|
||||
},
|
||||
控场解构型: {
|
||||
keywords: ['控场', '阵', '法', '机关', '解构', '牵制'],
|
||||
templateCharacterIds: ['fighter-4', 'girl-hero'],
|
||||
},
|
||||
续航承压型: {
|
||||
keywords: ['承压', '护体', '守御', '续航', '稳阵'],
|
||||
templateCharacterIds: ['fighter-4', 'punch-hero'],
|
||||
},
|
||||
潜行爆发型: {
|
||||
keywords: ['潜行', '爆发', '影袭', '突进', '追击'],
|
||||
templateCharacterIds: ['girl-hero', 'sword-princess'],
|
||||
},
|
||||
};
|
||||
|
||||
type ReferenceRoleSource = Pick<
|
||||
CustomWorldPlayableNpc | CustomWorldNpc,
|
||||
'id' | 'name' | 'title' | 'role' | 'description' | 'personality' | 'combatStyle' | 'tags'
|
||||
>;
|
||||
|
||||
type ReferenceCreatureSource = Partial<
|
||||
Pick<
|
||||
CustomWorldPlayableNpc & CustomWorldNpc,
|
||||
| 'id'
|
||||
| 'name'
|
||||
| 'title'
|
||||
| 'role'
|
||||
| 'description'
|
||||
| 'backstory'
|
||||
| 'personality'
|
||||
| 'motivation'
|
||||
| 'combatStyle'
|
||||
| 'relationshipHooks'
|
||||
| 'tags'
|
||||
>
|
||||
>;
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function dedupeStrings(
|
||||
values: Array<string | null | undefined>,
|
||||
max = 12,
|
||||
) {
|
||||
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
|
||||
.slice(0, max);
|
||||
}
|
||||
|
||||
function hashText(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function buildRoleSourceText(role: ReferenceRoleSource) {
|
||||
return dedupeStrings([
|
||||
role.name,
|
||||
role.title,
|
||||
role.role,
|
||||
role.description,
|
||||
role.personality,
|
||||
role.combatStyle,
|
||||
...(role.tags ?? []),
|
||||
]).join(' ');
|
||||
}
|
||||
|
||||
function buildCreatureSourceText(source: ReferenceCreatureSource) {
|
||||
return dedupeStrings([
|
||||
source.name,
|
||||
source.title,
|
||||
source.role,
|
||||
source.description,
|
||||
source.backstory,
|
||||
source.personality,
|
||||
source.motivation,
|
||||
source.combatStyle,
|
||||
...(source.relationshipHooks ?? []),
|
||||
...(source.tags ?? []),
|
||||
]).join(' ');
|
||||
}
|
||||
|
||||
function scoreTextMatches(sourceText: string, keywords: string[]) {
|
||||
return keywords.reduce((score, keyword) => {
|
||||
if (!keyword || !sourceText.includes(keyword)) {
|
||||
return score;
|
||||
}
|
||||
|
||||
if (keyword.length >= 4) {
|
||||
return score + 8;
|
||||
}
|
||||
|
||||
if (keyword.length === 3) {
|
||||
return score + 6;
|
||||
}
|
||||
|
||||
return score + 4;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function getReferenceProfile(profile: CustomWorldProfile | null | undefined) {
|
||||
return profile?.ownedSettingLayers?.referenceProfile ?? null;
|
||||
}
|
||||
|
||||
export function collectSceneBucketSignalKeywords(
|
||||
bucket: Pick<SceneArchetypeBucket, 'label' | 'keywords' | 'moodTags'>,
|
||||
) {
|
||||
const preset = SCENE_BUCKET_SIGNAL_PRESETS[bucket.label];
|
||||
return dedupeStrings([
|
||||
bucket.label,
|
||||
...bucket.keywords,
|
||||
...bucket.moodTags,
|
||||
...(preset?.keywords ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
export function resolveSceneBucketForLandmark(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
landmark: Pick<CustomWorldProfile['landmarks'][number], 'id' | 'name' | 'description'>,
|
||||
) {
|
||||
const sceneBuckets = getReferenceProfile(profile)?.sceneBuckets ?? [];
|
||||
if (sceneBuckets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const explicitBucket = sceneBuckets.find((bucket) =>
|
||||
bucket.referenceLandmarkIds.includes(landmark.id),
|
||||
);
|
||||
if (explicitBucket) {
|
||||
return explicitBucket;
|
||||
}
|
||||
|
||||
const sourceText = dedupeStrings([landmark.name, landmark.description]).join(' ');
|
||||
|
||||
const scoredBuckets = sceneBuckets
|
||||
.map((bucket) => ({
|
||||
bucket,
|
||||
score: scoreTextMatches(sourceText, collectSceneBucketSignalKeywords(bucket)),
|
||||
}))
|
||||
.sort((left, right) => right.score - left.score);
|
||||
|
||||
return (scoredBuckets[0]?.score ?? 0) > 0 ? scoredBuckets[0]?.bucket ?? null : null;
|
||||
}
|
||||
|
||||
export function collectCreatureArchetypeSignals(
|
||||
archetype: Pick<CreatureArchetypeProfile, 'label' | 'threatStyle' | 'keywords'>,
|
||||
) {
|
||||
const preset = CREATURE_ARCHETYPE_SIGNAL_PRESETS[archetype.label];
|
||||
|
||||
return {
|
||||
keywords: dedupeStrings([
|
||||
archetype.label,
|
||||
archetype.threatStyle,
|
||||
...archetype.keywords,
|
||||
...(preset?.keywords ?? []),
|
||||
]),
|
||||
combatTags: dedupeStrings(preset?.combatTags ?? [], 6),
|
||||
habitatTags: dedupeStrings(preset?.habitatTags ?? [], 6),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCreatureArchetypeForSource(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
source: ReferenceCreatureSource,
|
||||
) {
|
||||
const creatureArchetypes = getReferenceProfile(profile)?.creatureArchetypes ?? [];
|
||||
if (creatureArchetypes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceText = buildCreatureSourceText(source);
|
||||
const scoredArchetypes = creatureArchetypes
|
||||
.map((archetype) => ({
|
||||
archetype,
|
||||
score: scoreTextMatches(
|
||||
sourceText,
|
||||
collectCreatureArchetypeSignals(archetype).keywords,
|
||||
),
|
||||
}))
|
||||
.sort((left, right) => right.score - left.score);
|
||||
|
||||
return (scoredArchetypes[0]?.score ?? 0) > 0
|
||||
? scoredArchetypes[0]?.archetype ?? null
|
||||
: creatureArchetypes[0] ?? null;
|
||||
}
|
||||
|
||||
function collectRoleArchetypeSignals(
|
||||
archetype: Pick<
|
||||
RoleArchetypeProfile,
|
||||
| 'label'
|
||||
| 'combatFocus'
|
||||
| 'narrativeFunction'
|
||||
| 'tags'
|
||||
| 'sourceTemplateCharacterIds'
|
||||
>,
|
||||
) {
|
||||
const preset = ROLE_ARCHETYPE_SIGNAL_PRESETS[archetype.label];
|
||||
|
||||
return {
|
||||
keywords: dedupeStrings([
|
||||
archetype.label,
|
||||
archetype.combatFocus,
|
||||
archetype.narrativeFunction,
|
||||
...archetype.tags,
|
||||
...(preset?.keywords ?? []),
|
||||
]),
|
||||
templateCharacterIds:
|
||||
archetype.sourceTemplateCharacterIds.length > 0
|
||||
? archetype.sourceTemplateCharacterIds
|
||||
: preset?.templateCharacterIds ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRoleArchetypeForRole(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
role: ReferenceRoleSource,
|
||||
) {
|
||||
const roleArchetypes = getReferenceProfile(profile)?.roleArchetypes ?? [];
|
||||
if (roleArchetypes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const explicitArchetype = roleArchetypes.find((archetype) =>
|
||||
archetype.sourceRoleIds.includes(role.id),
|
||||
);
|
||||
if (explicitArchetype) {
|
||||
return explicitArchetype;
|
||||
}
|
||||
|
||||
const sourceText = buildRoleSourceText(role);
|
||||
const scoredArchetypes = roleArchetypes
|
||||
.map((archetype) => ({
|
||||
archetype,
|
||||
score: scoreTextMatches(sourceText, collectRoleArchetypeSignals(archetype).keywords),
|
||||
}))
|
||||
.sort((left, right) => right.score - left.score);
|
||||
|
||||
return (scoredArchetypes[0]?.score ?? 0) > 0
|
||||
? scoredArchetypes[0]?.archetype ?? null
|
||||
: roleArchetypes[0] ?? null;
|
||||
}
|
||||
|
||||
export function resolveRoleTemplateCharacterIdFromReferenceProfile(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
role: ReferenceRoleSource,
|
||||
) {
|
||||
const archetype = resolveRoleArchetypeForRole(profile, role);
|
||||
if (!archetype) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const templateCharacterIds = collectRoleArchetypeSignals(archetype).templateCharacterIds;
|
||||
if (templateCharacterIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const seedSource = toText(role.id) || buildRoleSourceText(role);
|
||||
return templateCharacterIds[hashText(seedSource) % templateCharacterIds.length] ?? null;
|
||||
}
|
||||
280
src/services/customWorldSceneActRuntime.ts
Normal file
280
src/services/customWorldSceneActRuntime.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import type { NpcChatTurnDirective } from '../../packages/shared/src/contracts/rpgRuntimeChat';
|
||||
import type {
|
||||
CustomWorldProfile,
|
||||
GameState,
|
||||
SceneActBlueprint,
|
||||
SceneActRuntimeState,
|
||||
SceneChapterBlueprint,
|
||||
SceneConnectionInfo,
|
||||
StoryEngineMemoryState,
|
||||
} from '../types';
|
||||
|
||||
function toSet(values: string[]) {
|
||||
return new Set(values.map((value) => value.trim()).filter(Boolean));
|
||||
}
|
||||
|
||||
export function resolveSceneChapterBlueprint(
|
||||
profile: CustomWorldProfile | null | undefined,
|
||||
sceneId: string | null | undefined,
|
||||
): SceneChapterBlueprint | null {
|
||||
if (!profile || !sceneId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
profile.sceneChapterBlueprints?.find(
|
||||
(entry) => entry.sceneId === sceneId || entry.linkedLandmarkIds.includes(sceneId),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveActiveSceneActBlueprint(params: {
|
||||
profile: CustomWorldProfile | null | undefined;
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}): SceneActBlueprint | null {
|
||||
const chapter = resolveSceneChapterBlueprint(params.profile, params.sceneId);
|
||||
if (!chapter || chapter.acts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtimeState = params.storyEngineMemory?.currentSceneActState;
|
||||
if (
|
||||
runtimeState &&
|
||||
runtimeState.sceneId === chapter.sceneId &&
|
||||
runtimeState.chapterId === chapter.id
|
||||
) {
|
||||
const matchedAct = chapter.acts.find((entry) => entry.id === runtimeState.currentActId);
|
||||
if (matchedAct) {
|
||||
return matchedAct;
|
||||
}
|
||||
}
|
||||
|
||||
return chapter.acts[0] ?? null;
|
||||
}
|
||||
|
||||
export function resolveSceneActProgression(params: {
|
||||
profile: CustomWorldProfile | null | undefined;
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}): {
|
||||
chapter: SceneChapterBlueprint;
|
||||
runtimeState: SceneActRuntimeState;
|
||||
activeAct: SceneActBlueprint;
|
||||
nextAct: SceneActBlueprint | null;
|
||||
isLastAct: boolean;
|
||||
} | null {
|
||||
const chapter = resolveSceneChapterBlueprint(params.profile, params.sceneId);
|
||||
if (!chapter || chapter.acts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtimeState = buildInitialSceneActRuntimeState(params);
|
||||
if (!runtimeState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeActIndex = chapter.acts.findIndex(
|
||||
(entry) => entry.id === runtimeState.currentActId,
|
||||
);
|
||||
const resolvedActIndex =
|
||||
activeActIndex >= 0
|
||||
? activeActIndex
|
||||
: Math.min(
|
||||
Math.max(runtimeState.currentActIndex, 0),
|
||||
chapter.acts.length - 1,
|
||||
);
|
||||
const activeAct = chapter.acts[resolvedActIndex] ?? chapter.acts[0]!;
|
||||
const nextAct = chapter.acts[resolvedActIndex + 1] ?? null;
|
||||
|
||||
return {
|
||||
chapter,
|
||||
runtimeState: {
|
||||
...runtimeState,
|
||||
currentActId: activeAct.id,
|
||||
currentActIndex: resolvedActIndex,
|
||||
},
|
||||
activeAct,
|
||||
nextAct,
|
||||
isLastAct: !nextAct,
|
||||
};
|
||||
}
|
||||
|
||||
export function advanceSceneActRuntimeState(params: {
|
||||
progress: NonNullable<ReturnType<typeof resolveSceneActProgression>>;
|
||||
}): SceneActRuntimeState | null {
|
||||
const { progress } = params;
|
||||
if (!progress.nextAct) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const completedActIds = toSet([
|
||||
...(progress.runtimeState.completedActIds ?? []),
|
||||
progress.activeAct.id,
|
||||
]);
|
||||
const visitedActIds = toSet([
|
||||
...(progress.runtimeState.visitedActIds ?? []),
|
||||
progress.nextAct.id,
|
||||
]);
|
||||
|
||||
return {
|
||||
sceneId: progress.chapter.sceneId,
|
||||
chapterId: progress.chapter.id,
|
||||
currentActId: progress.nextAct.id,
|
||||
currentActIndex: progress.runtimeState.currentActIndex + 1,
|
||||
completedActIds: [...completedActIds],
|
||||
visitedActIds: [...visitedActIds],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildInitialSceneActRuntimeState(params: {
|
||||
profile: CustomWorldProfile | null | undefined;
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}): SceneActRuntimeState | null {
|
||||
const chapter = resolveSceneChapterBlueprint(params.profile, params.sceneId);
|
||||
if (!chapter || chapter.acts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtimeState = params.storyEngineMemory?.currentSceneActState;
|
||||
if (
|
||||
runtimeState &&
|
||||
runtimeState.sceneId === chapter.sceneId &&
|
||||
runtimeState.chapterId === chapter.id &&
|
||||
chapter.acts.some((entry) => entry.id === runtimeState.currentActId)
|
||||
) {
|
||||
return {
|
||||
...runtimeState,
|
||||
completedActIds: [...toSet(runtimeState.completedActIds ?? [])],
|
||||
visitedActIds: [...toSet(runtimeState.visitedActIds ?? [])],
|
||||
};
|
||||
}
|
||||
|
||||
const firstAct = chapter.acts[0]!;
|
||||
return {
|
||||
sceneId: chapter.sceneId,
|
||||
chapterId: chapter.id,
|
||||
currentActId: firstAct.id,
|
||||
currentActIndex: 0,
|
||||
completedActIds: [],
|
||||
visitedActIds: [firstAct.id],
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveActiveSceneActEncounterNpcIds(params: {
|
||||
profile: CustomWorldProfile | null | undefined;
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}) {
|
||||
return (
|
||||
resolveActiveSceneActBlueprint(params)?.encounterNpcIds
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveActiveSceneActPrimaryNpcId(params: {
|
||||
profile: CustomWorldProfile | null | undefined;
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}) {
|
||||
return resolveActiveSceneActBlueprint(params)?.primaryNpcId?.trim() || null;
|
||||
}
|
||||
|
||||
export function resolveActiveSceneActBackgroundImage(params: {
|
||||
profile: CustomWorldProfile | null | undefined;
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
}) {
|
||||
return resolveActiveSceneActBlueprint(params)?.backgroundImageSrc?.trim() || null;
|
||||
}
|
||||
|
||||
export function canUseLimitedPrimaryNpcChat(params: {
|
||||
profile: CustomWorldProfile | null | undefined;
|
||||
sceneId: string | null | undefined;
|
||||
storyEngineMemory?: StoryEngineMemoryState | null;
|
||||
npcId: string | null | undefined;
|
||||
affinity: number;
|
||||
}) {
|
||||
if (params.affinity >= 0 || !params.npcId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
resolveActiveSceneActPrimaryNpcId({
|
||||
profile: params.profile,
|
||||
sceneId: params.sceneId,
|
||||
storyEngineMemory: params.storyEngineMemory,
|
||||
}) === params.npcId
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveLimitedPrimaryNpcChatState(params: {
|
||||
state: Pick<GameState, 'customWorldProfile' | 'currentScenePreset' | 'storyEngineMemory'>;
|
||||
npcId: string | null | undefined;
|
||||
affinity: number;
|
||||
nextTurnCount: number;
|
||||
}): NpcChatTurnDirective | null {
|
||||
if (
|
||||
!canUseLimitedPrimaryNpcChat({
|
||||
profile: params.state.customWorldProfile,
|
||||
sceneId: params.state.currentScenePreset?.id ?? null,
|
||||
storyEngineMemory: params.state.storyEngineMemory,
|
||||
npcId: params.npcId,
|
||||
affinity: params.affinity,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeAct = resolveActiveSceneActBlueprint({
|
||||
profile: params.state.customWorldProfile,
|
||||
sceneId: params.state.currentScenePreset?.id ?? null,
|
||||
storyEngineMemory: params.state.storyEngineMemory,
|
||||
});
|
||||
return {
|
||||
sceneActId: activeAct?.id ?? null,
|
||||
turnLimit: null,
|
||||
remainingTurns: null,
|
||||
limitReason: 'negative_affinity' as const,
|
||||
closingMode: 'free' as const,
|
||||
forceExitAfterTurn: false,
|
||||
terminationMode: 'hostile_model' as const,
|
||||
isHostileChat: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function getSceneConnectionDirectionText(
|
||||
relativePosition: SceneConnectionInfo['relativePosition'],
|
||||
) {
|
||||
switch (relativePosition) {
|
||||
case 'north':
|
||||
return '向北走';
|
||||
case 'south':
|
||||
return '向南走';
|
||||
case 'east':
|
||||
return '向东走';
|
||||
case 'west':
|
||||
return '向西走';
|
||||
case 'left':
|
||||
return '向左走';
|
||||
case 'right':
|
||||
return '向右走';
|
||||
case 'back':
|
||||
return '往回走';
|
||||
case 'up':
|
||||
return '向上走';
|
||||
case 'down':
|
||||
return '向下走';
|
||||
case 'inside':
|
||||
return '向内走';
|
||||
case 'outside':
|
||||
return '向外走';
|
||||
case 'portal':
|
||||
return '穿过通路';
|
||||
case 'forward':
|
||||
default:
|
||||
return '向前走';
|
||||
}
|
||||
}
|
||||
168
src/services/customWorldScenePresentation.ts
Normal file
168
src/services/customWorldScenePresentation.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
resolveCustomWorldCampSceneImage,
|
||||
resolveCustomWorldLandmarkImageMap,
|
||||
} from '../data/customWorldVisuals';
|
||||
import type {
|
||||
CustomWorldLandmark,
|
||||
CustomWorldProfile,
|
||||
SceneChapterBlueprint,
|
||||
} from '../types';
|
||||
import { resolveCustomWorldCampScene } from './customWorldCamp';
|
||||
|
||||
export type CustomWorldSceneKind = 'camp' | 'landmark';
|
||||
|
||||
export type CustomWorldSceneActImagePreview = {
|
||||
id: string;
|
||||
title: string;
|
||||
imageSrc: string;
|
||||
};
|
||||
|
||||
export type CustomWorldScenePresentation = {
|
||||
id: string;
|
||||
kind: CustomWorldSceneKind;
|
||||
name: string;
|
||||
description: string;
|
||||
imageSrc: string;
|
||||
sceneChapters: SceneChapterBlueprint[];
|
||||
actPreviews: CustomWorldSceneActImagePreview[];
|
||||
};
|
||||
|
||||
function normalizeText(value: string | null | undefined) {
|
||||
return value?.trim() ?? '';
|
||||
}
|
||||
|
||||
export function resolveScenePresentationChapters(params: {
|
||||
sceneChapters: CustomWorldProfile['sceneChapterBlueprints'];
|
||||
sceneId: string;
|
||||
sceneName: string;
|
||||
}) {
|
||||
const sceneChapters = params.sceneChapters ?? [];
|
||||
const normalizedSceneId = params.sceneId.trim();
|
||||
const normalizedSceneName = params.sceneName.trim();
|
||||
|
||||
const directMatches = sceneChapters.filter(
|
||||
(chapter) => chapter.sceneId.trim() === normalizedSceneId,
|
||||
);
|
||||
if (directMatches.length > 0) {
|
||||
return directMatches;
|
||||
}
|
||||
|
||||
const linkedMatches = sceneChapters.filter((chapter) =>
|
||||
chapter.linkedLandmarkIds.some(
|
||||
(landmarkId) => landmarkId.trim() === normalizedSceneId,
|
||||
),
|
||||
);
|
||||
if (linkedMatches.length > 0) {
|
||||
return linkedMatches;
|
||||
}
|
||||
|
||||
return sceneChapters.filter((chapter) => {
|
||||
const chapterTitle = chapter.title.trim();
|
||||
return (
|
||||
chapterTitle === normalizedSceneName ||
|
||||
chapter.summary.includes(normalizedSceneName) ||
|
||||
chapter.acts.some(
|
||||
(act) =>
|
||||
act.title.includes(normalizedSceneName) ||
|
||||
act.summary.includes(normalizedSceneName),
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveScenePresentationImage(params: {
|
||||
sceneImageSrc?: string | null;
|
||||
sceneChapters: SceneChapterBlueprint[];
|
||||
}) {
|
||||
const firstActImageSrc =
|
||||
params.sceneChapters
|
||||
.flatMap((chapter) => chapter.acts)
|
||||
.map((act) => normalizeText(act.backgroundImageSrc))
|
||||
.find(Boolean) || '';
|
||||
|
||||
return firstActImageSrc || normalizeText(params.sceneImageSrc);
|
||||
}
|
||||
|
||||
export function collectSceneActImagePreviews(params: {
|
||||
sceneChapters: SceneChapterBlueprint[];
|
||||
sceneImageSrc?: string | null;
|
||||
}) {
|
||||
const sceneImageSrc = normalizeText(params.sceneImageSrc);
|
||||
const actPreviews = params.sceneChapters.flatMap((chapter) =>
|
||||
chapter.acts
|
||||
.map((act, index) => {
|
||||
// 中文注释:幕预览图片必须优先读取当前幕背景;场景图只给缺图的旧数据兜底,避免开局场景和普通场景在列表、详情里显示不同图片。
|
||||
const imageSrc = normalizeText(act.backgroundImageSrc) || sceneImageSrc;
|
||||
return {
|
||||
id: act.id.trim() || `${chapter.id}-act-${index}`,
|
||||
title: act.title.trim() || `第${index + 1}幕`,
|
||||
imageSrc,
|
||||
};
|
||||
})
|
||||
.filter((act) => act.imageSrc),
|
||||
);
|
||||
|
||||
if (actPreviews.length > 0 || !sceneImageSrc) {
|
||||
return actPreviews;
|
||||
}
|
||||
|
||||
return [1, 2, 3].map((actNumber) => ({
|
||||
id: `fallback-scene-act-${actNumber}`,
|
||||
title: `第${actNumber}幕`,
|
||||
imageSrc: sceneImageSrc,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildScenePresentation(params: {
|
||||
profile: CustomWorldProfile;
|
||||
scene: CustomWorldLandmark;
|
||||
kind: CustomWorldSceneKind;
|
||||
sceneImageSrc?: string | null;
|
||||
}) {
|
||||
const sceneChapters = resolveScenePresentationChapters({
|
||||
sceneChapters: params.profile.sceneChapterBlueprints,
|
||||
sceneId: params.scene.id,
|
||||
sceneName: params.scene.name,
|
||||
});
|
||||
const imageSrc = resolveScenePresentationImage({
|
||||
sceneImageSrc: params.sceneImageSrc ?? params.scene.imageSrc,
|
||||
sceneChapters,
|
||||
});
|
||||
|
||||
return {
|
||||
id: params.scene.id,
|
||||
kind: params.kind,
|
||||
name: params.scene.name,
|
||||
description: params.scene.description,
|
||||
imageSrc,
|
||||
sceneChapters,
|
||||
actPreviews: collectSceneActImagePreviews({
|
||||
sceneChapters,
|
||||
sceneImageSrc: imageSrc,
|
||||
}),
|
||||
} satisfies CustomWorldScenePresentation;
|
||||
}
|
||||
|
||||
export function buildCustomWorldScenePresentations(profile: CustomWorldProfile) {
|
||||
const landmarkImageById = resolveCustomWorldLandmarkImageMap(profile);
|
||||
const campScene = resolveCustomWorldCampScene(profile);
|
||||
const campPresentation = buildScenePresentation({
|
||||
profile,
|
||||
scene: campScene,
|
||||
kind: 'camp',
|
||||
sceneImageSrc: resolveCustomWorldCampSceneImage(profile),
|
||||
});
|
||||
const landmarkPresentations = profile.landmarks.map((landmark) =>
|
||||
buildScenePresentation({
|
||||
profile,
|
||||
scene: landmark,
|
||||
kind: 'landmark',
|
||||
sceneImageSrc: landmarkImageById.get(landmark.id) || landmark.imageSrc,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
camp: campPresentation,
|
||||
landmarks: landmarkPresentations,
|
||||
};
|
||||
}
|
||||
109
src/services/customWorldTheme.ts
Normal file
109
src/services/customWorldTheme.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { CustomWorldProfile, WorldTemplateType, WorldType } from '../types';
|
||||
|
||||
export type CustomWorldThemeMode =
|
||||
| 'martial'
|
||||
| 'arcane'
|
||||
| 'machina'
|
||||
| 'tide'
|
||||
| 'rift'
|
||||
| 'mythic';
|
||||
|
||||
export function detectCustomWorldThemeMode(
|
||||
profile: Pick<
|
||||
CustomWorldProfile,
|
||||
| 'settingText'
|
||||
| 'summary'
|
||||
| 'tone'
|
||||
| 'playerGoal'
|
||||
| 'templateWorldType'
|
||||
| 'compatibilityTemplateWorldType'
|
||||
| 'ownedSettingLayers'
|
||||
>,
|
||||
): CustomWorldThemeMode {
|
||||
const semanticAnchor = profile.ownedSettingLayers?.semanticAnchor;
|
||||
const expressionProfile = profile.ownedSettingLayers?.expressionProfile;
|
||||
const source = [
|
||||
profile.settingText,
|
||||
profile.summary,
|
||||
profile.tone,
|
||||
profile.playerGoal,
|
||||
...(semanticAnchor?.genreSignals ?? []),
|
||||
...(semanticAnchor?.conflictForms ?? []),
|
||||
...(semanticAnchor?.institutionTypes ?? []),
|
||||
...(semanticAnchor?.tabooTypes ?? []),
|
||||
...(semanticAnchor?.carrierTypes ?? []),
|
||||
...(semanticAnchor?.forceSystemTypes ?? []),
|
||||
...(semanticAnchor?.atmosphereTags ?? []),
|
||||
...(expressionProfile?.presentationTone ?? []),
|
||||
].join(' ');
|
||||
|
||||
if (/[机关蒸汽齿轮工坊机巧城轨炮舰]/u.test(source)) return 'machina';
|
||||
if (/[海潮港湾船舟湖泊澜雾湾]/u.test(source)) return 'tide';
|
||||
if (/[裂缝裂界边境前线断层界桥灰域]/u.test(source)) return 'rift';
|
||||
if (/[修真仙灵宗门法器道脉秘境云阙]/u.test(source)) return 'arcane';
|
||||
if (/[江湖门派镖局朝廷刀剑侠客旧案]/u.test(source)) return 'martial';
|
||||
|
||||
return 'mythic';
|
||||
}
|
||||
|
||||
export function resolveCustomWorldCompatibilityTemplateWorldType(
|
||||
profile: Pick<
|
||||
CustomWorldProfile,
|
||||
| 'templateWorldType'
|
||||
| 'compatibilityTemplateWorldType'
|
||||
| 'ownedSettingLayers'
|
||||
> &
|
||||
Partial<
|
||||
Pick<
|
||||
CustomWorldProfile,
|
||||
'settingText' | 'summary' | 'tone' | 'playerGoal'
|
||||
>
|
||||
>,
|
||||
): WorldTemplateType {
|
||||
if (
|
||||
profile.compatibilityTemplateWorldType === WorldType.WUXIA ||
|
||||
profile.compatibilityTemplateWorldType === WorldType.XIANXIA
|
||||
) {
|
||||
return profile.compatibilityTemplateWorldType;
|
||||
}
|
||||
|
||||
const compatibilityTemplateWorldType =
|
||||
profile.ownedSettingLayers?.compatibilityProfile?.compatibilityTemplateWorldType;
|
||||
if (
|
||||
compatibilityTemplateWorldType === WorldType.WUXIA ||
|
||||
compatibilityTemplateWorldType === WorldType.XIANXIA
|
||||
) {
|
||||
return compatibilityTemplateWorldType;
|
||||
}
|
||||
|
||||
const legacyTemplateWorldType =
|
||||
profile.ownedSettingLayers?.compatibilityProfile?.legacyTemplateWorldType;
|
||||
|
||||
if (
|
||||
legacyTemplateWorldType === WorldType.WUXIA ||
|
||||
legacyTemplateWorldType === WorldType.XIANXIA
|
||||
) {
|
||||
return legacyTemplateWorldType;
|
||||
}
|
||||
|
||||
if (
|
||||
profile.templateWorldType === WorldType.WUXIA ||
|
||||
profile.templateWorldType === WorldType.XIANXIA
|
||||
) {
|
||||
return profile.templateWorldType;
|
||||
}
|
||||
|
||||
const themeMode = detectCustomWorldThemeMode({
|
||||
settingText: profile.settingText ?? '',
|
||||
summary: profile.summary ?? '',
|
||||
tone: profile.tone ?? '',
|
||||
playerGoal: profile.playerGoal ?? '',
|
||||
templateWorldType: profile.templateWorldType ?? WorldType.WUXIA,
|
||||
compatibilityTemplateWorldType: profile.compatibilityTemplateWorldType,
|
||||
ownedSettingLayers: profile.ownedSettingLayers,
|
||||
});
|
||||
return themeMode === 'arcane' ? WorldType.XIANXIA : WorldType.WUXIA;
|
||||
}
|
||||
|
||||
export const resolveCustomWorldAnchorWorldType =
|
||||
resolveCustomWorldCompatibilityTemplateWorldType;
|
||||
343
src/services/llmClient.ts
Normal file
343
src/services/llmClient.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import type {TextStreamOptions} from './aiTypes';
|
||||
import { fetchWithApiAuth } from './apiClient';
|
||||
|
||||
const ENV: Partial<ImportMetaEnv> = import.meta.env ?? {};
|
||||
|
||||
type NodeProcessLike = {
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
function getNodeEnv() {
|
||||
if (typeof window !== 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
return (
|
||||
(globalThis as typeof globalThis & {process?: NodeProcessLike}).process?.env
|
||||
?? {}
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: string) {
|
||||
return value.replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
function coerceBoolean(value: string | undefined) {
|
||||
return value?.trim().toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
function resolveHeaders(headers?: HeadersInit) {
|
||||
const nextHeaders: Record<string, string> = {};
|
||||
|
||||
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
|
||||
headers.forEach((value, key) => {
|
||||
nextHeaders[key] = value;
|
||||
});
|
||||
} else if (Array.isArray(headers)) {
|
||||
for (const [key, value] of headers) {
|
||||
nextHeaders[key] = value;
|
||||
}
|
||||
} else if (headers) {
|
||||
Object.assign(nextHeaders, headers);
|
||||
}
|
||||
|
||||
return nextHeaders;
|
||||
}
|
||||
|
||||
const NODE_ENV = getNodeEnv();
|
||||
const IS_SERVER_RUNTIME = typeof window === 'undefined';
|
||||
const SERVER_API_KEY =
|
||||
NODE_ENV.LLM_API_KEY || NODE_ENV.ARK_API_KEY || NODE_ENV.VITE_LLM_API_KEY || '';
|
||||
const API_BASE_URL = IS_SERVER_RUNTIME
|
||||
? normalizeBaseUrl(
|
||||
NODE_ENV.LLM_BASE_URL || 'https://ark.cn-beijing.volces.com/api/v3',
|
||||
)
|
||||
: (ENV.VITE_LLM_PROXY_BASE_URL || '/api/llm');
|
||||
const MODEL = IS_SERVER_RUNTIME
|
||||
? (NODE_ENV.LLM_MODEL
|
||||
|| NODE_ENV.VITE_LLM_MODEL
|
||||
|| 'doubao-1-5-pro-32k-character-250715')
|
||||
: (ENV.VITE_LLM_MODEL || 'doubao-1-5-pro-32k-character-250715');
|
||||
const ENABLE_LLM_DEBUG_LOG = IS_SERVER_RUNTIME
|
||||
? coerceBoolean(NODE_ENV.LLM_DEBUG_LOG)
|
||||
: (Boolean(ENV.DEV) || ENV.VITE_LLM_DEBUG_LOG === 'true');
|
||||
|
||||
export interface PlainTextCompletionOptions {
|
||||
timeoutMs?: number;
|
||||
debugLabel?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export class LlmConnectivityError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'LlmConnectivityError';
|
||||
}
|
||||
}
|
||||
|
||||
export class LlmTimeoutError extends LlmConnectivityError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'LlmTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTimeoutMs(rawValue: string | undefined, fallback: number) {
|
||||
const parsed = Number(rawValue);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback;
|
||||
}
|
||||
|
||||
export const REQUEST_TIMEOUT_MS = resolveTimeoutMs(
|
||||
IS_SERVER_RUNTIME
|
||||
? (NODE_ENV.LLM_REQUEST_TIMEOUT_MS || NODE_ENV.VITE_LLM_REQUEST_TIMEOUT_MS)
|
||||
: ENV.VITE_LLM_REQUEST_TIMEOUT_MS,
|
||||
15000,
|
||||
);
|
||||
export const CUSTOM_WORLD_REQUEST_TIMEOUT_MS = resolveTimeoutMs(
|
||||
IS_SERVER_RUNTIME
|
||||
? (NODE_ENV.LLM_CUSTOM_WORLD_TIMEOUT_MS || NODE_ENV.VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS)
|
||||
: ENV.VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS,
|
||||
Math.max(REQUEST_TIMEOUT_MS, 120000),
|
||||
);
|
||||
|
||||
function logLlmDebug(title: string, payload: unknown) {
|
||||
if (!ENABLE_LLM_DEBUG_LOG) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(title, payload);
|
||||
}
|
||||
|
||||
function normalizeLlmError(error: unknown): never {
|
||||
if (
|
||||
typeof DOMException !== 'undefined'
|
||||
&& error instanceof DOMException
|
||||
&& error.name === 'AbortError'
|
||||
) {
|
||||
throw new LlmTimeoutError('The LLM request timed out. Please check the network or endpoint.');
|
||||
}
|
||||
|
||||
if (error instanceof TypeError) {
|
||||
throw new LlmConnectivityError('Unable to reach the LLM endpoint. The network or proxy may be unavailable.');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
function requestLlmEndpoint(input: string, init: RequestInit = {}) {
|
||||
const headers = resolveHeaders(init.headers);
|
||||
if (IS_SERVER_RUNTIME && SERVER_API_KEY.trim()) {
|
||||
headers.Authorization = `Bearer ${SERVER_API_KEY.trim()}`;
|
||||
}
|
||||
|
||||
const nextInit = {
|
||||
...init,
|
||||
headers,
|
||||
} satisfies RequestInit;
|
||||
|
||||
return IS_SERVER_RUNTIME
|
||||
? fetch(input, nextInit)
|
||||
: fetchWithApiAuth(input, nextInit);
|
||||
}
|
||||
|
||||
export function isLlmConnectivityError(error: unknown): error is LlmConnectivityError {
|
||||
return error instanceof LlmConnectivityError;
|
||||
}
|
||||
|
||||
export function isLlmTimeoutError(error: unknown): error is LlmTimeoutError {
|
||||
return error instanceof LlmTimeoutError;
|
||||
}
|
||||
|
||||
async function requestMessageContent(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
options: PlainTextCompletionOptions = {},
|
||||
) {
|
||||
const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
||||
const debugLabel = options.debugLabel ?? 'chat';
|
||||
const externalSignal = options.signal;
|
||||
const controller = new AbortController();
|
||||
const handleExternalAbort = () => controller.abort();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const startedAt = performance.now();
|
||||
const requestBody = {
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{role: 'system' as const, content: systemPrompt},
|
||||
{role: 'user' as const, content: userPrompt},
|
||||
],
|
||||
};
|
||||
const rawPromptText = `[System]\n${systemPrompt}\n\n[User]\n${userPrompt}`;
|
||||
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
handleExternalAbort();
|
||||
} else {
|
||||
externalSignal.addEventListener('abort', handleExternalAbort, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
logLlmDebug(`[LLM:${debugLabel}] prompt text`, rawPromptText);
|
||||
|
||||
const response = await requestLlmEndpoint(`${API_BASE_URL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const rawResponseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error('LLM authentication failed. Check the configured API key on the dev server.');
|
||||
}
|
||||
|
||||
throw new Error(`LLM request failed: ${response.status} ${rawResponseText}`);
|
||||
}
|
||||
|
||||
const data = JSON.parse(rawResponseText);
|
||||
const content = data?.choices?.[0]?.message?.content;
|
||||
if (!content || typeof content !== 'string') {
|
||||
throw new Error('LLM response did not include message content.');
|
||||
}
|
||||
|
||||
logLlmDebug(`[LLM:${debugLabel}] output text`, content);
|
||||
logLlmDebug(`[LLM:${debugLabel}] completion success`, {
|
||||
model: MODEL,
|
||||
elapsedMs: Math.round(performance.now() - startedAt),
|
||||
responseLength: content.length,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
return content.trim();
|
||||
} catch (error) {
|
||||
if (externalSignal?.aborted) {
|
||||
throw externalSignal.reason instanceof Error
|
||||
? externalSignal.reason
|
||||
: new DOMException('The LLM request was aborted.', 'AbortError');
|
||||
}
|
||||
console.error(`[LLM:${debugLabel}] completion failed`, {
|
||||
model: MODEL,
|
||||
elapsedMs: Math.round(performance.now() - startedAt),
|
||||
timeoutMs,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return normalizeLlmError(error);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
externalSignal?.removeEventListener('abort', handleExternalAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestChatMessageContent(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
options: PlainTextCompletionOptions = {},
|
||||
) {
|
||||
return requestMessageContent(systemPrompt, userPrompt, options);
|
||||
}
|
||||
|
||||
export async function requestPlainTextCompletion(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
options: PlainTextCompletionOptions = {},
|
||||
) {
|
||||
return requestMessageContent(systemPrompt, userPrompt, options);
|
||||
}
|
||||
|
||||
export async function streamPlainTextCompletion(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await requestLlmEndpoint(`${API_BASE_URL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
stream: true,
|
||||
messages: [
|
||||
{role: 'system' as const, content: systemPrompt},
|
||||
{role: 'user' as const, content: userPrompt},
|
||||
],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const rawResponseText = await response.text();
|
||||
if (response.status === 401) {
|
||||
throw new Error('LLM authentication failed. Check the configured API key on the dev server.');
|
||||
}
|
||||
|
||||
throw new Error(`LLM request failed: ${response.status} ${rawResponseText}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
const fallbackText = await requestPlainTextCompletion(systemPrompt, userPrompt);
|
||||
let progressiveText = '';
|
||||
for (const char of fallbackText) {
|
||||
progressiveText += char;
|
||||
options.onUpdate?.(progressiveText);
|
||||
}
|
||||
return fallbackText;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
let buffer = '';
|
||||
let accumulatedText = '';
|
||||
|
||||
for (;;) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, {stream: true});
|
||||
|
||||
while (buffer.includes('\n\n')) {
|
||||
const boundary = buffer.indexOf('\n\n');
|
||||
const eventBlock = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
|
||||
for (const rawLine of eventBlock.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line.startsWith('data:')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = line.slice(5).trim();
|
||||
if (!data || data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const delta = parsed?.choices?.[0]?.delta?.content;
|
||||
if (typeof delta === 'string' && delta.length > 0) {
|
||||
accumulatedText += delta;
|
||||
options.onUpdate?.(accumulatedText);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed SSE frames and continue consuming the stream.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accumulatedText.trim();
|
||||
} catch (error) {
|
||||
return normalizeLlmError(error);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
29
src/services/llmParsers.test.ts
Normal file
29
src/services/llmParsers.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
import {parseJsonResponseText, parseLineListContent} from './llmParsers';
|
||||
|
||||
describe('llmParsers', () => {
|
||||
it('parses fenced json payloads', () => {
|
||||
expect(
|
||||
parseJsonResponseText('```json\n{"storyText":"hello","options":[]}\n```'),
|
||||
).toEqual({
|
||||
storyText: 'hello',
|
||||
options: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses embedded json objects', () => {
|
||||
expect(
|
||||
parseJsonResponseText('prefix {"storyText":"hello","options":[]} suffix'),
|
||||
).toEqual({
|
||||
storyText: 'hello',
|
||||
options: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts compact suggestion lines', () => {
|
||||
expect(
|
||||
parseLineListContent('- first\n2. second\nthird', 2),
|
||||
).toEqual(['first', 'second']);
|
||||
});
|
||||
});
|
||||
4
src/services/llmParsers.ts
Normal file
4
src/services/llmParsers.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
parseJsonResponseText,
|
||||
parseLineListContent,
|
||||
} from '../../packages/shared/src/llm/parsers';
|
||||
283
src/services/miniGameDraftGenerationProgress.ts
Normal file
283
src/services/miniGameDraftGenerationProgress.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import type { BigFishSessionSnapshotResponse } from '../../packages/shared/src/contracts/bigFish';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
CustomWorldGenerationStep,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldStructuredAnchorEntry } from './customWorldAgentGenerationProgress';
|
||||
|
||||
export type MiniGameDraftGenerationKind = 'puzzle' | 'big-fish';
|
||||
|
||||
export type MiniGameDraftGenerationPhase =
|
||||
| 'idle'
|
||||
| 'compile'
|
||||
| 'puzzle-images'
|
||||
| 'puzzle-select-image'
|
||||
| 'big-fish-main-images'
|
||||
| 'big-fish-motions'
|
||||
| 'big-fish-background'
|
||||
| 'ready'
|
||||
| 'failed';
|
||||
|
||||
export type MiniGameDraftGenerationState = {
|
||||
kind: MiniGameDraftGenerationKind;
|
||||
phase: MiniGameDraftGenerationPhase;
|
||||
startedAtMs: number;
|
||||
completedAssetCount: number;
|
||||
totalAssetCount: number;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type MiniGameStepDefinition = {
|
||||
id: MiniGameDraftGenerationPhase;
|
||||
label: string;
|
||||
detail: string;
|
||||
weight: number;
|
||||
};
|
||||
|
||||
type MiniGameAnchorSource = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const PUZZLE_STEPS = [
|
||||
{
|
||||
id: 'compile',
|
||||
label: '编译拼图草稿',
|
||||
detail: '整理主题、主体、构图与标签。',
|
||||
weight: 34,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-images',
|
||||
label: '生成拼图图片',
|
||||
detail: '根据草稿生成候选图。',
|
||||
weight: 33,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-select-image',
|
||||
label: '确认正式图片',
|
||||
detail: '选择候选图写入结果页。',
|
||||
weight: 33,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
const BIG_FISH_STEPS = [
|
||||
{
|
||||
id: 'compile',
|
||||
label: '编译玩法草稿',
|
||||
detail: '生成关卡角色描述、生态背景与运行参数。',
|
||||
weight: 25,
|
||||
},
|
||||
{
|
||||
id: 'big-fish-main-images',
|
||||
label: '生成角色图片',
|
||||
detail: '为每个成长阶段生成主形象。',
|
||||
weight: 30,
|
||||
},
|
||||
{
|
||||
id: 'big-fish-motions',
|
||||
label: '生成动作素材',
|
||||
detail: '补齐漂浮与游动动作素材。',
|
||||
weight: 30,
|
||||
},
|
||||
{
|
||||
id: 'big-fish-background',
|
||||
label: '生成场地背景',
|
||||
detail: '生成玩法场地背景图。',
|
||||
weight: 15,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
function clampProgress(value: number) {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
|
||||
return kind === 'puzzle' ? PUZZLE_STEPS : BIG_FISH_STEPS;
|
||||
}
|
||||
|
||||
function getActiveStepIndex(
|
||||
steps: ReadonlyArray<MiniGameStepDefinition>,
|
||||
phase: MiniGameDraftGenerationPhase,
|
||||
) {
|
||||
if (phase === 'ready') {
|
||||
return steps.length - 1;
|
||||
}
|
||||
const index = steps.findIndex((step) => step.id === phase);
|
||||
return index >= 0 ? index : 0;
|
||||
}
|
||||
|
||||
function buildMiniGameProgressSteps(
|
||||
steps: ReadonlyArray<MiniGameStepDefinition>,
|
||||
activeStepIndex: number,
|
||||
state: MiniGameDraftGenerationState,
|
||||
) {
|
||||
return steps.map((step, index) => {
|
||||
const isCompleted = state.phase === 'ready' || index < activeStepIndex;
|
||||
const isActive = state.phase !== 'failed' && !isCompleted && index === activeStepIndex;
|
||||
const isAssetStep = step.id === state.phase && state.totalAssetCount > 0;
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
label: step.label,
|
||||
detail: step.detail,
|
||||
completed: isCompleted
|
||||
? 1
|
||||
: isAssetStep
|
||||
? state.completedAssetCount
|
||||
: 0,
|
||||
total: isAssetStep ? state.totalAssetCount : 1,
|
||||
status: isCompleted ? 'completed' : isActive ? 'active' : 'pending',
|
||||
} satisfies CustomWorldGenerationStep;
|
||||
});
|
||||
}
|
||||
|
||||
export function createMiniGameDraftGenerationState(
|
||||
kind: MiniGameDraftGenerationKind,
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
kind,
|
||||
phase: 'compile',
|
||||
startedAtMs: Date.now(),
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMiniGameDraftGenerationProgress(
|
||||
state: MiniGameDraftGenerationState | null,
|
||||
nowMs = Date.now(),
|
||||
): CustomWorldGenerationProgress | null {
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steps = getStepDefinitions(state.kind);
|
||||
const activeStepIndex = getActiveStepIndex(steps, state.phase);
|
||||
const completedWeight = steps
|
||||
.slice(0, state.phase === 'ready' ? steps.length : activeStepIndex)
|
||||
.reduce((sum, step) => sum + step.weight, 0);
|
||||
const activeStep = steps[activeStepIndex] ?? steps[0];
|
||||
const assetRatio =
|
||||
state.totalAssetCount > 0
|
||||
? Math.min(1, state.completedAssetCount / state.totalAssetCount)
|
||||
: state.phase === 'ready'
|
||||
? 1
|
||||
: 0;
|
||||
const overallProgress =
|
||||
state.phase === 'failed'
|
||||
? Math.max(1, completedWeight)
|
||||
: state.phase === 'ready'
|
||||
? 100
|
||||
: completedWeight + activeStep.weight * assetRatio;
|
||||
|
||||
return {
|
||||
phaseId: state.phase,
|
||||
phaseLabel:
|
||||
state.phase === 'failed'
|
||||
? '生成失败'
|
||||
: state.phase === 'ready'
|
||||
? '生成完成'
|
||||
: activeStep.label,
|
||||
phaseDetail:
|
||||
state.error ??
|
||||
(state.phase === 'ready'
|
||||
? '完整草稿与资产已准备完成。'
|
||||
: activeStep.detail),
|
||||
batchLabel: activeStep.label,
|
||||
overallProgress: clampProgress(overallProgress),
|
||||
completedWeight: clampProgress(overallProgress),
|
||||
totalWeight: 100,
|
||||
elapsedMs: Math.max(0, nowMs - state.startedAtMs),
|
||||
estimatedRemainingMs: state.phase === 'ready' ? 0 : null,
|
||||
activeStepIndex,
|
||||
steps: buildMiniGameProgressSteps(steps, activeStepIndex, state),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPuzzleGenerationAnchorEntries(
|
||||
session: PuzzleAgentSessionSnapshot | null | undefined,
|
||||
): CustomWorldStructuredAnchorEntry[] {
|
||||
if (!session) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const draft = session.draft;
|
||||
const entries: Array<MiniGameAnchorSource | null> = [
|
||||
session.anchorPack.themePromise,
|
||||
session.anchorPack.visualSubject,
|
||||
session.anchorPack.visualMood,
|
||||
session.anchorPack.compositionHooks,
|
||||
session.anchorPack.tagsAndForbidden,
|
||||
draft
|
||||
? {
|
||||
key: 'draft-summary',
|
||||
label: '草稿摘要',
|
||||
value: draft.summary,
|
||||
}
|
||||
: null,
|
||||
draft?.coverImageSrc
|
||||
? {
|
||||
key: 'cover-image',
|
||||
label: '正式图片',
|
||||
value: '已生成并应用',
|
||||
}
|
||||
: null,
|
||||
];
|
||||
|
||||
return entries
|
||||
.filter((entry): entry is MiniGameAnchorSource => Boolean(entry))
|
||||
.map((entry) => ({
|
||||
id: entry.key,
|
||||
label: entry.label,
|
||||
value: entry.value,
|
||||
}))
|
||||
.filter((entry) => entry.value.trim());
|
||||
}
|
||||
|
||||
export function buildBigFishGenerationAnchorEntries(
|
||||
session: BigFishSessionSnapshotResponse | null | undefined,
|
||||
): CustomWorldStructuredAnchorEntry[] {
|
||||
if (!session) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const draft = session.draft;
|
||||
const assetReadyCount = session.assetSlots.filter(
|
||||
(slot) => slot.status === 'ready',
|
||||
).length;
|
||||
|
||||
const entries: Array<MiniGameAnchorSource | null> = [
|
||||
session.anchorPack.gameplayPromise,
|
||||
session.anchorPack.ecologyVisualTheme,
|
||||
session.anchorPack.growthLadder,
|
||||
session.anchorPack.riskTempo,
|
||||
draft
|
||||
? {
|
||||
key: 'level-characters',
|
||||
label: '角色描述',
|
||||
value: draft.levels
|
||||
.map((level) => `Lv.${level.level} ${level.name}:${level.oneLineFantasy}`)
|
||||
.join('\n'),
|
||||
}
|
||||
: null,
|
||||
draft
|
||||
? {
|
||||
key: 'asset-coverage',
|
||||
label: '图片与动作',
|
||||
value: `已生成 ${assetReadyCount}/${session.assetSlots.length} 个资产`,
|
||||
}
|
||||
: null,
|
||||
];
|
||||
|
||||
return entries
|
||||
.filter((entry): entry is MiniGameAnchorSource => Boolean(entry))
|
||||
.map((entry) => ({
|
||||
id: entry.key,
|
||||
label: entry.label,
|
||||
value: entry.value,
|
||||
}))
|
||||
.filter((entry) => entry.value.trim());
|
||||
}
|
||||
4
src/services/narrativeLanguage.ts
Normal file
4
src/services/narrativeLanguage.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
hasMixedNarrativeLanguage,
|
||||
sanitizePromptNarrativeText,
|
||||
} from '../../packages/shared/src/llm/narrativeLanguage';
|
||||
1
src/services/platform-entry/index.ts
Normal file
1
src/services/platform-entry/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { getPlatformProfileDashboard } from './platformProfileClient';
|
||||
5
src/services/platform-entry/platformProfileClient.ts
Normal file
5
src/services/platform-entry/platformProfileClient.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 平台首页资料读取入口。
|
||||
* 直连 RPG profile client,避免默认首页首访经过服务桶入口触发额外模块转译。
|
||||
*/
|
||||
export { getRpgProfileDashboard as getPlatformProfileDashboard } from '../rpg-entry/rpgProfileClient';
|
||||
301
src/services/prompt.test.ts
Normal file
301
src/services/prompt.test.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AnimationState, type Character, WorldType } from '../types';
|
||||
import { buildExpandedCustomWorldProfile } from './customWorldBuilder';
|
||||
import { buildUserPrompt } from './prompt';
|
||||
import { buildSceneNarrativeDirective } from './storyEngine/sceneNarrativeDirector';
|
||||
import { buildEncounterVisibilitySlice } from './storyEngine/visibilityEngine';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '林澈',
|
||||
title: '行旅客',
|
||||
description: '一名谨慎前行的旅人。',
|
||||
backstory: '从北境一路追着旧案残线而来。',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 9,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: '谨慎、克制、先看局势。',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildUserPrompt', () => {
|
||||
it('does not leak full custom-world backstory on first contact', () => {
|
||||
const profile = buildExpandedCustomWorldProfile(
|
||||
{
|
||||
id: 'prompt-world',
|
||||
name: '裂潮边城',
|
||||
subtitle: '旧案回响',
|
||||
summary: '一座在裂潮与旧案回响之间摇摇欲坠的边城。',
|
||||
tone: '紧张、克制、暗流涌动',
|
||||
playerGoal: '查清边城裂潮背后的封桥旧令',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['巡边司', '潮商会'],
|
||||
coreConflicts: ['裂潮再度逼近边路', '封桥旧案再被人提起'],
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-1',
|
||||
name: '沈砺',
|
||||
title: '灰炬向导',
|
||||
role: '向导',
|
||||
description: '熟悉裂潮边路的灰炬向导。',
|
||||
backstory: '曾在旧撤离线里失去一整支同行队。',
|
||||
personality: '谨慎寡言,先看风向再开口。',
|
||||
motivation: '想查清旧撤离线为何再次失控。',
|
||||
combatStyle: '短弓牵制后贴近补刀。',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['旧撤离线', '名单'],
|
||||
tags: ['裂潮', '向导'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '他只说自己熟悉边路。',
|
||||
chapters: [
|
||||
{ id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他总盯着风向和路标。', content: '他先把注意力放在边路是否还能走。', contextSnippet: '他总先谈路和风。' },
|
||||
{ id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '旧撤离线像在他身上留下了什么。', content: '那次撤离失控后,他一直没再离开这片边路。', contextSnippet: '撤离旧事还没过去。' },
|
||||
{ id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '名单上的名字让他一直不肯放手。', content: '他一直在比对那份回响名单与旧撤离线。', contextSnippet: '他一直在查名单。' },
|
||||
{ id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '他知道裂潮里有人故意改过路标。', content: '他怀疑有人借裂潮重启旧案。', contextSnippet: '有人在利用裂潮。' },
|
||||
],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-1',
|
||||
name: '梁砺',
|
||||
title: '断桥巡守',
|
||||
role: '巡守',
|
||||
description: '守着断桥与旧哨火的巡守。',
|
||||
backstory: '旧案爆发时,他是最后一个封桥的人。',
|
||||
personality: '警觉直接,不喜欢绕弯。',
|
||||
motivation: '不想让旧案再次借裂潮翻上来。',
|
||||
combatStyle: '长兵先压,再卡住路口。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['封桥', '旧哨火'],
|
||||
tags: ['巡守', '断桥'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '他只承认自己还在守桥。',
|
||||
chapters: [
|
||||
{ id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他只说桥还不能放开。', content: '他总先谈桥和路。', contextSnippet: '桥还不能放开。' },
|
||||
{ id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '封桥那夜明显留下了后劲。', content: '他始终忘不了那夜桥上的名单。', contextSnippet: '封桥旧事还压着他。' },
|
||||
{ id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '他像还在替谁守着一个错误。', content: '他一直替旧命令继续守线。', contextSnippet: '他还在守旧命令。' },
|
||||
{ id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '有人逼他在封桥和救人之间选过。', content: '那夜真正下封桥令的人还没有露面。', contextSnippet: '封桥命令另有来头。' },
|
||||
],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '旧哨铜钥',
|
||||
category: '稀有品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '钥身磨得发亮。',
|
||||
tags: ['旧哨火'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '断桥旧哨',
|
||||
description: '旧哨火和断桥一起守着边城北口。',
|
||||
sceneNpcIds: ['story-1'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
'玩家想要一个裂潮边城与旧案回响交织的世界。',
|
||||
);
|
||||
|
||||
const npc = profile.storyNpcs[0]!;
|
||||
const visibilitySlice = buildEncounterVisibilitySlice({
|
||||
narrativeProfile: npc.narrativeProfile,
|
||||
backstoryReveal: npc.backstoryReveal,
|
||||
disclosureStage: 'guarded',
|
||||
isFirstMeaningfulContact: true,
|
||||
seenBackstoryChapterIds: [],
|
||||
storyEngineMemory: {
|
||||
discoveredFactIds: [],
|
||||
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
},
|
||||
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
|
||||
});
|
||||
const prompt = buildUserPrompt(
|
||||
WorldType.CUSTOM,
|
||||
createCharacter(),
|
||||
[],
|
||||
[],
|
||||
{
|
||||
playerHp: 30,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 10,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
sceneId: 'custom-scene-landmark-1',
|
||||
sceneName: '断桥旧哨',
|
||||
sceneDescription: '风里尽是旧哨火和潮声。',
|
||||
encounterKind: 'npc',
|
||||
encounterId: npc.id,
|
||||
encounterName: npc.name,
|
||||
encounterDescription: npc.description,
|
||||
encounterContext: npc.role,
|
||||
encounterAffinity: npc.initialAffinity,
|
||||
encounterAffinityText: '对你仍有戒备,也在观察你会怎么试探。',
|
||||
encounterDisclosureStage: 'guarded',
|
||||
encounterWarmthStage: 'distant',
|
||||
encounterAnswerMode: 'situational_only',
|
||||
encounterAllowedTopics: ['眼前危险', '现场判断', '模糊钩子'],
|
||||
encounterBlockedTopics: ['完整来历', '真正目标', '旧事全貌'],
|
||||
isFirstMeaningfulContact: true,
|
||||
firstContactRelationStance: 'guarded',
|
||||
recentSharedEvent: '你们还只是刚刚真正把话对上。',
|
||||
talkPriority: '优先谈桥口、来意和眼前压力,不要直接摊开旧案全貌。',
|
||||
encounterCustomProfile: npc,
|
||||
encounterNarrativeProfile: npc.narrativeProfile,
|
||||
visibilitySlice,
|
||||
sceneNarrativeDirective: buildSceneNarrativeDirective({
|
||||
sceneId: 'custom-scene-landmark-1',
|
||||
sceneName: '断桥旧哨',
|
||||
encounterId: npc.id,
|
||||
encounterName: npc.name,
|
||||
recentActions: [],
|
||||
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
|
||||
visibilitySlice,
|
||||
encounterNarrativeProfile: npc.narrativeProfile,
|
||||
disclosureStage: 'guarded',
|
||||
isFirstMeaningfulContact: true,
|
||||
affinity: npc.initialAffinity,
|
||||
}),
|
||||
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
|
||||
customWorldProfile: profile,
|
||||
},
|
||||
);
|
||||
|
||||
expect(prompt).toContain(npc.narrativeProfile?.publicMask ?? '');
|
||||
expect(prompt).toContain(npc.narrativeProfile?.immediatePressure ?? '');
|
||||
expect(prompt).not.toContain(npc.backstory);
|
||||
expect(prompt).not.toContain(npc.backstoryReveal.chapters[3]!.content);
|
||||
expect(prompt).not.toContain(npc.initialItems[0]!.name);
|
||||
});
|
||||
|
||||
it('requires an empty encounter payload during non-pending follow-up reasoning such as post-battle continuation', () => {
|
||||
const prompt = buildUserPrompt(
|
||||
WorldType.WUXIA,
|
||||
createCharacter(),
|
||||
[],
|
||||
[
|
||||
{
|
||||
text: '挥刀抢攻',
|
||||
options: [],
|
||||
historyRole: 'action',
|
||||
},
|
||||
{
|
||||
text: '山道客已经败下阵来。',
|
||||
options: [],
|
||||
historyRole: 'result',
|
||||
},
|
||||
],
|
||||
{
|
||||
playerHp: 26,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 8,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
sceneId: 'forest_road',
|
||||
sceneName: '山道',
|
||||
sceneDescription: '风从林梢压下来,地上还留着刚才交手的痕迹。',
|
||||
pendingSceneEncounter: false,
|
||||
},
|
||||
'挥刀抢攻',
|
||||
);
|
||||
|
||||
expect(prompt).toContain('encounter 必须为 null');
|
||||
expect(prompt).toContain('战斗结束后的续写');
|
||||
});
|
||||
|
||||
it('does not feed mixed-language history and directive snippets back into story prompts', () => {
|
||||
const prompt = buildUserPrompt(
|
||||
WorldType.WUXIA,
|
||||
createCharacter(),
|
||||
[],
|
||||
[
|
||||
{
|
||||
text: 'Move forward carefully.',
|
||||
options: [],
|
||||
historyRole: 'action',
|
||||
},
|
||||
{
|
||||
text: 'The wind is cold. 你听见山道尽头有脚步声。',
|
||||
options: [],
|
||||
historyRole: 'result',
|
||||
},
|
||||
],
|
||||
{
|
||||
playerHp: 26,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 8,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: AnimationState.ATTACK,
|
||||
skillCooldowns: {},
|
||||
sceneId: 'forest_road',
|
||||
sceneName: '山道',
|
||||
sceneDescription: '风从林梢压下来。',
|
||||
pendingSceneEncounter: false,
|
||||
conversationSituation: 'post_battle_breath',
|
||||
conversationPressure: 'medium',
|
||||
recentSharedEvent:
|
||||
'A fight just ended. Both sides are still catching their breath.',
|
||||
talkPriority:
|
||||
'Focus on the most useful judgment, danger, and next step.',
|
||||
partyRelationshipNotes:
|
||||
'Lan is becoming more open in private conversation.',
|
||||
recentChronicleSummary: 'Baseline summary from previous run.',
|
||||
sceneNarrativeDirective: {
|
||||
primaryPressure: 'Danger is still active near the camp.',
|
||||
activeThreadIds: ['thread-old-case'],
|
||||
foregroundActorIds: [],
|
||||
foregroundCarrierIds: [],
|
||||
revealBudget: 'low',
|
||||
emotionalCadence: 'tense',
|
||||
},
|
||||
},
|
||||
'Move forward carefully.',
|
||||
);
|
||||
|
||||
expect(prompt).not.toContain('A fight just ended');
|
||||
expect(prompt).not.toContain('Focus on the most useful judgment');
|
||||
expect(prompt).not.toContain('Baseline summary');
|
||||
expect(prompt).not.toContain('Move forward carefully');
|
||||
expect(prompt).not.toContain('thread-old-case');
|
||||
expect(prompt).not.toContain('Danger is still active');
|
||||
expect(prompt).toContain('战后缓气');
|
||||
expect(prompt).toContain('紧绷');
|
||||
expect(prompt).toContain('这一轮的局势已经出现了新的变化。');
|
||||
});
|
||||
});
|
||||
1
src/services/prompt.ts
Normal file
1
src/services/prompt.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from '../prompts/storyPromptBuilders';
|
||||
24
src/services/publicWorkCode.ts
Normal file
24
src/services/publicWorkCode.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export function normalizePublicCodeText(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9]/gu, '')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
export function buildPuzzlePublicWorkCode(profileId: string) {
|
||||
const normalized = normalizePublicCodeText(profileId);
|
||||
const fallback = normalized || '00000000';
|
||||
const suffix = fallback.slice(-8).padStart(8, '0');
|
||||
|
||||
return `PZ-${suffix}`;
|
||||
}
|
||||
|
||||
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
|
||||
const normalizedKeyword = normalizePublicCodeText(keyword);
|
||||
|
||||
return (
|
||||
normalizedKeyword ===
|
||||
normalizePublicCodeText(buildPuzzlePublicWorkCode(profileId)) ||
|
||||
normalizedKeyword === normalizePublicCodeText(profileId)
|
||||
);
|
||||
}
|
||||
8
src/services/puzzle-agent/index.ts
Normal file
8
src/services/puzzle-agent/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
createPuzzleAgentSession,
|
||||
executePuzzleAgentAction,
|
||||
getPuzzleAgentSession,
|
||||
puzzleAgentClient,
|
||||
sendPuzzleAgentMessage,
|
||||
streamPuzzleAgentMessage,
|
||||
} from './puzzleAgentClient';
|
||||
92
src/services/puzzle-agent/puzzleAgentClient.ts
Normal file
92
src/services/puzzle-agent/puzzleAgentClient.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
PuzzleAgentActionRequest,
|
||||
PuzzleAgentActionResponse,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
CreatePuzzleAgentSessionResponse,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
SendPuzzleAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
import { createCreationAgentClient } from '../creation-agent';
|
||||
|
||||
const PUZZLE_AGENT_API_BASE = '/api/runtime/puzzle/agent/sessions';
|
||||
const puzzleAgentHttpClient = createCreationAgentClient<
|
||||
CreatePuzzleAgentSessionRequest,
|
||||
CreatePuzzleAgentSessionResponse,
|
||||
CreatePuzzleAgentSessionResponse,
|
||||
PuzzleAgentSessionSnapshot,
|
||||
SendPuzzleAgentMessageRequest,
|
||||
{ session: PuzzleAgentSessionSnapshot },
|
||||
PuzzleAgentActionRequest,
|
||||
PuzzleAgentActionResponse
|
||||
>({
|
||||
apiBase: PUZZLE_AGENT_API_BASE,
|
||||
messages: {
|
||||
createSession: '创建拼图共创会话失败',
|
||||
getSession: '读取拼图共创会话失败',
|
||||
sendMessage: '发送拼图共创消息失败',
|
||||
streamIncomplete: '拼图共创消息流式结果不完整',
|
||||
executeAction: '执行拼图共创操作失败',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 创建拼图 Agent 共创会话。
|
||||
* 首版继续走 Axum facade,前端不直连 SpacetimeDB。
|
||||
*/
|
||||
export async function createPuzzleAgentSession(
|
||||
payload: CreatePuzzleAgentSessionRequest = {},
|
||||
) {
|
||||
return puzzleAgentHttpClient.createSession(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取拼图 Agent 会话快照。
|
||||
*/
|
||||
export async function getPuzzleAgentSession(sessionId: string) {
|
||||
return puzzleAgentHttpClient.getSession(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 非流式发送拼图 Agent 消息。
|
||||
* 当前 UI 主链使用 SSE,但保留普通接口便于后续降级。
|
||||
*/
|
||||
export async function sendPuzzleAgentMessage(
|
||||
sessionId: string,
|
||||
payload: SendPuzzleAgentMessageRequest,
|
||||
) {
|
||||
return puzzleAgentHttpClient.sendMessage(sessionId, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式发送拼图 Agent 消息。
|
||||
* 后端当前会先回传一段 assistant 文本,再附上最新 session 快照。
|
||||
*/
|
||||
export async function streamPuzzleAgentMessage(
|
||||
sessionId: string,
|
||||
payload: SendPuzzleAgentMessageRequest,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
return puzzleAgentHttpClient.streamMessage(sessionId, payload, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行拼图结果页相关操作。
|
||||
* 后端会返回 operation 记录,前端再按需刷新 session 或 works/gallery。
|
||||
*/
|
||||
export async function executePuzzleAgentAction(
|
||||
sessionId: string,
|
||||
payload: PuzzleAgentActionRequest,
|
||||
) {
|
||||
return puzzleAgentHttpClient.executeAction(sessionId, payload);
|
||||
}
|
||||
|
||||
export const puzzleAgentClient = {
|
||||
createSession: createPuzzleAgentSession,
|
||||
getSession: getPuzzleAgentSession,
|
||||
sendMessage: sendPuzzleAgentMessage,
|
||||
streamMessage: streamPuzzleAgentMessage,
|
||||
executeAction: executePuzzleAgentAction,
|
||||
};
|
||||
5
src/services/puzzle-gallery/index.ts
Normal file
5
src/services/puzzle-gallery/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
getPuzzleGalleryDetail,
|
||||
listPuzzleGallery,
|
||||
puzzleGalleryClient,
|
||||
} from './puzzleGalleryClient';
|
||||
49
src/services/puzzle-gallery/puzzleGalleryClient.ts
Normal file
49
src/services/puzzle-gallery/puzzleGalleryClient.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type {
|
||||
PuzzleWorksResponse,
|
||||
PuzzleWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const PUZZLE_GALLERY_API_BASE = '/api/runtime/puzzle/gallery';
|
||||
const PUZZLE_GALLERY_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取拼图广场列表。
|
||||
*/
|
||||
export async function listPuzzleGallery() {
|
||||
return requestJson<PuzzleWorksResponse>(
|
||||
PUZZLE_GALLERY_API_BASE,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取拼图广场失败',
|
||||
{
|
||||
retry: PUZZLE_GALLERY_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取拼图广场详情。
|
||||
*/
|
||||
export async function getPuzzleGalleryDetail(profileId: string) {
|
||||
return requestJson<{ item: PuzzleWorkSummary }>(
|
||||
`${PUZZLE_GALLERY_API_BASE}/${encodeURIComponent(profileId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取拼图广场详情失败',
|
||||
{
|
||||
retry: PUZZLE_GALLERY_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const puzzleGalleryClient = {
|
||||
getDetail: getPuzzleGalleryDetail,
|
||||
list: listPuzzleGallery,
|
||||
};
|
||||
9
src/services/puzzle-runtime/index.ts
Normal file
9
src/services/puzzle-runtime/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
advanceLocalPuzzleNextLevel,
|
||||
advancePuzzleNextLevel,
|
||||
dragPuzzlePieceOrGroup,
|
||||
getPuzzleRun,
|
||||
puzzleRuntimeClient,
|
||||
startPuzzleRun,
|
||||
swapPuzzlePieces,
|
||||
} from './puzzleRuntimeClient';
|
||||
69
src/services/puzzle-runtime/puzzleLocalRuntime.test.ts
Normal file
69
src/services/puzzle-runtime/puzzleLocalRuntime.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import {
|
||||
advanceLocalPuzzleLevel,
|
||||
dragLocalPuzzlePiece,
|
||||
startLocalPuzzleRun,
|
||||
} from './puzzleLocalRuntime';
|
||||
|
||||
const baseWork: PuzzleWorkSummary = {
|
||||
workId: 'work-1',
|
||||
profileId: 'profile-1',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: null,
|
||||
authorDisplayName: '测试作者',
|
||||
levelName: '测试拼图',
|
||||
summary: '服务层测试用拼图。',
|
||||
themeTags: ['测试', '拼图'],
|
||||
coverImageSrc: '/generated-puzzle-assets/test.png',
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T00:00:00.000Z',
|
||||
publishedAt: '2026-04-25T00:00:00.000Z',
|
||||
playCount: 0,
|
||||
publishReady: true,
|
||||
};
|
||||
|
||||
function solveCurrentLevel(run: ReturnType<typeof startLocalPuzzleRun>) {
|
||||
let nextRun = run;
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
const currentLevel = nextRun.currentLevel;
|
||||
if (!currentLevel || currentLevel.status === 'cleared') {
|
||||
return nextRun;
|
||||
}
|
||||
|
||||
const misplacedPiece = currentLevel.board.pieces.find(
|
||||
(piece) =>
|
||||
piece.currentRow !== piece.correctRow ||
|
||||
piece.currentCol !== piece.correctCol,
|
||||
);
|
||||
if (!misplacedPiece) {
|
||||
return nextRun;
|
||||
}
|
||||
|
||||
nextRun = dragLocalPuzzlePiece(nextRun, {
|
||||
pieceId: misplacedPiece.pieceId,
|
||||
targetRow: misplacedPiece.correctRow,
|
||||
targetCol: misplacedPiece.correctCol,
|
||||
});
|
||||
}
|
||||
return nextRun;
|
||||
}
|
||||
|
||||
describe('puzzleLocalRuntime', () => {
|
||||
test('通关后提供下一关入口并能推进到新棋盘', () => {
|
||||
const clearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork));
|
||||
|
||||
expect(clearedRun.currentLevel?.status).toBe('cleared');
|
||||
expect(clearedRun.recommendedNextProfileId).toBe('profile-1::local-level-2');
|
||||
|
||||
const nextRun = advanceLocalPuzzleLevel(clearedRun);
|
||||
|
||||
expect(nextRun.currentLevelIndex).toBe(2);
|
||||
expect(nextRun.currentLevel?.status).toBe('playing');
|
||||
expect(nextRun.currentLevel?.levelName).toBe('测试拼图 · 第 2 关');
|
||||
expect(nextRun.currentLevel?.board.allTilesResolved).toBe(false);
|
||||
expect(nextRun.recommendedNextProfileId).toBeNull();
|
||||
});
|
||||
});
|
||||
244
src/services/puzzle-runtime/puzzleLocalRuntime.ts
Normal file
244
src/services/puzzle-runtime/puzzleLocalRuntime.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type {
|
||||
DragPuzzlePieceRequest,
|
||||
PuzzleBoardSnapshot,
|
||||
PuzzleGridSize,
|
||||
PuzzlePieceState,
|
||||
PuzzleRunSnapshot,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
|
||||
function resolvePuzzleGridSize(clearedLevelCount: number): PuzzleGridSize {
|
||||
return clearedLevelCount >= 3 ? 4 : 3;
|
||||
}
|
||||
|
||||
function buildInitialPositions(gridSize: PuzzleGridSize) {
|
||||
const positions = Array.from({ length: gridSize * gridSize }, (_, index) => ({
|
||||
row: Math.floor(index / gridSize),
|
||||
col: index % gridSize,
|
||||
}));
|
||||
return positions.slice(1).concat(positions.slice(0, 1));
|
||||
}
|
||||
|
||||
function rebuildBoardSnapshot(
|
||||
gridSize: PuzzleGridSize,
|
||||
pieces: PuzzlePieceState[],
|
||||
): PuzzleBoardSnapshot {
|
||||
const resolvedPieceIds = new Set(
|
||||
pieces
|
||||
.filter(
|
||||
(piece) =>
|
||||
piece.currentRow === piece.correctRow &&
|
||||
piece.currentCol === piece.correctCol,
|
||||
)
|
||||
.map((piece) => piece.pieceId),
|
||||
);
|
||||
const allTilesResolved = resolvedPieceIds.size === pieces.length;
|
||||
|
||||
return {
|
||||
rows: gridSize,
|
||||
cols: gridSize,
|
||||
pieces: pieces.map((piece) => ({
|
||||
...piece,
|
||||
mergedGroupId: resolvedPieceIds.has(piece.pieceId)
|
||||
? 'resolved-main'
|
||||
: null,
|
||||
})),
|
||||
mergedGroups: resolvedPieceIds.size
|
||||
? [
|
||||
{
|
||||
groupId: 'resolved-main',
|
||||
pieceIds: Array.from(resolvedPieceIds),
|
||||
occupiedCells: pieces
|
||||
.filter((piece) => resolvedPieceIds.has(piece.pieceId))
|
||||
.map((piece) => ({
|
||||
row: piece.currentRow,
|
||||
col: piece.currentCol,
|
||||
})),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
selectedPieceId: null,
|
||||
allTilesResolved,
|
||||
};
|
||||
}
|
||||
|
||||
function buildInitialBoard(gridSize: PuzzleGridSize): PuzzleBoardSnapshot {
|
||||
const shuffledPositions = buildInitialPositions(gridSize);
|
||||
const pieces = Array.from({ length: gridSize * gridSize }, (_, index) => {
|
||||
const correctRow = Math.floor(index / gridSize);
|
||||
const correctCol = index % gridSize;
|
||||
const current = shuffledPositions[index] ?? { row: correctRow, col: correctCol };
|
||||
return {
|
||||
pieceId: `piece-${index}`,
|
||||
correctRow,
|
||||
correctCol,
|
||||
currentRow: current.row,
|
||||
currentCol: current.col,
|
||||
mergedGroupId: null,
|
||||
};
|
||||
});
|
||||
return rebuildBoardSnapshot(gridSize, pieces);
|
||||
}
|
||||
|
||||
function applyNextBoard(
|
||||
run: PuzzleRunSnapshot,
|
||||
nextBoard: PuzzleBoardSnapshot,
|
||||
): PuzzleRunSnapshot {
|
||||
if (!run.currentLevel) {
|
||||
return run;
|
||||
}
|
||||
const status = nextBoard.allTilesResolved ? 'cleared' : 'playing';
|
||||
const nextClearedLevelCount =
|
||||
status === 'cleared' && run.currentLevel.status !== 'cleared'
|
||||
? run.clearedLevelCount + 1
|
||||
: run.clearedLevelCount;
|
||||
return {
|
||||
...run,
|
||||
clearedLevelCount: nextClearedLevelCount,
|
||||
currentLevel: {
|
||||
...run.currentLevel,
|
||||
board: nextBoard,
|
||||
status,
|
||||
},
|
||||
recommendedNextProfileId:
|
||||
status === 'cleared'
|
||||
? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1)
|
||||
: run.recommendedNextProfileId,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLocalNextProfileId(entryProfileId: string, levelIndex: number) {
|
||||
return `${entryProfileId}::local-level-${levelIndex}`;
|
||||
}
|
||||
|
||||
// 第一版单机兜底没有后端推荐池时,才沿用当前作品图片生成可推进的临时关卡名。
|
||||
function buildLocalLevelName(previousLevelName: string, levelIndex: number) {
|
||||
return `${previousLevelName.replace(/ · 第 \d+ 关$/, '')} · 第 ${levelIndex} 关`;
|
||||
}
|
||||
|
||||
// 本地兜底只保证单次游玩闭环:通关后立即重建下一关棋盘,不写回后端。
|
||||
function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (!currentLevel || currentLevel.status !== 'cleared') {
|
||||
return run;
|
||||
}
|
||||
|
||||
const nextLevelIndex = run.currentLevelIndex + 1;
|
||||
const gridSize = resolvePuzzleGridSize(run.clearedLevelCount);
|
||||
const nextProfileId =
|
||||
run.recommendedNextProfileId ??
|
||||
buildLocalNextProfileId(run.entryProfileId, nextLevelIndex);
|
||||
|
||||
return {
|
||||
...run,
|
||||
currentLevelIndex: nextLevelIndex,
|
||||
currentGridSize: gridSize,
|
||||
playedProfileIds: run.playedProfileIds.includes(nextProfileId)
|
||||
? run.playedProfileIds
|
||||
: [...run.playedProfileIds, nextProfileId],
|
||||
previousLevelTags: currentLevel.themeTags,
|
||||
currentLevel: {
|
||||
...currentLevel,
|
||||
runId: run.runId,
|
||||
levelIndex: nextLevelIndex,
|
||||
gridSize,
|
||||
profileId: nextProfileId,
|
||||
levelName: buildLocalLevelName(currentLevel.levelName, nextLevelIndex),
|
||||
board: buildInitialBoard(gridSize),
|
||||
status: 'playing',
|
||||
},
|
||||
recommendedNextProfileId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot {
|
||||
const gridSize = resolvePuzzleGridSize(0);
|
||||
return {
|
||||
runId: `local-puzzle-run-${item.profileId}-${Date.now()}`,
|
||||
entryProfileId: item.profileId,
|
||||
clearedLevelCount: 0,
|
||||
currentLevelIndex: 1,
|
||||
currentGridSize: gridSize,
|
||||
playedProfileIds: [item.profileId],
|
||||
previousLevelTags: item.themeTags,
|
||||
currentLevel: {
|
||||
runId: `local-puzzle-run-${item.profileId}`,
|
||||
levelIndex: 1,
|
||||
gridSize,
|
||||
profileId: item.profileId,
|
||||
levelName: item.levelName,
|
||||
authorDisplayName: item.authorDisplayName,
|
||||
themeTags: item.themeTags,
|
||||
coverImageSrc: item.coverImageSrc,
|
||||
board: buildInitialBoard(gridSize),
|
||||
status: 'playing',
|
||||
},
|
||||
recommendedNextProfileId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function swapLocalPuzzlePieces(
|
||||
run: PuzzleRunSnapshot,
|
||||
payload: SwapPuzzlePiecesRequest,
|
||||
): PuzzleRunSnapshot {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (!currentLevel || currentLevel.status === 'cleared') {
|
||||
return run;
|
||||
}
|
||||
const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece }));
|
||||
const first = pieces.find((piece) => piece.pieceId === payload.firstPieceId);
|
||||
const second = pieces.find((piece) => piece.pieceId === payload.secondPieceId);
|
||||
if (!first || !second) {
|
||||
return run;
|
||||
}
|
||||
const firstPosition = { row: first.currentRow, col: first.currentCol };
|
||||
first.currentRow = second.currentRow;
|
||||
first.currentCol = second.currentCol;
|
||||
second.currentRow = firstPosition.row;
|
||||
second.currentCol = firstPosition.col;
|
||||
|
||||
return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
|
||||
}
|
||||
|
||||
export function dragLocalPuzzlePiece(
|
||||
run: PuzzleRunSnapshot,
|
||||
payload: DragPuzzlePieceRequest,
|
||||
): PuzzleRunSnapshot {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (!currentLevel || currentLevel.status === 'cleared') {
|
||||
return run;
|
||||
}
|
||||
if (
|
||||
payload.targetRow < 0 ||
|
||||
payload.targetCol < 0 ||
|
||||
payload.targetRow >= currentLevel.gridSize ||
|
||||
payload.targetCol >= currentLevel.gridSize
|
||||
) {
|
||||
return run;
|
||||
}
|
||||
const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece }));
|
||||
const moving = pieces.find((piece) => piece.pieceId === payload.pieceId);
|
||||
if (!moving) {
|
||||
return run;
|
||||
}
|
||||
const occupying = pieces.find(
|
||||
(piece) =>
|
||||
piece.pieceId !== payload.pieceId &&
|
||||
piece.currentRow === payload.targetRow &&
|
||||
piece.currentCol === payload.targetCol,
|
||||
);
|
||||
const source = { row: moving.currentRow, col: moving.currentCol };
|
||||
moving.currentRow = payload.targetRow;
|
||||
moving.currentCol = payload.targetCol;
|
||||
if (occupying) {
|
||||
occupying.currentRow = source.row;
|
||||
occupying.currentCol = source.col;
|
||||
}
|
||||
|
||||
return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces));
|
||||
}
|
||||
|
||||
export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
return buildFallbackLocalLevel(run);
|
||||
}
|
||||
142
src/services/puzzle-runtime/puzzleRuntimeClient.ts
Normal file
142
src/services/puzzle-runtime/puzzleRuntimeClient.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type {
|
||||
AdvanceLocalPuzzleNextLevelRequest,
|
||||
DragPuzzlePieceRequest,
|
||||
PuzzleRunResponse,
|
||||
StartPuzzleRunRequest,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const PUZZLE_RUNTIME_API_BASE = '/api/runtime/puzzle/runs';
|
||||
const PUZZLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
};
|
||||
const PUZZLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* 从某个已发布拼图作品开始一次 run。
|
||||
*/
|
||||
export async function startPuzzleRun(payload: StartPuzzleRunRequest) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
PUZZLE_RUNTIME_API_BASE,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'启动拼图玩法失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取拼图运行态快照。
|
||||
*/
|
||||
export async function getPuzzleRun(runId: string) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取拼图运行快照失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交两块交换请求。
|
||||
*/
|
||||
export async function swapPuzzlePieces(
|
||||
runId: string,
|
||||
payload: SwapPuzzlePiecesRequest,
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/swap`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'交换拼图块失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交单块或合并块拖动请求。
|
||||
*/
|
||||
export async function dragPuzzlePieceOrGroup(
|
||||
runId: string,
|
||||
payload: DragPuzzlePieceRequest,
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/drag`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'拖动拼图块失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入推荐出的下一关。
|
||||
*/
|
||||
export async function advancePuzzleNextLevel(runId: string) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/next-level`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'进入下一关失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单机运行态进入下一关,图片来源选择全部由后端裁决。
|
||||
*/
|
||||
export async function advanceLocalPuzzleNextLevel(
|
||||
payload: AdvanceLocalPuzzleNextLevelRequest,
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/local-next-level`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'进入下一关失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const puzzleRuntimeClient = {
|
||||
advanceLocalNextLevel: advanceLocalPuzzleNextLevel,
|
||||
advanceNextLevel: advancePuzzleNextLevel,
|
||||
drag: dragPuzzlePieceOrGroup,
|
||||
getRun: getPuzzleRun,
|
||||
startRun: startPuzzleRun,
|
||||
swap: swapPuzzlePieces,
|
||||
};
|
||||
7
src/services/puzzle-works/index.ts
Normal file
7
src/services/puzzle-works/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
getPuzzleWorkDetail,
|
||||
deletePuzzleWork,
|
||||
listPuzzleWorks,
|
||||
puzzleWorksClient,
|
||||
updatePuzzleWork,
|
||||
} from './puzzleWorksClient';
|
||||
102
src/services/puzzle-works/puzzleWorksClient.ts
Normal file
102
src/services/puzzle-works/puzzleWorksClient.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
PuzzleWorkDetailResponse,
|
||||
PuzzleWorkMutationResponse,
|
||||
PuzzleWorksResponse,
|
||||
} from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const PUZZLE_WORKS_API_BASE = '/api/runtime/puzzle/works';
|
||||
const PUZZLE_WORKS_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
};
|
||||
const PUZZLE_WORKS_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 120,
|
||||
maxDelayMs: 360,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取当前用户的拼图作品列表。
|
||||
*/
|
||||
export async function listPuzzleWorks() {
|
||||
return requestJson<PuzzleWorksResponse>(
|
||||
PUZZLE_WORKS_API_BASE,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取拼图作品列表失败',
|
||||
{
|
||||
retry: PUZZLE_WORKS_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取拼图作品详情。
|
||||
*/
|
||||
export async function getPuzzleWorkDetail(profileId: string) {
|
||||
return requestJson<PuzzleWorkDetailResponse>(
|
||||
`${PUZZLE_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取拼图作品详情失败',
|
||||
{
|
||||
retry: PUZZLE_WORKS_READ_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新已发布或草稿态拼图作品的轻量字段。
|
||||
* 只覆盖结果页约定的标题、摘要、标签与正式图。
|
||||
*/
|
||||
export async function updatePuzzleWork(
|
||||
profileId: string,
|
||||
payload: {
|
||||
levelName: string;
|
||||
summary: string;
|
||||
themeTags: string[];
|
||||
coverImageSrc?: string | null;
|
||||
coverAssetId?: string | null;
|
||||
},
|
||||
) {
|
||||
return requestJson<PuzzleWorkMutationResponse>(
|
||||
`${PUZZLE_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'更新拼图作品失败',
|
||||
{
|
||||
retry: PUZZLE_WORKS_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除当前用户的拼图作品,并返回删除后的作品列表。
|
||||
*/
|
||||
export async function deletePuzzleWork(profileId: string) {
|
||||
return requestJson<PuzzleWorksResponse>(
|
||||
`${PUZZLE_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
'删除拼图作品失败',
|
||||
{
|
||||
retry: PUZZLE_WORKS_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const puzzleWorksClient = {
|
||||
delete: deletePuzzleWork,
|
||||
getDetail: getPuzzleWorkDetail,
|
||||
list: listPuzzleWorks,
|
||||
update: updatePuzzleWork,
|
||||
};
|
||||
87
src/services/questTypes.ts
Normal file
87
src/services/questTypes.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type {
|
||||
QuestNarrativeBinding,
|
||||
QuestNarrativeType,
|
||||
QuestObjectiveKind,
|
||||
QuestReward,
|
||||
QuestStatus,
|
||||
QuestStep,
|
||||
ScenePresetInfo,
|
||||
} from '../types';
|
||||
import type {QuestGenerationContext} from './aiTypes';
|
||||
|
||||
export type QuestUrgency = 'low' | 'medium' | 'high';
|
||||
export type QuestIntimacy = 'transactional' | 'cooperative' | 'trust_based';
|
||||
export type QuestRewardTheme = 'currency' | 'resource' | 'relationship' | 'intel' | 'rare_item';
|
||||
export type QuestFailPolicy = 'never' | 'leave_scene' | 'issuer_hostile' | 'time_window';
|
||||
|
||||
export type QuestSceneSnapshot = Pick<
|
||||
ScenePresetInfo,
|
||||
'id' | 'name' | 'npcs' | 'treasureHints'
|
||||
> & {
|
||||
description?: ScenePresetInfo['description'];
|
||||
};
|
||||
|
||||
export interface QuestIntent {
|
||||
title: string;
|
||||
description: string;
|
||||
summary: string;
|
||||
narrativeType: QuestNarrativeType;
|
||||
dramaticNeed: string;
|
||||
issuerGoal: string;
|
||||
playerHook: string;
|
||||
worldReason: string;
|
||||
recommendedObjectiveKinds: QuestObjectiveKind[];
|
||||
urgency: QuestUrgency;
|
||||
intimacy: QuestIntimacy;
|
||||
rewardTheme: QuestRewardTheme;
|
||||
followupHooks: string[];
|
||||
}
|
||||
|
||||
export interface QuestContract {
|
||||
id: string;
|
||||
issuerNpcId: string;
|
||||
issuerNpcName: string;
|
||||
sceneId: string | null;
|
||||
questArchetype: QuestNarrativeType;
|
||||
title: string;
|
||||
description: string;
|
||||
summary: string;
|
||||
steps: QuestStep[];
|
||||
reward: QuestReward;
|
||||
rewardText: string;
|
||||
narrativeBinding: QuestNarrativeBinding;
|
||||
failPolicy: QuestFailPolicy;
|
||||
}
|
||||
|
||||
export interface QuestOpportunity {
|
||||
shouldOffer: boolean;
|
||||
reason: string;
|
||||
suggestedIssuerNpcId?: string;
|
||||
suggestedThreatType?: 'hostile_npc' | 'treasure' | 'relationship' | 'travel';
|
||||
}
|
||||
|
||||
export type QuestProgressSignal =
|
||||
| {kind: 'hostile_npc_defeated'; sceneId?: string | null; hostileNpcId: string}
|
||||
| {kind: 'treasure_inspected'; sceneId?: string | null}
|
||||
| {kind: 'npc_spar_completed'; npcId: string}
|
||||
| {kind: 'npc_talk_completed'; npcId: string}
|
||||
| {kind: 'scene_reached'; sceneId: string}
|
||||
| {kind: 'item_delivered'; npcId: string; itemId: string; quantity: number};
|
||||
|
||||
export interface QuestCompilationRequest {
|
||||
issuerNpcId: string;
|
||||
issuerNpcName: string;
|
||||
roleText: string;
|
||||
scene: QuestSceneSnapshot | null;
|
||||
worldType: QuestGenerationContext['worldType'];
|
||||
context?: QuestGenerationContext;
|
||||
origin?: QuestNarrativeBinding['origin'];
|
||||
}
|
||||
|
||||
export interface QuestPreviewRequest extends QuestCompilationRequest {
|
||||
currentQuests?: Array<{
|
||||
id: string;
|
||||
issuerNpcId: string;
|
||||
status: QuestStatus;
|
||||
}>;
|
||||
}
|
||||
49
src/services/rpg-creation/index.ts
Normal file
49
src/services/rpg-creation/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export {
|
||||
createRpgCreationSession,
|
||||
executeRpgCreationAction,
|
||||
getRpgCreationCardDetail,
|
||||
getRpgCreationOperation,
|
||||
getRpgCreationSession,
|
||||
rpgCreationAgentClient,
|
||||
sendRpgCreationMessage,
|
||||
streamRpgCreationMessage,
|
||||
} from './rpgCreationAgentClient';
|
||||
export { rpgCreationAssetClient } from './rpgCreationAssetClient';
|
||||
export {
|
||||
generateRpgWorldCoverImage,
|
||||
generateRpgWorldLandmark,
|
||||
generateRpgWorldPlayableNpc,
|
||||
generateRpgWorldSceneImage,
|
||||
generateRpgWorldSceneNpc,
|
||||
generateRpgWorldStoryNpc,
|
||||
uploadRpgWorldCoverImage,
|
||||
} from './rpgCreationAssetClient';
|
||||
export type {
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from './rpgCreationGenerationClient';
|
||||
export {
|
||||
generateCustomWorldProfile as generateLegacyCustomWorldProfile,
|
||||
generateRpgWorldProfile,
|
||||
} from './rpgCreationGenerationClient';
|
||||
export {
|
||||
deleteRpgWorldProfile,
|
||||
getRpgWorldGalleryDetail,
|
||||
listRpgWorldGallery,
|
||||
listRpgWorldLibrary,
|
||||
publishRpgWorldProfile,
|
||||
rpgCreationLibraryClient,
|
||||
unpublishRpgWorldProfile,
|
||||
upsertRpgWorldProfile,
|
||||
} from './rpgCreationLibraryClient';
|
||||
export {
|
||||
buildRpgCreationPreviewFromResultPreview,
|
||||
buildRpgCreationPreviewFromSession,
|
||||
rpgCreationPreviewAdapter,
|
||||
} from './rpgCreationPreviewAdapter';
|
||||
export {
|
||||
deleteRpgCreationAgentSession,
|
||||
listRpgCreationWorks,
|
||||
rpgCreationWorkClient,
|
||||
} from './rpgCreationWorkClient';
|
||||
141
src/services/rpg-creation/rpgCreationAgentClient.ts
Normal file
141
src/services/rpg-creation/rpgCreationAgentClient.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type {
|
||||
CreateRpgAgentSessionRequest,
|
||||
CreateRpgAgentSessionResponse,
|
||||
GetRpgAgentCardDetailResponse,
|
||||
RpgAgentDraftCardDetail,
|
||||
RpgAgentOperationRecord,
|
||||
RpgAgentSessionSnapshot,
|
||||
SendRpgAgentMessageRequest,
|
||||
} from '../../../packages/shared/src';
|
||||
import type { RpgAgentActionRequest } from '../../../packages/shared/src';
|
||||
import type { TextStreamOptions } from '../aiTypes';
|
||||
import { readCreationAgentSessionFromSse } from '../creation-agent';
|
||||
import {
|
||||
openRpgCreationSsePost,
|
||||
requestRpgCreationPostJson,
|
||||
} from './rpgCreationRequestHelpers';
|
||||
import { requestRpgCreationRuntimeJson } from './rpgCreationRuntimeClient';
|
||||
|
||||
const RPG_AGENT_API_BASE = '/custom-world/agent/sessions';
|
||||
const CREATION_SESSION_START_TIMEOUT_MS = 15000;
|
||||
|
||||
export async function createRpgCreationSession(
|
||||
payload: CreateRpgAgentSessionRequest,
|
||||
) {
|
||||
return requestRpgCreationRuntimeJson<CreateRpgAgentSessionResponse>(
|
||||
RPG_AGENT_API_BASE,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'创建世界共创会话失败',
|
||||
{
|
||||
timeoutMs: CREATION_SESSION_START_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getRpgCreationSession(sessionId: string) {
|
||||
return requestRpgCreationRuntimeJson<RpgAgentSessionSnapshot>(
|
||||
`${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取世界共创会话失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendRpgCreationMessage(
|
||||
sessionId: string,
|
||||
payload: SendRpgAgentMessageRequest,
|
||||
) {
|
||||
return requestRpgCreationPostJson<{ operation: RpgAgentOperationRecord }>(
|
||||
`/api/runtime${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages`,
|
||||
payload,
|
||||
'发送共创消息失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function streamRpgCreationMessage(
|
||||
sessionId: string,
|
||||
payload: SendRpgAgentMessageRequest,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const response = await openRpgCreationSsePost(
|
||||
`/api/runtime${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`,
|
||||
payload,
|
||||
'发送共创消息失败',
|
||||
options.signal,
|
||||
);
|
||||
|
||||
return readCreationAgentSessionFromSse<RpgAgentSessionSnapshot>(response, {
|
||||
...options,
|
||||
fallbackMessage: '发送共创消息失败',
|
||||
incompleteMessage: '共创消息流式结果不完整',
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeRpgCreationAction(
|
||||
sessionId: string,
|
||||
payload: RpgAgentActionRequest,
|
||||
) {
|
||||
return requestRpgCreationRuntimeJson<{ operation: RpgAgentOperationRecord }>(
|
||||
`${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/actions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'执行共创操作失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function getRpgCreationOperation(
|
||||
sessionId: string,
|
||||
operationId: string,
|
||||
): Promise<RpgAgentOperationRecord> {
|
||||
const response = await requestRpgCreationRuntimeJson<
|
||||
{
|
||||
operation?: RpgAgentOperationRecord;
|
||||
data?: RpgAgentOperationRecord;
|
||||
} & Partial<RpgAgentOperationRecord>
|
||||
>(
|
||||
`${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/operations/${encodeURIComponent(operationId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取共创操作状态失败',
|
||||
);
|
||||
|
||||
return (response.operation ?? response.data ?? response) as RpgAgentOperationRecord;
|
||||
}
|
||||
|
||||
export async function getRpgCreationCardDetail(
|
||||
sessionId: string,
|
||||
cardId: string,
|
||||
) {
|
||||
const response = await requestRpgCreationRuntimeJson<GetRpgAgentCardDetailResponse>(
|
||||
`${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/cards/${encodeURIComponent(cardId)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取草稿卡详情失败',
|
||||
);
|
||||
|
||||
return response.card as RpgAgentDraftCardDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作包 D 开始让 RPG 创作 Agent client 持有真实请求实现,
|
||||
* 旧 `aiService.ts` 仅保留兼容导出,避免主链请求继续回流到通用服务文件。
|
||||
*/
|
||||
export const rpgCreationAgentClient = {
|
||||
createSession: createRpgCreationSession,
|
||||
getSession: getRpgCreationSession,
|
||||
sendMessage: sendRpgCreationMessage,
|
||||
streamMessage: streamRpgCreationMessage,
|
||||
executeAction: executeRpgCreationAction,
|
||||
getOperation: getRpgCreationOperation,
|
||||
getCardDetail: getRpgCreationCardDetail,
|
||||
};
|
||||
116
src/services/rpg-creation/rpgCreationAssetClient.ts
Normal file
116
src/services/rpg-creation/rpgCreationAssetClient.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { CustomWorldSceneImageRequest, CustomWorldSceneImageResult } from '../aiTypes';
|
||||
import {
|
||||
generateCustomWorldCoverImage,
|
||||
uploadCustomWorldCoverImage,
|
||||
} from '../customWorldCoverAssetService';
|
||||
import { requestJson } from '../apiClient';
|
||||
import type {
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
} from '../../types';
|
||||
import { requestRpgCreationPostJson } from './rpgCreationRequestHelpers';
|
||||
|
||||
const RPG_CREATION_ASSET_API_BASE = '/api/custom-world';
|
||||
|
||||
export async function generateRpgWorldSceneImage(
|
||||
payload: CustomWorldSceneImageRequest,
|
||||
) {
|
||||
return requestJson<CustomWorldSceneImageResult>(
|
||||
`${RPG_CREATION_ASSET_API_BASE}/scene-image`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'生成自定义世界场景图失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateRpgWorldSceneNpc(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
landmarkId: string;
|
||||
}) {
|
||||
const response = await requestRpgCreationPostJson<{ npc: CustomWorldNpc }>(
|
||||
`${RPG_CREATION_ASSET_API_BASE}/scene-npc`,
|
||||
payload,
|
||||
'生成场景 NPC 失败',
|
||||
);
|
||||
|
||||
return response.npc;
|
||||
}
|
||||
|
||||
async function requestRpgWorldEntity<T>(
|
||||
payload: {
|
||||
profile: CustomWorldProfile;
|
||||
kind: 'playable' | 'story' | 'landmark';
|
||||
},
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return requestRpgCreationPostJson<{
|
||||
kind: 'playable' | 'story' | 'landmark';
|
||||
entity: T;
|
||||
}>(`${RPG_CREATION_ASSET_API_BASE}/entity`, payload, fallbackMessage);
|
||||
}
|
||||
|
||||
export async function generateRpgWorldPlayableNpc(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const response = await requestRpgWorldEntity<CustomWorldPlayableNpc>(
|
||||
{
|
||||
...payload,
|
||||
kind: 'playable',
|
||||
},
|
||||
'生成可扮演角色失败',
|
||||
);
|
||||
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
export async function generateRpgWorldStoryNpc(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const response = await requestRpgWorldEntity<CustomWorldNpc>(
|
||||
{
|
||||
...payload,
|
||||
kind: 'story',
|
||||
},
|
||||
'生成场景角色失败',
|
||||
);
|
||||
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
export async function generateRpgWorldLandmark(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const response = await requestRpgWorldEntity<CustomWorldLandmark>(
|
||||
{
|
||||
...payload,
|
||||
kind: 'landmark',
|
||||
},
|
||||
'生成场景失败',
|
||||
);
|
||||
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作包 D 把结果页与编辑器依赖的资产请求迁入 RPG 创作域 client,
|
||||
* 保留封面资产服务的既有边界,不把逻辑重新塞回 `aiService.ts`。
|
||||
*/
|
||||
export const rpgCreationAssetClient = {
|
||||
generateSceneImage: generateRpgWorldSceneImage,
|
||||
generateSceneNpc: generateRpgWorldSceneNpc,
|
||||
generatePlayableNpc: generateRpgWorldPlayableNpc,
|
||||
generateStoryNpc: generateRpgWorldStoryNpc,
|
||||
generateLandmark: generateRpgWorldLandmark,
|
||||
generateCoverImage: generateCustomWorldCoverImage,
|
||||
uploadCoverImage: uploadCustomWorldCoverImage,
|
||||
};
|
||||
|
||||
export {
|
||||
generateCustomWorldCoverImage as generateRpgWorldCoverImage,
|
||||
uploadCustomWorldCoverImage as uploadRpgWorldCoverImage,
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import { generateRpgWorldProfile } from './rpgCreationGenerationClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
describe('rpgCreationGenerationClient', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({
|
||||
id: 'custom-world-1',
|
||||
name: '测试世界',
|
||||
subtitle: '副标题',
|
||||
summary: '概述',
|
||||
tone: '基调',
|
||||
playerGoal: '目标',
|
||||
settingText: '设定',
|
||||
});
|
||||
});
|
||||
|
||||
it('posts world generation to the runtime custom world profile route', async () => {
|
||||
await generateRpgWorldProfile('一个被灵潮反复改写地形的边境世界');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world/profile',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'生成自定义世界失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects immediately when the caller aborts before sending', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('手动中断生成'));
|
||||
|
||||
await expect(
|
||||
generateRpgWorldProfile('一个会被中断的世界', {
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).rejects.toThrow('手动中断生成');
|
||||
|
||||
expect(requestJsonMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
68
src/services/rpg-creation/rpgCreationGenerationClient.ts
Normal file
68
src/services/rpg-creation/rpgCreationGenerationClient.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { requestJson } from '../apiClient';
|
||||
|
||||
type LegacyAiModule = typeof import('../ai');
|
||||
|
||||
let legacyAiModulePromise: Promise<LegacyAiModule> | null = null;
|
||||
|
||||
async function loadLegacyAiModule() {
|
||||
if (!legacyAiModulePromise) {
|
||||
legacyAiModulePromise = import('../ai');
|
||||
}
|
||||
|
||||
return legacyAiModulePromise;
|
||||
}
|
||||
|
||||
export async function generateRpgWorldProfile(
|
||||
input: GenerateCustomWorldProfileInput | string,
|
||||
options: GenerateCustomWorldProfileOptions = {},
|
||||
): Promise<CustomWorldProfile> {
|
||||
const normalizedInput =
|
||||
typeof input === 'string'
|
||||
? {
|
||||
settingText: input,
|
||||
}
|
||||
: input;
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateCustomWorldProfile(normalizedInput, options);
|
||||
}
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
throw options.signal.reason instanceof Error
|
||||
? options.signal.reason
|
||||
: new Error('世界生成已中断。');
|
||||
}
|
||||
|
||||
const profile = await requestJson<CustomWorldProfile>(
|
||||
'/api/runtime/custom-world/profile',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(normalizedInput),
|
||||
},
|
||||
'生成自定义世界失败',
|
||||
);
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
throw options.signal.reason instanceof Error
|
||||
? options.signal.reason
|
||||
: new Error('世界生成已中断。');
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
export type {
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
};
|
||||
|
||||
export { generateRpgWorldProfile as generateCustomWorldProfile };
|
||||
154
src/services/rpg-creation/rpgCreationLibraryClient.ts
Normal file
154
src/services/rpg-creation/rpgCreationLibraryClient.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type {
|
||||
CustomWorldGalleryDetailResponse,
|
||||
CustomWorldGalleryResponse,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
requestPublicRpgCreationRuntimeJson,
|
||||
requestRpgCreationRuntimeJson,
|
||||
type RpgCreationRuntimeRequestOptions,
|
||||
} from './rpgCreationRuntimeClient';
|
||||
|
||||
export async function listRpgWorldLibrary(
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgCreationRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
'/custom-world-library',
|
||||
{ method: 'GET' },
|
||||
'读取自定义世界库失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function upsertRpgWorldProfile(
|
||||
profile: CustomWorldProfile,
|
||||
request: {
|
||||
sourceAgentSessionId?: string | null;
|
||||
} = {},
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgCreationRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profile.id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profile,
|
||||
sourceAgentSessionId: request.sourceAgentSessionId ?? null,
|
||||
}),
|
||||
},
|
||||
'保存自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteRpgWorldProfile(
|
||||
profileId: string,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgCreationRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function publishRpgWorldProfile(
|
||||
profileId: string,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgCreationRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/publish`,
|
||||
{ method: 'POST' },
|
||||
'发布自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function unpublishRpgWorldProfile(
|
||||
profileId: string,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgCreationRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/unpublish`,
|
||||
{ method: 'POST' },
|
||||
'下架自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function listRpgWorldGallery(
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestPublicRpgCreationRuntimeJson<CustomWorldGalleryResponse>(
|
||||
'/custom-world-gallery',
|
||||
{ method: 'GET' },
|
||||
'读取作品广场失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function getRpgWorldGalleryDetail(
|
||||
ownerUserId: string,
|
||||
profileId: string,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestPublicRpgCreationRuntimeJson<
|
||||
CustomWorldGalleryDetailResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取作品详情失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作包 D 把作品库与作品广场请求迁入 RPG 创作域 client,
|
||||
* 后续前端调用优先从这里进入,不再反向依赖通用存储聚合层。
|
||||
*/
|
||||
export const rpgCreationLibraryClient = {
|
||||
listLibrary: listRpgWorldLibrary,
|
||||
upsertProfile: upsertRpgWorldProfile,
|
||||
deleteProfile: deleteRpgWorldProfile,
|
||||
publishProfile: publishRpgWorldProfile,
|
||||
unpublishProfile: unpublishRpgWorldProfile,
|
||||
listGallery: listRpgWorldGallery,
|
||||
getGalleryDetail: getRpgWorldGalleryDetail,
|
||||
};
|
||||
231
src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts
Normal file
231
src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import {
|
||||
buildRpgCreationPreviewFromResultPreview,
|
||||
buildRpgCreationPreviewFromSession,
|
||||
} from './rpgCreationPreviewAdapter';
|
||||
|
||||
const sessionWithPreview: CustomWorldAgentSessionSnapshot = {
|
||||
sessionId: 'session-preview-1',
|
||||
currentTurn: 3,
|
||||
anchorContent: {
|
||||
worldPromise: null,
|
||||
playerFantasy: null,
|
||||
themeBoundary: null,
|
||||
playerEntryPoint: null,
|
||||
coreConflict: null,
|
||||
keyRelationships: null,
|
||||
hiddenLines: null,
|
||||
iconicElements: null,
|
||||
},
|
||||
progressPercent: 100,
|
||||
lastAssistantReply: '第一版世界底稿已经准备好了。',
|
||||
stage: 'object_refining',
|
||||
focusCardId: null,
|
||||
creatorIntent: null,
|
||||
creatorIntentReadiness: {
|
||||
isReady: true,
|
||||
completedKeys: [],
|
||||
missingKeys: [],
|
||||
},
|
||||
anchorPack: null,
|
||||
lockState: null,
|
||||
draftProfile: {
|
||||
id: 'draft-profile-1',
|
||||
settingText: '草稿 profile 直接进入游戏。',
|
||||
name: '只作为 fallback 的本地草稿名',
|
||||
subtitle: 'fallback',
|
||||
summary: 'fallback',
|
||||
tone: 'fallback',
|
||||
playerGoal: 'fallback',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
id: 'schema:draft:test',
|
||||
worldId: 'custom:草稿',
|
||||
schemaVersion: 1,
|
||||
schemaName: '草稿六维',
|
||||
generatedFrom: {
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '只作为 fallback 的本地草稿名',
|
||||
settingSummary: '草稿 profile 直接进入游戏。',
|
||||
tone: 'fallback',
|
||||
conflictCore: '验证草稿直读链路',
|
||||
},
|
||||
slots: [
|
||||
{
|
||||
slotId: 'axis_a',
|
||||
name: '稿骨',
|
||||
definition: '草稿承压维度。',
|
||||
positiveSignals: ['承压'],
|
||||
negativeSignals: ['虚浮'],
|
||||
combatUseText: '顶住正面压力。',
|
||||
socialUseText: '稳住对话姿态。',
|
||||
explorationUseText: '维持探索状态。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_b',
|
||||
name: '稿步',
|
||||
definition: '草稿换位维度。',
|
||||
positiveSignals: ['灵动'],
|
||||
negativeSignals: ['迟滞'],
|
||||
combatUseText: '快速换位。',
|
||||
socialUseText: '顺势接话。',
|
||||
explorationUseText: '穿越复杂路径。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_c',
|
||||
name: '稿识',
|
||||
definition: '草稿洞察维度。',
|
||||
positiveSignals: ['洞察'],
|
||||
negativeSignals: ['误判'],
|
||||
combatUseText: '看破破绽。',
|
||||
socialUseText: '识别隐藏动机。',
|
||||
explorationUseText: '整理线索。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_d',
|
||||
name: '稿魄',
|
||||
definition: '草稿推进维度。',
|
||||
positiveSignals: ['果断'],
|
||||
negativeSignals: ['犹疑'],
|
||||
combatUseText: '推进突破口。',
|
||||
socialUseText: '关键时刻定调。',
|
||||
explorationUseText: '面对未知继续前探。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_e',
|
||||
name: '稿契',
|
||||
definition: '草稿关系维度。',
|
||||
positiveSignals: ['协同'],
|
||||
negativeSignals: ['疏离'],
|
||||
combatUseText: '形成协同收益。',
|
||||
socialUseText: '建立信任交换。',
|
||||
explorationUseText: '从关系打开线索。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_f',
|
||||
name: '稿澜',
|
||||
definition: '草稿续航维度。',
|
||||
positiveSignals: ['回稳'],
|
||||
negativeSignals: ['紊乱'],
|
||||
combatUseText: '久战不乱。',
|
||||
socialUseText: '情绪稳定。',
|
||||
explorationUseText: '长线保持行动力。',
|
||||
},
|
||||
],
|
||||
},
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'draft-playable-1',
|
||||
name: '草稿角色',
|
||||
title: '直读测试',
|
||||
role: '可扮演角色',
|
||||
description: '从 draftProfile 直接进入角色选择页。',
|
||||
backstory: '草稿角色的背景不经过 resultPreview 转换。',
|
||||
personality: '直接、清醒',
|
||||
motivation: '验证草稿直读链路',
|
||||
combatStyle: '以直读链路破局',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['来自草稿'],
|
||||
tags: ['draft-profile'],
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
imageSrc: '/generated-characters/draft-playable-1/portrait.png',
|
||||
},
|
||||
],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
},
|
||||
messages: [],
|
||||
draftCards: [
|
||||
{
|
||||
id: 'world-foundation',
|
||||
kind: 'world',
|
||||
title: '世界底稿',
|
||||
subtitle: '阶段三预览',
|
||||
summary: '测试服务端 result preview 优先级。',
|
||||
status: 'warning',
|
||||
linkedIds: [],
|
||||
warningCount: 0,
|
||||
},
|
||||
],
|
||||
pendingClarifications: [],
|
||||
suggestedActions: [],
|
||||
recommendedReplies: [],
|
||||
qualityFindings: [],
|
||||
assetCoverage: {
|
||||
roleAssets: [],
|
||||
sceneAssets: [],
|
||||
allRoleAssetsReady: false,
|
||||
allSceneAssetsReady: false,
|
||||
},
|
||||
resultPreview: {
|
||||
source: 'session_preview',
|
||||
preview: {
|
||||
id: 'preview-profile-1',
|
||||
settingText: '被海雾吞没的旧航路群岛',
|
||||
name: '服务端结果预览',
|
||||
subtitle: '优先于前端 fallback',
|
||||
summary: '结果页应该优先消费 session.resultPreview。',
|
||||
tone: '压抑、潮湿、悬疑',
|
||||
playerGoal: '查清沉船与禁航区异动的真相。',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['守灯会', '航运公会'],
|
||||
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
sessionId: 'session-preview-1',
|
||||
},
|
||||
generatedAt: '2026-04-21T10:00:00.000Z',
|
||||
qualityFindings: [],
|
||||
blockers: [],
|
||||
},
|
||||
updatedAt: '2026-04-21T10:00:00.000Z',
|
||||
};
|
||||
|
||||
test('buildRpgCreationPreviewFromResultPreview normalizes server preview envelope', () => {
|
||||
const profile = buildRpgCreationPreviewFromResultPreview(
|
||||
sessionWithPreview.resultPreview,
|
||||
);
|
||||
|
||||
expect(profile?.name).toBe('服务端结果预览');
|
||||
expect(profile?.subtitle).toBe('优先于前端 fallback');
|
||||
expect(profile?.id).toBe('preview-profile-1');
|
||||
expect(profile?.settingText).toBe('被海雾吞没的旧航路群岛');
|
||||
});
|
||||
|
||||
test('buildRpgCreationPreviewFromSession prefers server result preview', () => {
|
||||
const profile = buildRpgCreationPreviewFromSession(sessionWithPreview);
|
||||
|
||||
expect(profile?.name).toBe('服务端结果预览');
|
||||
expect(profile?.summary).toBe('结果页应该优先消费 session.resultPreview。');
|
||||
expect(profile?.id).toBe('preview-profile-1');
|
||||
});
|
||||
|
||||
test('buildRpgCreationPreviewFromSession does not require resultPreview', () => {
|
||||
const profile = buildRpgCreationPreviewFromSession({
|
||||
...sessionWithPreview,
|
||||
resultPreview: null,
|
||||
});
|
||||
|
||||
expect(profile?.name).toBe('只作为 fallback 的本地草稿名');
|
||||
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
|
||||
'/generated-characters/draft-playable-1/portrait.png',
|
||||
);
|
||||
expect(profile?.attributeSchema.slots.map((slot) => slot.name)).toEqual([
|
||||
'稿骨',
|
||||
'稿步',
|
||||
'稿识',
|
||||
'稿魄',
|
||||
'稿契',
|
||||
'稿澜',
|
||||
]);
|
||||
});
|
||||
35
src/services/rpg-creation/rpgCreationPreviewAdapter.ts
Normal file
35
src/services/rpg-creation/rpgCreationPreviewAdapter.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export function buildCustomWorldProfileFromResultPreview(
|
||||
resultPreview:
|
||||
| CustomWorldAgentSessionSnapshot['resultPreview']
|
||||
| null
|
||||
| undefined,
|
||||
): CustomWorldProfile | null {
|
||||
return normalizeCustomWorldProfileRecord(resultPreview?.preview ?? null);
|
||||
}
|
||||
|
||||
export function buildCustomWorldProfileFromAgentSession(
|
||||
session: CustomWorldAgentSessionSnapshot | null | undefined,
|
||||
): CustomWorldProfile | null {
|
||||
return (
|
||||
buildCustomWorldProfileFromResultPreview(session?.resultPreview) ??
|
||||
normalizeCustomWorldProfileRecord(session?.draftProfile ?? null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 这是工作包 A 提供的新命名兼容层。
|
||||
* 主入口保持命名稳定,优先消费服务端 resultPreview,缺失时回退到 draftProfile。
|
||||
*/
|
||||
export const rpgCreationPreviewAdapter = {
|
||||
buildPreviewFromSession: buildCustomWorldProfileFromAgentSession,
|
||||
buildPreviewFromResultPreview: buildCustomWorldProfileFromResultPreview,
|
||||
};
|
||||
|
||||
export {
|
||||
buildCustomWorldProfileFromAgentSession as buildRpgCreationPreviewFromSession,
|
||||
buildCustomWorldProfileFromResultPreview as buildRpgCreationPreviewFromResultPreview,
|
||||
};
|
||||
43
src/services/rpg-creation/rpgCreationRequestHelpers.ts
Normal file
43
src/services/rpg-creation/rpgCreationRequestHelpers.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { parseApiErrorMessage } from '../../../packages/shared/src/http';
|
||||
import { fetchWithApiAuth, requestJson } from '../apiClient';
|
||||
|
||||
export async function requestRpgCreationPostJson<T>(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
fallbackMessage: string,
|
||||
) {
|
||||
return requestJson<T>(
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
fallbackMessage,
|
||||
);
|
||||
}
|
||||
|
||||
export async function openRpgCreationSsePost(
|
||||
url: string,
|
||||
payload: unknown,
|
||||
fallbackMessage: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const response = await fetchWithApiAuth(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const responseText = await response.text();
|
||||
throw new Error(parseApiErrorMessage(responseText, fallbackMessage));
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('streaming response body is unavailable');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
62
src/services/rpg-creation/rpgCreationRuntimeClient.ts
Normal file
62
src/services/rpg-creation/rpgCreationRuntimeClient.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
const RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 180,
|
||||
maxDelayMs: 480,
|
||||
};
|
||||
const RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 240,
|
||||
maxDelayMs: 640,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
export type RpgCreationRuntimeRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
skipAuth?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export function requestRpgCreationRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry =
|
||||
options.retry ??
|
||||
(method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY);
|
||||
|
||||
return requestJson<T>(
|
||||
`${RUNTIME_API_BASE}${path}`,
|
||||
{
|
||||
...init,
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{
|
||||
retry,
|
||||
skipAuth: options.skipAuth,
|
||||
skipRefresh: options.skipRefresh,
|
||||
timeoutMs: options.timeoutMs,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function requestPublicRpgCreationRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RpgCreationRuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgCreationRuntimeJson<T>(path, init, fallbackMessage, {
|
||||
...options,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
});
|
||||
}
|
||||
30
src/services/rpg-creation/rpgCreationWorkClient.ts
Normal file
30
src/services/rpg-creation/rpgCreationWorkClient.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { ListRpgCreationWorksResponse } from '../../../packages/shared/src';
|
||||
import { requestRpgCreationRuntimeJson } from './rpgCreationRuntimeClient';
|
||||
|
||||
export async function listRpgCreationWorks() {
|
||||
const response = await requestRpgCreationRuntimeJson<ListRpgCreationWorksResponse>(
|
||||
'/custom-world/works',
|
||||
{ method: 'GET' },
|
||||
'读取创作作品列表失败',
|
||||
);
|
||||
|
||||
return Array.isArray(response?.items) ? response.items : [];
|
||||
}
|
||||
|
||||
export async function deleteRpgCreationAgentSession(sessionId: string) {
|
||||
const response = await requestRpgCreationRuntimeJson<ListRpgCreationWorksResponse>(
|
||||
`/custom-world/agent/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除 RPG 草稿失败',
|
||||
);
|
||||
|
||||
return Array.isArray(response?.items) ? response.items : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作包 D 把作品列表请求正式迁入 RPG 创作域 client。
|
||||
*/
|
||||
export const rpgCreationWorkClient = {
|
||||
deleteAgentSession: deleteRpgCreationAgentSession,
|
||||
listWorks: listRpgCreationWorks,
|
||||
};
|
||||
27
src/services/rpg-entry/index.ts
Normal file
27
src/services/rpg-entry/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export {
|
||||
deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldGalleryDetail,
|
||||
listRpgEntryWorldGallery,
|
||||
listRpgEntryWorldLibrary,
|
||||
publishRpgEntryWorldProfile,
|
||||
rpgEntryLibraryClient,
|
||||
type RuntimeRequestOptions,
|
||||
unpublishRpgEntryWorldProfile,
|
||||
upsertRpgEntryWorldProfile,
|
||||
} from './rpgEntryLibraryClient';
|
||||
export {
|
||||
clearRpgProfileBrowseHistory,
|
||||
createRpgProfileRechargeOrder,
|
||||
getRpgProfileDashboard,
|
||||
getRpgProfilePlayStats,
|
||||
getRpgProfileRechargeCenter,
|
||||
getRpgProfileSettings,
|
||||
getRpgProfileWalletLedger,
|
||||
listRpgProfileBrowseHistory,
|
||||
listRpgProfileSaveArchives,
|
||||
putRpgProfileSettings,
|
||||
resumeRpgProfileSaveArchive,
|
||||
rpgProfileClient,
|
||||
syncRpgProfileBrowseHistory,
|
||||
upsertRpgProfileBrowseHistory,
|
||||
} from './rpgProfileClient';
|
||||
206
src/services/rpg-entry/rpgEntryClients.routing.test.ts
Normal file
206
src/services/rpg-entry/rpgEntryClients.routing.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
clearRpgProfileBrowseHistory,
|
||||
listRpgProfileBrowseHistory,
|
||||
listRpgProfileSaveArchives,
|
||||
resumeRpgProfileSaveArchive,
|
||||
syncRpgProfileBrowseHistory,
|
||||
upsertRpgProfileBrowseHistory,
|
||||
} from './rpgProfileClient';
|
||||
import {
|
||||
getRpgEntryWorldGalleryDetail,
|
||||
listRpgEntryWorldGallery,
|
||||
} from './rpgEntryLibraryClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
describe('rpgEntry profile browse history routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads browse history from the runtime profile route', async () => {
|
||||
await listRpgProfileBrowseHistory();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取浏览历史失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes browse history through the runtime profile route', async () => {
|
||||
await upsertRpgProfileBrowseHistory({
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
worldName: '测试世界',
|
||||
subtitle: '测试副标题',
|
||||
summaryText: '测试摘要',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'mythic',
|
||||
authorDisplayName: '测试作者',
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'写入浏览历史失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('syncs browse history through the runtime profile route', async () => {
|
||||
await syncRpgProfileBrowseHistory([
|
||||
{
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
worldName: '测试世界',
|
||||
subtitle: '测试副标题',
|
||||
summaryText: '测试摘要',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'mythic',
|
||||
authorDisplayName: '测试作者',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'同步浏览历史失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears browse history through the runtime profile route', async () => {
|
||||
await clearRpgProfileBrowseHistory();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
'清空浏览历史失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rpgEntry public custom world gallery routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads the public gallery without attaching auth or refresh coupling', async () => {
|
||||
await listRpgEntryWorldGallery();
|
||||
|
||||
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 getRpgEntryWorldGalleryDetail('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('rpgEntry save archive routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads save archives from the runtime profile route', async () => {
|
||||
await listRpgProfileSaveArchives();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/save-archives',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取存档列表失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes a save archive through the runtime profile route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
worldKey: 'custom:world-1',
|
||||
},
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-19T10:15:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await resumeRpgProfileSaveArchive('custom:world-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/save-archives/custom%3Aworld-1',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'恢复存档失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
165
src/services/rpg-entry/rpgEntryLibraryClient.test.ts
Normal file
165
src/services/rpg-entry/rpgEntryLibraryClient.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldGalleryDetail,
|
||||
listRpgEntryWorldGallery,
|
||||
listRpgEntryWorldLibrary,
|
||||
publishRpgEntryWorldProfile,
|
||||
unpublishRpgEntryWorldProfile,
|
||||
upsertRpgEntryWorldProfile,
|
||||
} from './rpgEntryLibraryClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
describe('rpgEntryLibraryClient world library routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads world library from the runtime entry route', async () => {
|
||||
await listRpgEntryWorldLibrary();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-library',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取自定义世界库失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads world gallery from the public runtime entry route', async () => {
|
||||
await listRpgEntryWorldGallery();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-gallery',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取作品广场失败',
|
||||
expect.objectContaining({
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads gallery detail from the public runtime entry route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
ownerUserId: 'owner-1',
|
||||
profileId: 'profile-1',
|
||||
},
|
||||
});
|
||||
|
||||
await getRpgEntryWorldGalleryDetail('owner-1', 'profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-gallery/owner-1/profile-1',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取作品详情失败',
|
||||
expect.objectContaining({
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes world profile through the runtime entry route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
profileId: 'profile-1',
|
||||
},
|
||||
entries: [],
|
||||
});
|
||||
|
||||
await upsertRpgEntryWorldProfile({
|
||||
id: 'profile-1',
|
||||
name: '测试世界',
|
||||
} as never);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-library/profile-1',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'保存自定义世界失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes world profile through the runtime entry route', async () => {
|
||||
await deleteRpgEntryWorldProfile('profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-library/profile-1',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
'删除自定义世界失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('publishes world profile through the runtime entry route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
profileId: 'profile-1',
|
||||
},
|
||||
entries: [],
|
||||
});
|
||||
|
||||
await publishRpgEntryWorldProfile('profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-library/profile-1/publish',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'发布自定义世界失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('unpublishes world profile through the runtime entry route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
profileId: 'profile-1',
|
||||
},
|
||||
entries: [],
|
||||
});
|
||||
|
||||
await unpublishRpgEntryWorldProfile('profile-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world-library/profile-1/unpublish',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'下架自定义世界失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
169
src/services/rpg-entry/rpgEntryLibraryClient.ts
Normal file
169
src/services/rpg-entry/rpgEntryLibraryClient.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
type RuntimeRequestOptions,
|
||||
requestPublicRpgRuntimeJson,
|
||||
requestRpgRuntimeJson,
|
||||
} from '../rpg-runtime/rpgRuntimeRequest';
|
||||
import type {
|
||||
CustomWorldGalleryDetailResponse,
|
||||
CustomWorldGalleryResponse,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export type { RuntimeRequestOptions };
|
||||
|
||||
/**
|
||||
* RPG 入口世界库 client 的真实实现。
|
||||
* 第三批收口后,平台首页/详情页开始游戏链直接走 rpg-entry 域请求,不再反向穿旧 storageService 兼容层。
|
||||
*/
|
||||
export async function listRpgEntryWorldLibrary(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
'/custom-world-library',
|
||||
{ method: 'GET' },
|
||||
'读取自定义世界库失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function listRpgEntryWorldGallery(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestPublicRpgRuntimeJson<CustomWorldGalleryResponse>(
|
||||
'/custom-world-gallery',
|
||||
{ method: 'GET' },
|
||||
'读取作品广场失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function getRpgEntryWorldGalleryDetail(
|
||||
ownerUserId: string,
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestPublicRpgRuntimeJson<
|
||||
CustomWorldGalleryDetailResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取作品详情失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
}
|
||||
|
||||
export async function getRpgEntryWorldGalleryDetailByCode(
|
||||
code: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestPublicRpgRuntimeJson<
|
||||
CustomWorldGalleryDetailResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-gallery/by-code/${encodeURIComponent(code)}`,
|
||||
{ method: 'GET' },
|
||||
'读取作品详情失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
}
|
||||
|
||||
export async function upsertRpgEntryWorldProfile(
|
||||
profile: CustomWorldProfile,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profile.id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profile,
|
||||
}),
|
||||
},
|
||||
'保存自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteRpgEntryWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function publishRpgEntryWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/publish`,
|
||||
{ method: 'POST' },
|
||||
'发布自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function unpublishRpgEntryWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/unpublish`,
|
||||
{ method: 'POST' },
|
||||
'下架自定义世界失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
}
|
||||
|
||||
export const rpgEntryLibraryClient = {
|
||||
listWorldLibrary: listRpgEntryWorldLibrary,
|
||||
listWorldGallery: listRpgEntryWorldGallery,
|
||||
getWorldGalleryDetail: getRpgEntryWorldGalleryDetail,
|
||||
getWorldGalleryDetailByCode: getRpgEntryWorldGalleryDetailByCode,
|
||||
upsertWorldProfile: upsertRpgEntryWorldProfile,
|
||||
deleteWorldProfile: deleteRpgEntryWorldProfile,
|
||||
publishWorldProfile: publishRpgEntryWorldProfile,
|
||||
unpublishWorldProfile: unpublishRpgEntryWorldProfile,
|
||||
};
|
||||
158
src/services/rpg-entry/rpgProfileClient.test.ts
Normal file
158
src/services/rpg-entry/rpgProfileClient.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
clearRpgProfileBrowseHistory,
|
||||
listRpgProfileBrowseHistory,
|
||||
listRpgProfileSaveArchives,
|
||||
resumeRpgProfileSaveArchive,
|
||||
syncRpgProfileBrowseHistory,
|
||||
upsertRpgProfileBrowseHistory,
|
||||
} from './rpgProfileClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
describe('rpgProfileClient browse history routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads browse history from the runtime profile route', async () => {
|
||||
await listRpgProfileBrowseHistory();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取浏览历史失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes browse history through the runtime profile route', async () => {
|
||||
await upsertRpgProfileBrowseHistory({
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
worldName: '测试世界',
|
||||
subtitle: '测试副标题',
|
||||
summaryText: '测试摘要',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'mythic',
|
||||
authorDisplayName: '测试作者',
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'写入浏览历史失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('syncs browse history through the runtime profile route', async () => {
|
||||
await syncRpgProfileBrowseHistory([
|
||||
{
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
worldName: '测试世界',
|
||||
subtitle: '测试副标题',
|
||||
summaryText: '测试摘要',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'mythic',
|
||||
authorDisplayName: '测试作者',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'同步浏览历史失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears browse history through the runtime profile route', async () => {
|
||||
await clearRpgProfileBrowseHistory();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
'清空浏览历史失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rpgProfileClient save archive routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
it('reads save archives from the runtime profile route', async () => {
|
||||
await listRpgProfileSaveArchives();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/save-archives',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取存档列表失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes a save archive through the runtime profile route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
entry: {
|
||||
worldKey: 'custom:world-1',
|
||||
},
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-19T10:15:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await resumeRpgProfileSaveArchive('custom:world-1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/save-archives/custom%3Aworld-1',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
'恢复存档失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
249
src/services/rpg-entry/rpgProfileClient.ts
Normal file
249
src/services/rpg-entry/rpgProfileClient.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import type {
|
||||
CreateProfileRechargeOrderResponse,
|
||||
PlatformBrowseHistoryBatchSyncRequest,
|
||||
PlatformBrowseHistoryResponse,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileReferralInviteCenterResponse,
|
||||
ProfileRechargeCenterResponse,
|
||||
ProfileSaveArchiveListResponse,
|
||||
ProfileSaveArchiveResumeResponse,
|
||||
ProfileWalletLedgerResponse,
|
||||
RedeemProfileReferralInviteCodeResponse,
|
||||
RuntimeSettings,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
requestRpgRuntimeJson,
|
||||
type RuntimeRequestOptions,
|
||||
} from '../rpg-runtime/rpgRuntimeRequest';
|
||||
|
||||
export type { RuntimeRequestOptions };
|
||||
|
||||
/**
|
||||
* RPG profile 域 client。
|
||||
* 工作包 C 需要把继续游戏归档与资料读取收进新域目录,避免继续堆在 `storageService`。
|
||||
*/
|
||||
export function getRpgProfileSettings(options: RuntimeRequestOptions = {}) {
|
||||
return requestRpgRuntimeJson<RuntimeSettings>(
|
||||
'/settings',
|
||||
{ method: 'GET' },
|
||||
'读取设置失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function putRpgProfileSettings(
|
||||
settings: RuntimeSettings,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<RuntimeSettings>(
|
||||
'/settings',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
},
|
||||
'保存设置失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRpgProfileDashboard(options: RuntimeRequestOptions = {}) {
|
||||
return requestRpgRuntimeJson<ProfileDashboardSummary>(
|
||||
'/profile/dashboard',
|
||||
{ method: 'GET' },
|
||||
'读取个人看板失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRpgProfileWalletLedger(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<ProfileWalletLedgerResponse>(
|
||||
'/profile/wallet-ledger',
|
||||
{ method: 'GET' },
|
||||
'读取资产流水失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRpgProfileRechargeCenter(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<ProfileRechargeCenterResponse>(
|
||||
'/profile/recharge-center',
|
||||
{ method: 'GET' },
|
||||
'读取账户充值失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function createRpgProfileRechargeOrder(
|
||||
productId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<CreateProfileRechargeOrderResponse>(
|
||||
'/profile/recharge/orders',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ productId, paymentChannel: 'mock' }),
|
||||
},
|
||||
'充值失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRpgProfileReferralInviteCenter(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<ProfileReferralInviteCenterResponse>(
|
||||
'/profile/referrals/invite-center',
|
||||
{ method: 'GET' },
|
||||
'读取邀请码失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function redeemRpgProfileReferralInviteCode(
|
||||
inviteCode: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<RedeemProfileReferralInviteCodeResponse>(
|
||||
'/profile/referrals/redeem-code',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inviteCode }),
|
||||
},
|
||||
'填写邀请码失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) {
|
||||
return requestRpgRuntimeJson<ProfilePlayStatsResponse>(
|
||||
'/profile/play-stats',
|
||||
{ method: 'GET' },
|
||||
'读取游玩统计失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function listRpgProfileSaveArchives(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<ProfileSaveArchiveListResponse>(
|
||||
'/profile/save-archives',
|
||||
{ method: 'GET' },
|
||||
'读取存档列表失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function resumeRpgProfileSaveArchive(
|
||||
worldKey: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<ProfileSaveArchiveResumeResponse>(
|
||||
`/profile/save-archives/${encodeURIComponent(worldKey)}`,
|
||||
{ method: 'POST' },
|
||||
'恢复存档失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
snapshot: rehydrateSavedSnapshot(
|
||||
response.snapshot as HydratedSavedGameSnapshot,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listRpgProfileBrowseHistory(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{ method: 'GET' },
|
||||
'读取浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function upsertRpgProfileBrowseHistory(
|
||||
entry: PlatformBrowseHistoryWriteEntry,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(entry),
|
||||
},
|
||||
'写入浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function syncRpgProfileBrowseHistory(
|
||||
entries: PlatformBrowseHistoryWriteEntry[],
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
entries,
|
||||
} satisfies PlatformBrowseHistoryBatchSyncRequest),
|
||||
},
|
||||
'同步浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function clearRpgProfileBrowseHistory(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRpgRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{ method: 'DELETE' },
|
||||
'清空浏览历史失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export const rpgProfileClient = {
|
||||
getDashboard: getRpgProfileDashboard,
|
||||
getPlayStats: getRpgProfilePlayStats,
|
||||
getWalletLedger: getRpgProfileWalletLedger,
|
||||
getRechargeCenter: getRpgProfileRechargeCenter,
|
||||
createRechargeOrder: createRpgProfileRechargeOrder,
|
||||
getReferralInviteCenter: getRpgProfileReferralInviteCenter,
|
||||
redeemReferralInviteCode: redeemRpgProfileReferralInviteCode,
|
||||
getSettings: getRpgProfileSettings,
|
||||
putSettings: putRpgProfileSettings,
|
||||
listSaveArchives: listRpgProfileSaveArchives,
|
||||
resumeSaveArchive: resumeRpgProfileSaveArchive,
|
||||
listBrowseHistory: listRpgProfileBrowseHistory,
|
||||
upsertBrowseHistory: upsertRpgProfileBrowseHistory,
|
||||
syncBrowseHistory: syncRpgProfileBrowseHistory,
|
||||
clearBrowseHistory: clearRpgProfileBrowseHistory,
|
||||
};
|
||||
32
src/services/rpg-runtime/index.ts
Normal file
32
src/services/rpg-runtime/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export {
|
||||
deleteRpgSaveSnapshot,
|
||||
getRpgSaveSnapshot,
|
||||
putRpgSaveSnapshot,
|
||||
rpgSnapshotClient,
|
||||
type RuntimeRequestOptions,
|
||||
} from './rpgSnapshotClient';
|
||||
export {
|
||||
getRpgCharacterChatSuggestions,
|
||||
getRpgCharacterChatSummary,
|
||||
rpgRuntimeChatClient,
|
||||
streamRpgCharacterChatReply,
|
||||
streamRpgNpcChatDialogue,
|
||||
streamRpgNpcChatTurn,
|
||||
streamRpgNpcRecruitDialogue,
|
||||
} from './rpgRuntimeChatClient';
|
||||
export {
|
||||
getRpgRuntimeActionSnapshot,
|
||||
getRpgRuntimeClientVersion,
|
||||
getRpgRuntimeSessionId,
|
||||
getRpgRuntimeStoryState,
|
||||
isRpgRuntimeServerFunctionId,
|
||||
isRpgRuntimeTaskFunctionId,
|
||||
resolveRpgRuntimeStoryAction,
|
||||
resolveRpgRuntimeStoryMoment,
|
||||
rpgRuntimeStoryClient,
|
||||
shouldUseRpgRuntimeServerOptions,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryResponse,
|
||||
type RpgRuntimeStoryClientOptions,
|
||||
type RuntimeStorySnapshotRequest,
|
||||
} from './rpgRuntimeStoryClient';
|
||||
29
src/services/rpg-runtime/rpgRuntimeChatClient.ts
Normal file
29
src/services/rpg-runtime/rpgRuntimeChatClient.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
generateCharacterPanelChatSuggestions,
|
||||
generateCharacterPanelChatSummary,
|
||||
streamCharacterPanelChatReply,
|
||||
streamNpcChatDialogue,
|
||||
streamNpcChatTurn,
|
||||
streamNpcRecruitDialogue,
|
||||
} from '../aiService';
|
||||
|
||||
/**
|
||||
* RPG 运行时聊天相关 client 的兼容收口层。
|
||||
* 当前仍桥接旧 `aiService`,避免工作包 A 提前改动聊天与 NPC 对话请求语义。
|
||||
*/
|
||||
export const getRpgCharacterChatSuggestions =
|
||||
generateCharacterPanelChatSuggestions;
|
||||
export const getRpgCharacterChatSummary = generateCharacterPanelChatSummary;
|
||||
export const streamRpgCharacterChatReply = streamCharacterPanelChatReply;
|
||||
export const streamRpgNpcChatDialogue = streamNpcChatDialogue;
|
||||
export const streamRpgNpcChatTurn = streamNpcChatTurn;
|
||||
export const streamRpgNpcRecruitDialogue = streamNpcRecruitDialogue;
|
||||
|
||||
export const rpgRuntimeChatClient = {
|
||||
getCharacterChatSuggestions: getRpgCharacterChatSuggestions,
|
||||
getCharacterChatSummary: getRpgCharacterChatSummary,
|
||||
streamCharacterChatReply: streamRpgCharacterChatReply,
|
||||
streamNpcChatDialogue: streamRpgNpcChatDialogue,
|
||||
streamNpcChatTurn: streamRpgNpcChatTurn,
|
||||
streamNpcRecruitDialogue: streamRpgNpcRecruitDialogue,
|
||||
};
|
||||
66
src/services/rpg-runtime/rpgRuntimeRequest.ts
Normal file
66
src/services/rpg-runtime/rpgRuntimeRequest.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
const RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 180,
|
||||
maxDelayMs: 480,
|
||||
};
|
||||
const RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 240,
|
||||
maxDelayMs: 640,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
export type RuntimeRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
skipAuth?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一封装 RPG 运行时域的请求重试与鉴权透传,避免各 client 重复维护同一套规则。
|
||||
*/
|
||||
export function requestRpgRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry =
|
||||
options.retry ??
|
||||
(method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY);
|
||||
|
||||
return requestJson<T>(
|
||||
`${RUNTIME_API_BASE}${path}`,
|
||||
{
|
||||
...init,
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{
|
||||
retry,
|
||||
skipAuth: options.skipAuth,
|
||||
skipRefresh: options.skipRefresh,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共世界广场等匿名接口统一走公开请求入口,避免误附带鉴权状态。
|
||||
*/
|
||||
export function requestPublicRpgRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<T>(path, init, fallbackMessage, {
|
||||
...options,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
});
|
||||
}
|
||||
424
src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts
Normal file
424
src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../apiClient', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../apiClient')>('../apiClient');
|
||||
return {
|
||||
...actual,
|
||||
requestJson: requestJsonMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { AnimationState } from '../../types';
|
||||
import {
|
||||
buildStoryMomentFromRuntimeOptions,
|
||||
getRpgRuntimeClientVersion,
|
||||
getRpgRuntimeSessionId,
|
||||
getRpgRuntimeStoryState,
|
||||
isRpgRuntimeServerFunctionId,
|
||||
isRpgRuntimeTaskFunctionId,
|
||||
resolveRpgRuntimeStoryAction,
|
||||
resolveRpgRuntimeStoryMoment,
|
||||
shouldUseRpgRuntimeServerOptions,
|
||||
} from './rpgRuntimeStoryClient';
|
||||
|
||||
describe('rpgRuntimeStoryClient', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
|
||||
it('builds runtime action requests against the dedicated story endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 2,
|
||||
viewModel: {},
|
||||
presentation: {
|
||||
actionText: '继续交谈',
|
||||
resultText: '后端已结算',
|
||||
storyText: '后端已结算',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {},
|
||||
currentStory: null,
|
||||
},
|
||||
});
|
||||
|
||||
await resolveRpgRuntimeStoryAction({
|
||||
sessionId: 'runtime-custom',
|
||||
clientVersion: 9,
|
||||
option: {
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/story/actions/resolve',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-custom',
|
||||
clientVersion: 9,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: 'npc_chat',
|
||||
targetId: undefined,
|
||||
payload: {
|
||||
optionText: '继续交谈',
|
||||
},
|
||||
},
|
||||
snapshot: undefined,
|
||||
}),
|
||||
}),
|
||||
'执行运行时动作失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('merges custom runtime payload fields into the action request body', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 3,
|
||||
viewModel: {},
|
||||
presentation: {
|
||||
actionText: '使用凝神灵液',
|
||||
resultText: '后端已结算物品使用',
|
||||
storyText: '后端已结算物品使用',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 3,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {},
|
||||
currentStory: null,
|
||||
},
|
||||
});
|
||||
|
||||
await resolveRpgRuntimeStoryAction({
|
||||
option: {
|
||||
functionId: 'inventory_use',
|
||||
actionText: '使用凝神灵液',
|
||||
},
|
||||
payload: {
|
||||
itemId: 'focus-tonic',
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/story/actions/resolve',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: undefined,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: 'inventory_use',
|
||||
targetId: undefined,
|
||||
payload: {
|
||||
optionText: '使用凝神灵液',
|
||||
itemId: 'focus-tonic',
|
||||
},
|
||||
},
|
||||
snapshot: undefined,
|
||||
}),
|
||||
}),
|
||||
'执行运行时动作失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('submits runtime state resolution with snapshot context to the server', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 4,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '服务端故事',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {},
|
||||
currentStory: null,
|
||||
},
|
||||
});
|
||||
|
||||
await getRpgRuntimeStoryState({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 7,
|
||||
snapshot: {
|
||||
gameState: { currentScene: 'Story' } as never,
|
||||
bottomTab: 'adventure',
|
||||
currentStory: {
|
||||
text: '本地故事',
|
||||
options: [],
|
||||
} as never,
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/story/state/resolve',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 7,
|
||||
snapshot: {
|
||||
gameState: {
|
||||
currentScene: 'Story',
|
||||
},
|
||||
bottomTab: 'adventure',
|
||||
currentStory: {
|
||||
text: '本地故事',
|
||||
options: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
'读取运行时故事状态失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps disabled runtime options when rebuilding a story moment', () => {
|
||||
const story = buildStoryMomentFromRuntimeOptions({
|
||||
storyText: '服务端返回的新故事',
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
scope: 'npc',
|
||||
},
|
||||
{
|
||||
functionId: 'npc_recruit',
|
||||
actionText: '邀请加入队伍',
|
||||
scope: 'npc',
|
||||
disabled: true,
|
||||
reason: '队伍已满',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(story.text).toBe('服务端返回的新故事');
|
||||
expect(story.options).toHaveLength(2);
|
||||
expect(story.options[0]?.functionId).toBe('npc_chat');
|
||||
expect(story.options[1]?.functionId).toBe('npc_recruit');
|
||||
expect(story.options[1]?.disabled).toBe(true);
|
||||
expect(story.options[1]?.disabledReason).toBe('队伍已满');
|
||||
});
|
||||
|
||||
it('recognizes server-runtime option pools for server-side legality checks', () => {
|
||||
expect(isRpgRuntimeTaskFunctionId('npc_chat')).toBe(true);
|
||||
expect(isRpgRuntimeTaskFunctionId('battle_attack_basic')).toBe(true);
|
||||
expect(isRpgRuntimeTaskFunctionId('npc_trade')).toBe(false);
|
||||
expect(isRpgRuntimeServerFunctionId('npc_trade')).toBe(true);
|
||||
expect(isRpgRuntimeServerFunctionId('unknown_action')).toBe(false);
|
||||
expect(
|
||||
shouldUseRpgRuntimeServerOptions([
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
text: '继续交谈',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldUseRpgRuntimeServerOptions([
|
||||
{
|
||||
functionId: 'npc_trade',
|
||||
actionText: '交易',
|
||||
text: '交易',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldUseRpgRuntimeServerOptions([
|
||||
{
|
||||
functionId: 'unknown_action',
|
||||
actionText: '未知动作',
|
||||
text: '未知动作',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe(false);
|
||||
expect(getRpgRuntimeSessionId({ runtimeSessionId: '' })).toBe(
|
||||
'runtime-main',
|
||||
);
|
||||
expect(getRpgRuntimeClientVersion({ runtimeActionVersion: 3 })).toBe(3);
|
||||
});
|
||||
|
||||
it('preserves runtime option interaction metadata from the server response', () => {
|
||||
const story = buildStoryMomentFromRuntimeOptions({
|
||||
storyText: '服务端返回的新故事',
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_trade',
|
||||
actionText: '交易',
|
||||
scope: 'npc',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-merchant',
|
||||
action: 'trade',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(story.options[0]?.interaction).toEqual({
|
||||
kind: 'npc',
|
||||
npcId: 'npc-merchant',
|
||||
action: 'trade',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the richer snapshot story when the server persisted dialogue mode', () => {
|
||||
const story = resolveRpgRuntimeStoryMoment({
|
||||
response: {
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 4,
|
||||
viewModel: {
|
||||
player: { hp: 10, maxHp: 10, mana: 5, maxMana: 5 },
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: true,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '继续交谈',
|
||||
resultText: '后端已结算',
|
||||
storyText: '普通文本',
|
||||
options: [],
|
||||
battle: null,
|
||||
toast: null,
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {} as never,
|
||||
currentStory: {
|
||||
text: '你:先把话说开。\n梁伯:那我就直说了。',
|
||||
options: [],
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'player', text: '先把话说开。' },
|
||||
{ speaker: 'npc', speakerName: '梁伯', text: '那我就直说了。' },
|
||||
],
|
||||
deferredOptions: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
text: '继续交谈',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
hydratedSnapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {} as never,
|
||||
currentStory: {
|
||||
text: '你:先把话说开。\n梁伯:那我就直说了。',
|
||||
options: [],
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'player', text: '先把话说开。' },
|
||||
{ speaker: 'npc', speakerName: '梁伯', text: '那我就直说了。' },
|
||||
],
|
||||
deferredOptions: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
text: '继续交谈',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
fallbackStoryText: '普通文本',
|
||||
});
|
||||
|
||||
expect(story.displayMode).toBe('dialogue');
|
||||
expect(story.deferredOptions).toHaveLength(1);
|
||||
expect(story.text).toContain('梁伯');
|
||||
});
|
||||
});
|
||||
281
src/services/rpg-runtime/rpgRuntimeStoryClient.ts
Normal file
281
src/services/rpg-runtime/rpgRuntimeStoryClient.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import type {
|
||||
RuntimeStoryActionRequest,
|
||||
RuntimeStoryActionResponse,
|
||||
RuntimeStoryOptionView,
|
||||
RuntimeStoryStateRequest,
|
||||
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryState';
|
||||
import type {
|
||||
RuntimeStoryChoicePayload,
|
||||
ServerRuntimeFunctionId,
|
||||
Task5RuntimeFunctionId,
|
||||
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryAction';
|
||||
import {
|
||||
SERVER_RUNTIME_FUNCTION_IDS,
|
||||
TASK5_RUNTIME_FUNCTION_IDS,
|
||||
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryAction';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import { AnimationState } from '../../types';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const RUNTIME_STORY_API_BASE = '/api/runtime/story';
|
||||
const DEFAULT_SESSION_ID = 'runtime-main';
|
||||
const RUNTIME_STORY_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 220,
|
||||
maxDelayMs: 640,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
const TASK5_RUNTIME_FUNCTION_ID_SET = new Set<string>(
|
||||
TASK5_RUNTIME_FUNCTION_IDS,
|
||||
);
|
||||
const SERVER_RUNTIME_FUNCTION_ID_SET = new Set<string>([
|
||||
...SERVER_RUNTIME_FUNCTION_IDS,
|
||||
]);
|
||||
|
||||
export type RpgRuntimeStoryClientOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
};
|
||||
|
||||
export type RuntimeStoryResponse = RuntimeStoryActionResponse<
|
||||
GameState,
|
||||
StoryMoment
|
||||
>;
|
||||
export type { RuntimeStoryChoicePayload };
|
||||
export type RuntimeStorySnapshotRequest = RuntimeStoryStateRequest<
|
||||
GameState,
|
||||
StoryMoment
|
||||
>['snapshot'];
|
||||
|
||||
function requestRuntimeStoryJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RpgRuntimeStoryClientOptions = {},
|
||||
) {
|
||||
return requestJson<T>(
|
||||
`${RUNTIME_STORY_API_BASE}${path}`,
|
||||
{
|
||||
...init,
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{ retry: options.retry ?? RUNTIME_STORY_RETRY },
|
||||
);
|
||||
}
|
||||
|
||||
function createRuntimeStoryOption(
|
||||
option: RuntimeStoryOptionView,
|
||||
_gameState?: Pick<GameState, 'currentEncounter'>,
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId: option.functionId,
|
||||
actionText: option.actionText,
|
||||
text: option.actionText,
|
||||
detailText: option.detailText,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
interaction: option.interaction as StoryOption['interaction'] | undefined,
|
||||
runtimePayload: option.payload,
|
||||
disabled: option.disabled,
|
||||
disabledReason: option.reason,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRuntimeSessionId(
|
||||
gameState: Pick<GameState, 'runtimeSessionId'>,
|
||||
) {
|
||||
return gameState.runtimeSessionId?.trim() || DEFAULT_SESSION_ID;
|
||||
}
|
||||
|
||||
export function getRuntimeClientVersion(
|
||||
gameState: Pick<GameState, 'runtimeActionVersion'>,
|
||||
) {
|
||||
return typeof gameState.runtimeActionVersion === 'number'
|
||||
? gameState.runtimeActionVersion
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function isTask5RuntimeFunctionId(
|
||||
functionId: string,
|
||||
): functionId is Task5RuntimeFunctionId {
|
||||
return TASK5_RUNTIME_FUNCTION_ID_SET.has(functionId);
|
||||
}
|
||||
|
||||
export function isServerRuntimeFunctionId(
|
||||
functionId: string,
|
||||
): functionId is ServerRuntimeFunctionId {
|
||||
return SERVER_RUNTIME_FUNCTION_ID_SET.has(functionId);
|
||||
}
|
||||
|
||||
export function shouldUseServerRuntimeOptions(options: StoryOption[] | null) {
|
||||
return Boolean(
|
||||
options?.length &&
|
||||
options.every((option) => isServerRuntimeFunctionId(option.functionId)),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildStoryMomentFromRuntimeOptions(params: {
|
||||
storyText: string;
|
||||
options: RuntimeStoryOptionView[];
|
||||
gameState?: Pick<GameState, 'currentEncounter'>;
|
||||
}): StoryMoment {
|
||||
return {
|
||||
text: params.storyText,
|
||||
options: params.options.map((option) =>
|
||||
createRuntimeStoryOption(option, params.gameState),
|
||||
),
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
function shouldPreferSnapshotStory(story: StoryMoment | null) {
|
||||
return Boolean(
|
||||
story &&
|
||||
(story.displayMode === 'dialogue' ||
|
||||
story.deferredOptions?.length ||
|
||||
story.dialogue?.length),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveRuntimeStoryMoment(params: {
|
||||
response: RuntimeStoryResponse;
|
||||
hydratedSnapshot: HydratedSavedGameSnapshot;
|
||||
fallbackGameState?: Pick<GameState, 'currentEncounter'>;
|
||||
fallbackStoryText?: string;
|
||||
}) {
|
||||
if (shouldPreferSnapshotStory(params.hydratedSnapshot.currentStory)) {
|
||||
return params.hydratedSnapshot.currentStory!;
|
||||
}
|
||||
|
||||
const options =
|
||||
params.response.viewModel.availableOptions.length > 0
|
||||
? params.response.viewModel.availableOptions
|
||||
: params.response.presentation.options;
|
||||
|
||||
return buildStoryMomentFromRuntimeOptions({
|
||||
storyText:
|
||||
params.response.presentation.storyText ||
|
||||
params.hydratedSnapshot.currentStory?.text ||
|
||||
params.fallbackStoryText ||
|
||||
'',
|
||||
options,
|
||||
gameState: params.hydratedSnapshot.gameState.currentEncounter
|
||||
? params.hydratedSnapshot.gameState
|
||||
: params.fallbackGameState,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRuntimeStoryState(
|
||||
params: {
|
||||
sessionId: string;
|
||||
clientVersion?: number;
|
||||
snapshot?: RuntimeStorySnapshotRequest;
|
||||
},
|
||||
options: RpgRuntimeStoryClientOptions = {},
|
||||
) {
|
||||
const normalizedSessionId = params.sessionId || DEFAULT_SESSION_ID;
|
||||
const response = params.snapshot
|
||||
? await requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
'/state/resolve',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: normalizedSessionId,
|
||||
clientVersion: params.clientVersion,
|
||||
snapshot: params.snapshot,
|
||||
} satisfies RuntimeStoryStateRequest<GameState, StoryMoment>),
|
||||
},
|
||||
'读取运行时故事状态失败',
|
||||
options,
|
||||
)
|
||||
: await requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
`/state/${encodeURIComponent(normalizedSessionId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取运行时故事状态失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
...response,
|
||||
snapshot: rehydrateSavedSnapshot(
|
||||
response.snapshot as HydratedSavedGameSnapshot,
|
||||
),
|
||||
} satisfies RuntimeStoryResponse;
|
||||
}
|
||||
|
||||
export async function resolveRuntimeStoryAction(
|
||||
params: {
|
||||
sessionId?: string;
|
||||
clientVersion?: number;
|
||||
option: Pick<StoryOption, 'functionId' | 'actionText'>;
|
||||
targetId?: string;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
snapshot?: RuntimeStorySnapshotRequest;
|
||||
},
|
||||
options: RpgRuntimeStoryClientOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
'/actions/resolve',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: params.sessionId || DEFAULT_SESSION_ID,
|
||||
clientVersion: params.clientVersion,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: params.option.functionId,
|
||||
targetId: params.targetId,
|
||||
payload: {
|
||||
optionText: params.option.actionText,
|
||||
...(params.payload ?? {}),
|
||||
},
|
||||
},
|
||||
snapshot: params.snapshot,
|
||||
} satisfies RuntimeStoryActionRequest),
|
||||
},
|
||||
'执行运行时动作失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
...response,
|
||||
snapshot: rehydrateSavedSnapshot(
|
||||
response.snapshot as HydratedSavedGameSnapshot,
|
||||
),
|
||||
} satisfies RuntimeStoryResponse;
|
||||
}
|
||||
|
||||
export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {
|
||||
return rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot);
|
||||
}
|
||||
|
||||
export const getRpgRuntimeActionSnapshot = getRuntimeActionSnapshot;
|
||||
export const getRpgRuntimeClientVersion = getRuntimeClientVersion;
|
||||
export const getRpgRuntimeSessionId = getRuntimeSessionId;
|
||||
export const getRpgRuntimeStoryState = getRuntimeStoryState;
|
||||
export const isRpgRuntimeServerFunctionId = isServerRuntimeFunctionId;
|
||||
export const isRpgRuntimeTaskFunctionId = isTask5RuntimeFunctionId;
|
||||
export const resolveRpgRuntimeStoryAction = resolveRuntimeStoryAction;
|
||||
export const resolveRpgRuntimeStoryMoment = resolveRuntimeStoryMoment;
|
||||
export const shouldUseRpgRuntimeServerOptions = shouldUseServerRuntimeOptions;
|
||||
|
||||
export const rpgRuntimeStoryClient = {
|
||||
getActionSnapshot: getRpgRuntimeActionSnapshot,
|
||||
getClientVersion: getRpgRuntimeClientVersion,
|
||||
getSessionId: getRpgRuntimeSessionId,
|
||||
getState: getRpgRuntimeStoryState,
|
||||
resolveAction: resolveRpgRuntimeStoryAction,
|
||||
resolveMoment: resolveRpgRuntimeStoryMoment,
|
||||
shouldUseServerOptions: shouldUseRpgRuntimeServerOptions,
|
||||
};
|
||||
89
src/services/rpg-runtime/rpgSnapshotClient.test.ts
Normal file
89
src/services/rpg-runtime/rpgSnapshotClient.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
deleteRpgSaveSnapshot,
|
||||
getRpgSaveSnapshot,
|
||||
putRpgSaveSnapshot,
|
||||
} from './rpgSnapshotClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
describe('rpgSnapshotClient routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
|
||||
it('reads the current save snapshot from the runtime save route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce(null);
|
||||
|
||||
await getRpgSaveSnapshot();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/save/snapshot',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取存档失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes the current save snapshot through the runtime save route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
version: 2,
|
||||
savedAt: '2026-04-21T09:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
},
|
||||
});
|
||||
|
||||
await putRpgSaveSnapshot({
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/save/snapshot',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'保存存档失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes the current save snapshot through the runtime save route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
await deleteRpgSaveSnapshot();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/save/snapshot',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
'删除存档失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
60
src/services/rpg-runtime/rpgSnapshotClient.ts
Normal file
60
src/services/rpg-runtime/rpgSnapshotClient.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { BasicOkResult } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SavedGameSnapshotInput } from '../../persistence/gameSaveStorage';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
requestRpgRuntimeJson,
|
||||
type RuntimeRequestOptions,
|
||||
} from './rpgRuntimeRequest';
|
||||
|
||||
export type { RuntimeRequestOptions };
|
||||
|
||||
/**
|
||||
* RPG 运行时快照 client。
|
||||
* 工作包 C 起由新域目录承载真实实现,旧 `storageService` 仅保留兼容转发。
|
||||
*/
|
||||
export async function getRpgSaveSnapshot(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const snapshot = await requestRpgRuntimeJson<HydratedSavedGameSnapshot | null>(
|
||||
'/save/snapshot',
|
||||
{ method: 'GET' },
|
||||
'读取存档失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return snapshot ? rehydrateSavedSnapshot(snapshot) : null;
|
||||
}
|
||||
|
||||
export async function putRpgSaveSnapshot(
|
||||
snapshot: SavedGameSnapshotInput,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const savedSnapshot = await requestRpgRuntimeJson<HydratedSavedGameSnapshot>(
|
||||
'/save/snapshot',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(snapshot),
|
||||
},
|
||||
'保存存档失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return rehydrateSavedSnapshot(savedSnapshot);
|
||||
}
|
||||
|
||||
export function deleteRpgSaveSnapshot(options: RuntimeRequestOptions = {}) {
|
||||
return requestRpgRuntimeJson<BasicOkResult>(
|
||||
'/save/snapshot',
|
||||
{ method: 'DELETE' },
|
||||
'删除存档失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export const rpgSnapshotClient = {
|
||||
getSnapshot: getRpgSaveSnapshot,
|
||||
putSnapshot: putRpgSaveSnapshot,
|
||||
deleteSnapshot: deleteRpgSaveSnapshot,
|
||||
};
|
||||
24
src/services/runtimeItemAiDirector.ts
Normal file
24
src/services/runtimeItemAiDirector.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type {
|
||||
RuntimeItemAiIntent,
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
} from '../types';
|
||||
import { requestJson } from './apiClient';
|
||||
|
||||
export async function generateRuntimeItemAiIntents(params: {
|
||||
context: RuntimeItemGenerationContext;
|
||||
plans: RuntimeItemPlan[];
|
||||
}) {
|
||||
const response = await requestJson<{
|
||||
intents?: RuntimeItemAiIntent[];
|
||||
}>(
|
||||
'/api/runtime/items/runtime-intent',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
},
|
||||
'运行时物品意图生成失败',
|
||||
);
|
||||
return Array.isArray(response.intents) ? response.intents : [];
|
||||
}
|
||||
26
src/services/storyEngine/actPlanner.test.ts
Normal file
26
src/services/storyEngine/actPlanner.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveCurrentActState } from './actPlanner';
|
||||
|
||||
describe('actPlanner', () => {
|
||||
it('maps chapter stages to act states', () => {
|
||||
const actState = resolveCurrentActState({
|
||||
state: {
|
||||
storyEngineMemory: {
|
||||
activeThreadIds: ['thread-1'],
|
||||
},
|
||||
} as never,
|
||||
chapterState: {
|
||||
id: 'chapter-1',
|
||||
title: '封桥旧案·高潮',
|
||||
theme: '封桥旧案',
|
||||
primaryThreadIds: ['thread-1'],
|
||||
stage: 'climax',
|
||||
chapterSummary: '旧案被逼到台前。',
|
||||
},
|
||||
});
|
||||
|
||||
expect(actState?.actIndex).toBe(2);
|
||||
expect(actState?.status).toBe('finale');
|
||||
});
|
||||
});
|
||||
69
src/services/storyEngine/actPlanner.ts
Normal file
69
src/services/storyEngine/actPlanner.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { ActState, ChapterState, GameState } from '../../types';
|
||||
|
||||
function resolveActIndex(chapterState: ChapterState | null | undefined) {
|
||||
if (!chapterState) return 0;
|
||||
if (chapterState.stage === 'climax' || chapterState.stage === 'aftermath') return 2;
|
||||
if (chapterState.stage === 'turning_point') return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function buildActPlan(params: {
|
||||
state: GameState;
|
||||
}) {
|
||||
const primaryThreads = params.state.storyEngineMemory?.activeThreadIds ?? [];
|
||||
return [
|
||||
{
|
||||
id: 'act-1',
|
||||
title: '第一幕·起线',
|
||||
actIndex: 0,
|
||||
theme: '铺陈与引线',
|
||||
primaryThreadIds: primaryThreads.slice(0, 2),
|
||||
status: 'opening',
|
||||
},
|
||||
{
|
||||
id: 'act-2',
|
||||
title: '第二幕·扩张',
|
||||
actIndex: 1,
|
||||
theme: '冲突升级',
|
||||
primaryThreadIds: primaryThreads.slice(0, 3),
|
||||
status: 'midgame',
|
||||
},
|
||||
{
|
||||
id: 'act-3',
|
||||
title: '第三幕·收束',
|
||||
actIndex: 2,
|
||||
theme: '决战与余波',
|
||||
primaryThreadIds: primaryThreads.slice(0, 3),
|
||||
status: 'finale',
|
||||
},
|
||||
] satisfies ActState[];
|
||||
}
|
||||
|
||||
export function resolveCurrentActState(params: {
|
||||
state: GameState;
|
||||
chapterState?: ChapterState | null;
|
||||
}) {
|
||||
const chapterState = params.chapterState ?? params.state.chapterState ?? null;
|
||||
const actIndex = resolveActIndex(chapterState);
|
||||
const actPlan = buildActPlan(params);
|
||||
const candidate = actPlan[actIndex] ?? actPlan[0];
|
||||
if (!candidate) return null;
|
||||
|
||||
return {
|
||||
...candidate,
|
||||
theme: chapterState?.theme ?? candidate.theme,
|
||||
primaryThreadIds: chapterState?.primaryThreadIds ?? candidate.primaryThreadIds,
|
||||
status:
|
||||
chapterState?.stage === 'opening'
|
||||
? 'opening'
|
||||
: chapterState?.stage === 'expansion'
|
||||
? 'midgame'
|
||||
: chapterState?.stage === 'turning_point'
|
||||
? 'late_game'
|
||||
: chapterState?.stage === 'climax'
|
||||
? 'finale'
|
||||
: chapterState?.stage === 'aftermath'
|
||||
? 'resolved'
|
||||
: candidate.status,
|
||||
} satisfies ActState;
|
||||
}
|
||||
206
src/services/storyEngine/actorNarrativeProfile.ts
Normal file
206
src/services/storyEngine/actorNarrativeProfile.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type {
|
||||
ActorNarrativeProfile,
|
||||
CustomWorldRoleProfile,
|
||||
ThemePack,
|
||||
WorldStoryGraph,
|
||||
} from '../../types';
|
||||
|
||||
function dedupeStrings(values: Array<string | null | undefined>, limit = 6) {
|
||||
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function pickFirst(values: Array<string | null | undefined>, fallback: string) {
|
||||
const found = values.find((value) => typeof value === 'string' && value.trim());
|
||||
return found?.trim() ?? fallback;
|
||||
}
|
||||
|
||||
function findRelatedThreadIds(
|
||||
role: Pick<CustomWorldRoleProfile, 'id' | 'name' | 'role' | 'backstory' | 'motivation' | 'relationshipHooks' | 'tags'>,
|
||||
graph: WorldStoryGraph,
|
||||
) {
|
||||
const source = [
|
||||
role.name,
|
||||
role.role,
|
||||
role.backstory,
|
||||
role.motivation,
|
||||
...role.relationshipHooks,
|
||||
...role.tags,
|
||||
].join(' ');
|
||||
|
||||
return dedupeStrings(
|
||||
[...graph.visibleThreads, ...graph.hiddenThreads].flatMap((thread) => {
|
||||
if (thread.involvedActorIds.includes(role.id)) {
|
||||
return [thread.id];
|
||||
}
|
||||
|
||||
return source.includes(thread.title) || source.includes(thread.summary)
|
||||
? [thread.id]
|
||||
: [];
|
||||
}),
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
function findRelatedScarIds(
|
||||
role: Pick<CustomWorldRoleProfile, 'id' | 'backstory' | 'motivation' | 'relationshipHooks' | 'tags'>,
|
||||
graph: WorldStoryGraph,
|
||||
) {
|
||||
const source = [
|
||||
role.backstory,
|
||||
role.motivation,
|
||||
...role.relationshipHooks,
|
||||
...role.tags,
|
||||
].join(' ');
|
||||
|
||||
return dedupeStrings(
|
||||
graph.scars.flatMap((scar) => {
|
||||
if (scar.relatedActorIds.includes(role.id)) {
|
||||
return [scar.id];
|
||||
}
|
||||
|
||||
return source.includes(scar.title) || source.includes(scar.publicResidue)
|
||||
? [scar.id]
|
||||
: [];
|
||||
}),
|
||||
4,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildFallbackActorNarrativeProfile(
|
||||
role: CustomWorldRoleProfile,
|
||||
graph: WorldStoryGraph,
|
||||
themePack?: ThemePack | null,
|
||||
) {
|
||||
const relatedThreadIds = (() => {
|
||||
const matched = findRelatedThreadIds(role, graph);
|
||||
if (matched.length > 0) {
|
||||
return matched;
|
||||
}
|
||||
return graph.visibleThreads[0]?.id ? [graph.visibleThreads[0].id] : [];
|
||||
})();
|
||||
const relatedScarIds = (() => {
|
||||
const matched = findRelatedScarIds(role, graph);
|
||||
if (matched.length > 0) {
|
||||
return matched;
|
||||
}
|
||||
return graph.scars[0]?.id ? [graph.scars[0].id] : [];
|
||||
})();
|
||||
const primaryThread =
|
||||
[...graph.visibleThreads, ...graph.hiddenThreads].find((thread) =>
|
||||
relatedThreadIds.includes(thread.id),
|
||||
) ?? graph.visibleThreads[0] ?? graph.hiddenThreads[0];
|
||||
const primaryScar =
|
||||
graph.scars.find((scar) => relatedScarIds.includes(scar.id)) ?? graph.scars[0];
|
||||
const fallbackRevealStyle =
|
||||
themePack?.revealStyles[0] ?? '试探式回应';
|
||||
|
||||
return {
|
||||
publicMask: pickFirst(
|
||||
[role.backstoryReveal.publicSummary, role.description, `${role.title},${role.role}`],
|
||||
`${role.name}对外只承认自己是${role.role}。`,
|
||||
),
|
||||
firstContactMask: pickFirst(
|
||||
[
|
||||
role.backstoryReveal.chapters[0]?.teaser,
|
||||
`${role.name}会先拿${role.role}的身份与眼前局势挡在前面。`,
|
||||
],
|
||||
`${role.name}会先以${fallbackRevealStyle}的方式挡开过深的问题。`,
|
||||
),
|
||||
visibleLine: pickFirst(
|
||||
[role.motivation, role.description, primaryThread?.summary],
|
||||
`${role.name}显然正在被眼前局势推着走。`,
|
||||
),
|
||||
hiddenLine: pickFirst(
|
||||
[
|
||||
role.backstoryReveal.chapters[3]?.content,
|
||||
role.backstory,
|
||||
primaryThread?.summary,
|
||||
],
|
||||
`${role.name}和${primaryThread?.title ?? '世界暗线'}之间仍有一段没说完的牵连。`,
|
||||
),
|
||||
contradiction: pickFirst(
|
||||
[
|
||||
role.backstoryReveal.chapters[1]?.teaser,
|
||||
`${role.name}嘴上把话收得很稳,但提到${role.relationshipHooks[0] ?? '旧事'}时会明显变调。`,
|
||||
],
|
||||
`${role.name}的说辞和真正的焦点并不完全一致。`,
|
||||
),
|
||||
debtOrBurden: pickFirst(
|
||||
[
|
||||
primaryScar?.title,
|
||||
role.backstoryReveal.chapters[2]?.content,
|
||||
role.backstory,
|
||||
],
|
||||
`${role.name}背后压着一件还没了结的旧事。`,
|
||||
),
|
||||
taboo: pickFirst(
|
||||
[
|
||||
role.relationshipHooks[0],
|
||||
role.tags[0],
|
||||
primaryScar?.title,
|
||||
],
|
||||
'某个旧称呼或旧地点',
|
||||
),
|
||||
immediatePressure: pickFirst(
|
||||
[
|
||||
role.motivation,
|
||||
primaryThread?.stakes,
|
||||
primaryScar?.publicResidue,
|
||||
],
|
||||
`${role.name}眼下正被${primaryThread?.title ?? '当前局势'}逼着表态。`,
|
||||
),
|
||||
relatedThreadIds,
|
||||
relatedScarIds,
|
||||
reactionHooks: dedupeStrings([
|
||||
...role.relationshipHooks,
|
||||
...role.tags,
|
||||
primaryThread?.title,
|
||||
primaryScar?.title,
|
||||
], 5),
|
||||
} satisfies ActorNarrativeProfile;
|
||||
}
|
||||
|
||||
export async function generateActorNarrativeProfileWithAi(
|
||||
role: CustomWorldRoleProfile,
|
||||
graph: WorldStoryGraph,
|
||||
themePack?: ThemePack | null,
|
||||
) {
|
||||
return buildFallbackActorNarrativeProfile(role, graph, themePack);
|
||||
}
|
||||
|
||||
export function normalizeActorNarrativeProfile(
|
||||
value: unknown,
|
||||
fallback: ActorNarrativeProfile,
|
||||
) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const item = value as Partial<ActorNarrativeProfile>;
|
||||
const readText = (candidate: unknown, fallbackText: string) =>
|
||||
typeof candidate === 'string' && candidate.trim()
|
||||
? candidate.trim()
|
||||
: fallbackText;
|
||||
|
||||
return {
|
||||
publicMask: readText(item.publicMask, fallback.publicMask),
|
||||
firstContactMask: readText(item.firstContactMask, fallback.firstContactMask),
|
||||
visibleLine: readText(item.visibleLine, fallback.visibleLine),
|
||||
hiddenLine: readText(item.hiddenLine, fallback.hiddenLine),
|
||||
contradiction: readText(item.contradiction, fallback.contradiction),
|
||||
debtOrBurden: readText(item.debtOrBurden, fallback.debtOrBurden),
|
||||
taboo: readText(item.taboo, fallback.taboo),
|
||||
immediatePressure: readText(item.immediatePressure, fallback.immediatePressure),
|
||||
relatedThreadIds: dedupeStrings(item.relatedThreadIds as string[], 6),
|
||||
relatedScarIds: dedupeStrings(item.relatedScarIds as string[], 6),
|
||||
reactionHooks:
|
||||
dedupeStrings(item.reactionHooks as string[], fallback.reactionHooks.length || 5)
|
||||
.length > 0
|
||||
? dedupeStrings(
|
||||
item.reactionHooks as string[],
|
||||
fallback.reactionHooks.length || 5,
|
||||
)
|
||||
: fallback.reactionHooks,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user