init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

View File

@@ -0,0 +1,8 @@
export {
bigFishCreationClient,
createBigFishCreationSession,
executeBigFishCreationAction,
getBigFishCreationSession,
sendBigFishCreationMessage,
streamBigFishCreationMessage,
} from './bigFishCreationClient';

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

View File

@@ -0,0 +1,6 @@
export {
bigFishRuntimeClient,
getBigFishRuntimeRun,
startBigFishRuntimeRun,
submitBigFishRuntimeInput,
} from './bigFishRuntimeClient';

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

View File

@@ -0,0 +1,5 @@
export {
bigFishWorksClient,
deleteBigFishWork,
listBigFishWorks,
} from './bigFishWorksClient';

View File

@@ -0,0 +1 @@
export * from '../prompts/characterChatPrompts';

53
src/services/clipboard.ts Normal file
View 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;
}

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

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

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

View File

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

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

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

View 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: '世界共创',
});
});

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

View File

@@ -0,0 +1,5 @@
export * from './creationAgentClientFactory';
export * from './creationAgentChat';
export * from './creationAgentDocumentInput';
export * from './creationAgentProgress';
export * from './creationAgentSse';

View 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

File diff suppressed because it is too large Load Diff

View 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('玩家幻想');
});

View 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() ||
'正在整理当前共创设定。'
);
}

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

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

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

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

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

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

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

View 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),
},
'上传作品封面失败',
);
}

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

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

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

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

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

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

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

View 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 '向前走';
}
}

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

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

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

View File

@@ -0,0 +1,4 @@
export {
parseJsonResponseText,
parseLineListContent,
} from '../../packages/shared/src/llm/parsers';

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

View File

@@ -0,0 +1,4 @@
export {
hasMixedNarrativeLanguage,
sanitizePromptNarrativeText,
} from '../../packages/shared/src/llm/narrativeLanguage';

View File

@@ -0,0 +1 @@
export { getPlatformProfileDashboard } from './platformProfileClient';

View File

@@ -0,0 +1,5 @@
/**
* 平台首页资料读取入口。
* 直连 RPG profile client避免默认首页首访经过服务桶入口触发额外模块转译。
*/
export { getRpgProfileDashboard as getPlatformProfileDashboard } from '../rpg-entry/rpgProfileClient';

301
src/services/prompt.test.ts Normal file
View 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
View File

@@ -0,0 +1 @@
export * from '../prompts/storyPromptBuilders';

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

View File

@@ -0,0 +1,8 @@
export {
createPuzzleAgentSession,
executePuzzleAgentAction,
getPuzzleAgentSession,
puzzleAgentClient,
sendPuzzleAgentMessage,
streamPuzzleAgentMessage,
} from './puzzleAgentClient';

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

View File

@@ -0,0 +1,5 @@
export {
getPuzzleGalleryDetail,
listPuzzleGallery,
puzzleGalleryClient,
} from './puzzleGalleryClient';

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

View File

@@ -0,0 +1,9 @@
export {
advanceLocalPuzzleNextLevel,
advancePuzzleNextLevel,
dragPuzzlePieceOrGroup,
getPuzzleRun,
puzzleRuntimeClient,
startPuzzleRun,
swapPuzzlePieces,
} from './puzzleRuntimeClient';

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

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

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

View File

@@ -0,0 +1,7 @@
export {
getPuzzleWorkDetail,
deletePuzzleWork,
listPuzzleWorks,
puzzleWorksClient,
updatePuzzleWork,
} from './puzzleWorksClient';

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

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

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

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

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

View File

@@ -0,0 +1,54 @@
/* @vitest-environment jsdom */
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { requestJsonMock } = vi.hoisted(() => ({
requestJsonMock: vi.fn(),
}));
import { generateRpgWorldProfile } from './rpgCreationGenerationClient';
vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
}));
describe('rpgCreationGenerationClient', () => {
beforeEach(() => {
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({
id: 'custom-world-1',
name: '测试世界',
subtitle: '副标题',
summary: '概述',
tone: '基调',
playerGoal: '目标',
settingText: '设定',
});
});
it('posts world generation to the runtime custom world profile route', async () => {
await generateRpgWorldProfile('一个被灵潮反复改写地形的边境世界');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/custom-world/profile',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
'生成自定义世界失败',
);
});
it('rejects immediately when the caller aborts before sending', async () => {
const controller = new AbortController();
controller.abort(new Error('手动中断生成'));
await expect(
generateRpgWorldProfile('一个会被中断的世界', {
signal: controller.signal,
}),
).rejects.toThrow('手动中断生成');
expect(requestJsonMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,68 @@
import type {
CustomWorldGenerationProgress,
GenerateCustomWorldProfileInput,
GenerateCustomWorldProfileOptions,
} from '../../../packages/shared/src/contracts/runtime';
import type { CustomWorldProfile } from '../../types';
import { requestJson } from '../apiClient';
type LegacyAiModule = typeof import('../ai');
let legacyAiModulePromise: Promise<LegacyAiModule> | null = null;
async function loadLegacyAiModule() {
if (!legacyAiModulePromise) {
legacyAiModulePromise = import('../ai');
}
return legacyAiModulePromise;
}
export async function generateRpgWorldProfile(
input: GenerateCustomWorldProfileInput | string,
options: GenerateCustomWorldProfileOptions = {},
): Promise<CustomWorldProfile> {
const normalizedInput =
typeof input === 'string'
? {
settingText: input,
}
: input;
if (typeof window === 'undefined') {
const aiClient = await loadLegacyAiModule();
return aiClient.generateCustomWorldProfile(normalizedInput, options);
}
if (options.signal?.aborted) {
throw options.signal.reason instanceof Error
? options.signal.reason
: new Error('世界生成已中断。');
}
const profile = await requestJson<CustomWorldProfile>(
'/api/runtime/custom-world/profile',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(normalizedInput),
},
'生成自定义世界失败',
);
if (options.signal?.aborted) {
throw options.signal.reason instanceof Error
? options.signal.reason
: new Error('世界生成已中断。');
}
return profile;
}
export type {
CustomWorldGenerationProgress,
GenerateCustomWorldProfileInput,
GenerateCustomWorldProfileOptions,
};
export { generateRpgWorldProfile as generateCustomWorldProfile };

View File

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

View 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([
'稿骨',
'稿步',
'稿识',
'稿魄',
'稿契',
'稿澜',
]);
});

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

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

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

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

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

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

View File

@@ -0,0 +1,165 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { requestJsonMock } = vi.hoisted(() => ({
requestJsonMock: vi.fn(),
}));
import {
deleteRpgEntryWorldProfile,
getRpgEntryWorldGalleryDetail,
listRpgEntryWorldGallery,
listRpgEntryWorldLibrary,
publishRpgEntryWorldProfile,
unpublishRpgEntryWorldProfile,
upsertRpgEntryWorldProfile,
} from './rpgEntryLibraryClient';
vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
}));
describe('rpgEntryLibraryClient world library routes', () => {
beforeEach(() => {
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({ entries: [] });
});
it('reads world library from the runtime entry route', async () => {
await listRpgEntryWorldLibrary();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/custom-world-library',
expect.objectContaining({ method: 'GET' }),
'读取自定义世界库失败',
expect.objectContaining({
retry: expect.objectContaining({ maxRetries: 1 }),
}),
);
});
it('reads world gallery from the public runtime entry route', async () => {
await listRpgEntryWorldGallery();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/custom-world-gallery',
expect.objectContaining({ method: 'GET' }),
'读取作品广场失败',
expect.objectContaining({
skipAuth: true,
skipRefresh: true,
}),
);
});
it('reads gallery detail from the public runtime entry route', async () => {
requestJsonMock.mockResolvedValueOnce({
entry: {
ownerUserId: 'owner-1',
profileId: 'profile-1',
},
});
await getRpgEntryWorldGalleryDetail('owner-1', 'profile-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/custom-world-gallery/owner-1/profile-1',
expect.objectContaining({ method: 'GET' }),
'读取作品详情失败',
expect.objectContaining({
skipAuth: true,
skipRefresh: true,
}),
);
});
it('writes world profile through the runtime entry route', async () => {
requestJsonMock.mockResolvedValueOnce({
entry: {
profileId: 'profile-1',
},
entries: [],
});
await upsertRpgEntryWorldProfile({
id: 'profile-1',
name: '测试世界',
} as never);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/custom-world-library/profile-1',
expect.objectContaining({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
}),
'保存自定义世界失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
it('deletes world profile through the runtime entry route', async () => {
await deleteRpgEntryWorldProfile('profile-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/custom-world-library/profile-1',
expect.objectContaining({ method: 'DELETE' }),
'删除自定义世界失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
it('publishes world profile through the runtime entry route', async () => {
requestJsonMock.mockResolvedValueOnce({
entry: {
profileId: 'profile-1',
},
entries: [],
});
await publishRpgEntryWorldProfile('profile-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/custom-world-library/profile-1/publish',
expect.objectContaining({ method: 'POST' }),
'发布自定义世界失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
it('unpublishes world profile through the runtime entry route', async () => {
requestJsonMock.mockResolvedValueOnce({
entry: {
profileId: 'profile-1',
},
entries: [],
});
await unpublishRpgEntryWorldProfile('profile-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/custom-world-library/profile-1/unpublish',
expect.objectContaining({ method: 'POST' }),
'下架自定义世界失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
});

View File

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

View File

@@ -0,0 +1,158 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { requestJsonMock } = vi.hoisted(() => ({
requestJsonMock: vi.fn(),
}));
import {
clearRpgProfileBrowseHistory,
listRpgProfileBrowseHistory,
listRpgProfileSaveArchives,
resumeRpgProfileSaveArchive,
syncRpgProfileBrowseHistory,
upsertRpgProfileBrowseHistory,
} from './rpgProfileClient';
vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
}));
describe('rpgProfileClient browse history routes', () => {
beforeEach(() => {
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({ entries: [] });
});
it('reads browse history from the runtime profile route', async () => {
await listRpgProfileBrowseHistory();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/profile/browse-history',
expect.objectContaining({ method: 'GET' }),
'读取浏览历史失败',
expect.objectContaining({
retry: expect.objectContaining({ maxRetries: 1 }),
}),
);
});
it('writes browse history through the runtime profile route', async () => {
await upsertRpgProfileBrowseHistory({
ownerUserId: 'user-1',
profileId: 'profile-1',
worldName: '测试世界',
subtitle: '测试副标题',
summaryText: '测试摘要',
coverImageSrc: null,
themeMode: 'mythic',
authorDisplayName: '测试作者',
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/profile/browse-history',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
'写入浏览历史失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
it('syncs browse history through the runtime profile route', async () => {
await syncRpgProfileBrowseHistory([
{
ownerUserId: 'user-1',
profileId: 'profile-1',
worldName: '测试世界',
subtitle: '测试副标题',
summaryText: '测试摘要',
coverImageSrc: null,
themeMode: 'mythic',
authorDisplayName: '测试作者',
},
]);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/profile/browse-history',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
'同步浏览历史失败',
expect.any(Object),
);
});
it('clears browse history through the runtime profile route', async () => {
await clearRpgProfileBrowseHistory();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/profile/browse-history',
expect.objectContaining({ method: 'DELETE' }),
'清空浏览历史失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
});
describe('rpgProfileClient save archive routes', () => {
beforeEach(() => {
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({ entries: [] });
});
it('reads save archives from the runtime profile route', async () => {
await listRpgProfileSaveArchives();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/profile/save-archives',
expect.objectContaining({ method: 'GET' }),
'读取存档列表失败',
expect.objectContaining({
retry: expect.objectContaining({ maxRetries: 1 }),
}),
);
});
it('resumes a save archive through the runtime profile route', async () => {
requestJsonMock.mockResolvedValueOnce({
entry: {
worldKey: 'custom:world-1',
},
snapshot: {
version: 2,
savedAt: '2026-04-19T10:15:00.000Z',
bottomTab: 'adventure',
currentStory: null,
gameState: {
worldType: 'CUSTOM',
},
},
});
await resumeRpgProfileSaveArchive('custom:world-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/profile/save-archives/custom%3Aworld-1',
expect.objectContaining({ method: 'POST' }),
'恢复存档失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
});

View File

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

View File

@@ -0,0 +1,32 @@
export {
deleteRpgSaveSnapshot,
getRpgSaveSnapshot,
putRpgSaveSnapshot,
rpgSnapshotClient,
type RuntimeRequestOptions,
} from './rpgSnapshotClient';
export {
getRpgCharacterChatSuggestions,
getRpgCharacterChatSummary,
rpgRuntimeChatClient,
streamRpgCharacterChatReply,
streamRpgNpcChatDialogue,
streamRpgNpcChatTurn,
streamRpgNpcRecruitDialogue,
} from './rpgRuntimeChatClient';
export {
getRpgRuntimeActionSnapshot,
getRpgRuntimeClientVersion,
getRpgRuntimeSessionId,
getRpgRuntimeStoryState,
isRpgRuntimeServerFunctionId,
isRpgRuntimeTaskFunctionId,
resolveRpgRuntimeStoryAction,
resolveRpgRuntimeStoryMoment,
rpgRuntimeStoryClient,
shouldUseRpgRuntimeServerOptions,
type RuntimeStoryChoicePayload,
type RuntimeStoryResponse,
type RpgRuntimeStoryClientOptions,
type RuntimeStorySnapshotRequest,
} from './rpgRuntimeStoryClient';

View File

@@ -0,0 +1,29 @@
import {
generateCharacterPanelChatSuggestions,
generateCharacterPanelChatSummary,
streamCharacterPanelChatReply,
streamNpcChatDialogue,
streamNpcChatTurn,
streamNpcRecruitDialogue,
} from '../aiService';
/**
* RPG 运行时聊天相关 client 的兼容收口层。
* 当前仍桥接旧 `aiService`,避免工作包 A 提前改动聊天与 NPC 对话请求语义。
*/
export const getRpgCharacterChatSuggestions =
generateCharacterPanelChatSuggestions;
export const getRpgCharacterChatSummary = generateCharacterPanelChatSummary;
export const streamRpgCharacterChatReply = streamCharacterPanelChatReply;
export const streamRpgNpcChatDialogue = streamNpcChatDialogue;
export const streamRpgNpcChatTurn = streamNpcChatTurn;
export const streamRpgNpcRecruitDialogue = streamNpcRecruitDialogue;
export const rpgRuntimeChatClient = {
getCharacterChatSuggestions: getRpgCharacterChatSuggestions,
getCharacterChatSummary: getRpgCharacterChatSummary,
streamCharacterChatReply: streamRpgCharacterChatReply,
streamNpcChatDialogue: streamRpgNpcChatDialogue,
streamNpcChatTurn: streamRpgNpcChatTurn,
streamNpcRecruitDialogue: streamRpgNpcRecruitDialogue,
};

View File

@@ -0,0 +1,66 @@
import { type ApiRetryOptions, requestJson } from '../apiClient';
const RUNTIME_API_BASE = '/api/runtime';
const RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 180,
maxDelayMs: 480,
};
const RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 240,
maxDelayMs: 640,
retryUnsafeMethods: true,
};
export type RuntimeRequestOptions = {
signal?: AbortSignal;
retry?: ApiRetryOptions;
skipAuth?: boolean;
skipRefresh?: boolean;
};
/**
* 统一封装 RPG 运行时域的请求重试与鉴权透传,避免各 client 重复维护同一套规则。
*/
export function requestRpgRuntimeJson<T>(
path: string,
init: RequestInit,
fallbackMessage: string,
options: RuntimeRequestOptions = {},
) {
const method = (init.method ?? 'GET').toUpperCase();
const retry =
options.retry ??
(method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY);
return requestJson<T>(
`${RUNTIME_API_BASE}${path}`,
{
...init,
signal: options.signal,
},
fallbackMessage,
{
retry,
skipAuth: options.skipAuth,
skipRefresh: options.skipRefresh,
},
);
}
/**
* 公共世界广场等匿名接口统一走公开请求入口,避免误附带鉴权状态。
*/
export function requestPublicRpgRuntimeJson<T>(
path: string,
init: RequestInit,
fallbackMessage: string,
options: RuntimeRequestOptions = {},
) {
return requestRpgRuntimeJson<T>(path, init, fallbackMessage, {
...options,
skipAuth: true,
skipRefresh: true,
});
}

View File

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

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

View File

@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { requestJsonMock } = vi.hoisted(() => ({
requestJsonMock: vi.fn(),
}));
import {
deleteRpgSaveSnapshot,
getRpgSaveSnapshot,
putRpgSaveSnapshot,
} from './rpgSnapshotClient';
vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
}));
describe('rpgSnapshotClient routes', () => {
beforeEach(() => {
requestJsonMock.mockReset();
});
it('reads the current save snapshot from the runtime save route', async () => {
requestJsonMock.mockResolvedValueOnce(null);
await getRpgSaveSnapshot();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/save/snapshot',
expect.objectContaining({ method: 'GET' }),
'读取存档失败',
expect.objectContaining({
retry: expect.objectContaining({ maxRetries: 1 }),
}),
);
});
it('writes the current save snapshot through the runtime save route', async () => {
requestJsonMock.mockResolvedValueOnce({
version: 2,
savedAt: '2026-04-21T09:00:00.000Z',
bottomTab: 'adventure',
currentStory: null,
gameState: {
worldType: 'CUSTOM',
},
});
await putRpgSaveSnapshot({
bottomTab: 'adventure',
currentStory: null,
gameState: {
worldType: 'CUSTOM',
} as never,
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/save/snapshot',
expect.objectContaining({
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
}),
'保存存档失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
it('deletes the current save snapshot through the runtime save route', async () => {
requestJsonMock.mockResolvedValueOnce({ ok: true });
await deleteRpgSaveSnapshot();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/save/snapshot',
expect.objectContaining({ method: 'DELETE' }),
'删除存档失败',
expect.objectContaining({
retry: expect.objectContaining({
maxRetries: 1,
retryUnsafeMethods: true,
}),
}),
);
});
});

View File

@@ -0,0 +1,60 @@
import type { BasicOkResult } from '../../../packages/shared/src/contracts/runtime';
import type { SavedGameSnapshotInput } from '../../persistence/gameSaveStorage';
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
requestRpgRuntimeJson,
type RuntimeRequestOptions,
} from './rpgRuntimeRequest';
export type { RuntimeRequestOptions };
/**
* RPG 运行时快照 client。
* 工作包 C 起由新域目录承载真实实现,旧 `storageService` 仅保留兼容转发。
*/
export async function getRpgSaveSnapshot(
options: RuntimeRequestOptions = {},
) {
const snapshot = await requestRpgRuntimeJson<HydratedSavedGameSnapshot | null>(
'/save/snapshot',
{ method: 'GET' },
'读取存档失败',
options,
);
return snapshot ? rehydrateSavedSnapshot(snapshot) : null;
}
export async function putRpgSaveSnapshot(
snapshot: SavedGameSnapshotInput,
options: RuntimeRequestOptions = {},
) {
const savedSnapshot = await requestRpgRuntimeJson<HydratedSavedGameSnapshot>(
'/save/snapshot',
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(snapshot),
},
'保存存档失败',
options,
);
return rehydrateSavedSnapshot(savedSnapshot);
}
export function deleteRpgSaveSnapshot(options: RuntimeRequestOptions = {}) {
return requestRpgRuntimeJson<BasicOkResult>(
'/save/snapshot',
{ method: 'DELETE' },
'删除存档失败',
options,
);
}
export const rpgSnapshotClient = {
getSnapshot: getRpgSaveSnapshot,
putSnapshot: putRpgSaveSnapshot,
deleteSnapshot: deleteRpgSaveSnapshot,
};

View File

@@ -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 : [];
}

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

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

View 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