1
This commit is contained in:
@@ -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={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -33,7 +33,7 @@ test('creation hub draft card renders compiled work summary fields', () => {
|
||||
onBack={() => {}}
|
||||
onRetry={() => {}}
|
||||
onCreateNew={() => {}}
|
||||
onResumeDraft={() => {}}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ export interface QuestFlowUi {
|
||||
}
|
||||
|
||||
export interface NpcChatQuestOfferUi {
|
||||
replacePendingOffer: () => Promise<boolean>;
|
||||
replacePendingOffer: () => boolean;
|
||||
abandonPendingOffer: () => boolean;
|
||||
acceptPendingOffer: () => string | null;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
'任务生成失败',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 : [];
|
||||
}
|
||||
|
||||
@@ -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: '服务端返回的新故事',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user