This commit is contained in:
2026-04-21 10:30:12 +08:00
parent ae28dab032
commit 13bc79306f
49 changed files with 3691 additions and 1357 deletions

View File

@@ -17,7 +17,7 @@ const baseDraftItem: CustomWorldWorkSummary = {
updatedAt: new Date('2026-04-14T10:00:00.000Z').toISOString(),
publishedAt: null,
stage: 'object_refining',
stageLabel: '精修对象',
stageLabel: '待完善草稿',
playableNpcCount: 3,
landmarkCount: 4,
sessionId: 'session-1',
@@ -35,7 +35,7 @@ test('creation hub reflects updated draft title summary and counts after rerende
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onResumeDraft={() => {}}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
/>,
);
@@ -62,7 +62,7 @@ test('creation hub reflects updated draft title summary and counts after rerende
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onResumeDraft={() => {}}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
/>,
);

View File

@@ -33,7 +33,7 @@ test('creation hub draft card renders compiled work summary fields', () => {
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onResumeDraft={() => {}}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
/>,
);

View File

@@ -15,7 +15,7 @@ type CustomWorldCreationHubProps = {
onBack: () => void;
onRetry: () => void;
onCreateNew: () => void;
onResumeDraft: (sessionId: string) => void;
onOpenDraft: (item: CustomWorldWorkSummary) => void;
onEnterPublished: (profileId: string) => void;
};
@@ -36,7 +36,7 @@ export function CustomWorldCreationHub({
onBack,
onRetry,
onCreateNew,
onResumeDraft,
onOpenDraft,
onEnterPublished,
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
@@ -129,7 +129,7 @@ export function CustomWorldCreationHub({
item={item}
onClick={() => {
if (item.sourceType === 'agent_session' && item.sessionId) {
onResumeDraft(item.sessionId);
onOpenDraft(item);
return;
}

View File

@@ -27,6 +27,11 @@ export function CustomWorldWorkCard({
const isDraft = item.status === 'draft';
const hasFoundationDraft =
item.playableNpcCount > 0 || item.landmarkCount > 0;
const actionLabel = isDraft
? hasFoundationDraft
? '继续完善'
: '继续创作'
: '进入世界';
const roleCountLabel = isDraft ? '角色' : '可扮演角色';
return (
@@ -104,7 +109,7 @@ export function CustomWorldWorkCard({
onClick={onClick}
className="platform-button platform-button--primary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm"
>
{isDraft ? (hasFoundationDraft ? '继续精修' : '继续创作') : '进入世界'}
{actionLabel}
</button>
</div>
</div>

View File

@@ -30,13 +30,13 @@ import {
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
PlatformBrowseHistoryEntry,
ProfileDashboardCardKey,
ProfileDashboardSummary,
ProfileSaveArchiveSummary,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { AuthUser } from '../../services/authService';
import type { PlatformBrowseHistoryEntry } from '../../services/platformBrowseHistory';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformBrandLogo } from './PlatformBrandLogo';

View File

@@ -223,6 +223,97 @@ const mockAuthUser: AuthUser = {
wechatBound: false,
};
const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = {
...mockSession,
stage: 'object_refining',
creatorIntent: {
sourceMode: 'card',
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
themeKeywords: ['海雾', '旧航路'],
toneDirectives: ['压抑', '悬疑'],
openingSituation: '首夜就有陌生船只闯入禁航区。',
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: ['会移动的海雾'],
forbiddenDirectives: [],
rawSettingText: '',
},
draftProfile: {
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '旧航路引路人',
role: '关键同行者',
publicIdentity: '最熟悉旧航路的人。',
publicMask: '看上去像可靠旧友。',
currentPressure: '他必须在两股势力间站队。',
hiddenHook: '暗中替沉船商盟引路。',
relationToPlayer: '旧友兼潜在背叛者',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
storyNpcs: [
{
id: 'story-1',
name: '顾潮音',
title: '守灯会值夜人',
role: '场景关键角色',
publicIdentity: '负责夜间巡灯与封锁。',
publicMask: '对外一直冷静克制。',
currentPressure: '她知道更多禁航区真相。',
hiddenHook: '曾亲眼见过失控海雾吞船。',
relationToPlayer: '最早愿意交换线索的人',
threadIds: ['thread-1'],
summary: '她总像比所有人更早知道海雾会往哪一侧压下来。',
},
],
landmarks: [
{
id: 'landmark-1',
name: '回潮旧灯塔',
purpose: '观察雾潮与往来船只',
mood: '潮湿、压抑、风声不止',
importance: '开局核心场景',
characterIds: ['story-1'],
threadIds: ['thread-1'],
summary: '旧灯塔是整片群岛最先看见异动的地方。',
},
],
factions: [],
threads: [],
chapters: [],
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
openingSituation: '首夜就有陌生船只闯入禁航区。',
iconicElements: ['会移动的海雾'],
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
},
draftCards: [
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
status: 'warning',
linkedIds: ['playable-1', 'story-1', 'landmark-1'],
warningCount: 0,
},
],
};
type TestAuthValue = {
user: AuthUser | null;
openLoginModal: (postLoginAction?: (() => void) | null) => void;
@@ -416,7 +507,7 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and
).toBeTruthy();
});
test('create tab uses unified creation hub and can resume an agent draft', async () => {
test('create tab opens compiled agent draft in result refinement page', async () => {
const user = userEvent.setup();
vi.mocked(listCustomWorldWorks).mockResolvedValue([
@@ -425,7 +516,7 @@ test('create tab uses unified creation hub and can resume an agent draft', async
sourceType: 'agent_session',
status: 'draft',
title: '潮雾列岛',
subtitle: '精修对象',
subtitle: '待完善草稿',
summary: '玩家是失职返乡的守灯人。',
coverImageSrc: null,
coverRenderMode: 'image',
@@ -433,7 +524,7 @@ test('create tab uses unified creation hub and can resume an agent draft', async
updatedAt: '2026-04-20T10:00:00.000Z',
publishedAt: null,
stage: 'object_refining',
stageLabel: '精修对象',
stageLabel: '待完善草稿',
playableNpcCount: 3,
landmarkCount: 4,
roleVisualReadyCount: 1,
@@ -445,21 +536,70 @@ test('create tab uses unified creation hub and can resume an agent draft', async
canEnterWorld: false,
},
]);
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(
compiledAgentDraftSession,
);
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(
screen.getByRole('button', { name: //u }),
).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(
async () => {
expect(await screen.findByText('世界档案')).toBeTruthy();
expect(screen.queryByText('Agent工作区custom-world-agent-session-1')).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
},
{ timeout: 2500 },
);
});
test('create tab resumes agent workspace when draft has no compiled result yet', async () => {
const user = userEvent.setup();
vi.mocked(listCustomWorldWorks).mockResolvedValue([
{
workId: 'draft:custom-world-agent-session-1',
sourceType: 'agent_session',
status: 'draft',
title: '潮雾列岛',
subtitle: '补齐关键锚点',
summary: '玩家是失职返乡的守灯人。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-20T10:00:00.000Z',
publishedAt: null,
stage: 'clarifying',
stageLabel: '补齐关键锚点',
playableNpcCount: 0,
landmarkCount: 0,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: 'custom-world-agent-session-1',
profileId: null,
canResume: true,
canEnterWorld: false,
},
]);
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
await user.click(screen.getByRole('button', { name: //u }));
expect(
await screen.findByText('Agent工作区custom-world-agent-session-1'),
).toBeTruthy();
expect(screen.queryByText('世界档案')).toBeNull();
});
test('clicking a public work while logged out routes through requireAuth', async () => {
@@ -581,7 +721,7 @@ test('starting draft generation leaves the agent workspace and shows the generat
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();
});
test('existing draft sessions enter the agent preview layout without opening legacy editor', async () => {
test('existing draft sessions open result page refinement instead of agent dialog', async () => {
const user = userEvent.setup();
vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({
@@ -593,96 +733,9 @@ test('existing draft sessions enter the agent preview layout without opening leg
progress: 100,
error: null,
});
vi.mocked(getCustomWorldAgentSession).mockResolvedValue({
...mockSession,
stage: 'object_refining',
creatorIntent: {
sourceMode: 'card',
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
themeKeywords: ['海雾', '旧航路'],
toneDirectives: ['压抑', '悬疑'],
openingSituation: '首夜就有陌生船只闯入禁航区。',
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: ['会移动的海雾'],
forbiddenDirectives: [],
rawSettingText: '',
},
draftProfile: {
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '旧航路引路人',
role: '关键同行者',
publicIdentity: '最熟悉旧航路的人。',
publicMask: '看上去像可靠旧友。',
currentPressure: '他必须在两股势力间站队。',
hiddenHook: '暗中替沉船商盟引路。',
relationToPlayer: '旧友兼潜在背叛者',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
storyNpcs: [
{
id: 'story-1',
name: '顾潮音',
title: '守灯会值夜人',
role: '场景关键角色',
publicIdentity: '负责夜间巡灯与封锁。',
publicMask: '对外一直冷静克制。',
currentPressure: '她知道更多禁航区真相。',
hiddenHook: '曾亲眼见过失控海雾吞船。',
relationToPlayer: '最早愿意交换线索的人',
threadIds: ['thread-1'],
summary: '她总像比所有人更早知道海雾会往哪一侧压下来。',
},
],
landmarks: [
{
id: 'landmark-1',
name: '回潮旧灯塔',
purpose: '观察雾潮与往来船只',
mood: '潮湿、压抑、风声不止',
importance: '开局核心场景',
characterIds: ['story-1'],
threadIds: ['thread-1'],
summary: '旧灯塔是整片群岛最先看见异动的地方。',
},
],
factions: [],
threads: [],
chapters: [],
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
openingSituation: '首夜就有陌生船只闯入禁航区。',
iconicElements: ['会移动的海雾'],
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
},
draftCards: [
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
status: 'warning',
linkedIds: ['playable-1', 'story-1', 'landmark-1'],
warningCount: 0,
},
],
});
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(
compiledAgentDraftSession,
);
render(<TestWrapper withAuth />);
@@ -704,12 +757,12 @@ test('existing draft sessions enter the agent preview layout without opening leg
await user.click(screen.getByRole('button', { name: //u }));
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
expect(screen.queryByRole('button', { name: /AI/u })).toBeNull();
expect(screen.queryByText('技能')).toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
expect(await screen.findByText(//u)).toBeTruthy();
expect(screen.getByRole('button', { name: /AI/u })).toBeTruthy();
});
test('agent draft result back button returns to workspace without redundant sync when session is already latest', async () => {
test('agent draft result back button returns to creation hub without redundant sync when session is already latest', async () => {
const user = userEvent.setup();
vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({
@@ -843,7 +896,7 @@ test('agent draft result back button returns to workspace without redundant sync
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(resultSession);
render(<TestWrapper />);
render(<TestWrapper withAuth />);
await openNewRpgCreation(user);
@@ -858,9 +911,7 @@ test('agent draft result back button returns to workspace without redundant sync
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(
screen.getByText('Agent工作区custom-world-agent-session-1'),
).toBeTruthy();
expect(screen.getByText('创作中心')).toBeTruthy();
});
expect(
@@ -968,7 +1019,7 @@ test('agent draft result auto-save persists the latest profile rebuilt from sync
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(syncedSession);
render(<TestWrapper />);
render(<TestWrapper withAuth />);
await openNewRpgCreation(user);

View File

@@ -20,6 +20,8 @@ import type {
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
PlatformBrowseHistoryEntry,
PlatformBrowseHistoryWriteEntry,
ProfileDashboardSummary,
ProfileSaveArchiveSummary,
} from '../../../packages/shared/src/contracts/runtime';
@@ -46,14 +48,6 @@ import {
writeCustomWorldAgentUiState,
} from '../../services/customWorldAgentUiState';
import { buildCustomWorldCreatorIntentFoundationText } from '../../services/customWorldCreatorIntent';
import {
hasPendingPlatformBrowseHistoryMigration,
markPlatformBrowseHistoryMigrated,
type PlatformBrowseHistoryEntry,
type PlatformBrowseHistoryWriteEntry,
readPlatformBrowseHistory,
writePlatformBrowseHistory,
} from '../../services/platformBrowseHistory';
import {
deleteCustomWorldProfile,
getCustomWorldGalleryDetail,
@@ -64,7 +58,6 @@ import {
listProfileSaveArchives,
publishCustomWorldProfile,
resumeProfileSaveArchive,
syncProfileBrowseHistory,
unpublishCustomWorldProfile,
upsertCustomWorldProfile,
upsertProfileBrowseHistory,
@@ -381,18 +374,11 @@ export function PreGameSelectionFlow({
const appendBrowseHistoryEntry = useCallback(
async (entry: PlatformBrowseHistoryWriteEntry) => {
const nextEntries = writePlatformBrowseHistory(authUi?.user, entry);
setHistoryEntries(nextEntries);
setHistoryError(null);
if (!authUi?.user) {
return;
}
try {
const syncedEntries = await upsertProfileBrowseHistory(entry);
setHistoryEntries(syncedEntries);
markPlatformBrowseHistoryMigrated(authUi?.user);
} catch (error) {
setHistoryError(resolveErrorMessage(error, '写入浏览历史失败。'));
}
@@ -444,8 +430,7 @@ export function PreGameSelectionFlow({
let isActive = true;
void (async () => {
const localHistoryEntries = readPlatformBrowseHistory(authUi?.user);
setHistoryEntries(localHistoryEntries);
setHistoryEntries([]);
setHistoryError(null);
setSaveError(null);
setIsLoadingPlatform(true);
@@ -472,22 +457,7 @@ export function PreGameSelectionFlow({
isAuthenticated ? listCustomWorldWorks() : Promise.resolve([]),
listCustomWorldGallery(),
isAuthenticated ? getProfileDashboard() : Promise.resolve(null),
isAuthenticated
? (async () => {
let nextEntries = await listProfileBrowseHistory();
if (
hasPendingPlatformBrowseHistoryMigration(authUi?.user) &&
localHistoryEntries.length > 0
) {
nextEntries =
await syncProfileBrowseHistory(localHistoryEntries);
markPlatformBrowseHistoryMigrated(authUi?.user);
}
return nextEntries;
})()
: Promise.resolve(localHistoryEntries),
isAuthenticated ? listProfileBrowseHistory() : Promise.resolve([]),
isAuthenticated ? listProfileSaveArchives() : Promise.resolve([]),
]);
if (!isActive) {
@@ -881,8 +851,6 @@ export function PreGameSelectionFlow({
const isAgentDraftGenerationView =
customWorldGenerationViewSource === 'agent-draft-foundation';
const isAgentDraftResultView = customWorldResultViewSource === 'agent-draft';
const isAgentDraftResultEditingFrozen =
customWorldResultViewSource === 'agent-draft';
const activeGenerationSettingText = agentDraftSettingPreview;
const activeGenerationProgress = agentDraftGenerationProgress;
const isActiveGenerationRunning =
@@ -1096,7 +1064,8 @@ export function PreGameSelectionFlow({
setCustomWorldAutoSaveState('idle');
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
setSelectionStage('agent-workspace');
setPlatformTab('create');
setSelectionStage('platform');
};
const retryAgentDraftGeneration = () => {
@@ -1133,17 +1102,39 @@ export function PreGameSelectionFlow({
const handleOpenCreationWork = useCallback(
async (work: CustomWorldWorkSummary) => {
if (work.status === 'draft' && work.sessionId) {
// 阶段二要求草稿优先回到 Agent 工作区,而不是再次自动顶回结果页。
isAgentDraftResultAutoOpenSuppressedRef.current = true;
persistAgentUiState(work.sessionId, null);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
const shouldOpenAgentWorkspace =
work.playableNpcCount <= 0 && work.landmarkCount <= 0;
if (shouldOpenAgentWorkspace) {
// 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。
isAgentDraftResultAutoOpenSuppressedRef.current = true;
setGeneratedCustomWorldProfile(null);
setCustomWorldResultViewSource(null);
setPlatformTab('create');
setSelectionStage('agent-workspace');
return;
}
isAgentDraftResultAutoOpenSuppressedRef.current = false;
const latestSession = await syncAgentSessionSnapshot(work.sessionId);
const nextProfile = buildCustomWorldProfileFromAgentDraft(latestSession);
if (!nextProfile) {
setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。');
setPlatformTab('create');
setSelectionStage('agent-workspace');
return;
}
setGeneratedCustomWorldProfile(normalizeAgentBackedProfile(nextProfile));
setCustomWorldResultViewSource('agent-draft');
setPlatformTab('create');
setSelectionStage('agent-workspace');
setSelectionStage('custom-world-result');
return;
}
@@ -1179,6 +1170,7 @@ export function PreGameSelectionFlow({
openLibraryDetail,
persistAgentUiState,
savedCustomWorldEntries,
syncAgentSessionSnapshot,
setSelectionStage,
],
);
@@ -1583,32 +1575,9 @@ export function PreGameSelectionFlow({
});
}}
onCreateNew={openCreationTypePicker}
onResumeDraft={(sessionId) => {
onOpenDraft={(item) => {
runProtectedAction(() => {
void handleOpenCreationWork({
workId: `draft:${sessionId}`,
sourceType: 'agent_session',
status: 'draft',
title: '',
subtitle: '',
summary: '',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: new Date().toISOString(),
publishedAt: null,
stage: null,
stageLabel: '',
playableNpcCount: 0,
landmarkCount: 0,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId,
profileId: null,
canResume: true,
canEnterWorld: false,
});
void handleOpenCreationWork(item);
});
}}
onEnterPublished={(profileId) => {
@@ -1918,10 +1887,10 @@ export function PreGameSelectionFlow({
});
});
}}
readOnly={isAgentDraftResultEditingFrozen}
readOnly={false}
compactAgentResultMode={isAgentDraftResultView}
backLabel={isAgentDraftResultView ? '返回创作' : undefined}
editActionLabel="去Agent调整设定"
editActionLabel="继续调整设定"
enterWorldActionLabel="进入世界"
autoSaveState={customWorldAutoSaveState}
/>

View File

@@ -3,11 +3,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
resolveServerRuntimeChoiceMock,
streamNpcChatTurnMock,
generateQuestForNpcEncounterMock,
} = vi.hoisted(() => ({
resolveServerRuntimeChoiceMock: vi.fn(),
streamNpcChatTurnMock: vi.fn(),
generateQuestForNpcEncounterMock: vi.fn(),
}));
vi.mock('./runtimeStoryCoordinator', () => ({
@@ -18,10 +16,6 @@ vi.mock('../../services/aiService', () => ({
streamNpcChatTurn: streamNpcChatTurnMock,
}));
vi.mock('../../services/questDirector', () => ({
generateQuestForNpcEncounter: generateQuestForNpcEncounterMock,
}));
import {
AnimationState,
type Character,
@@ -593,7 +587,6 @@ describe('npcEncounterActions', () => {
beforeEach(() => {
resolveServerRuntimeChoiceMock.mockReset();
streamNpcChatTurnMock.mockReset();
generateQuestForNpcEncounterMock.mockReset();
});
it.each([
@@ -1371,8 +1364,6 @@ describe('npcEncounterActions', () => {
}),
}),
);
expect(generateQuestForNpcEncounterMock).not.toHaveBeenCalled();
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState?.pendingQuestOffer?.quest).toEqual(pendingQuest);
expect(lastStory.npcAffinityEffect).toEqual({
@@ -1393,18 +1384,38 @@ describe('npcEncounterActions', () => {
);
});
it('replaces a pending quest offer by reusing the existing quest generator', async () => {
it('replaces a pending quest offer through the server runtime resolver', async () => {
const currentQuest = createQuest('quest-bridge-offer', '断桥口的密信');
const nextQuest = createQuest('quest-bridge-replaced', '断桥夜巡');
generateQuestForNpcEncounterMock.mockResolvedValueOnce(nextQuest);
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
hydratedSnapshot: {
gameState: createState(),
},
nextStory: createPendingQuestOfferStory(nextQuest),
});
const actions = createNpcEncounterActions({
currentStory: createPendingQuestOfferStory(currentQuest),
});
await expect(actions.replacePendingNpcQuestOffer()).resolves.toBe(true);
expect(actions.replacePendingNpcQuestOffer()).toBe(true);
await flushAsyncWork();
expect(generateQuestForNpcEncounterMock).toHaveBeenCalledTimes(1);
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith(
expect.objectContaining({
gameState: actions.gameState,
currentStory: actions.currentStory,
option: expect.objectContaining({
functionId: 'npc_chat_quest_offer_replace',
actionText: '你请断桥客换一份更合适的委托。',
interaction: {
kind: 'npc',
npcId: 'npc-rival',
action: 'quest_offer_replace',
},
}),
}),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState?.pendingQuestOffer?.quest).toEqual(nextQuest);
expect(lastStory.options.map((option) => option.actionText)).toEqual([
@@ -1485,14 +1496,78 @@ describe('npcEncounterActions', () => {
);
});
it('abandons a pending quest offer and returns to free npc chat', () => {
it('abandons a pending quest offer through the server runtime resolver', async () => {
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
hydratedSnapshot: {
gameState: createState(),
},
nextStory: {
...createPendingQuestOfferStory(pendingQuest),
options: [
createOption('npc_chat', '那先继续聊聊你刚才没说完的部分', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_chat', '除了委托,你对眼前局势还有什么判断', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_chat', '先把这附近真正危险的地方说清楚', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
],
dialogue: [
{
speaker: 'npc',
speakerName: '断桥客',
text: '这件事我只想托给你。',
},
{
speaker: 'player',
text: '这件事我先不接,咱们还是先聊别的。',
},
{
speaker: 'npc',
speakerName: '断桥客',
text: '那就先聊别的。',
},
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 2,
customInputPlaceholder: '输入你想对 TA 说的话',
pendingQuestOffer: null,
},
} satisfies StoryMoment,
});
const actions = createNpcEncounterActions({
currentStory: createPendingQuestOfferStory(pendingQuest),
});
expect(actions.abandonPendingNpcQuestOffer()).toBe(true);
await flushAsyncWork();
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith(
expect.objectContaining({
gameState: actions.gameState,
currentStory: actions.currentStory,
option: expect.objectContaining({
functionId: 'npc_chat_quest_offer_abandon',
actionText: '你暂时没有接下断桥客提出的委托。',
interaction: {
kind: 'npc',
npcId: 'npc-rival',
action: 'quest_offer_abandon',
},
}),
}),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState?.pendingQuestOffer).toBeNull();
expect(lastStory.options.map((option) => option.actionText)).toEqual([

View File

@@ -24,7 +24,6 @@ import type { StoryGenerationContext } from '../../services/aiTypes';
import {
resolveLimitedPrimaryNpcChatState,
} from '../../services/customWorldSceneActRuntime';
import { generateQuestForNpcEncounter } from '../../services/questDirector';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
@@ -1558,81 +1557,36 @@ export function createStoryNpcEncounterActions({
}
};
const replacePendingNpcQuestOffer = async () => {
const playerCharacter = gameState.playerCharacter;
const replacePendingNpcQuestOffer = () => {
const encounter = gameState.currentEncounter;
const pendingQuestOffer = isNpcEncounter(encounter)
? getPendingQuestOffer(currentStory, encounter)
: null;
if (!playerCharacter || !encounter || !pendingQuestOffer) {
if (!encounter || !pendingQuestOffer) {
return false;
}
const encounterKey = getNpcEncounterKey(encounter);
const currentNpcChatState =
currentStory?.npcChatState?.npcId === encounterKey
? currentStory.npcChatState
: null;
const currentDialogue =
currentStory?.dialogue && currentNpcChatState
? [...currentStory.dialogue]
: [];
const turnCount = currentNpcChatState?.turnCount ?? 0;
const playerLine = '能不能换一份更适合眼下局势的委托?';
const generationState = {
...gameState,
storyHistory: appendHistory(
gameState,
`你请${encounter.npcName}换一份更合适的委托。`,
`${encounter.npcName}重新斟酌起该交给你的事。`,
),
};
setAiError(null);
setIsLoading(true);
try {
const nextQuest = await generateQuestForNpcEncounter({
state: generationState,
encounter,
});
if (!nextQuest) {
setAiError('当前没有更合适的委托可供更换。');
return false;
}
setGameState(generationState);
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: [
...currentDialogue,
{
speaker: 'player',
text: playerLine,
},
{
speaker: 'npc',
speakerName: encounter.npcName,
text: buildQuestOfferDialogueText(encounter, nextQuest),
},
],
options: buildPendingQuestOfferOptions(encounter),
streaming: false,
turnCount,
pendingQuestOffer: {
quest: nextQuest,
},
}),
);
return true;
} catch (error) {
console.error('Failed to replace pending npc quest offer:', error);
setAiError(error instanceof Error ? error.message : '更换任务失败');
return false;
} finally {
setIsLoading(false);
}
void resolveServerNpcStoryAction({
option: {
functionId: 'npc_chat_quest_offer_replace',
actionText: `你请${encounter.npcName}换一份更合适的委托。`,
text: `你请${encounter.npcName}换一份更合适的委托。`,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'quest_offer_replace',
},
},
});
return true;
};
const abandonPendingNpcQuestOffer = () => {
@@ -1643,49 +1597,27 @@ export function createStoryNpcEncounterActions({
if (!encounter || !pendingQuestOffer) {
return false;
}
const encounterKey = getNpcEncounterKey(encounter);
const currentNpcChatState =
currentStory?.npcChatState?.npcId === encounterKey
? currentStory.npcChatState
: null;
const currentDialogue =
currentStory?.dialogue && currentNpcChatState
? [...currentStory.dialogue]
: [];
const turnCount = currentNpcChatState?.turnCount ?? 0;
const playerLine = '这件事我先不接,咱们还是先聊别的。';
const npcReply = `${encounter.npcName}点了点头,没有继续强求,只把这份委托暂时收了回去。`;
const nextState = {
...gameState,
storyHistory: appendHistory(
gameState,
`你暂时没有接下${encounter.npcName}提出的委托。`,
npcReply,
),
};
setGameState(nextState);
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: [
...currentDialogue,
{
speaker: 'player',
text: playerLine,
},
{
speaker: 'npc',
speakerName: encounter.npcName,
text: npcReply,
},
],
options: buildPostQuestOfferChatSuggestions(encounter),
streaming: false,
turnCount,
}),
);
void resolveServerNpcStoryAction({
option: {
functionId: 'npc_chat_quest_offer_abandon',
actionText: `你暂时没有接下${encounter.npcName}提出的委托。`,
text: `你暂时没有接下${encounter.npcName}提出的委托。`,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'quest_offer_abandon',
},
},
});
return true;
};

View File

@@ -1,23 +1,17 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
putSaveSnapshotMock,
getRuntimeStoryStateMock,
resolveRuntimeStoryActionMock,
getRuntimeSessionIdMock,
getRuntimeClientVersionMock,
} = vi.hoisted(() => ({
putSaveSnapshotMock: vi.fn(),
getRuntimeStoryStateMock: vi.fn(),
resolveRuntimeStoryActionMock: vi.fn(),
getRuntimeSessionIdMock: vi.fn(() => 'runtime-main'),
getRuntimeClientVersionMock: vi.fn(() => 0),
}));
vi.mock('../../services/storageService', () => ({
putSaveSnapshot: putSaveSnapshotMock,
}));
vi.mock('../../services/runtimeStoryService', async () => {
const actual =
await vi.importActual<typeof import('../../services/runtimeStoryService')>(
@@ -149,7 +143,6 @@ function createRuntimeNpcBattleSnapshot(
describe('runtimeStoryCoordinator', () => {
beforeEach(() => {
putSaveSnapshotMock.mockReset();
getRuntimeStoryStateMock.mockReset();
resolveRuntimeStoryActionMock.mockReset();
getRuntimeSessionIdMock.mockReset();
@@ -209,12 +202,15 @@ describe('runtimeStoryCoordinator', () => {
currentStory,
});
expect(putSaveSnapshotMock).toHaveBeenCalledWith({
gameState,
bottomTab: 'adventure',
currentStory,
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
sessionId: 'runtime-main',
clientVersion: 7,
snapshot: {
gameState,
bottomTab: 'adventure',
currentStory,
},
});
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith('runtime-main');
expect(options).toEqual([
expect.objectContaining({
functionId: 'npc_chat',
@@ -306,11 +302,6 @@ describe('runtimeStoryCoordinator', () => {
},
});
expect(putSaveSnapshotMock).toHaveBeenCalledWith({
gameState,
bottomTab: 'adventure',
currentStory,
});
expect(resolveRuntimeStoryActionMock).toHaveBeenCalledWith({
sessionId: 'runtime-main',
clientVersion: 7,
@@ -319,6 +310,11 @@ describe('runtimeStoryCoordinator', () => {
payload: {
note: 'server-runtime-test',
},
snapshot: {
gameState,
bottomTab: 'adventure',
currentStory,
},
});
expect(result.hydratedSnapshot).toBe(hydratedSnapshot);
expect(result.nextStory).toEqual(
@@ -414,7 +410,9 @@ describe('runtimeStoryCoordinator', () => {
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith('runtime-main');
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
sessionId: 'runtime-main',
});
expect(result.hydratedSnapshot).toBe(serverHydratedSnapshot);
expect(result.nextStory).toEqual(
expect.objectContaining({
@@ -614,6 +612,9 @@ describe('runtimeStoryCoordinator', () => {
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
sessionId: 'runtime-main',
});
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
expect.objectContaining({
id: 'npc-bandit',

View File

@@ -5,11 +5,11 @@ import {
getRuntimeSessionId,
getRuntimeStoryState,
resolveRuntimeStoryAction,
type RuntimeStorySnapshotRequest,
resolveRuntimeStoryMoment,
type RuntimeStoryChoicePayload,
type RuntimeStoryResponse,
} from '../../services/runtimeStoryService';
import { putSaveSnapshot } from '../../services/storageService';
import type { GameState, StoryMoment, StoryOption } from '../../types';
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
@@ -18,26 +18,26 @@ function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
: response.presentation.options;
}
async function syncRuntimeSnapshot(
function buildRuntimeSnapshotRequest(
gameState: GameState,
currentStory: StoryMoment | null,
) {
await putSaveSnapshot({
return {
gameState,
bottomTab: 'adventure',
currentStory,
});
} satisfies RuntimeStorySnapshotRequest;
}
export async function loadServerRuntimeOptionCatalog(params: {
gameState: GameState;
currentStory: StoryMoment | null;
}) {
await syncRuntimeSnapshot(params.gameState, params.currentStory);
const response = await getRuntimeStoryState(
getRuntimeSessionId(params.gameState),
);
const response = await getRuntimeStoryState({
sessionId: getRuntimeSessionId(params.gameState),
clientVersion: getRuntimeClientVersion(params.gameState),
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
});
const options = resolveRuntimeStoryMoment({
response,
hydratedSnapshot: response.snapshot,
@@ -64,9 +64,9 @@ export async function resumeServerRuntimeStory(
};
}
const response = await getRuntimeStoryState(
getRuntimeSessionId(hydratedSnapshot.gameState),
);
const response = await getRuntimeStoryState({
sessionId: getRuntimeSessionId(hydratedSnapshot.gameState),
});
const resumedSnapshot = rehydrateSavedSnapshot(response.snapshot);
const runtimeOptions = getRuntimeResponseOptions(response);
const nextStory =
@@ -96,8 +96,6 @@ export async function resolveServerRuntimeChoice(params: {
Partial<Pick<StoryOption, 'interaction'>>;
payload?: RuntimeStoryChoicePayload;
}) {
await syncRuntimeSnapshot(params.gameState, params.currentStory);
const response = await resolveRuntimeStoryAction({
sessionId: getRuntimeSessionId(params.gameState),
clientVersion: getRuntimeClientVersion(params.gameState),
@@ -107,6 +105,7 @@ export async function resolveServerRuntimeChoice(params: {
? params.option.interaction.npcId
: undefined,
payload: params.payload,
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
});
const hydratedSnapshot = rehydrateSavedSnapshot(response.snapshot);

View File

@@ -82,7 +82,7 @@ export interface QuestFlowUi {
}
export interface NpcChatQuestOfferUi {
replacePendingOffer: () => Promise<boolean>;
replacePendingOffer: () => boolean;
abandonPendingOffer: () => boolean;
acceptPendingOffer: () => string | null;
}

View File

@@ -353,8 +353,37 @@ export async function generateCustomWorldProfile(
input: GenerateCustomWorldProfileInput | string,
options: GenerateCustomWorldProfileOptions = {},
): Promise<CustomWorldProfile> {
const aiClient = await loadLegacyAiModule();
return aiClient.generateCustomWorldProfile(input, options);
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 requestPostJson<CustomWorldProfile>(
`${RUNTIME_API_BASE}/custom-world/profile`,
normalizedInput,
'生成自定义世界失败',
);
if (options.signal?.aborted) {
throw options.signal.reason instanceof Error
? options.signal.reason
: new Error('世界生成已中断。');
}
return profile;
}
export async function generateCustomWorldSceneImage(

View File

@@ -1,33 +1,12 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
AUTH_STATE_EVENT,
ApiClientError,
clearStoredAccessToken,
fetchWithApiAuth,
getStoredAccessToken,
requestJson,
setStoredAccessToken,
} from './apiClient';
function createMemoryStorage() {
const values = new Map<string, string>();
return {
getItem(key: string) {
return values.has(key) ? values.get(key)! : null;
},
setItem(key: string, value: string) {
values.set(key, value);
},
removeItem(key: string) {
values.delete(key);
},
clear() {
values.clear();
},
};
}
function createResponseMock(params: {
status: number;
body?: string;
@@ -54,50 +33,18 @@ function createResponseMock(params: {
describe('apiClient', () => {
const fetchMock = vi.fn();
const dispatchEventMock = vi.fn();
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('window', {
localStorage: createMemoryStorage(),
dispatchEvent: vi.fn(),
dispatchEvent: dispatchEventMock,
});
fetchMock.mockReset();
clearStoredAccessToken();
dispatchEventMock.mockReset();
});
it('attaches auth headers and clears stale tokens on unauthorized responses', async () => {
setStoredAccessToken('jwt-token');
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
const response = await fetchWithApiAuth('/api/protected', { method: 'GET' });
expect(response.status).toBe(401);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledWith(
'/api/protected',
expect.objectContaining({
credentials: 'same-origin',
headers: expect.objectContaining({
Authorization: 'Bearer jwt-token',
'x-genarrative-response-envelope': 'v1',
}),
}),
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/api/auth/refresh',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
}),
);
expect(getStoredAccessToken()).toBe('');
});
it('refreshes the access token once and retries the original request', async () => {
setStoredAccessToken('expired-token');
it('refreshes cookie session once and retries the original request', async () => {
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockResolvedValueOnce(
@@ -106,7 +53,7 @@ describe('apiClient', () => {
body: JSON.stringify({
ok: true,
data: {
token: 'fresh-token',
ok: true,
},
error: null,
meta: {
@@ -138,41 +85,115 @@ describe('apiClient', () => {
);
expect(result).toEqual({ value: 7 });
expect(getStoredAccessToken()).toBe('fresh-token');
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/api/runtime/protected',
expect.objectContaining({
credentials: 'same-origin',
headers: expect.objectContaining({
'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({
headers: expect.objectContaining({
Authorization: 'Bearer fresh-token',
}),
credentials: 'same-origin',
}),
);
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
expect(dispatchEventMock).toHaveBeenCalledWith(
expect.objectContaining({
type: AUTH_STATE_EVENT,
}),
);
});
it('does not refresh or emit auth changes for 401 responses without auth context', async () => {
it('does not emit auth change events when 401 probe requests opt into silent mode', async () => {
fetchMock.mockResolvedValueOnce(createResponseMock({ status: 401 }));
const response = await fetchWithApiAuth(
'/api/auth/me',
{
method: 'GET',
},
{
notifyAuthStateChange: false,
skipRefresh: true,
},
);
expect(response.status).toBe(401);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(dispatchEventMock).not.toHaveBeenCalled();
});
it('emits auth change events when refresh fails on protected requests', async () => {
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockResolvedValueOnce(createResponseMock({ status: 401 }));
const response = await fetchWithApiAuth('/api/runtime/protected', {
method: 'GET',
});
expect(response.status).toBe(401);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(dispatchEventMock).toHaveBeenCalledTimes(1);
});
it('accepts refresh responses that only acknowledge renewed cookie state', async () => {
fetchMock
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
.mockResolvedValueOnce(
createResponseMock({
status: 200,
body: JSON.stringify({
ok: true,
data: {
ok: true,
},
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',
expect.objectContaining({
credentials: 'same-origin',
}),
{ method: 'GET' },
'读取受保护数据失败',
);
expect(window.dispatchEvent).not.toHaveBeenCalled();
expect(result).toEqual({ value: 9 });
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('retries transient get requests before unwrapping the response envelope', async () => {

View File

@@ -7,8 +7,8 @@ import {
parseApiErrorMessage,
unwrapApiResponse,
} from '../../packages/shared/src/http';
import type { AuthRefreshResponse } from '../../packages/shared/src/contracts/auth';
const ACCESS_TOKEN_KEY = 'genarrative.auth.access-token.v1';
export const AUTH_STATE_EVENT = 'genarrative-auth-state-changed';
const REQUEST_ID_HEADER = 'x-request-id';
const API_VERSION_HEADER = 'x-api-version';
@@ -30,6 +30,8 @@ export type ApiRequestOptions = {
skipAuth?: boolean;
omitEnvelopeHeader?: boolean;
skipRefresh?: boolean;
// 会话探测类请求需要静默处理 401避免 AuthGate 因自发广播再次触发 hydrate。
notifyAuthStateChange?: boolean;
};
type ResolvedRetryOptions = {
@@ -48,10 +50,6 @@ type ParsedApiErrorShape = {
meta: Partial<ApiMeta>;
};
type RefreshTokenResponse = {
token: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
@@ -311,11 +309,7 @@ export class ApiClientError extends Error {
}
}
function canUseLocalStorage() {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
}
function emitAuthStateChange() {
export function emitAuthStateChange() {
if (typeof window === 'undefined') {
return;
}
@@ -330,72 +324,18 @@ function emitAuthStateChange() {
}
}
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(
function withApiHeaders(
headers?: HeadersInit,
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader' | 'skipAuth'> = {},
options: Pick<ApiRequestOptions, 'omitEnvelopeHeader'> = {},
) {
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;
let refreshAccessTokenPromise: Promise<void> | null = null;
async function refreshAccessToken() {
if (refreshAccessTokenPromise) {
@@ -412,24 +352,19 @@ async function refreshAccessToken() {
});
if (!response.ok) {
clearStoredAccessToken();
throw await buildApiClientError(response, '刷新登录状态失败');
}
const responseText = await response.text();
const payload = responseText
? unwrapApiResponse<RefreshTokenResponse>(
JSON.parse(responseText) as RefreshTokenResponse,
? unwrapApiResponse<AuthRefreshResponse>(
JSON.parse(responseText) as AuthRefreshResponse,
)
: null;
if (!payload?.token?.trim()) {
clearStoredAccessToken();
if (payload?.ok !== true) {
throw new Error('刷新登录状态失败');
}
setStoredAccessToken(payload.token, { emit: false });
return payload.token;
})();
try {
@@ -446,25 +381,20 @@ export async function fetchWithApiAuth(
) {
const method = (init.method ?? 'GET').toUpperCase();
const retry = resolveRetryOptions(method, options.retry);
const shouldNotifyAuthStateChange = options.notifyAuthStateChange !== false;
let attempt = 0;
let refreshAttempted = false;
for (;;) {
try {
const requestHeaders = withAuthorizationHeaders(init.headers, options);
const hasAuthHeader = Boolean(
requestHeaders.Authorization?.trim() ||
requestHeaders.authorization?.trim(),
);
const response = await fetch(input, {
credentials: 'same-origin',
...init,
headers: requestHeaders,
headers: withApiHeaders(init.headers, options),
});
if (
response.status === 401 &&
hasAuthHeader &&
!options.skipAuth &&
!options.skipRefresh &&
!refreshAttempted
@@ -472,12 +402,19 @@ export async function fetchWithApiAuth(
try {
await refreshAccessToken();
refreshAttempted = true;
if (shouldNotifyAuthStateChange) {
emitAuthStateChange();
}
continue;
} catch {
clearStoredAccessToken();
if (shouldNotifyAuthStateChange) {
emitAuthStateChange();
}
}
} else if (response.status === 401 && !options.skipAuth) {
if (shouldNotifyAuthStateChange) {
emitAuthStateChange();
}
} else if (response.status === 401) {
clearStoredAccessToken();
}
if (!shouldRetryResponse(response.status, attempt, retry)) {

View File

@@ -1,15 +1,20 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { requestJsonMock } = vi.hoisted(() => ({
requestJsonMock: vi.fn(),
const apiClientMocks = vi.hoisted(() => ({
emitAuthStateChange: vi.fn(),
requestJson: vi.fn(),
}));
import {
ApiClientError,
clearStoredAccessToken,
getStoredAccessToken,
setStoredAccessToken,
} from './apiClient';
vi.mock('./apiClient', async () => {
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
return {
...actual,
emitAuthStateChange: apiClientMocks.emitAuthStateChange,
requestJson: apiClientMocks.requestJson,
};
});
import { ApiClientError } from './apiClient';
import {
authEntryWithStoredCredentials,
bindWechatPhone,
@@ -22,49 +27,34 @@ import {
getAuthRiskBlocks,
getAuthSessions,
getCaptchaChallengeFromError,
getCurrentAuthUser,
liftAuthRiskBlock,
loginWithPhoneCode,
logoutAllAuthSessions,
revokeAuthSession,
sendPhoneLoginCode,
startWechatLogin,
} from './authService';
function createMemoryStorage() {
const values = new Map<string, string>();
function createWindowMock(overrides: Record<string, unknown> = {}) {
return {
getItem(key: string) {
return values.has(key) ? values.get(key)! : null;
dispatchEvent: vi.fn(),
location: {
pathname: '/',
hash: '',
search: '',
assign: vi.fn(),
},
setItem(key: string, value: string) {
values.set(key, value);
},
removeItem(key: string) {
values.delete(key);
},
clear() {
values.clear();
history: {
replaceState: vi.fn(),
},
...overrides,
};
}
vi.mock('./apiClient', async () => {
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
return {
...actual,
requestJson: requestJsonMock,
};
});
describe('authService auto auth', () => {
describe('authService', () => {
beforeEach(() => {
vi.stubGlobal('window', {
localStorage: createMemoryStorage(),
dispatchEvent: vi.fn(),
});
requestJsonMock.mockReset();
clearStoredAccessToken();
vi.clearAllMocks();
vi.stubGlobal('window', createWindowMock());
});
it('creates credentials that match current username/password constraints', () => {
@@ -75,9 +65,8 @@ describe('authService auto auth', () => {
expect(credentials.password.length).toBeGreaterThanOrEqual(6);
});
it('stores jwt after auth entry without persisting guest credentials locally', async () => {
requestJsonMock.mockResolvedValue({
token: 'jwt-token-value',
it('auth entry trims guest credentials and emits auth state changes', async () => {
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_1',
username: 'guest_abc123abc123',
@@ -95,8 +84,7 @@ describe('authService auto auth', () => {
});
expect(user.username).toBe('guest_abc123abc123');
expect(getStoredAccessToken()).toBe('jwt-token-value');
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/entry',
expect.objectContaining({
body: JSON.stringify({
@@ -106,11 +94,11 @@ describe('authService auto auth', () => {
}),
'登录失败',
);
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
});
it('creates a fresh guest credential pair for auto auth when a session is missing', async () => {
requestJsonMock.mockResolvedValue({
token: 'jwt-restored',
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_saved',
username: 'guest_saveduser01',
@@ -124,7 +112,7 @@ describe('authService auto auth', () => {
const result = await ensureAutoAuthUser();
const authEntryBody = JSON.parse(
requestJsonMock.mock.calls[0]?.[1]?.body as string,
apiClientMocks.requestJson.mock.calls[0]?.[1]?.body as string,
) as {
username: string;
password: string;
@@ -136,19 +124,11 @@ describe('authService auto auth', () => {
/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u,
);
expect(authEntryBody).toEqual(result.credentials);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/entry',
expect.objectContaining({
method: 'POST',
body: expect.any(String),
}),
'登录失败',
);
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1);
});
it('deduplicates concurrent auto auth requests', async () => {
requestJsonMock.mockResolvedValue({
token: 'jwt-auto',
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_auto',
username: 'guest_auto',
@@ -165,19 +145,12 @@ describe('authService auto auth', () => {
ensureAutoAuthUser(),
]);
expect(requestJsonMock).toHaveBeenCalledTimes(1);
expect(apiClientMocks.requestJson).toHaveBeenCalledTimes(1);
expect(firstResult).toEqual(secondResult);
const authEntryBody = JSON.parse(
requestJsonMock.mock.calls[0]?.[1]?.body as string,
) as {
username: string;
password: string;
};
expect(authEntryBody).toEqual(firstResult.credentials);
});
it('sends phone login code through the new auth endpoint', async () => {
requestJsonMock.mockResolvedValue({
it('sends phone login code through the auth endpoint', async () => {
apiClientMocks.requestJson.mockResolvedValue({
ok: true,
cooldownSeconds: 60,
expiresInSeconds: 300,
@@ -187,7 +160,7 @@ describe('authService auto auth', () => {
const result = await sendPhoneLoginCode(' 138 0013 8000 ');
expect(result.cooldownSeconds).toBe(60);
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/phone/send-code',
expect.objectContaining({
body: JSON.stringify({
@@ -199,28 +172,6 @@ describe('authService auto auth', () => {
);
});
it('sends phone change code with the correct scene', async () => {
requestJsonMock.mockResolvedValue({
ok: true,
cooldownSeconds: 60,
expiresInSeconds: 300,
providerRequestId: 'mock-request-id',
});
await sendPhoneLoginCode('13900139000', 'change_phone');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/phone/send-code',
expect.objectContaining({
body: JSON.stringify({
phone: '13900139000',
scene: 'change_phone',
}),
}),
'发送验证码失败',
);
});
it('extracts captcha challenge details from api errors', () => {
expect(getCaptchaChallengeFromError(new Error('plain error'))).toBeNull();
@@ -246,9 +197,8 @@ describe('authService auto auth', () => {
});
});
it('stores jwt after phone login', async () => {
requestJsonMock.mockResolvedValue({
token: 'phone-jwt-token',
it('emits auth state changes after phone login', async () => {
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_phone',
username: '138****8000',
@@ -263,8 +213,7 @@ describe('authService auto auth', () => {
const user = await loginWithPhoneCode('13800138000', '123456');
expect(user.username).toBe('138****8000');
expect(getStoredAccessToken()).toBe('phone-jwt-token');
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/phone/login',
expect.objectContaining({
body: JSON.stringify({
@@ -274,11 +223,11 @@ describe('authService auto auth', () => {
}),
'登录失败',
);
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
});
it('binds wechat phone and stores jwt after activation', async () => {
requestJsonMock.mockResolvedValue({
token: 'wechat-bind-token',
it('emits auth state changes after wechat bind activation', async () => {
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_wechat',
username: '138****8000',
@@ -293,22 +242,11 @@ describe('authService auto auth', () => {
const user = await bindWechatPhone('13800138000', '123456');
expect(user.wechatBound).toBe(true);
expect(getStoredAccessToken()).toBe('wechat-bind-token');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/wechat/bind-phone',
expect.objectContaining({
body: JSON.stringify({
phone: '13800138000',
code: '123456',
}),
}),
'绑定手机号失败',
);
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
});
it('changes phone number without replacing the stored access token', async () => {
setStoredAccessToken('active-token');
requestJsonMock.mockResolvedValue({
it('changes phone number without emitting a global auth state refresh', async () => {
apiClientMocks.requestJson.mockResolvedValue({
user: {
id: 'user_phone',
username: '139****9000',
@@ -323,41 +261,29 @@ describe('authService auto auth', () => {
const user = await changePhoneNumber('13900139000', '123456');
expect(user.phoneNumberMasked).toBe('139****9000');
expect(getStoredAccessToken()).toBe('active-token');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/phone/change',
expect.objectContaining({
body: JSON.stringify({
phone: '13900139000',
code: '123456',
}),
}),
'更换手机号失败',
);
expect(apiClientMocks.emitAuthStateChange).not.toHaveBeenCalled();
});
it('starts wechat login by navigating to backend authorization url', async () => {
const assignMock = vi.fn();
vi.stubGlobal('window', {
localStorage: createMemoryStorage(),
dispatchEvent: vi.fn(),
location: {
pathname: '/',
hash: '',
search: '',
assign: assignMock,
},
history: {
replaceState: vi.fn(),
},
});
requestJsonMock.mockResolvedValue({
vi.stubGlobal(
'window',
createWindowMock({
location: {
pathname: '/',
hash: '',
search: '',
assign: assignMock,
},
}),
);
apiClientMocks.requestJson.mockResolvedValue({
authorizationUrl: '/api/auth/wechat/callback?mock_code=wx-user&state=state123',
});
await startWechatLogin();
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/wechat/start?redirectPath=%2F',
expect.objectContaining({
method: 'GET',
@@ -370,14 +296,14 @@ describe('authService auto auth', () => {
});
it('loads available login methods for the unauthenticated login screen', async () => {
requestJsonMock.mockResolvedValue({
apiClientMocks.requestJson.mockResolvedValue({
availableLoginMethods: ['phone', 'wechat'],
});
const result = await getAuthLoginOptions();
expect(result.availableLoginMethods).toEqual(['phone', 'wechat']);
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/login-options',
expect.objectContaining({
method: 'GET',
@@ -386,20 +312,22 @@ describe('authService auto auth', () => {
);
});
it('consumes auth callback hash and stores token', () => {
it('consumes auth callback hash without trying to persist tokens locally', () => {
const replaceStateMock = vi.fn();
vi.stubGlobal('window', {
localStorage: createMemoryStorage(),
dispatchEvent: vi.fn(),
location: {
pathname: '/',
search: '',
hash: '#auth_provider=wechat&auth_token=wx-token&auth_binding_status=pending_bind_phone',
},
history: {
replaceState: replaceStateMock,
},
});
vi.stubGlobal(
'window',
createWindowMock({
location: {
pathname: '/',
search: '',
hash: '#auth_provider=wechat&auth_binding_status=pending_bind_phone',
assign: vi.fn(),
},
history: {
replaceState: replaceStateMock,
},
}),
);
const result = consumeAuthCallbackResult();
@@ -408,12 +336,36 @@ describe('authService auto auth', () => {
bindingStatus: 'pending_bind_phone',
error: null,
});
expect(getStoredAccessToken()).toBe('wx-token');
expect(apiClientMocks.emitAuthStateChange).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 auth sessions from account center endpoint', async () => {
requestJsonMock.mockResolvedValue({
apiClientMocks.requestJson.mockResolvedValue({
sessions: [
{
sessionId: 'usess_1',
@@ -432,17 +384,10 @@ describe('authService auto auth', () => {
const sessions = await getAuthSessions();
expect(sessions).toHaveLength(1);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/sessions',
expect.objectContaining({
method: 'GET',
}),
'读取登录设备失败',
);
});
it('loads recent auth audit logs', async () => {
requestJsonMock.mockResolvedValue({
apiClientMocks.requestJson.mockResolvedValue({
logs: [
{
id: 'audit_1',
@@ -459,17 +404,10 @@ describe('authService auto auth', () => {
const logs = await getAuthAuditLogs();
expect(logs).toHaveLength(1);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/audit-logs',
expect.objectContaining({
method: 'GET',
}),
'读取账号操作记录失败',
);
});
it('loads current risk blocks', async () => {
requestJsonMock.mockResolvedValue({
apiClientMocks.requestJson.mockResolvedValue({
blocks: [
{
scopeType: 'phone',
@@ -484,23 +422,16 @@ describe('authService auto auth', () => {
const blocks = await getAuthRiskBlocks();
expect(blocks).toHaveLength(1);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/risk-blocks',
expect.objectContaining({
method: 'GET',
}),
'读取安全状态失败',
);
});
it('lifts a risk block by scope type', async () => {
requestJsonMock.mockResolvedValue({
apiClientMocks.requestJson.mockResolvedValue({
ok: true,
});
await liftAuthRiskBlock('phone');
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/risk-blocks/phone/lift',
expect.objectContaining({
method: 'POST',
@@ -509,37 +440,20 @@ describe('authService auto auth', () => {
);
});
it('revokes a remote auth session by id', async () => {
requestJsonMock.mockResolvedValue({
ok: true,
});
await revokeAuthSession('usess_123');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/auth/sessions/usess_123/revoke',
expect.objectContaining({
method: 'POST',
}),
'移除登录设备失败',
);
});
it('clears local auth state after logout all sessions', async () => {
setStoredAccessToken('stale-token');
requestJsonMock.mockResolvedValue({
it('emits auth change after logout all sessions', async () => {
apiClientMocks.requestJson.mockResolvedValue({
ok: true,
});
await logoutAllAuthSessions();
expect(requestJsonMock).toHaveBeenCalledWith(
expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/logout-all',
expect.objectContaining({
method: 'POST',
}),
'退出全部设备失败',
);
expect(getStoredAccessToken()).toBe('');
expect(apiClientMocks.emitAuthStateChange).toHaveBeenCalledTimes(1);
});
});

View File

@@ -23,9 +23,8 @@ import type {
} from '../../packages/shared/src/contracts/auth';
import {
ApiClientError,
clearStoredAccessToken,
emitAuthStateChange,
requestJson,
setStoredAccessToken,
} from './apiClient';
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
@@ -117,7 +116,7 @@ export function createAutoAuthCredentials(): AutoAuthCredentials {
}
export function clearAuthSession() {
clearStoredAccessToken();
emitAuthStateChange();
}
export async function sendPhoneLoginCode(
@@ -160,7 +159,7 @@ export async function loginWithPhoneCode(phone: string, code: string) {
'登录失败',
);
setStoredAccessToken(response.token);
emitAuthStateChange();
return response.user;
}
@@ -178,7 +177,7 @@ export async function bindWechatPhone(phone: string, code: string) {
'绑定手机号失败',
);
setStoredAccessToken(response.token);
emitAuthStateChange();
return response.user;
}
@@ -233,7 +232,7 @@ export async function authEntry(username: string, password: string) {
'登录失败',
);
setStoredAccessToken(response.token);
emitAuthStateChange();
return response.user;
}
@@ -279,19 +278,14 @@ export function consumeAuthCallbackResult(): ConsumedAuthCallback | 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) {
if (!bindingStatus && !authError && !providerValue) {
return null;
}
if (authToken) {
setStoredAccessToken(authToken);
}
if (typeof window.history?.replaceState === 'function') {
window.history.replaceState(
null,
@@ -314,6 +308,10 @@ export async function getCurrentAuthUser(): Promise<AuthSessionSnapshot> {
method: 'GET',
},
'读取当前用户失败',
{
// 会话恢复阶段允许 401 作为“未登录”信号,不应再广播一次全局鉴权事件。
notifyAuthStateChange: false,
},
);
return {

View File

@@ -777,3 +777,85 @@ test('embedded legacy result profile keeps result-page settings in runtime chara
'守灯会值夜人,对外总像比别人更冷静一步。',
);
});
test('embedded legacy result profile uses latest draft role collection when legacy role ids drift', () => {
const profile = buildCustomWorldProfileFromAgentDraft({
...session,
draftProfile: {
...session.draftProfile,
legacyResultProfile: buildLegacyResultProfile(),
storyNpcs: [
{
id: 'story-npc-latest-1',
name: '林教授',
title: '深海学院导师',
role: '场景关键角色',
publicIdentity: '研究古代海洋遗迹的资深学者。',
publicMask: '总是先观察,再给出判断。',
currentPressure: '必须在遗迹崩塌前带出关键样本。',
hiddenHook: '他知道遗迹深处那扇门为何会苏醒。',
relationToPlayer: '最早愿意共享海图的人',
threadIds: ['thread-1'],
summary: '他像学者,也像提前看见灾变的人。',
imageSrc:
'/generated-characters/story-npc-latest-1/visual/asset-latest/master.png',
generatedVisualAssetId: 'asset-latest-story',
},
],
sceneChapters: [
{
id: 'scene-chapter-latest-1',
sceneId: 'landmark-1',
sceneName: '回潮旧灯塔',
title: '灯塔新章',
summary: '围绕林教授推进的新章节。',
linkedThreadIds: ['thread-1'],
linkedLandmarkIds: ['landmark-1'],
acts: [
{
id: 'scene-act-latest-1',
title: '第一幕',
summary: '先接林教授的入口信息。',
stageCoverage: ['opening'],
backgroundImageSrc:
'/generated-custom-world-scenes/landmark-1/scene-act-latest-1/scene.png',
backgroundAssetId: 'scene-asset-latest',
encounterNpcIds: ['story-npc-latest-1'],
primaryNpcId: 'story-npc-latest-1',
linkedThreadIds: ['thread-1'],
actGoal: '接住新的入口信息',
transitionHook: '向下一幕推进。',
advanceRule: 'after_primary_contact',
},
],
},
],
landmarks: [
{
...session.draftProfile.landmarks[0],
imageSrc: '/generated-custom-world-scenes/landmark-1/latest-scene.png',
},
],
},
});
expect(profile?.storyNpcs).toHaveLength(1);
expect(profile?.storyNpcs[0]?.id).toBe('story-npc-latest-1');
expect(profile?.storyNpcs[0]?.name).toBe('林教授');
expect(profile?.storyNpcs[0]?.imageSrc).toBe(
'/generated-characters/story-npc-latest-1/visual/asset-latest/master.png',
);
expect(profile?.storyNpcs[0]?.generatedVisualAssetId).toBe(
'asset-latest-story',
);
expect(profile?.storyNpcs[0]?.narrativeProfile).toBeFalsy();
expect(profile?.landmarks[0]?.imageSrc).toBe(
'/generated-custom-world-scenes/landmark-1/latest-scene.png',
);
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.primaryNpcId).toBe(
'story-npc-latest-1',
);
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundImageSrc).toBe(
'/generated-custom-world-scenes/landmark-1/scene-act-latest-1/scene.png',
);
});

View File

@@ -147,6 +147,13 @@ type AdaptedDraftLandmark = {
connections: never[];
};
type AdaptedDraftCamp = {
name: string;
description: string;
dangerLevel: string;
imageSrc?: string;
};
function adaptDraftLandmarks(value: unknown, storyNpcIdSet: Set<string>) {
return toRecordArray(value)
.map((record, index) => {
@@ -178,108 +185,225 @@ function adaptDraftLandmarks(value: unknown, storyNpcIdSet: Set<string>) {
.filter(Boolean) as AdaptedDraftLandmark[];
}
function mergeDraftRoleAssetsIntoProfile(
baseProfile: CustomWorldProfile,
draftRoles: AdaptedDraftCharacter[],
roleKind: 'playable' | 'story',
) {
const draftRoleById = new Map(draftRoles.map((role) => [role.id, role]));
const currentRoles =
roleKind === 'playable' ? baseProfile.playableNpcs : baseProfile.storyNpcs;
const mergedRoles = currentRoles.map((role) => {
const draftRole = draftRoleById.get(role.id);
if (!draftRole) {
return role;
}
function adaptDraftCamp(value: unknown): AdaptedDraftCamp | null {
if (!isRecord(value)) {
return null;
}
return {
...role,
imageSrc: draftRole.imageSrc ?? role.imageSrc,
generatedVisualAssetId:
draftRole.generatedVisualAssetId ?? role.generatedVisualAssetId,
generatedAnimationSetId:
draftRole.generatedAnimationSetId ?? role.generatedAnimationSetId,
animationMap: draftRole.animationMap ?? role.animationMap,
};
});
if (roleKind === 'playable') {
return {
...baseProfile,
playableNpcs: mergedRoles,
} satisfies CustomWorldProfile;
const name = toText(value.name);
const description = toText(value.description);
if (!name && !description) {
return null;
}
return {
...baseProfile,
storyNpcs: mergedRoles,
} satisfies CustomWorldProfile;
name: name || '开局据点',
description: description || '开局落脚点仍待继续精修。',
dangerLevel:
toText(value.dangerLevel) || toText(value.mood) || 'medium',
imageSrc: toText(value.imageSrc) || undefined,
} satisfies AdaptedDraftCamp;
}
function mergeDraftSceneAssetsIntoProfile(
baseProfile: CustomWorldProfile,
draftSceneChapters: CustomWorldProfile['sceneChapterBlueprints'],
draftLandmarks: AdaptedDraftLandmark[],
function normalizeMatchText(value: unknown) {
return toText(value).toLocaleLowerCase();
}
function findRecordMatchIndex(
records: Record<string, unknown>[],
matcher: (record: Record<string, unknown>) => boolean,
usedIndexes: Set<number>,
) {
const normalizedDraftSceneChapters = draftSceneChapters ?? [];
const draftSceneChapterBySceneId = new Map(
normalizedDraftSceneChapters.map((chapter) => [chapter.sceneId, chapter]),
const matchedIndex = records.findIndex(
(record, index) => !usedIndexes.has(index) && matcher(record),
);
const draftLandmarkById = new Map(draftLandmarks.map((entry) => [entry.id, entry]));
if (matchedIndex >= 0) {
usedIndexes.add(matchedIndex);
}
return matchedIndex;
}
const nextCamp = baseProfile.camp
? {
...baseProfile.camp,
imageSrc: baseProfile.camp.imageSrc,
}
: baseProfile.camp;
function mergeDraftRolesIntoProfileRecord(params: {
baseRoles: unknown;
draftRoles: AdaptedDraftCharacter[];
}) {
const baseRoles = toRecordArray(params.baseRoles);
if (params.draftRoles.length <= 0) {
return baseRoles;
}
const nextLandmarks = baseProfile.landmarks.map((landmark) => {
const draftLandmark = draftLandmarkById.get(landmark.id);
const usedIndexes = new Set<number>();
// 当前 draft 才是最新角色集合legacy 只负责为同一对象补运行时富字段,
// 不能再让旧列表继续主导结果页,否则会把新角色主图和新对象列表吞掉。
return params.draftRoles.map((draftRole) => {
let matchedIndex = findRecordMatchIndex(
baseRoles,
(record) => toText(record.id) === draftRole.id,
usedIndexes,
);
if (matchedIndex < 0) {
matchedIndex = findRecordMatchIndex(
baseRoles,
(record) => normalizeMatchText(record.name) === normalizeMatchText(draftRole.name),
usedIndexes,
);
}
const baseRole = matchedIndex >= 0 ? baseRoles[matchedIndex] : null;
const baseImageSrc = toText(baseRole?.imageSrc) || undefined;
const baseGeneratedVisualAssetId =
toText(baseRole?.generatedVisualAssetId) || undefined;
const baseGeneratedAnimationSetId =
toText(baseRole?.generatedAnimationSetId) || undefined;
return {
...landmark,
imageSrc: draftLandmark?.imageSrc ?? landmark.imageSrc,
};
...(baseRole ?? {}),
...draftRole,
imageSrc: draftRole.imageSrc ?? baseImageSrc,
generatedVisualAssetId:
draftRole.generatedVisualAssetId ?? baseGeneratedVisualAssetId,
generatedAnimationSetId:
draftRole.generatedAnimationSetId ?? baseGeneratedAnimationSetId,
animationMap:
draftRole.animationMap ??
(isRecord(baseRole?.animationMap) ? baseRole?.animationMap : undefined),
} satisfies Record<string, unknown>;
});
}
function mergeDraftLandmarksIntoProfileRecord(params: {
baseLandmarks: unknown;
draftLandmarks: AdaptedDraftLandmark[];
}) {
const baseLandmarks = toRecordArray(params.baseLandmarks);
if (params.draftLandmarks.length <= 0) {
return baseLandmarks;
}
const usedIndexes = new Set<number>();
const mergedLandmarks = params.draftLandmarks.map((draftLandmark) => {
let matchedIndex = findRecordMatchIndex(
baseLandmarks,
(record) => toText(record.id) === draftLandmark.id,
usedIndexes,
);
if (matchedIndex < 0) {
matchedIndex = findRecordMatchIndex(
baseLandmarks,
(record) =>
normalizeMatchText(record.name) === normalizeMatchText(draftLandmark.name),
usedIndexes,
);
}
const baseLandmark = matchedIndex >= 0 ? baseLandmarks[matchedIndex] : null;
const baseImageSrc = toText(baseLandmark?.imageSrc) || undefined;
return {
...(baseLandmark ?? {}),
id: draftLandmark.id,
name: draftLandmark.name,
description: draftLandmark.description,
dangerLevel: draftLandmark.dangerLevel,
imageSrc: draftLandmark.imageSrc ?? baseImageSrc,
sceneNpcIds:
draftLandmark.sceneNpcIds.length > 0
? draftLandmark.sceneNpcIds
: toStringArray(baseLandmark?.sceneNpcIds),
} satisfies Record<string, unknown>;
});
const nextSceneChapterBlueprints =
normalizedDraftSceneChapters.length > 0
? baseProfile.sceneChapterBlueprints?.map((chapter) => {
const draftChapter = draftSceneChapterBySceneId.get(chapter.sceneId);
if (!draftChapter) {
return chapter;
}
const remainingLegacyLandmarks = baseLandmarks.filter(
(_entry, index) => !usedIndexes.has(index),
);
const draftActById = new Map(
draftChapter.acts.map((act) => [act.id, act]),
);
return [...mergedLandmarks, ...remainingLegacyLandmarks];
}
return {
...chapter,
acts: chapter.acts.map((act) => {
const draftAct = draftActById.get(act.id);
if (!draftAct) {
return act;
}
function mergeDraftSceneChaptersIntoProfileRecord(params: {
baseSceneChapters: unknown;
draftSceneChapters: CustomWorldProfile['sceneChapterBlueprints'];
}) {
const baseSceneChapters = toRecordArray(params.baseSceneChapters);
const draftSceneChapters = params.draftSceneChapters ?? [];
if (draftSceneChapters.length <= 0) {
return baseSceneChapters;
}
return {
...act,
backgroundImageSrc:
draftAct.backgroundImageSrc ?? act.backgroundImageSrc,
backgroundAssetId:
draftAct.backgroundAssetId ?? act.backgroundAssetId,
};
}),
};
}) ?? normalizedDraftSceneChapters
: baseProfile.sceneChapterBlueprints;
const usedChapterIndexes = new Set<number>();
return draftSceneChapters.map((draftChapter) => {
let matchedChapterIndex = findRecordMatchIndex(
baseSceneChapters,
(record) => toText(record.sceneId) === draftChapter.sceneId,
usedChapterIndexes,
);
if (matchedChapterIndex < 0) {
matchedChapterIndex = findRecordMatchIndex(
baseSceneChapters,
(record) =>
normalizeMatchText(record.title) === normalizeMatchText(draftChapter.title),
usedChapterIndexes,
);
}
const baseChapter =
matchedChapterIndex >= 0 ? baseSceneChapters[matchedChapterIndex] : null;
const baseActs = toRecordArray(baseChapter?.acts);
const usedActIndexes = new Set<number>();
const mergedActs = draftChapter.acts.map((draftAct) => {
let matchedActIndex = findRecordMatchIndex(
baseActs,
(record) => toText(record.id) === draftAct.id,
usedActIndexes,
);
if (matchedActIndex < 0) {
matchedActIndex = findRecordMatchIndex(
baseActs,
(record) =>
normalizeMatchText(record.title) === normalizeMatchText(draftAct.title),
usedActIndexes,
);
}
const baseAct = matchedActIndex >= 0 ? baseActs[matchedActIndex] : null;
const baseBackgroundImageSrc =
toText(baseAct?.backgroundImageSrc) || undefined;
const baseBackgroundAssetId =
toText(baseAct?.backgroundAssetId) || undefined;
return {
...(baseAct ?? {}),
...draftAct,
backgroundImageSrc: draftAct.backgroundImageSrc ?? baseBackgroundImageSrc,
backgroundAssetId: draftAct.backgroundAssetId ?? baseBackgroundAssetId,
} satisfies Record<string, unknown>;
});
return {
...(baseChapter ?? {}),
...draftChapter,
acts: mergedActs,
} satisfies Record<string, unknown>;
});
}
function mergeDraftCampIntoProfileRecord(params: {
baseCamp: unknown;
draftCamp: AdaptedDraftCamp | null;
}) {
if (!params.draftCamp) {
return isRecord(params.baseCamp) ? params.baseCamp : undefined;
}
const baseCamp = isRecord(params.baseCamp) ? params.baseCamp : null;
const baseImageSrc = toText(baseCamp?.imageSrc) || undefined;
return {
...baseProfile,
camp: nextCamp,
landmarks: nextLandmarks,
sceneChapterBlueprints: nextSceneChapterBlueprints,
} satisfies CustomWorldProfile;
...(baseCamp ?? {}),
...params.draftCamp,
imageSrc: params.draftCamp.imageSrc ?? baseImageSrc,
} satisfies Record<string, unknown>;
}
function toStageCoverage(value: unknown) {
@@ -396,25 +520,36 @@ export function buildCustomWorldProfileFromAgentDraft(
storyNpcIdSet,
landmarkIdSet,
);
const draftCamp = adaptDraftCamp(draftProfile.camp);
const legacyResultProfile = normalizeCustomWorldProfileRecord(
draftProfile.legacyResultProfile,
);
if (legacyResultProfile) {
const mergedPlayableProfile = mergeDraftRoleAssetsIntoProfile(
legacyResultProfile,
playableNpcs,
'playable',
);
const mergedStoryProfile = mergeDraftRoleAssetsIntoProfile(
mergedPlayableProfile,
storyNpcs,
'story',
);
return mergeDraftSceneAssetsIntoProfile(
mergedStoryProfile,
draftSceneChapterBlueprints,
adaptedLandmarks,
);
const mergedProfile = normalizeCustomWorldProfileRecord({
...legacyResultProfile,
playableNpcs: mergeDraftRolesIntoProfileRecord({
baseRoles: legacyResultProfile.playableNpcs,
draftRoles: playableNpcs,
}),
storyNpcs: mergeDraftRolesIntoProfileRecord({
baseRoles: legacyResultProfile.storyNpcs,
draftRoles: storyNpcs,
}),
landmarks: mergeDraftLandmarksIntoProfileRecord({
baseLandmarks: legacyResultProfile.landmarks,
draftLandmarks: adaptedLandmarks,
}),
camp: mergeDraftCampIntoProfileRecord({
baseCamp: legacyResultProfile.camp,
draftCamp,
}),
sceneChapterBlueprints: mergeDraftSceneChaptersIntoProfileRecord({
baseSceneChapters: legacyResultProfile.sceneChapterBlueprints,
draftSceneChapters: draftSceneChapterBlueprints,
}),
});
return mergedProfile ?? legacyResultProfile;
}
const normalized = normalizeCustomWorldProfileRecord({
@@ -435,14 +570,12 @@ export function buildCustomWorldProfileFromAgentDraft(
playableNpcs,
storyNpcs,
landmarks: adaptedLandmarks,
camp: isRecord(draftProfile.camp)
camp: draftCamp
? {
name: toText(draftProfile.camp.name),
description: toText(draftProfile.camp.description),
dangerLevel:
toText(draftProfile.camp.dangerLevel) ||
toText(draftProfile.camp.mood),
imageSrc: toText(draftProfile.camp.imageSrc) || undefined,
name: draftCamp.name,
description: draftCamp.description,
dangerLevel: draftCamp.dangerLevel,
imageSrc: draftCamp.imageSrc,
}
: undefined,
sceneChapterBlueprints: draftSceneChapterBlueprints,

View File

@@ -295,8 +295,8 @@ const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
},
{
id: 'workspace',
label: '准备精修工作区',
detail: '正在写回草稿数据,并切回可继续精修的工作区。',
label: '准备结果页',
detail: '正在写回草稿数据,并打开可继续完善的结果页。',
matchers: ['世界底稿已生成'],
minProgress: 100,
},
@@ -324,7 +324,8 @@ function resolveAgentDraftFoundationStepIndexByProgress(progress: number) {
index < AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.length - 1;
index += 1
) {
if (progress >= AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index].minProgress) {
const step = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index];
if (step && progress >= step.minProgress) {
matchedIndex = index;
}
}
@@ -348,7 +349,7 @@ function resolveAgentDraftFoundationStepIndex(
index -= 1
) {
const step = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index];
if (step.matchers.some((matcher) => phaseLabel.includes(matcher))) {
if (step?.matchers.some((matcher) => phaseLabel.includes(matcher))) {
return index;
}
}

View File

@@ -1,172 +0,0 @@
import type {
PlatformBrowseHistoryEntry,
PlatformBrowseHistoryWriteEntry,
} from '../../packages/shared/src/contracts/runtime';
import type { AuthUser } from './authService';
export type { PlatformBrowseHistoryEntry, PlatformBrowseHistoryWriteEntry };
const HISTORY_STORAGE_KEY_PREFIX = 'genarrative.platform.browse-history.v1';
const HISTORY_SYNC_KEY_PREFIX = 'genarrative.platform.browse-history.synced.v1';
const MAX_HISTORY_ENTRIES = 20;
function canUseLocalStorage() {
return (
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
);
}
function buildHistoryStorageKey(user: AuthUser | null | undefined) {
const accountId = user?.id?.trim() || user?.username?.trim() || 'guest';
return `${HISTORY_STORAGE_KEY_PREFIX}:${accountId}`;
}
function buildHistorySyncKey(user: AuthUser | null | undefined) {
const accountId = user?.id?.trim() || user?.username?.trim() || 'guest';
return `${HISTORY_SYNC_KEY_PREFIX}:${accountId}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readString(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeHistoryEntry(
value: unknown,
): PlatformBrowseHistoryEntry | null {
if (!isRecord(value)) {
return null;
}
const ownerUserId = readString(value.ownerUserId);
const profileId = readString(value.profileId);
const worldName = readString(value.worldName);
const visitedAt = readString(value.visitedAt);
if (!ownerUserId || !profileId || !worldName || !visitedAt) {
return null;
}
return {
ownerUserId,
profileId,
worldName,
subtitle: readString(value.subtitle),
summaryText: readString(value.summaryText),
coverImageSrc: readString(value.coverImageSrc) || null,
themeMode:
(readString(
value.themeMode,
) as PlatformBrowseHistoryEntry['themeMode']) || 'mythic',
authorDisplayName: readString(value.authorDisplayName) || '玩家',
visitedAt,
};
}
function sortHistoryEntries(entries: PlatformBrowseHistoryEntry[]) {
return [...entries].sort((left, right) => {
return (
new Date(right.visitedAt).getTime() - new Date(left.visitedAt).getTime()
);
});
}
export function readPlatformBrowseHistory(user: AuthUser | null | undefined) {
if (!canUseLocalStorage()) {
return [] as PlatformBrowseHistoryEntry[];
}
const raw = window.localStorage.getItem(buildHistoryStorageKey(user));
if (!raw?.trim()) {
return [] as PlatformBrowseHistoryEntry[];
}
try {
const parsed = JSON.parse(raw) as unknown[];
if (!Array.isArray(parsed)) {
return [] as PlatformBrowseHistoryEntry[];
}
return sortHistoryEntries(
parsed
.map((entry) => normalizeHistoryEntry(entry))
.filter((entry): entry is PlatformBrowseHistoryEntry => Boolean(entry)),
).slice(0, MAX_HISTORY_ENTRIES);
} catch {
return [] as PlatformBrowseHistoryEntry[];
}
}
export function writePlatformBrowseHistory(
user: AuthUser | null | undefined,
entry: PlatformBrowseHistoryWriteEntry,
) {
if (!canUseLocalStorage()) {
return [] as PlatformBrowseHistoryEntry[];
}
const nextEntry: PlatformBrowseHistoryEntry = {
ownerUserId: entry.ownerUserId.trim(),
profileId: entry.profileId.trim(),
worldName: entry.worldName.trim(),
subtitle: entry.subtitle?.trim() || '',
summaryText: entry.summaryText?.trim() || '',
coverImageSrc: entry.coverImageSrc?.trim() || null,
themeMode: entry.themeMode || 'mythic',
authorDisplayName: entry.authorDisplayName?.trim() || '玩家',
visitedAt: entry.visitedAt?.trim() || new Date().toISOString(),
};
const deduped = readPlatformBrowseHistory(user).filter(
(current) =>
!(
current.ownerUserId === nextEntry.ownerUserId &&
current.profileId === nextEntry.profileId
),
);
const nextEntries = sortHistoryEntries([nextEntry, ...deduped]).slice(
0,
MAX_HISTORY_ENTRIES,
);
window.localStorage.setItem(
buildHistoryStorageKey(user),
JSON.stringify(nextEntries),
);
return nextEntries;
}
export function clearPlatformBrowseHistory(user: AuthUser | null | undefined) {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.removeItem(buildHistoryStorageKey(user));
window.localStorage.removeItem(buildHistorySyncKey(user));
}
export function hasPendingPlatformBrowseHistoryMigration(
user: AuthUser | null | undefined,
) {
if (!canUseLocalStorage()) {
return false;
}
return (
readPlatformBrowseHistory(user).length > 0 &&
window.localStorage.getItem(buildHistorySyncKey(user)) !== '1'
);
}
export function markPlatformBrowseHistoryMigrated(
user: AuthUser | null | undefined,
) {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.setItem(buildHistorySyncKey(user), '1');
}

View File

@@ -2,21 +2,11 @@ import {
getNpcDisclosureStage,
getNpcWarmthStage,
} from '../data/npcInteractions';
import {
buildFallbackQuestIntent,
compileQuestIntentToQuest,
evaluateQuestOpportunity,
} from '../data/questFlow';
import { evaluateQuestOpportunity } from '../data/questFlow';
import type { Encounter, GameState, QuestLogEntry } from '../types';
import type { QuestGenerationContext } from './aiTypes';
import { requestJson } from './apiClient';
import { requestChatMessageContent } from './llmClient';
import { parseJsonResponseText } from './llmParsers';
import {
buildQuestIntentPrompt,
QUEST_INTENT_SYSTEM_PROMPT,
} from './questPrompt';
import type { QuestIntent, QuestPreviewRequest } from './questTypes';
import type { QuestPreviewRequest } from './questTypes';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
@@ -24,37 +14,6 @@ import {
import { buildThemePackFromWorldProfile } from './storyEngine/themePack';
import { buildFallbackWorldStoryGraph } from './storyEngine/worldStoryGraph';
const QUEST_DIRECTOR_TIMEOUT_MS = 12000;
function coerceString(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function coerceQuestTitle(value: unknown, fallback: string) {
const title = coerceString(value, fallback)
.replace(/["']/gu, '')
.replace(/[,.!?;:].*$/u, '')
.trim();
if (title.length <= 12) {
return title;
}
return fallback.length <= 12 ? fallback : fallback.slice(0, 10);
}
function coerceStringArray(value: unknown, fallback: string[]) {
if (!Array.isArray(value)) {
return fallback;
}
const items = value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean);
return items.length > 0 ? items : fallback;
}
function resolveIssuerNarrativeProfile(state: GameState, encounter: Encounter) {
if (encounter.narrativeProfile) {
return encounter.narrativeProfile;
@@ -87,73 +46,6 @@ function resolveIssuerNarrativeProfile(state: GameState, encounter: Encounter) {
);
}
function sanitizeQuestIntent(
rawIntent: unknown,
fallback: QuestIntent,
): QuestIntent {
if (!rawIntent || typeof rawIntent !== 'object') {
return fallback;
}
const intent = rawIntent as Record<string, unknown>;
return {
title: coerceQuestTitle(intent.title, fallback.title),
description: coerceString(intent.description, fallback.description),
summary: coerceString(intent.summary, fallback.summary),
narrativeType:
typeof intent.narrativeType === 'string' &&
[
'bounty',
'escort',
'investigation',
'retrieval',
'relationship',
'trial',
].includes(intent.narrativeType)
? (intent.narrativeType as QuestIntent['narrativeType'])
: fallback.narrativeType,
dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed),
issuerGoal: coerceString(intent.issuerGoal, fallback.issuerGoal),
playerHook: coerceString(intent.playerHook, fallback.playerHook),
worldReason: coerceString(intent.worldReason, fallback.worldReason),
recommendedObjectiveKinds: coerceStringArray(
intent.recommendedObjectiveKinds,
fallback.recommendedObjectiveKinds,
).filter((kind) =>
[
'defeat_hostile_npc',
'inspect_treasure',
'spar_with_npc',
'talk_to_npc',
'reach_scene',
'deliver_item',
].includes(kind),
) as QuestIntent['recommendedObjectiveKinds'],
urgency:
typeof intent.urgency === 'string' &&
['low', 'medium', 'high'].includes(intent.urgency)
? (intent.urgency as QuestIntent['urgency'])
: fallback.urgency,
intimacy:
typeof intent.intimacy === 'string' &&
['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy)
? (intent.intimacy as QuestIntent['intimacy'])
: fallback.intimacy,
rewardTheme:
typeof intent.rewardTheme === 'string' &&
['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(
intent.rewardTheme,
)
? (intent.rewardTheme as QuestIntent['rewardTheme'])
: fallback.rewardTheme,
followupHooks: coerceStringArray(
intent.followupHooks,
fallback.followupHooks,
),
};
}
export function buildQuestGenerationContextFromState(params: {
state: GameState;
encounter: Encounter;
@@ -235,67 +127,13 @@ export async function generateQuestForNpcEncounter(params: {
return null;
}
const fallbackIntent = buildFallbackQuestIntent(request);
if (typeof window !== 'undefined') {
try {
return await requestJson<QuestLogEntry | null>(
'/api/runtime/quests/generate',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
},
'任务生成失败',
);
} catch (error) {
console.warn(
'[QuestDirector] backend quest generation failed, using deterministic fallback',
error,
);
return compileQuestIntentToQuest(
{
...request,
origin: 'fallback_builder',
},
fallbackIntent,
);
}
}
try {
const content = await requestChatMessageContent(
QUEST_INTENT_SYSTEM_PROMPT,
buildQuestIntentPrompt({
context: request.context!,
scene: request.scene,
opportunity,
}),
{
timeoutMs: QUEST_DIRECTOR_TIMEOUT_MS,
debugLabel: 'quest-intent',
},
);
const parsed = parseJsonResponseText(content) as { intent?: unknown };
const intent = sanitizeQuestIntent(parsed.intent, fallbackIntent);
return compileQuestIntentToQuest(
{
...request,
origin: 'ai_compiled',
},
intent,
);
} catch (error) {
console.warn(
'[QuestDirector] falling back to deterministic quest intent',
error,
);
return compileQuestIntentToQuest(
{
...request,
origin: 'fallback_builder',
},
fallbackIntent,
);
}
return requestJson<QuestLogEntry | null>(
'/api/runtime/quests/generate',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
},
'任务生成失败',
);
}

View File

@@ -1,134 +1,24 @@
import {buildRuntimeItemAiIntent} from '../data/runtimeItemNarrative';
import type {
RuntimeItemAiIntent,
RuntimeItemGenerationContext,
RuntimeItemPlan,
} from '../types';
import { requestJson } from './apiClient';
import {requestChatMessageContent} from './llmClient';
import {parseJsonResponseText} from './llmParsers';
import {
buildRuntimeItemIntentPrompt,
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
} from './runtimeItemAiPrompt';
const RUNTIME_ITEM_INTENT_TIMEOUT_MS = 9000;
function coerceString(value: unknown, fallback: string) {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function coerceStringArray(value: unknown, fallback: string[], limit: number) {
if (!Array.isArray(value)) {
return fallback;
}
const normalized = value
.map(item => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean)
.slice(0, limit);
return normalized.length > 0 ? normalized : fallback;
}
function sanitizeRuntimeItemAiIntent(
rawIntent: unknown,
fallback: RuntimeItemAiIntent,
): RuntimeItemAiIntent {
if (!rawIntent || typeof rawIntent !== 'object') {
return fallback;
}
const intent = rawIntent as Record<string, unknown>;
const desiredFunctionalBias = coerceStringArray(
intent.desiredFunctionalBias,
fallback.desiredFunctionalBias,
2,
).filter(
(
item,
): item is RuntimeItemAiIntent['desiredFunctionalBias'][number] =>
['heal', 'mana', 'cooldown', 'guard', 'damage'].includes(item),
);
const tone = coerceString(intent.tone, fallback.tone);
return {
shortNameSeed: coerceString(intent.shortNameSeed, fallback.shortNameSeed),
sourcePhrase: coerceString(intent.sourcePhrase, fallback.sourcePhrase),
reasonToAppear: coerceString(intent.reasonToAppear, fallback.reasonToAppear),
relationHooks: coerceStringArray(intent.relationHooks, fallback.relationHooks, 2),
desiredBuildTags: coerceStringArray(intent.desiredBuildTags, fallback.desiredBuildTags, 3),
desiredFunctionalBias:
desiredFunctionalBias.length > 0
? desiredFunctionalBias
: fallback.desiredFunctionalBias,
tone: ['grim', 'mysterious', 'martial', 'ritual', 'survival'].includes(tone)
? (tone as RuntimeItemAiIntent['tone'])
: fallback.tone,
visibleClue: coerceString(intent.visibleClue, fallback.visibleClue ?? ''),
witnessMark: coerceString(intent.witnessMark, fallback.witnessMark ?? ''),
unfinishedBusiness: coerceString(
intent.unfinishedBusiness,
fallback.unfinishedBusiness ?? '',
),
hiddenHook: coerceString(intent.hiddenHook, fallback.hiddenHook ?? ''),
reactionHooks: coerceStringArray(
intent.reactionHooks,
fallback.reactionHooks ?? [],
4,
),
namingPattern: coerceString(intent.namingPattern, fallback.namingPattern ?? ''),
};
}
export async function generateRuntimeItemAiIntents(params: {
context: RuntimeItemGenerationContext;
plans: RuntimeItemPlan[];
}) {
const fallbackIntents = params.plans.map(plan =>
buildRuntimeItemAiIntent(params.context, plan),
);
if (typeof window !== 'undefined') {
try {
const response = await requestJson<{
intents?: unknown[];
}>(
'/api/runtime/items/runtime-intent',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
},
'运行时物品意图生成失败',
);
const rawIntents = Array.isArray(response.intents) ? response.intents : [];
return params.plans.map((_, index) =>
sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!),
);
} catch (error) {
console.warn(
'[runtimeItemAiDirector] backend intent generation failed, using deterministic fallback',
error,
);
return fallbackIntents;
}
}
const content = await requestChatMessageContent(
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
buildRuntimeItemIntentPrompt(params),
const response = await requestJson<{
intents?: RuntimeItemAiIntent[];
}>(
'/api/runtime/items/runtime-intent',
{
timeoutMs: RUNTIME_ITEM_INTENT_TIMEOUT_MS,
debugLabel: 'runtime-item-intent',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
},
'运行时物品意图生成失败',
);
const parsed = parseJsonResponseText(content) as {
intents?: unknown[];
};
const rawIntents = Array.isArray(parsed.intents) ? parsed.intents : [];
return params.plans.map((_, index) =>
sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]!),
);
return Array.isArray(response.intents) ? response.intents : [];
}

View File

@@ -18,6 +18,7 @@ import {
buildStoryMomentFromRuntimeOptions,
getRuntimeClientVersion,
getRuntimeSessionId,
getRuntimeStoryState,
isServerRuntimeFunctionId,
isTask5RuntimeFunctionId,
resolveRuntimeStoryAction,
@@ -75,6 +76,7 @@ describe('runtimeStoryService', () => {
optionText: '继续交谈',
},
},
snapshot: undefined,
}),
}),
'执行运行时动作失败',
@@ -129,6 +131,7 @@ describe('runtimeStoryService', () => {
itemId: 'focus-tonic',
},
},
snapshot: undefined,
}),
}),
'执行运行时动作失败',
@@ -136,6 +139,80 @@ describe('runtimeStoryService', () => {
);
});
it('submits runtime state resolution with snapshot context to the server', async () => {
requestJsonMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 4,
viewModel: {
player: {
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
},
encounter: null,
companions: [],
availableOptions: [],
status: {
inBattle: false,
npcInteractionActive: false,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '',
resultText: '',
storyText: '服务端故事',
options: [],
},
patches: [],
snapshot: {
version: 2,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {},
currentStory: null,
},
});
await getRuntimeStoryState({
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: '服务端返回的新故事',

View File

@@ -1,7 +1,9 @@
import type {
RuntimeStoryActionRequest,
RuntimeStoryActionResponse,
RuntimeStoryChoicePayload,
RuntimeStoryOptionView,
RuntimeStoryStateRequest,
ServerRuntimeFunctionId,
Task5RuntimeFunctionId,
} from '../../packages/shared/src/contracts/story';
@@ -44,6 +46,10 @@ export type RuntimeStoryResponse = RuntimeStoryActionResponse<
StoryMoment
>;
export type { RuntimeStoryChoicePayload };
export type RuntimeStorySnapshotRequest = RuntimeStoryStateRequest<
HydratedGameState,
StoryMoment
>['snapshot'];
function requestRuntimeStoryJson<T>(
path: string,
@@ -170,15 +176,35 @@ export function resolveRuntimeStoryMoment(params: {
}
export async function getRuntimeStoryState(
sessionId: string,
params: {
sessionId: string;
clientVersion?: number;
snapshot?: RuntimeStorySnapshotRequest;
},
options: RuntimeStoryServiceOptions = {},
) {
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
`/state/${encodeURIComponent(sessionId || DEFAULT_SESSION_ID)}`,
{ method: 'GET' },
'读取运行时故事状态失败',
options,
);
const normalizedSessionId = params.sessionId || DEFAULT_SESSION_ID;
const response = params.snapshot
? await requestRuntimeStoryJson<RuntimeStoryResponse>(
'/state/resolve',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: normalizedSessionId,
clientVersion: params.clientVersion,
snapshot: params.snapshot,
} satisfies RuntimeStoryStateRequest),
},
'读取运行时故事状态失败',
options,
)
: await requestRuntimeStoryJson<RuntimeStoryResponse>(
`/state/${encodeURIComponent(normalizedSessionId)}`,
{ method: 'GET' },
'读取运行时故事状态失败',
options,
);
return {
...response,
@@ -195,6 +221,7 @@ export async function resolveRuntimeStoryAction(
option: Pick<StoryOption, 'functionId' | 'actionText'>;
targetId?: string;
payload?: RuntimeStoryChoicePayload;
snapshot?: RuntimeStorySnapshotRequest;
},
options: RuntimeStoryServiceOptions = {},
) {
@@ -215,7 +242,8 @@ export async function resolveRuntimeStoryAction(
...(params.payload ?? {}),
},
},
}),
snapshot: params.snapshot,
} satisfies RuntimeStoryActionRequest),
},
'执行运行时动作失败',
options,