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

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

View File

@@ -0,0 +1,25 @@
export interface RpgEntryBrandLogoProps {
className?: string;
decorative?: boolean;
}
/**
* RPG 入口品牌标识真实入口。
* 第三批收口后,入口主链直接落在 `rpg-entry` 平台品牌组件。
*/
export function RpgEntryBrandLogo({
className = '',
decorative = false,
}: RpgEntryBrandLogoProps) {
return (
<span
className={`platform-brand-logo ${className}`.trim()}
role={decorative ? undefined : 'img'}
aria-hidden={decorative || undefined}
aria-label={decorative ? undefined : '叙世 GENARRATIVE'}
>
<span className="platform-brand-logo__title"></span>
<span className="platform-brand-logo__subtitle">GENARRATIVE</span>
</span>
);
}

View File

@@ -0,0 +1,228 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import {
buildCustomWorldPlayableCharacters,
} from '../../data/characterPresets';
import {
type Character,
type CustomWorldProfile,
WorldType,
} from '../../types';
import { RpgEntryCharacterSelectView } from './RpgEntryCharacterSelectView';
vi.mock('../../data/characterPresets', () => ({
ROLE_TEMPLATE_CHARACTERS: [],
buildCustomWorldPlayableCharacters: vi.fn(),
}));
vi.mock('../CharacterAnimator', () => ({
CharacterAnimator: ({ character }: { character: Character }) => (
<div>{character.name}</div>
),
}));
vi.mock('../CharacterDetailModal', () => ({
CharacterDetailModal: () => null,
}));
vi.mock('../SelectionCustomizationModals', () => ({
CharacterDraftModal: () => null,
}));
function createCharacter(name: string, title: string): Character {
return {
id: '',
name,
title,
description: `${name}的定位描述`,
backstory: `${name}的背景故事`,
personality: `${name} 冷静 果断`,
gender: 'female',
portrait: `/portraits/${name}.png`,
attributes: {
strength: 10,
agility: 11,
intelligence: 12,
spirit: 13,
},
skills: [],
} as unknown as Character;
}
afterEach(() => {
vi.restoreAllMocks();
});
test('custom world character selection stays stable when character ids are empty', async () => {
const user = userEvent.setup();
const handleConfirm = vi.fn();
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
vi.mocked(buildCustomWorldPlayableCharacters).mockReturnValue([
createCharacter('沈砺', '潮锋斥候'),
createCharacter('闻潮', '雾海哨兵'),
]);
HTMLElement.prototype.scrollTo = function scrollTo(
this: HTMLElement,
options?: ScrollToOptions | number,
) {
if (typeof options === 'object' && options) {
if (typeof options.left === 'number') {
this.scrollLeft = options.left;
}
if (typeof options.top === 'number') {
this.scrollTop = options.top;
}
}
this.dispatchEvent(new Event('scroll'));
};
vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function mockGetBoundingClientRect(this: HTMLElement) {
if ((this as HTMLElement).dataset.carouselCard === 'true') {
return {
width: 240,
height: 360,
top: 0,
right: 240,
bottom: 360,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect;
}
return {
width: 0,
height: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect;
});
render(
<RpgEntryCharacterSelectView
worldType={WorldType.CUSTOM}
customWorldProfile={{
attributeSchema: {
id: 'schema:custom:test',
worldId: 'custom:test',
schemaVersion: 1,
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: '潮城',
settingSummary: '潮水与迷雾交织的港城。',
tone: '潮湿、危险、带着试探。',
conflictCore: '在涨落之间抢先一步。',
},
slots: [
{
slotId: 'axis_a',
name: '潮骨',
definition: '扛住潮压与正面冲击的底子。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '顶住正面浪涌。',
socialUseText: '给人能扛事的可靠感。',
explorationUseText: '在风浪里稳住自己。',
},
{
slotId: 'axis_b',
name: '浪步',
definition: '顺潮借势、换位穿行的能力。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '借势切线。',
socialUseText: '谈吐灵活。',
explorationUseText: '穿越复杂地形。',
},
{
slotId: 'axis_c',
name: '舟识',
definition: '辨流向、识潮眼的能力。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '抓住变化时机。',
socialUseText: '看懂局势留白。',
explorationUseText: '辨认水路与遗痕。',
},
{
slotId: 'axis_d',
name: '潮魄',
definition: '在剧烈变化中仍敢推进的胆气。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '顶着压力推进。',
socialUseText: '在冲突里压住场子。',
explorationUseText: '面对异变继续前探。',
},
{
slotId: 'axis_e',
name: '契汐',
definition: '与人和约定形成牵引的能力。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '借协同形成连锁。',
socialUseText: '结盟、安抚与交换。',
explorationUseText: '从旧约中打开局面。',
},
{
slotId: 'axis_f',
name: '回澜',
definition: '在漫长消耗中回稳状态的能力。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '久战不乱。',
socialUseText: '遇事沉静。',
explorationUseText: '在恶劣天气里保有余力。',
},
],
},
} as unknown as CustomWorldProfile}
onBack={() => {}}
onConfirm={handleConfirm}
/>,
);
expect(screen.getByText(/:/u)).toBeTruthy();
expect(screen.queryByText(/:/u)).toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: //u }));
expect(handleConfirm).toHaveBeenCalledWith(
expect.objectContaining({
name: '闻潮',
title: '雾海哨兵',
}),
);
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
call.some(
(arg) =>
typeof arg === 'string'
&& arg.includes('Encountered two children with the same key'),
),
);
expect(duplicateKeyCalls).toHaveLength(0);
});

View File

@@ -0,0 +1,510 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
buildCharacterAttributeProfile,
} from '../../data/attributeProfileGenerator';
import {
resolveAttributeSchema,
resolveCharacterAttributeProfile,
} from '../../data/attributeResolver';
import {
buildCustomWorldPlayableCharacters,
ROLE_TEMPLATE_CHARACTERS,
} from '../../data/characterPresets';
import {
AnimationState,
type Character,
type CustomWorldProfile,
WorldType,
} from '../../types';
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { CharacterAnimator } from '../CharacterAnimator';
import { CharacterDetailModal } from '../CharacterDetailModal';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { CharacterDraftModal } from '../SelectionCustomizationModals';
type CharacterSelectionDraft = {
name: string;
backstory: string;
};
type CarouselOrientation = 'horizontal' | 'vertical';
export type RpgEntryCharacterSelectViewProps = {
worldType: WorldType;
customWorldProfile: CustomWorldProfile | null;
onBack: () => void;
onConfirm: (character: Character) => void;
};
const CHARACTER_DISPLAY: Record<
string,
{ name: string; title: string; role: string; tags: string[] }
> = {
'sword-princess': {
name: '剑姬',
title: '皇家之刃',
role: '先锋',
tags: ['剑术', '压制', '突进'],
},
'archer-hero': {
name: '弓手英雄',
title: '风之射手',
role: '远程',
tags: ['射程', '齐射', '风筝'],
},
'girl-hero': {
name: '双刃刺客',
title: '暗影之牙',
role: '刺客',
tags: ['连击', '冲锋', '机动'],
},
'punch-hero': {
name: '战拳',
title: '近战大师',
role: '战士',
tags: ['爆发', '格斗', '仇恨'],
},
'fighter-4': {
name: '装甲长矛手',
title: '重装先锋',
role: '前线',
tags: ['守护', '稳定', '突破'],
},
};
function getGenderLabel(gender: Character['gender']) {
if (gender === 'female') return '女性';
if (gender === 'male') return '男性';
return '未知';
}
function clampIndex(value: number, length: number) {
if (length <= 0) return 0;
return Math.max(0, Math.min(length - 1, value));
}
function getCharacterMeta(
character: Character,
overrides: Partial<Pick<Character, 'name' | 'title'>> = {},
) {
const preset = CHARACTER_DISPLAY[character.id];
return {
name: overrides.name ?? character.name ?? preset?.name,
title: overrides.title ?? character.title ?? preset?.title,
role: preset?.role ?? '角色',
tags: preset?.tags ?? [],
};
}
function buildSelectionCharacterKey(character: Character, index: number) {
const normalizedId = character.id.trim();
if (normalizedId) {
return normalizedId;
}
const fallbackSeed =
character.name.trim()
|| character.title.trim()
|| character.description.trim()
|| 'character';
return `selection-character-${index}-${fallbackSeed}`;
}
function applyCharacterSelectionDraft(
character: Character | null,
draft?: CharacterSelectionDraft | null,
) {
if (!character || !draft) return character;
return {
...character,
name: draft.name,
backstory: draft.backstory,
} satisfies Character;
}
function getPersonalityTags(personality: string) {
const tags = personality
.split(/[,.!?/\\\s]+/u)
.map(tag => tag.trim())
.filter(Boolean);
return tags.length > 0 ? [...new Set(tags)] : [personality.trim()].filter(Boolean);
}
function readCarouselProgress(container: HTMLDivElement, orientation: CarouselOrientation) {
const firstCard = container.querySelector<HTMLElement>('[data-carousel-card="true"]');
if (!firstCard) return 0;
const styles = window.getComputedStyle(container);
const gap = parseFloat(
orientation === 'vertical'
? styles.rowGap || styles.gap || '0'
: styles.columnGap || styles.gap || '0',
);
const stride = orientation === 'vertical'
? firstCard.getBoundingClientRect().height + gap
: firstCard.getBoundingClientRect().width + gap;
if (stride <= 0) return 0;
return orientation === 'vertical' ? container.scrollTop / stride : container.scrollLeft / stride;
}
function scrollCarouselToIndex(container: HTMLDivElement | null, index: number, orientation: CarouselOrientation) {
if (!container) return;
const firstCard = container.querySelector<HTMLElement>('[data-carousel-card="true"]');
if (!firstCard) return;
const styles = window.getComputedStyle(container);
const gap = parseFloat(
orientation === 'vertical'
? styles.rowGap || styles.gap || '0'
: styles.columnGap || styles.gap || '0',
);
const stride = orientation === 'vertical'
? firstCard.getBoundingClientRect().height + gap
: firstCard.getBoundingClientRect().width + gap;
const behavior: ScrollBehavior = 'smooth';
if (orientation === 'vertical') {
container.scrollTo({top: stride * index, behavior});
} else {
container.scrollTo({left: stride * index, behavior});
}
}
function getCharacterCardStyle(index: number, progress: number) {
const delta = index - progress;
const distance = Math.min(Math.abs(delta), 2.4);
if (distance < 0.08) {
return {
opacity: 1,
zIndex: 30,
transform: 'none',
filter: 'none',
willChange: 'auto' as const,
};
}
const scale = 1 - distance * 0.12;
const opacity = 1 - distance * 0.28;
const rotate = delta * 8;
const translateY = distance * 12;
const translateX = delta * -12;
return {
opacity,
zIndex: 30 - Math.round(distance * 10),
transform: `translate3d(${translateX}px, ${translateY}px, 0) scale(${scale}) rotate(${rotate}deg)`,
filter: distance < 0.08 ? 'none' : `saturate(${1 - distance * 0.08})`,
};
}
export function RpgEntryCharacterSelectView({
worldType,
customWorldProfile,
onBack,
onConfirm,
}: RpgEntryCharacterSelectViewProps) {
const selectionCharacters = useMemo(
() => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : ROLE_TEMPLATE_CHARACTERS),
[customWorldProfile],
);
const selectionEntries = useMemo(
() =>
selectionCharacters.map((character, index) => ({
character,
selectionKey: buildSelectionCharacterKey(character, index),
})),
[selectionCharacters],
);
const [selectedCharacterKey, setSelectedCharacterKey] = useState(selectionEntries[0]?.selectionKey ?? '');
const [detailCharacter, setDetailCharacter] = useState<Character | null>(null);
const characterCarouselRef = useRef<HTMLDivElement | null>(null);
const [characterCarouselProgress, setCharacterCarouselProgress] = useState(0);
const [showCharacterDraftModal, setShowCharacterDraftModal] = useState(false);
const [characterDraftName, setCharacterDraftName] = useState('');
const [characterDraftBackstory, setCharacterDraftBackstory] = useState('');
const [characterDraftError, setCharacterDraftError] = useState<string | null>(null);
const [characterSelectionDrafts, setCharacterSelectionDrafts] = useState<Record<string, CharacterSelectionDraft>>({});
const selectedCharacterEntry = useMemo(
() => selectionEntries.find(entry => entry.selectionKey === selectedCharacterKey) ?? selectionEntries[0] ?? null,
[selectedCharacterKey, selectionEntries],
);
const selectedCharacter = selectedCharacterEntry?.character ?? null;
const selectedCharacterDraft = selectedCharacterEntry
? characterSelectionDrafts[selectedCharacterEntry.selectionKey] ?? null
: null;
const selectedCharacterPreview = useMemo(
() => applyCharacterSelectionDraft(selectedCharacter, selectedCharacterDraft),
[selectedCharacter, selectedCharacterDraft],
);
const selectedCharacterMeta = selectedCharacter
? getCharacterMeta(selectedCharacter, {name: selectedCharacterDraft?.name})
: null;
const attributeSchema = useMemo(
() => resolveAttributeSchema(worldType, customWorldProfile),
[customWorldProfile, worldType],
);
const selectedAttributeProfile = useMemo(
() =>
selectedCharacter
? resolveCharacterAttributeProfile(
selectedCharacter,
worldType,
customWorldProfile,
)
?? buildCharacterAttributeProfile(selectedCharacter, attributeSchema)
: null,
[attributeSchema, customWorldProfile, selectedCharacter, worldType],
);
const selectedCharacterPersonalityTags = useMemo(
() => (selectedCharacterPreview ? getPersonalityTags(selectedCharacterPreview.personality) : []),
[selectedCharacterPreview],
);
const focusedCharacterIndex = clampIndex(Math.round(characterCarouselProgress), selectionCharacters.length);
const syncCharacterCarousel = useCallback(() => {
if (!characterCarouselRef.current) return;
setCharacterCarouselProgress(readCarouselProgress(characterCarouselRef.current, 'horizontal'));
}, []);
useEffect(() => {
syncCharacterCarousel();
window.addEventListener('resize', syncCharacterCarousel);
return () => window.removeEventListener('resize', syncCharacterCarousel);
}, [syncCharacterCarousel]);
useEffect(() => {
const focusedEntry = selectionEntries[focusedCharacterIndex];
if (focusedEntry && focusedEntry.selectionKey !== selectedCharacterKey) {
setSelectedCharacterKey(focusedEntry.selectionKey);
}
}, [focusedCharacterIndex, selectedCharacterKey, selectionEntries]);
useEffect(() => {
if (selectionEntries.length === 0) return;
if (!selectionEntries.some(entry => entry.selectionKey === selectedCharacterKey)) {
const firstEntry = selectionEntries[0];
if (firstEntry) {
setSelectedCharacterKey(firstEntry.selectionKey);
}
}
}, [selectedCharacterKey, selectionEntries]);
const openCharacterDraftEditor = () => {
if (!selectedCharacterPreview) return;
setCharacterDraftName(selectedCharacterPreview.name);
setCharacterDraftBackstory(selectedCharacterPreview.backstory);
setCharacterDraftError(null);
setShowCharacterDraftModal(true);
};
const saveCharacterDraft = () => {
if (!selectedCharacter || !selectedCharacterEntry) return;
const nextName = characterDraftName.trim();
const nextBackstory = characterDraftBackstory.trim();
if (!nextName) {
setCharacterDraftError('请输入角色名称。');
return;
}
if (!nextBackstory) {
setCharacterDraftError('请输入角色背景故事。');
return;
}
setCharacterSelectionDrafts(current => ({
...current,
[selectedCharacterEntry.selectionKey]: {
name: nextName,
backstory: nextBackstory,
},
}));
setCharacterDraftError(null);
setShowCharacterDraftModal(false);
};
if (!selectedCharacter || !selectedCharacterMeta) {
return null;
}
return (
<>
<div className="flex h-full min-h-0 flex-col">
<div className="mb-3 flex justify-start">
<button
type="button"
onClick={onBack}
className="rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white"
>
</button>
</div>
<div className="mb-4 text-center">
<div className="text-2xl font-black text-white sm:text-[2rem]"></div>
<div className="mt-1 text-[11px] tracking-[0.14em] text-zinc-500"></div>
</div>
<div
ref={characterCarouselRef}
onScroll={syncCharacterCarousel}
className="character-carousel scrollbar-hide flex-[1_1_auto]"
>
{selectionEntries.map(({ character, selectionKey }, index) => {
const characterDraft = characterSelectionDrafts[selectionKey];
const meta = getCharacterMeta(character, {name: characterDraft?.name});
const selected = selectionKey === selectedCharacterKey;
return (
<button
key={selectionKey}
type="button"
onClick={() => {
setSelectedCharacterKey(selectionKey);
scrollCarouselToIndex(characterCarouselRef.current, index, 'horizontal');
}}
data-carousel-card="true"
className={`character-carousel__card ${selected ? 'character-carousel__card--active' : ''}`}
style={getCharacterCardStyle(index, characterCarouselProgress)}
>
<span className={`character-carousel__surface ${selected ? 'character-carousel__surface--active' : ''}`}>
<span className="character-carousel__cover">
{selected ? (
<CharacterAnimator
state={AnimationState.RUN}
character={character}
className="character-carousel__portrait character-carousel__portrait--animated"
/>
) : (
<ResolvedAssetImage
src={character.portrait}
alt={meta.name}
className="character-carousel__portrait"
style={{imageRendering: 'pixelated'}}
/>
)}
</span>
{selected ? (
<>
<span className="character-carousel__selected-name">{meta.name}</span>
<span className="character-carousel__meta character-carousel__meta--selected">
<span className="character-carousel__title character-carousel__title--selected">{meta.title}</span>
</span>
</>
) : (
<span className="character-carousel__meta">
<span className="character-carousel__name">{meta.name}</span>
<span className="character-carousel__title">{meta.title}</span>
</span>
)}
</span>
</button>
);
})}
</div>
<div className="mt-3 grid gap-2 md:grid-cols-[0.85fr_1.15fr]">
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.statsPanel, {paddingX: 12, paddingY: 10})}>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-xs font-bold text-white"></div>
<div className="flex items-center gap-2 text-[10px] text-zinc-500">
<span>{selectedCharacterMeta.title}</span>
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
: {getGenderLabel(selectedCharacter.gender)}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-1 text-[11px] text-zinc-300 sm:grid-cols-3 sm:gap-1.5 sm:text-[13px]">
{attributeSchema.slots.map((slot) => (
<div key={slot.slotId} className="rounded-lg border border-white/6 bg-black/20 px-2 py-1.5 text-center sm:px-2.5">
{slot.name}: {selectedAttributeProfile?.values?.[slot.slotId] ?? 0}
</div>
))}
</div>
</div>
<div
className="pixel-nine-slice pixel-panel character-backstory-panel flex flex-col"
style={getNineSliceStyle(UI_CHROME.panel, {paddingX: 12, paddingY: 10})}
>
<div className="mb-2 flex items-start justify-between gap-3">
<div className="character-backstory-title text-xs font-bold text-white"></div>
<button
type="button"
onClick={openCharacterDraftEditor}
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[10px] text-sky-100 transition-colors hover:text-white"
>
</button>
</div>
<div className="flex flex-1 flex-col text-[13px] leading-6 text-zinc-300">
<div>{selectedCharacterPreview?.backstory ?? selectedCharacter.backstory}</div>
<div className="mt-auto flex items-end justify-between gap-3 pt-3">
<div className="min-w-0 flex flex-wrap gap-1.5">
{selectedCharacterPersonalityTags.map(tag => (
<span key={tag} className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[10px] text-zinc-300">
{tag}
</span>
))}
</div>
<button
type="button"
onClick={() => setDetailCharacter(selectedCharacterPreview)}
aria-label={`查看${selectedCharacterPreview?.name ?? selectedCharacter.name}的详情`}
className="shrink-0 text-[11px] font-medium text-sky-200 transition-colors hover:text-white"
>
</button>
</div>
</div>
</div>
</div>
<div className="mt-3">
<button
type="button"
onClick={() => onConfirm(selectedCharacterPreview ?? selectedCharacter)}
className="pixel-nine-slice pixel-pressable mx-auto block w-full max-w-[16rem] text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton, {paddingX: 14, paddingY: 9})}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white"></span>
<span className="text-white/60"></span>
</div>
</button>
</div>
</div>
<CharacterDetailModal
character={detailCharacter}
worldType={worldType}
customWorldProfile={customWorldProfile}
subtitle="角色详情"
onClose={() => setDetailCharacter(null)}
/>
<CharacterDraftModal
isOpen={showCharacterDraftModal}
characterLabel={selectedCharacterMeta ? `${selectedCharacterMeta.name} / ${selectedCharacterMeta.title}` : '当前角色'}
draftName={characterDraftName}
draftBackstory={characterDraftBackstory}
onNameChange={value => {
setCharacterDraftName(value);
if (characterDraftError) setCharacterDraftError(null);
}}
onBackstoryChange={value => {
setCharacterDraftBackstory(value);
if (characterDraftError) setCharacterDraftError(null);
}}
onClose={() => setShowCharacterDraftModal(false)}
onConfirm={saveCharacterDraft}
error={characterDraftError}
/>
</>
);
}
export const CharacterSelectionFlow = RpgEntryCharacterSelectView;

View File

@@ -0,0 +1,4 @@
export {
PlatformEntryCreationTypeModal as RpgEntryCreationTypeModal,
type PlatformEntryCreationTypeModalProps as RpgEntryCreationTypeModalProps,
} from '../platform-entry/PlatformEntryCreationTypeModal';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
import { PlatformEntryFlowShell } from '../platform-entry/PlatformEntryFlowShell';
import type { RpgEntryFlowShellProps } from './rpgEntryTypes';
import type { SelectionStage } from './rpgEntryTypes';
export type { RpgEntryFlowShellProps, SelectionStage };
/**
* 兼容旧 RPG 入口导入路径。
* 多玩法入口真实实现已迁移到 `platform-entry`,避免非 RPG 玩法写入 RPG 脚本。
*/
export function RpgEntryFlowShell(props: RpgEntryFlowShellProps) {
return <PlatformEntryFlowShell {...props} />;
}
/**
* 兼容创作链已经接入的旧组件命名,避免本轮迁移扩大影响面。
*/
export const RpgCreationShell = RpgEntryFlowShell;
export default RpgEntryFlowShell;

View File

@@ -0,0 +1,5 @@
export {
PlatformEntryFlowShellImpl as RpgCreationShellImpl,
PlatformEntryFlowShellImpl as RpgEntryFlowShellImpl,
} from '../platform-entry/PlatformEntryFlowShellImpl';
export { PlatformEntryFlowShellImpl as default } from '../platform-entry/PlatformEntryFlowShellImpl';

View File

@@ -0,0 +1,380 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import { AuthUiContext } from '../auth/AuthUiContext';
import { RpgEntryHomeView, type RpgEntryHomeViewProps } from './RpgEntryHomeView';
import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
getRpgProfileRechargeCenter: vi.fn(async () => ({
walletBalance: 0,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [
{
productId: 'points_60',
title: '60叙世币',
priceCents: 600,
kind: 'points',
pointsAmount: 60,
bonusPoints: 60,
durationDays: 0,
badgeLabel: '首充双倍',
description: '首充送60叙世币',
tier: 'normal',
},
],
membershipProducts: [
{
productId: 'member_month',
title: '月卡',
priceCents: 2800,
kind: 'membership',
pointsAmount: 0,
bonusPoints: 0,
durationDays: 30,
badgeLabel: '',
description: '30天会员',
tier: 'month',
},
],
benefits: [
{
benefitName: '免叙世币回合数',
normalValue: '30',
monthValue: '100',
seasonValue: '100',
yearValue: '100',
},
],
latestOrder: null,
hasPointsRecharged: false,
})),
createRpgProfileRechargeOrder: vi.fn(async () => ({
order: {
orderId: 'order-1',
productId: 'points_60',
productTitle: '60叙世币',
kind: 'points',
amountCents: 600,
status: 'paid',
paymentChannel: 'mock',
paidAt: '2026-04-25T10:00:00Z',
createdAt: '2026-04-25T10:00:00Z',
pointsDelta: 120,
membershipExpiresAt: null,
},
center: {
walletBalance: 120,
membership: {
status: 'normal',
tier: 'normal',
startedAt: null,
expiresAt: null,
updatedAt: null,
},
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: true,
},
})),
}));
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: () => null,
}));
const originalMatchMedia = window.matchMedia;
const puzzlePublicEntry = {
sourceType: 'puzzle',
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
publicWorkCode: 'PZ-EPUBLIC1',
ownerUserId: 'user-2',
authorDisplayName: '拼图玩家',
worldName: '奇幻拼图',
subtitle: '拼图关卡',
summaryText: '一张用于公开分享的拼图作品。',
coverImageSrc: null,
themeTags: ['奇幻'],
visibility: 'published',
publishedAt: '1777110165.990127Z',
updatedAt: '2026-04-25T10:00:00.000Z',
} satisfies PlatformPublicGalleryCard;
function mockDesktopLayout() {
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: vi.fn().mockImplementation(() => ({
matches: true,
media: '(min-width: 1024px)',
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
function renderProfileView(onRechargeSuccess = vi.fn()) {
return render(
<AuthUiContext.Provider
value={{
user: {
id: 'user-1',
publicUserCode: '100001',
username: 'tester',
displayName: '测试玩家',
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
canAccessProtectedData: true,
openLoginModal: vi.fn(),
requireAuth: (action) => action(),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
logout: vi.fn(async () => undefined),
musicVolume: 0.42,
setMusicVolume: vi.fn(),
platformTheme: 'light',
setPlatformTheme: vi.fn(),
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
}}
>
<RpgEntryHomeView
activeTab="profile"
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={[]}
latestEntries={[]}
myEntries={[]}
historyEntries={[]}
profileDashboard={{
walletBalance: 0,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: null,
}}
isLoadingPlatform={false}
isLoadingDashboard={false}
isResumingSaveWorldKey={null}
platformError={null}
dashboardError={null}
onContinueGame={vi.fn()}
onResumeSave={vi.fn()}
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={vi.fn()}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={vi.fn()}
onRechargeSuccess={onRechargeSuccess}
/>
</AuthUiContext.Provider>,
);
}
function renderLoggedOutHomeView(
openLoginModal = vi.fn(),
overrides: Partial<
Pick<
RpgEntryHomeViewProps,
| 'featuredEntries'
| 'latestEntries'
| 'onOpenGalleryDetail'
| 'onSearchPublicCode'
>
> = {},
) {
return render(
<AuthUiContext.Provider
value={{
user: null,
canAccessProtectedData: false,
openLoginModal,
requireAuth: vi.fn(),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
logout: vi.fn(async () => undefined),
musicVolume: 0.42,
setMusicVolume: vi.fn(),
platformTheme: 'light',
setPlatformTheme: vi.fn(),
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
}}
>
<RpgEntryHomeView
activeTab="home"
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={overrides.featuredEntries ?? []}
latestEntries={overrides.latestEntries ?? []}
myEntries={[]}
historyEntries={[]}
profileDashboard={null}
isLoadingPlatform={false}
isLoadingDashboard={false}
isResumingSaveWorldKey={null}
platformError={null}
dashboardError={null}
onContinueGame={vi.fn()}
onResumeSave={vi.fn()}
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
/>
</AuthUiContext.Provider>,
);
}
afterEach(() => {
vi.clearAllMocks();
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: originalMatchMedia,
});
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
});
});
test('opens recharge modal and submits points product', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
renderProfileView(onRechargeSuccess);
await user.click(screen.getByText('会员充值'));
expect(await screen.findByText('账户充值')).toBeTruthy();
expect(await screen.findByText('叙世币充值')).toBeTruthy();
expect(await screen.findByText('60叙世币')).toBeTruthy();
await user.click(screen.getByText('首充送60叙世币'));
await waitFor(() => expect(onRechargeSuccess).toHaveBeenCalledTimes(1));
});
test('shows a reachable login entry in logged out mobile shell', async () => {
const user = userEvent.setup();
const openLoginModal = vi.fn();
renderLoggedOutHomeView(openLoginModal);
await user.click(screen.getByRole('button', { name: '登录' }));
expect(openLoginModal).toHaveBeenCalledTimes(1);
});
test('mobile home search submits public work code', async () => {
const user = userEvent.setup();
const onSearchPublicCode = vi.fn();
render(
<AuthUiContext.Provider
value={{
user: null,
canAccessProtectedData: false,
openLoginModal: vi.fn(),
requireAuth: vi.fn(),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
logout: vi.fn(async () => undefined),
musicVolume: 0.42,
setMusicVolume: vi.fn(),
platformTheme: 'light',
setPlatformTheme: vi.fn(),
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
}}
>
<RpgEntryHomeView
activeTab="home"
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={[]}
latestEntries={[]}
myEntries={[]}
historyEntries={[]}
profileDashboard={null}
isLoadingPlatform={false}
isLoadingDashboard={false}
isResumingSaveWorldKey={null}
platformError={null}
dashboardError={null}
onContinueGame={vi.fn()}
onResumeSave={vi.fn()}
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={vi.fn()}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={onSearchPublicCode}
/>
</AuthUiContext.Provider>,
);
const searchInput = screen.getByPlaceholderText('输入 SY / CW / PZ 编号');
await user.type(searchInput, 'PZ-PROFILE1{enter}');
expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1');
});
test('public gallery cards hide work code until detail is opened', async () => {
const user = userEvent.setup();
const onOpenGalleryDetail = vi.fn();
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
onOpenGalleryDetail,
});
expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
expect(screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }))
.toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
});
test('desktop trending list shows kind instead of work code or timestamp text', () => {
mockDesktopLayout();
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
});
expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
expect(screen.queryByText('1777110165.990127Z')).toBeNull();
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,329 @@
import { ArrowLeft, Copy } from 'lucide-react';
import { useState } from 'react';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { copyTextToClipboard } from '../../services/clipboard';
import type { CustomWorldProfile } from '../../types';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
buildPlatformWorldTags,
describePlatformThemeLabel,
formatPlatformWorldTime,
resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverImage,
resolvePlatformWorldLeadPortrait,
} from './rpgEntryWorldPresentation';
export interface RpgEntryWorldDetailViewProps {
entry: CustomWorldLibraryEntry<CustomWorldProfile>;
isMutating: boolean;
error: string | null;
onBack: () => void;
onStartGame: () => void;
onContinueEdit?: (() => void) | null;
onPublish?: (() => void) | null;
onDelete?: (() => void) | null;
onUnpublish?: (() => void) | null;
}
function ActionButton({
label,
onClick,
tone = 'default',
disabled = false,
}: {
label: string;
onClick: () => void;
tone?: 'default' | 'primary' | 'danger';
disabled?: boolean;
}) {
const toneClass =
tone === 'primary'
? 'platform-button platform-button--primary'
: tone === 'danger'
? 'platform-button platform-button--danger'
: 'platform-button platform-button--secondary';
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`${toneClass} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
>
{label}
</button>
);
}
export function RpgEntryWorldDetailView({
entry,
isMutating,
error,
onBack,
onStartGame,
onContinueEdit,
onPublish,
onDelete,
onUnpublish,
}: RpgEntryWorldDetailViewProps) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const canStartGame = entry.visibility === 'published';
const previewCharacters = buildCustomWorldPlayableCharacters(
entry.profile,
).slice(0, 3);
const previewLandmarks = entry.profile.landmarks.slice(0, 3);
const tags = [
...new Set(
buildPlatformWorldTags(entry)
.map((tag) => tag.trim())
.filter(Boolean),
),
].slice(0, 3);
const copyPublicWorkCode = () => {
if (!publicWorkCode) {
return;
}
void copyTextToClipboard(publicWorkCode).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
window.setTimeout(() => setCopyState('idle'), 1400);
});
};
return (
<div className="flex h-full min-h-0 flex-col">
<div className="mb-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
className="platform-button platform-button--ghost px-3 py-1.5 text-[11px]"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="platform-pill platform-pill--neutral px-3 py-1.5 text-[11px] tracking-[0.08em]">
{entry.visibility === 'published' ? '已发布' : '草稿'}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide">
<div className="space-y-4 pb-2">
<div className="platform-surface platform-surface--hero relative overflow-hidden px-[18px] py-4">
{coverImage ? (
<ResolvedAssetImage
src={coverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover opacity-38"
/>
) : null}
{leadPortrait ? (
<ResolvedAssetImage
src={leadPortrait}
alt=""
aria-hidden="true"
className="absolute bottom-0 right-2 h-32 w-32 object-contain opacity-25"
/>
) : null}
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10">
<div className="flex flex-wrap items-center gap-2">
<span className="platform-pill platform-pill--warm">
{describePlatformThemeLabel(entry.themeMode)}
</span>
<span className="platform-pill platform-pill--neutral px-3">
{entry.authorDisplayName}
</span>
<span className="platform-pill platform-pill--neutral px-3">
{entry.visibility === 'published'
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
: '仅自己可见'}
</span>
{publicWorkCode ? (
<button
type="button"
onClick={copyPublicWorkCode}
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
aria-label={`复制作品号 ${publicWorkCode}`}
title="复制作品号"
>
<span> {publicWorkCode}</span>
<Copy className="h-3 w-3" />
{copyState !== 'idle' ? (
<span className="text-xs">
{copyState === 'copied' ? '已复制' : '复制失败'}
</span>
) : null}
</button>
) : null}
</div>
<div className="mt-4 text-3xl font-black text-white">
{entry.worldName}
</div>
{entry.subtitle ? (
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-300/88">
{entry.subtitle}
</div>
) : null}
<div className="mt-4 max-w-[36rem] text-sm leading-7 text-zinc-200/88">
{entry.summaryText || '等待补充世界摘要。'}
</div>
<div className="mt-4 flex flex-wrap gap-2">
{tags.map((tag, index) => (
<span
key={`world-detail-tag-${index}-${tag || 'empty'}`}
className="platform-pill platform-pill--neutral px-3"
>
{tag}
</span>
))}
</div>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<div className="platform-surface platform-surface--soft px-4 py-3.5">
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-3 grid grid-cols-2 gap-3 text-sm text-[var(--platform-text-strong)] sm:grid-cols-4">
<div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-2 text-lg font-bold">
{entry.playableNpcCount}
</div>
</div>
<div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-2 text-lg font-bold">
{entry.landmarkCount}
</div>
</div>
<div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-2 text-lg font-bold">
{entry.profile.majorFactions.length}
</div>
</div>
<div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-2 text-lg font-bold">
{entry.profile.coreConflicts.length}
</div>
</div>
</div>
<div className="mt-5">
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
{previewCharacters.map((character, index) => (
<div
key={character.id || `preview-character-${index}`}
className="platform-subpanel rounded-2xl px-3 py-3"
>
<div className="line-clamp-1 text-sm font-bold text-[var(--platform-text-strong)]">
{character.title}
</div>
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
{character.description}
</div>
</div>
))}
</div>
</div>
<div className="mt-5">
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
{previewLandmarks.map((landmark, index) => (
<div
key={landmark.id || `preview-landmark-${index}`}
className="platform-subpanel rounded-2xl px-3 py-3"
>
<div className="line-clamp-1 text-sm font-bold text-[var(--platform-text-strong)]">
{landmark.name}
</div>
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
{landmark.description}
</div>
</div>
))}
</div>
</div>
</div>
<div className="platform-surface platform-surface--soft px-4 py-3.5">
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-4 flex flex-col gap-3">
<ActionButton
label={canStartGame ? '开始游戏' : '请先发布作品'}
onClick={onStartGame}
tone="primary"
disabled={!canStartGame || isMutating}
/>
{onContinueEdit ? (
<ActionButton
label="继续创作"
onClick={onContinueEdit}
disabled={isMutating}
/>
) : null}
{onPublish ? (
<ActionButton
label="发布到广场"
onClick={onPublish}
tone="primary"
disabled={isMutating}
/>
) : null}
{onUnpublish ? (
<ActionButton
label="下架作品"
onClick={onUnpublish}
tone="danger"
disabled={isMutating}
/>
) : null}
{onDelete ? (
<ActionButton
label="删除作品"
onClick={onDelete}
tone="danger"
disabled={isMutating}
/>
) : null}
</div>
{error ? (
<div className="mt-4 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{error}
</div>
) : null}
</div>
</div>
</div>
</div>
</div>
);
}
export const PlatformWorldDetailView = RpgEntryWorldDetailView;

View File

@@ -0,0 +1,27 @@
export {
RpgEntryCharacterSelectView,
type RpgEntryCharacterSelectViewProps,
} from './RpgEntryCharacterSelectView';
export {
RpgEntryFlowShell,
type RpgEntryFlowShellProps,
type SelectionStage,
} from './RpgEntryFlowShell';
export {
type PlatformHomeTab,
RpgEntryHomeView,
type RpgEntryHomeViewProps,
} from './RpgEntryHomeView';
export {
RpgEntryWorldDetailView,
type RpgEntryWorldDetailViewProps,
} from './RpgEntryWorldDetailView';
export { useRpgCreationAgentOperationPolling } from './useRpgCreationAgentOperationPolling';
export { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
export { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave';
export { useRpgCreationSessionController } from './useRpgCreationSessionController';
export { useRpgEntryBootstrap } from './useRpgEntryBootstrap';
export { useRpgEntryCharacterSelect } from './useRpgEntryCharacterSelect';
export { useRpgEntryLibraryDetail } from './useRpgEntryLibraryDetail';
export { useRpgEntryNavigation } from './useRpgEntryNavigation';
export { useRpgEntrySaveResume } from './useRpgEntrySaveResume';

View File

@@ -0,0 +1,135 @@
import type {
CustomWorldAgentMessage,
CustomWorldAgentOperationRecord,
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { ApiClientError, isTimeoutError } from '../../services/apiClient';
import { buildCustomWorldCreatorIntentFoundationText } from '../../services/customWorldCreatorIntent';
import type { CustomWorldProfile } from '../../types';
export function resolveRpgEntryErrorMessage(
error: unknown,
fallback: string,
) {
if (isTimeoutError(error)) {
if (//u.test(fallback)) {
return '开启拼图创作工作台超时,请确认运行时后端已启动后重试。';
}
if (//u.test(fallback)) {
return '开启大鱼吃小鱼创作工作台超时,请确认运行时后端已启动后重试。';
}
if (//u.test(fallback)) {
return '开启创作工作台超时,请确认运行时后端已启动后重试。';
}
return '请求超时,请稍后重试。';
}
if (
error instanceof ApiClientError &&
error.status === 401 &&
(error.code === 'UNAUTHORIZED' ||
error.message.includes('Authorization Bearer Token'))
) {
return '当前登录状态已失效,请重新登录后继续。';
}
return error instanceof Error ? error.message : fallback;
}
export function createFailedRpgEntryAgentOperation(params: {
type: CustomWorldAgentOperationRecord['type'];
phaseLabel: string;
error: string;
}): CustomWorldAgentOperationRecord {
return {
operationId: `local-failed-${Date.now()}`,
type: params.type,
status: 'failed',
phaseLabel: params.phaseLabel,
phaseDetail: params.error,
progress: 0,
error: params.error,
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
export function buildOptimisticRpgEntryAgentMessage(
payload: Pick<CustomWorldAgentMessage, 'id' | 'role' | 'kind' | 'text'>,
): CustomWorldAgentMessage {
return {
...payload,
createdAt: new Date().toISOString(),
relatedOperationId: null,
};
}
export function normalizeRpgEntryAgentBackedProfile(
profile: CustomWorldProfile,
) {
const foundationText = buildCustomWorldCreatorIntentFoundationText(
profile.creatorIntent,
).trim();
if (!foundationText || foundationText === profile.settingText.trim()) {
return profile;
}
return {
...profile,
settingText: foundationText,
} satisfies CustomWorldProfile;
}
export function stringifyRpgEntryAgentBackedProfile(
profile: CustomWorldProfile,
) {
return JSON.stringify(normalizeRpgEntryAgentBackedProfile(profile));
}
export function buildRpgEntryCreationHubFallbackItems(
entries: CustomWorldLibraryEntry<CustomWorldProfile>[],
): CustomWorldWorkSummary[] {
return entries
.filter((entry) => entry.visibility === 'published')
.map((entry) => ({
workId: `fallback:${entry.profileId}`,
sourceType: 'published_profile',
status: 'published',
title: entry.worldName,
subtitle: entry.subtitle || '已发布作品',
summary: entry.summaryText || '继续补完这个世界的设定与游玩入口。',
coverImageSrc: entry.coverImageSrc,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: entry.updatedAt,
publishedAt: entry.publishedAt,
stage: null,
stageLabel: '已发布',
playableNpcCount: entry.playableNpcCount,
landmarkCount: entry.landmarkCount,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: null,
profileId: entry.profileId,
canResume: false,
canEnterWorld: true,
}));
}
/**
* 兼容创作链工作包已经接入的旧 helper 命名,避免本轮迁移波及其他并行改动。
*/
export const resolveRpgCreationErrorMessage = resolveRpgEntryErrorMessage;
export const createFailedAgentOperation =
createFailedRpgEntryAgentOperation;
export const buildOptimisticAgentMessage =
buildOptimisticRpgEntryAgentMessage;
export const normalizeAgentBackedProfile =
normalizeRpgEntryAgentBackedProfile;
export const stringifyAgentBackedProfile =
stringifyRpgEntryAgentBackedProfile;
export const buildCreationHubFallbackItems =
buildRpgEntryCreationHubFallbackItems;

View File

@@ -0,0 +1,9 @@
export type {
CustomWorldAutoSaveState,
CustomWorldGenerationViewSource,
CustomWorldResultViewSource,
PlatformEntryFlowShellProps as RpgEntryFlowShellProps,
PlatformEntryFlowShellProps as RpgCreationShellProps,
SelectionStage,
SyncedAgentDraftResult,
} from '../platform-entry/platformEntryTypes';

View File

@@ -0,0 +1,155 @@
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
export type PlatformWorldCardLike =
| CustomWorldGalleryCard
| CustomWorldLibraryEntry<CustomWorldProfile>
| PlatformPuzzleGalleryCard;
export type PlatformPuzzleGalleryCard = {
sourceType: 'puzzle';
workId: string;
profileId: string;
publicWorkCode: string;
ownerUserId: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeTags: string[];
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
};
export type PlatformPublicGalleryCard =
| CustomWorldGalleryCard
| PlatformPuzzleGalleryCard;
export function isLibraryWorldEntry(
entry: PlatformWorldCardLike,
): entry is CustomWorldLibraryEntry<CustomWorldProfile> {
return 'profile' in entry;
}
export function isPuzzleGalleryEntry(
entry: PlatformWorldCardLike,
): entry is PlatformPuzzleGalleryCard {
return 'sourceType' in entry && entry.sourceType === 'puzzle';
}
export function mapPuzzleWorkToPlatformGalleryCard(
work: PuzzleWorkSummary,
): PlatformPuzzleGalleryCard {
return {
sourceType: 'puzzle',
workId: work.workId,
profileId: work.profileId,
publicWorkCode: buildPuzzlePublicWorkCode(work.profileId),
ownerUserId: work.ownerUserId,
authorDisplayName: work.authorDisplayName,
worldName: work.levelName,
subtitle: '拼图关卡',
summaryText: work.summary,
coverImageSrc: work.coverImageSrc,
themeTags: work.themeTags,
visibility: 'published',
publishedAt: work.publishedAt,
updatedAt: work.updatedAt,
};
}
export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) {
if (entry.coverImageSrc) {
return entry.coverImageSrc;
}
if (isLibraryWorldEntry(entry)) {
return resolveCustomWorldCampSceneImage(entry.profile) ?? '';
}
return '';
}
export function resolvePlatformWorldLeadPortrait(entry: PlatformWorldCardLike) {
if (!isLibraryWorldEntry(entry)) {
return '';
}
return buildCustomWorldPlayableCharacters(entry.profile)[0]?.portrait ?? '';
}
export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
if (isPuzzleGalleryEntry(entry)) {
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['拼图'];
}
if (!isLibraryWorldEntry(entry)) {
return [
describePlatformThemeLabel(entry.themeMode),
`${entry.playableNpcCount} 角色`,
`${entry.landmarkCount} 地标`,
];
}
return [
...entry.profile.majorFactions.slice(0, 2),
...entry.profile.coreConflicts.slice(0, 1),
]
.map((value) => value.trim())
.filter(Boolean)
.slice(0, 3);
}
export function formatPlatformWorldTime(value: string | null) {
if (!value) {
return '未发布';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit',
});
}
export function resolvePlatformPublicWorkCode(
entry: PlatformWorldCardLike,
): string | null {
if (isPuzzleGalleryEntry(entry)) {
return entry.publicWorkCode;
}
return entry.publicWorkCode;
}
export function describePlatformThemeLabel(
themeMode: CustomWorldGalleryCard['themeMode'],
) {
switch (themeMode) {
case 'martial':
return '江湖';
case 'arcane':
return '灵脉';
case 'machina':
return '机巧';
case 'tide':
return '潮痕';
case 'rift':
return '裂界';
default:
return '回响';
}
}

View File

@@ -0,0 +1,118 @@
import { useEffect } from 'react';
import type {
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import {
getRpgCreationOperation,
} from '../../services/rpg-creation';
import {
createFailedAgentOperation,
resolveRpgCreationErrorMessage,
} from './rpgEntryShared';
type UseRpgCreationAgentOperationPollingParams = {
activeAgentSessionId: string | null;
activeAgentOperationId: string | null;
userId: string | null | undefined;
setAgentOperation: (
operation: CustomWorldAgentOperationRecord | null,
) => void;
persistAgentUiState: (
sessionId: string | null,
operationId: string | null,
generationSource?: 'agent-draft-foundation' | null,
) => void;
syncAgentSessionSnapshot: (
sessionId: string,
) => Promise<CustomWorldAgentSessionSnapshot | null>;
};
/**
* 只负责当前 Agent operation 的轮询与完成态刷新。
* 壳层不再直接维护轮询 interval 与失败兜底细节。
*/
export function useRpgCreationAgentOperationPolling(
params: UseRpgCreationAgentOperationPollingParams,
) {
const {
activeAgentSessionId,
activeAgentOperationId,
userId,
setAgentOperation,
persistAgentUiState,
syncAgentSessionSnapshot,
} = params;
useEffect(() => {
if (!activeAgentSessionId || !activeAgentOperationId || !userId) {
return;
}
let cancelled = false;
const pollOperation = async () => {
try {
const nextOperation = await getRpgCreationOperation(
activeAgentSessionId,
activeAgentOperationId,
);
if (cancelled) {
return;
}
setAgentOperation(nextOperation);
if (
nextOperation.status === 'completed' ||
nextOperation.status === 'failed'
) {
persistAgentUiState(
activeAgentSessionId,
nextOperation.type === 'draft_foundation'
? activeAgentOperationId
: null,
nextOperation.type === 'draft_foundation'
? 'agent-draft-foundation'
: null,
);
await syncAgentSessionSnapshot(activeAgentSessionId).catch(
() => null,
);
}
} catch (error) {
if (cancelled) {
return;
}
setAgentOperation(
createFailedAgentOperation({
type: 'process_message',
phaseLabel: '读取操作状态失败',
error: resolveRpgCreationErrorMessage(error, '读取共创操作状态失败。'),
}),
);
persistAgentUiState(activeAgentSessionId, null);
}
};
void pollOperation();
const intervalId = window.setInterval(() => {
void pollOperation();
}, 1200);
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [
activeAgentOperationId,
activeAgentSessionId,
persistAgentUiState,
setAgentOperation,
syncAgentSessionSnapshot,
userId,
]);
}

View File

@@ -0,0 +1,158 @@
/** @vitest-environment jsdom */
import { act, render } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import { WorldType, type CustomWorldProfile } from '../../types';
import { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
function buildProfile(params: {
id: string;
name: string;
imageSrc: string;
}): CustomWorldProfile {
return {
id: params.id,
settingText: params.name,
name: params.name,
subtitle: params.name,
summary: params.name,
tone: '测试',
playerGoal: '测试',
templateWorldType: WorldType.WUXIA,
compatibilityTemplateWorldType: WorldType.WUXIA,
majorFactions: [],
coreConflicts: [],
attributeSchema: {
id: `${params.id}-attribute-schema`,
worldId: params.id,
schemaVersion: 1,
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: params.name,
settingSummary: params.name,
tone: '测试',
conflictCore: '测试',
},
slots: [],
},
playableNpcs: [
{
id: `${params.id}-role`,
name: '可扮演角色',
title: '测试角色',
role: '主角',
description: '测试角色',
backstory: '测试背景',
personality: '测试性格',
motivation: '测试动机',
combatStyle: '测试战斗风格',
initialAffinity: 18,
relationshipHooks: [],
tags: [],
backstoryReveal: {
publicSummary: '测试角色',
privateChatUnlockAffinity: 60,
chapters: [],
},
skills: [],
initialItems: [],
imageSrc: params.imageSrc,
},
],
storyNpcs: [],
items: [],
landmarks: [],
generationMode: 'full',
generationStatus: 'complete',
};
}
function buildSession(): CustomWorldAgentSessionSnapshot {
return {
sessionId: 'session-1',
currentTurn: 1,
anchorContent: {
worldPromise: null,
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: null,
hiddenLines: null,
iconicElements: null,
},
progressPercent: 100,
lastAssistantReply: '',
stage: 'ready_to_publish',
focusCardId: null,
creatorIntent: null,
creatorIntentReadiness: { isReady: true, completedKeys: [], missingKeys: [] },
anchorPack: null,
lockState: null,
draftProfile: null,
messages: [],
draftCards: [],
pendingClarifications: [],
suggestedActions: [],
recommendedReplies: [],
qualityFindings: [],
assetCoverage: {
roleAssets: [],
sceneAssets: [],
allRoleAssetsReady: true,
allSceneAssetsReady: true,
},
resultPreview: null,
updatedAt: '2026-04-25T00:00:00.000Z',
};
}
describe('useRpgCreationEnterWorld', () => {
it('Agent 草稿进入游戏时使用 session draft profile 的角色形象', async () => {
const staleResultProfile = buildProfile({
id: 'stale-result',
name: '旧结果页快照',
imageSrc: '/template/old-role.png',
});
const draftProfile = buildProfile({
id: 'draft-profile',
name: '草稿真相源',
imageSrc: '/generated-characters/draft-role/portrait.png',
});
const handleCustomWorldSelect = vi.fn();
const setGeneratedCustomWorldProfile = vi.fn();
const executePublishWorld = vi.fn(async () => buildSession());
function Harness() {
const { enterWorldForTestFromCurrentResult } = useRpgCreationEnterWorld({
isAgentDraftResultView: true,
activeAgentSessionId: 'session-1',
generatedCustomWorldProfile: staleResultProfile,
agentSessionProfile: draftProfile,
agentSession: buildSession(),
handleCustomWorldSelect,
executePublishWorld,
setGeneratedCustomWorldProfile,
});
return (
<button type="button" onClick={() => void enterWorldForTestFromCurrentResult()}>
</button>
);
}
const { getByText } = render(<Harness />);
await act(async () => {
getByText('进入').click();
});
expect(executePublishWorld).not.toHaveBeenCalled();
expect(handleCustomWorldSelect).toHaveBeenCalledWith(draftProfile);
expect(handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc).toBe(
'/generated-characters/draft-role/portrait.png',
);
});
});

View File

@@ -0,0 +1,109 @@
import { useCallback } from 'react';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import type { CustomWorldProfile } from '../../types';
type UseRpgCreationEnterWorldParams = {
isAgentDraftResultView: boolean;
activeAgentSessionId: string | null;
generatedCustomWorldProfile: CustomWorldProfile | null;
agentSessionProfile: CustomWorldProfile | null;
agentSession: CustomWorldAgentSessionSnapshot | null;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
executePublishWorld: () => Promise<CustomWorldAgentSessionSnapshot | null>;
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
};
/**
* 统一“进入世界”前的最终同步策略。
* Agent 草稿结果进入游戏时只读 session.draftProfile不再把结果页快照回写成新的运行时 profile。
*/
export function useRpgCreationEnterWorld(
params: UseRpgCreationEnterWorldParams,
) {
const {
isAgentDraftResultView,
activeAgentSessionId,
generatedCustomWorldProfile,
agentSessionProfile,
agentSession,
handleCustomWorldSelect,
executePublishWorld,
setGeneratedCustomWorldProfile,
} = params;
const enterWorldForTestFromCurrentResult = useCallback(async () => {
if (!generatedCustomWorldProfile) {
return;
}
if (!isAgentDraftResultView || !activeAgentSessionId) {
handleCustomWorldSelect(generatedCustomWorldProfile);
return;
}
const latestProfile = agentSessionProfile ?? generatedCustomWorldProfile;
setGeneratedCustomWorldProfile(latestProfile);
handleCustomWorldSelect(latestProfile);
}, [
activeAgentSessionId,
agentSessionProfile,
generatedCustomWorldProfile,
handleCustomWorldSelect,
isAgentDraftResultView,
setGeneratedCustomWorldProfile,
]);
const publishCurrentResult = useCallback(async () => {
if (!generatedCustomWorldProfile) {
return null;
}
if (!isAgentDraftResultView || !activeAgentSessionId) {
return generatedCustomWorldProfile;
}
const latestProfile = agentSessionProfile ?? generatedCustomWorldProfile;
setGeneratedCustomWorldProfile(latestProfile);
const latestSession = agentSession;
const canEnterPublishedWorld =
latestSession?.stage === 'published' &&
latestSession.resultPreview?.canEnterWorld;
if (canEnterPublishedWorld) {
return latestProfile;
}
const publishedSession = await executePublishWorld();
const publishedProfile =
rpgCreationPreviewAdapter.buildPreviewFromSession(publishedSession) ??
latestProfile;
setGeneratedCustomWorldProfile(publishedProfile);
return publishedProfile;
}, [
activeAgentSessionId,
agentSession,
agentSessionProfile,
executePublishWorld,
generatedCustomWorldProfile,
handleCustomWorldSelect,
isAgentDraftResultView,
setGeneratedCustomWorldProfile,
]);
const enterWorldFromCurrentResult = useCallback(async () => {
const publishedProfile = await publishCurrentResult();
if (publishedProfile) {
handleCustomWorldSelect(publishedProfile);
}
}, [handleCustomWorldSelect, publishCurrentResult]);
return {
enterWorldFromCurrentResult,
enterWorldForTestFromCurrentResult,
publishCurrentResult,
};
}

View File

@@ -0,0 +1,379 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type {
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import {
executeRpgCreationAction,
getRpgCreationOperation,
upsertRpgWorldProfile,
} from '../../services/rpg-creation';
import type { CustomWorldProfile } from '../../types';
import {
normalizeAgentBackedProfile,
resolveRpgCreationErrorMessage,
stringifyAgentBackedProfile,
} from './rpgEntryShared';
import type {
CustomWorldAutoSaveState,
SelectionStage,
SyncedAgentDraftResult,
} from './rpgEntryTypes';
type UseRpgCreationResultAutosaveParams = {
selectionStage: SelectionStage;
activeAgentSessionId: string | null;
agentSession: CustomWorldAgentSessionSnapshot | null;
generatedCustomWorldProfile: CustomWorldProfile | null;
isAgentDraftResultView: boolean;
userId: string | null | undefined;
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
setAgentOperation: (
operation: CustomWorldAgentOperationRecord | null,
) => void;
setSavedCustomWorldEntries: (
entries: CustomWorldLibraryEntry<CustomWorldProfile>[],
) => void;
setSelectedDetailEntry: (
updater:
| CustomWorldLibraryEntry<CustomWorldProfile>
| null
| ((
current: CustomWorldLibraryEntry<CustomWorldProfile> | null,
) => CustomWorldLibraryEntry<CustomWorldProfile> | null),
) => void;
refreshCustomWorldWorks: () => Promise<unknown>;
persistAgentUiState: (
sessionId: string | null,
operationId: string | null,
generationSource?: 'agent-draft-foundation' | null,
) => void;
syncAgentSessionSnapshot: (
sessionId: string,
) => Promise<CustomWorldAgentSessionSnapshot | null>;
buildDraftResultProfile: (
session: CustomWorldAgentSessionSnapshot | null,
) => CustomWorldProfile | null;
};
/**
* 协调结果页自动保存与 Agent 草稿同步。
* 这里统一维护去重签名、延时保存和“先同步 session 再落作品库”的顺序。
*/
export function useRpgCreationResultAutosave(
params: UseRpgCreationResultAutosaveParams,
) {
const {
selectionStage,
activeAgentSessionId,
agentSession,
generatedCustomWorldProfile,
isAgentDraftResultView,
userId,
setGeneratedCustomWorldProfile,
setAgentOperation,
setSavedCustomWorldEntries,
setSelectedDetailEntry,
refreshCustomWorldWorks,
persistAgentUiState,
syncAgentSessionSnapshot,
buildDraftResultProfile,
} = params;
const customWorldAutoSaveTimeoutRef = useRef<number | null>(null);
const lastAutoSavedProfileSignatureRef = useRef<string | null>(null);
const latestAutoSaveRequestIdRef = useRef(0);
const latestAgentResultSyncSignatureRef = useRef<string | null>(null);
const isCustomWorldAutoSaveBusyRef = useRef(false);
const [customWorldAutoSaveState, setCustomWorldAutoSaveState] =
useState<CustomWorldAutoSaveState>('idle');
const [customWorldAutoSaveError, setCustomWorldAutoSaveError] = useState<
string | null
>(null);
const resetAutoSaveTrackingToIdle = useCallback(() => {
if (customWorldAutoSaveTimeoutRef.current !== null) {
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
customWorldAutoSaveTimeoutRef.current = null;
}
lastAutoSavedProfileSignatureRef.current = null;
latestAgentResultSyncSignatureRef.current = null;
setCustomWorldAutoSaveState('idle');
setCustomWorldAutoSaveError(null);
}, []);
const markAutoSavedProfile = useCallback((profile: CustomWorldProfile) => {
lastAutoSavedProfileSignatureRef.current =
stringifyAgentBackedProfile(profile);
}, []);
const saveGeneratedCustomWorld = useCallback(
async (profile = generatedCustomWorldProfile) => {
if (!profile) {
return null;
}
const normalizedProfile = normalizeAgentBackedProfile(profile);
const profileSignature = stringifyAgentBackedProfile(normalizedProfile);
const requestId = latestAutoSaveRequestIdRef.current + 1;
latestAutoSaveRequestIdRef.current = requestId;
setCustomWorldAutoSaveState('saving');
setCustomWorldAutoSaveError(null);
try {
const mutation =
await upsertRpgWorldProfile(
normalizedProfile,
{
sourceAgentSessionId:
isAgentDraftResultView && activeAgentSessionId
? activeAgentSessionId
: null,
},
);
if (latestAutoSaveRequestIdRef.current !== requestId) {
return mutation;
}
lastAutoSavedProfileSignatureRef.current = profileSignature;
setSavedCustomWorldEntries(mutation.entries);
if (userId) {
void refreshCustomWorldWorks().catch(() => {});
}
setSelectedDetailEntry((current) => {
if (!current || current.profileId === mutation.entry.profileId) {
return mutation.entry;
}
return current;
});
setCustomWorldAutoSaveState('saved');
setCustomWorldAutoSaveError(null);
return mutation;
} catch (error) {
if (latestAutoSaveRequestIdRef.current !== requestId) {
return null;
}
setCustomWorldAutoSaveState('error');
setCustomWorldAutoSaveError(
resolveRpgCreationErrorMessage(error, '保存自定义世界失败。'),
);
return null;
}
},
[
activeAgentSessionId,
generatedCustomWorldProfile,
isAgentDraftResultView,
refreshCustomWorldWorks,
setSavedCustomWorldEntries,
setSelectedDetailEntry,
userId,
],
);
const syncAgentDraftResultProfile = useCallback(
async (profile: CustomWorldProfile) => {
if (!activeAgentSessionId) {
return {
session: null,
profile: null,
} satisfies SyncedAgentDraftResult;
}
const normalizedProfile = normalizeAgentBackedProfile(profile);
const profileSignature = stringifyAgentBackedProfile(normalizedProfile);
const latestSessionProfile = buildDraftResultProfile(agentSession);
const latestSessionProfileSignature = latestSessionProfile
? stringifyAgentBackedProfile(latestSessionProfile)
: '';
const shouldRefreshPublishGate = Boolean(
agentSession?.resultPreview && !agentSession.resultPreview.publishReady,
);
if (
latestSessionProfileSignature === profileSignature &&
!shouldRefreshPublishGate
) {
latestAgentResultSyncSignatureRef.current = profileSignature;
return {
session: agentSession,
profile: normalizeAgentBackedProfile(latestSessionProfile ?? profile),
} satisfies SyncedAgentDraftResult;
}
if (
latestAgentResultSyncSignatureRef.current === profileSignature &&
!shouldRefreshPublishGate
) {
return {
session: agentSession,
profile: normalizeAgentBackedProfile(latestSessionProfile ?? profile),
} satisfies SyncedAgentDraftResult;
}
// Agent 结果页不再把前端 profile 回写到 session。
// session.draftProfile 是真相源;这里只刷新后端最新快照,避免在采集/生成早期误触 sync_result_profile。
const latestSession = await syncAgentSessionSnapshot(activeAgentSessionId);
const latestProfile = normalizeAgentBackedProfile(
buildDraftResultProfile(latestSession) ?? profile,
);
if (latestProfile) {
setGeneratedCustomWorldProfile(latestProfile);
}
latestAgentResultSyncSignatureRef.current =
stringifyAgentBackedProfile(latestProfile);
return {
session: latestSession,
profile: latestProfile,
} satisfies SyncedAgentDraftResult;
},
[
activeAgentSessionId,
agentSession,
buildDraftResultProfile,
setGeneratedCustomWorldProfile,
syncAgentSessionSnapshot,
],
);
const executeAgentActionAndWait = useCallback(
async (action: Parameters<typeof executeRpgCreationAction>[1]) => {
if (!activeAgentSessionId) {
throw new Error('当前世界草稿会话已失效,请返回创作页重新打开草稿。');
}
const { operation } = await executeRpgCreationAction(
activeAgentSessionId,
action,
);
setAgentOperation(operation);
persistAgentUiState(activeAgentSessionId, operation.operationId);
for (let attempt = 0; attempt < 60; attempt += 1) {
const latestOperation = await getRpgCreationOperation(
activeAgentSessionId,
operation.operationId,
);
setAgentOperation(latestOperation);
if (latestOperation.status === 'failed') {
throw new Error(
latestOperation.error ||
latestOperation.phaseDetail ||
'执行共创操作失败。',
);
}
if (latestOperation.status === 'completed') {
persistAgentUiState(activeAgentSessionId, null);
return syncAgentSessionSnapshot(activeAgentSessionId);
}
await new Promise((resolve) => window.setTimeout(resolve, 200));
}
throw new Error('执行共创操作超时。');
},
[
activeAgentSessionId,
persistAgentUiState,
setAgentOperation,
syncAgentSessionSnapshot,
],
);
useEffect(
() => () => {
if (customWorldAutoSaveTimeoutRef.current !== null) {
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
}
},
[],
);
useEffect(() => {
if (!generatedCustomWorldProfile) {
resetAutoSaveTrackingToIdle();
return;
}
if (selectionStage !== 'custom-world-result') {
return;
}
if (isCustomWorldAutoSaveBusyRef.current) {
return;
}
const nextSignature = stringifyAgentBackedProfile(generatedCustomWorldProfile);
if (nextSignature === lastAutoSavedProfileSignatureRef.current) {
return;
}
setCustomWorldAutoSaveState('saving');
if (customWorldAutoSaveTimeoutRef.current !== null) {
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
}
const profileToSave = generatedCustomWorldProfile;
customWorldAutoSaveTimeoutRef.current = window.setTimeout(() => {
void (async () => {
isCustomWorldAutoSaveBusyRef.current = true;
try {
let latestProfileToSave = normalizeAgentBackedProfile(profileToSave);
if (isAgentDraftResultView) {
const syncedResult =
await syncAgentDraftResultProfile(profileToSave);
// 作品库自动保存优先落同步后 session 重编译出的结果,避免继续保存旧的前端内存态。
latestProfileToSave = normalizeAgentBackedProfile(
syncedResult.profile ?? profileToSave,
);
}
await saveGeneratedCustomWorld(latestProfileToSave);
} catch (error) {
setCustomWorldAutoSaveState('error');
setCustomWorldAutoSaveError(
resolveRpgCreationErrorMessage(error, '保存自定义世界失败。'),
);
} finally {
isCustomWorldAutoSaveBusyRef.current = false;
}
})();
customWorldAutoSaveTimeoutRef.current = null;
}, 600);
return () => {
if (customWorldAutoSaveTimeoutRef.current !== null) {
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
customWorldAutoSaveTimeoutRef.current = null;
}
};
}, [
generatedCustomWorldProfile,
isAgentDraftResultView,
resetAutoSaveTrackingToIdle,
saveGeneratedCustomWorld,
selectionStage,
syncAgentDraftResultProfile,
]);
return {
customWorldAutoSaveState,
setCustomWorldAutoSaveState,
customWorldAutoSaveError,
setCustomWorldAutoSaveError,
resetAutoSaveTrackingToIdle,
markAutoSavedProfile,
saveGeneratedCustomWorld,
syncAgentDraftResultProfile,
executeAgentActionAndWait,
};
}

View File

@@ -0,0 +1,827 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type {
CustomWorldAgentActionRequest,
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
SendCustomWorldAgentMessageRequest,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import {
buildAgentDraftFoundationAnchorEntries,
buildAgentDraftFoundationGenerationProgress,
buildAgentDraftFoundationSettingText,
isDraftFoundationOperation,
isDraftFoundationOperationRunning,
} from '../../services/customWorldAgentGenerationProgress';
import {
readCustomWorldAgentUiState,
writeCustomWorldAgentUiState,
} from '../../services/customWorldAgentUiState';
import {
createRpgCreationSession,
executeRpgCreationAction,
getRpgCreationSession,
streamRpgCreationMessage,
} from '../../services/rpg-creation';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import type { CustomWorldProfile } from '../../types';
import {
buildOptimisticAgentMessage,
createFailedAgentOperation,
normalizeAgentBackedProfile,
resolveRpgCreationErrorMessage,
} from './rpgEntryShared';
import type {
CustomWorldGenerationViewSource,
CustomWorldResultViewSource,
SelectionStage,
} from './rpgEntryTypes';
type UseRpgCreationSessionControllerParams = {
userId: string | null | undefined;
openLoginModal?: ((postLoginAction?: (() => void) | null) => void) | undefined;
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
enterCreateTab?: (() => void) | undefined;
onSessionOpened?: (() => void) | undefined;
};
type PendingAgentUserMessage = {
sessionId: string;
message: CustomWorldAgentSessionSnapshot['messages'][number];
};
const AGENT_DRAFT_RESULT_AUTO_OPEN_MAX_ATTEMPTS = 12;
const AGENT_DRAFT_RESULT_AUTO_OPEN_RETRY_MS = 900;
export function useRpgCreationSessionController(
params: UseRpgCreationSessionControllerParams,
) {
const {
userId,
openLoginModal,
selectionStage,
setSelectionStage,
enterCreateTab,
onSessionOpened,
} = params;
const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState());
const isInitialAgentUiStateOwnedByCurrentUser =
!initialAgentUiStateRef.current.ownerUserId ||
initialAgentUiStateRef.current.ownerUserId === userId;
const isHydratingInitialAgentWorkspaceRef = useRef(
Boolean(
initialAgentUiStateRef.current.activeSessionId &&
isInitialAgentUiStateOwnedByCurrentUser,
),
);
const hasAppliedInitialAgentWorkspaceRef = useRef(false);
const hasRequestedInitialAgentWorkspaceAuthRef = useRef(false);
const isAgentDraftResultAutoOpenSuppressedRef = useRef(false);
const currentAgentSessionIdRef = useRef<string | null>(null);
const activeAgentReplyAbortControllerRef = useRef<AbortController | null>(
null,
);
const latestAgentSessionSyncRequestIdRef = useRef(0);
const [isCreatingAgentSession, setIsCreatingAgentSession] = useState(false);
const [activeAgentSessionId, setActiveAgentSessionId] = useState<
string | null
>(() =>
isInitialAgentUiStateOwnedByCurrentUser
? (initialAgentUiStateRef.current.activeSessionId ?? null)
: null,
);
const [activeAgentOperationId, setActiveAgentOperationId] = useState<
string | null
>(() =>
isInitialAgentUiStateOwnedByCurrentUser
? (initialAgentUiStateRef.current.activeOperationId ?? null)
: null,
);
const [agentSession, setAgentSession] =
useState<CustomWorldAgentSessionSnapshot | null>(null);
const [agentOperation, setAgentOperation] =
useState<CustomWorldAgentOperationRecord | null>(null);
const [streamingAgentReplyText, setStreamingAgentReplyText] = useState('');
const [isStreamingAgentReply, setIsStreamingAgentReply] = useState(false);
const [pendingAgentUserMessage, setPendingAgentUserMessage] =
useState<PendingAgentUserMessage | null>(null);
const [isLoadingAgentSession, setIsLoadingAgentSession] = useState(false);
const [creationTypeError, setCreationTypeError] = useState<string | null>(null);
const [agentWorkspaceRestoreError, setAgentWorkspaceRestoreError] =
useState<string | null>(null);
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
useState<CustomWorldProfile | null>(null);
const [customWorldError, setCustomWorldError] = useState<string | null>(null);
const [customWorldGenerationViewSource, setCustomWorldGenerationViewSource] =
useState<CustomWorldGenerationViewSource>(null);
const [customWorldResultViewSource, setCustomWorldResultViewSource] =
useState<CustomWorldResultViewSource>(null);
const [agentDraftGenerationStartedAt, setAgentDraftGenerationStartedAt] =
useState<number | null>(null);
const pendingAgentUserMessageRef = useRef<PendingAgentUserMessage | null>(null);
useEffect(() => {
currentAgentSessionIdRef.current = agentSession?.sessionId ?? null;
}, [agentSession]);
useEffect(() => {
pendingAgentUserMessageRef.current = pendingAgentUserMessage;
}, [pendingAgentUserMessage]);
const invalidateAgentSessionSyncRequests = useCallback(() => {
latestAgentSessionSyncRequestIdRef.current += 1;
}, []);
const abortActiveAgentReplyStream = useCallback(() => {
activeAgentReplyAbortControllerRef.current?.abort();
activeAgentReplyAbortControllerRef.current = null;
}, []);
const mergePendingAgentUserMessageIntoSession = useCallback(
(
session: CustomWorldAgentSessionSnapshot | null,
pending: PendingAgentUserMessage | null = pendingAgentUserMessageRef.current,
) => {
if (!session || !pending || pending.sessionId !== session.sessionId) {
return session;
}
const hasServerEchoedPendingMessage = session.messages.some(
(message) => message.id === pending.message.id,
);
if (hasServerEchoedPendingMessage) {
return session;
}
return {
...session,
messages: [...session.messages, pending.message],
updatedAt: pending.message.createdAt,
};
},
[],
);
const persistAgentUiState = useCallback(
(
nextSessionId: string | null,
nextOperationId: string | null,
nextGenerationSource: CustomWorldGenerationViewSource = null,
) => {
setActiveAgentSessionId(nextSessionId);
setActiveAgentOperationId(nextOperationId);
writeCustomWorldAgentUiState({
activeSessionId: nextSessionId,
activeOperationId: nextOperationId,
customWorldGenerationSource: nextGenerationSource,
// 工作区 session 是按 userId 持久化的,恢复指针必须绑定当前登录用户,
// 避免切换账号或复用旧 URL 时反复请求不属于当前用户的 session 产生 404。
ownerUserId: nextSessionId ? userId : null,
});
},
[userId],
);
const syncAgentSessionSnapshot = useCallback(async (sessionId: string) => {
const requestId = latestAgentSessionSyncRequestIdRef.current + 1;
latestAgentSessionSyncRequestIdRef.current = requestId;
const nextSession = await getRpgCreationSession(sessionId);
const mergedSession = mergePendingAgentUserMessageIntoSession(nextSession);
if (latestAgentSessionSyncRequestIdRef.current === requestId) {
setAgentSession(mergedSession);
const currentPendingAgentUserMessage = pendingAgentUserMessageRef.current;
const hasServerEchoedPendingMessage =
currentPendingAgentUserMessage?.sessionId === nextSession.sessionId &&
nextSession.messages.some(
(message) => message.id === currentPendingAgentUserMessage.message.id,
);
if (hasServerEchoedPendingMessage) {
setPendingAgentUserMessage(null);
}
}
return mergedSession;
}, [mergePendingAgentUserMessageIntoSession]);
useEffect(() => {
const initialAgentSessionId = initialAgentUiStateRef.current.activeSessionId;
if (!initialAgentSessionId || hasAppliedInitialAgentWorkspaceRef.current) {
return;
}
enterCreateTab?.();
if (!userId) {
if (!hasRequestedInitialAgentWorkspaceAuthRef.current) {
hasRequestedInitialAgentWorkspaceAuthRef.current = true;
openLoginModal?.(() => {
if (
initialAgentUiStateRef.current.activeOperationId &&
initialAgentUiStateRef.current.customWorldGenerationSource ===
'agent-draft-foundation'
) {
setCustomWorldGenerationViewSource('agent-draft-foundation');
setSelectionStage('custom-world-generating');
return;
}
setSelectionStage('agent-workspace');
});
}
return;
}
if (
initialAgentUiStateRef.current.ownerUserId &&
initialAgentUiStateRef.current.ownerUserId !== userId
) {
hasAppliedInitialAgentWorkspaceRef.current = true;
isHydratingInitialAgentWorkspaceRef.current = false;
persistAgentUiState(null, null);
return;
}
hasAppliedInitialAgentWorkspaceRef.current = true;
if (
initialAgentUiStateRef.current.activeOperationId &&
initialAgentUiStateRef.current.customWorldGenerationSource ===
'agent-draft-foundation'
) {
setCustomWorldGenerationViewSource('agent-draft-foundation');
setCustomWorldResultViewSource(null);
setSelectionStage('custom-world-generating');
return;
}
setSelectionStage('agent-workspace');
}, [enterCreateTab, openLoginModal, persistAgentUiState, setSelectionStage, userId]);
useEffect(() => {
if (
selectionStage !== 'agent-workspace' &&
selectionStage !== 'custom-world-generating'
) {
abortActiveAgentReplyStream();
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
}
}, [abortActiveAgentReplyStream, selectionStage]);
useEffect(() => {
return () => {
abortActiveAgentReplyStream();
};
}, [abortActiveAgentReplyStream]);
useEffect(() => {
if (!activeAgentSessionId) {
abortActiveAgentReplyStream();
invalidateAgentSessionSyncRequests();
setAgentSession(null);
setAgentOperation(null);
setIsLoadingAgentSession(false);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
setPendingAgentUserMessage(null);
setAgentWorkspaceRestoreError(null);
isHydratingInitialAgentWorkspaceRef.current = false;
return;
}
if (!userId) {
abortActiveAgentReplyStream();
invalidateAgentSessionSyncRequests();
setAgentSession(null);
setAgentOperation(null);
setIsLoadingAgentSession(false);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
setPendingAgentUserMessage(null);
setAgentWorkspaceRestoreError(null);
return;
}
let cancelled = false;
const isInitialWorkspaceRestore =
isHydratingInitialAgentWorkspaceRef.current &&
activeAgentSessionId === initialAgentUiStateRef.current.activeSessionId;
if (currentAgentSessionIdRef.current !== activeAgentSessionId) {
abortActiveAgentReplyStream();
setAgentSession(null);
setAgentOperation(null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
setPendingAgentUserMessage(null);
}
setIsLoadingAgentSession(true);
void syncAgentSessionSnapshot(activeAgentSessionId)
.then(() => {
if (!cancelled) {
setCreationTypeError(null);
setAgentWorkspaceRestoreError(null);
isHydratingInitialAgentWorkspaceRef.current = false;
}
})
.catch((error) => {
if (cancelled) {
return;
}
// 登录后自动恢复的是“上一次残留的工作区指针”,
// 这里失败时应优先静默清理,避免把旧恢复错误冒充成当前登录已失效。
if (isInitialWorkspaceRestore) {
setAgentWorkspaceRestoreError(null);
} else {
setAgentWorkspaceRestoreError(
resolveRpgCreationErrorMessage(error, '读取 Agent 共创工作区失败。'),
);
}
setAgentSession(null);
setAgentOperation(null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
isHydratingInitialAgentWorkspaceRef.current = false;
persistAgentUiState(null, null);
enterCreateTab?.();
setSelectionStage('platform');
})
.finally(() => {
if (!cancelled) {
setIsLoadingAgentSession(false);
}
});
return () => {
cancelled = true;
};
}, [
activeAgentSessionId,
abortActiveAgentReplyStream,
enterCreateTab,
invalidateAgentSessionSyncRequests,
persistAgentUiState,
setSelectionStage,
syncAgentSessionSnapshot,
userId,
]);
useEffect(() => {
if (
!isDraftFoundationOperationRunning(agentOperation) ||
agentDraftGenerationStartedAt
) {
return;
}
setAgentDraftGenerationStartedAt(Date.now());
}, [agentDraftGenerationStartedAt, agentOperation]);
useEffect(() => {
if (
selectionStage !== 'custom-world-generating' ||
customWorldGenerationViewSource !== 'agent-draft-foundation' ||
!isDraftFoundationOperation(agentOperation) ||
agentOperation.status !== 'completed'
) {
return;
}
let cancelled = false;
void (async () => {
for (
let attempt = 1;
attempt <= AGENT_DRAFT_RESULT_AUTO_OPEN_MAX_ATTEMPTS;
attempt += 1
) {
await new Promise((resolve) => {
window.setTimeout(
resolve,
AGENT_DRAFT_RESULT_AUTO_OPEN_RETRY_MS,
);
});
if (cancelled) {
return;
}
const latestSession = activeAgentSessionId
? await syncAgentSessionSnapshot(activeAgentSessionId).catch(
() => null,
)
: agentSession;
if (cancelled) {
return;
}
const draftResultProfile =
rpgCreationPreviewAdapter.buildPreviewFromSession(
latestSession ?? agentSession,
);
if (!draftResultProfile) {
continue;
}
setGeneratedCustomWorldProfile(
normalizeAgentBackedProfile(draftResultProfile),
);
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource('agent-draft');
setSelectionStage('custom-world-result');
return;
}
if (!cancelled) {
setAgentDraftGenerationStartedAt(null);
}
})();
return () => {
cancelled = true;
};
}, [
activeAgentSessionId,
agentOperation,
agentSession,
customWorldGenerationViewSource,
selectionStage,
setSelectionStage,
syncAgentSessionSnapshot,
]);
const agentDraftSettingPreview = useMemo(
() => buildAgentDraftFoundationSettingText(agentSession),
[agentSession],
);
const agentDraftAnchorPreviewEntries = useMemo(
() => buildAgentDraftFoundationAnchorEntries(agentSession),
[agentSession],
);
const agentDraftResultProfile = useMemo(
() => rpgCreationPreviewAdapter.buildPreviewFromSession(agentSession),
[agentSession],
);
const shouldAutoOpenAgentDraftResult = useMemo(
() =>
Boolean(
agentDraftResultProfile &&
agentSession &&
(agentSession.stage === 'object_refining' ||
agentSession.stage === 'visual_refining' ||
agentSession.stage === 'long_tail_review' ||
agentSession.stage === 'ready_to_publish' ||
agentSession.stage === 'published') &&
agentSession.draftCards.length > 0,
),
[agentDraftResultProfile, agentSession],
);
const agentDraftGenerationProgress = useMemo(
() =>
buildAgentDraftFoundationGenerationProgress(
agentOperation,
agentDraftGenerationStartedAt,
),
[agentDraftGenerationStartedAt, agentOperation],
);
const isAgentDraftGenerationView =
customWorldGenerationViewSource === 'agent-draft-foundation';
const isAgentDraftResultView = customWorldResultViewSource === 'agent-draft';
const isActiveGenerationRunning =
isDraftFoundationOperationRunning(agentOperation);
const activeGenerationError =
isDraftFoundationOperation(agentOperation) &&
agentOperation.status === 'failed'
? agentOperation.error || agentOperation.phaseDetail
: null;
useEffect(() => {
if (!shouldAutoOpenAgentDraftResult || !agentDraftResultProfile) {
return;
}
if (isAgentDraftResultAutoOpenSuppressedRef.current) {
return;
}
if (selectionStage === 'agent-workspace') {
setGeneratedCustomWorldProfile(agentDraftResultProfile);
setCustomWorldResultViewSource('agent-draft');
isAgentDraftResultAutoOpenSuppressedRef.current = false;
setSelectionStage('custom-world-result');
return;
}
if (
selectionStage === 'custom-world-result' &&
!generatedCustomWorldProfile
) {
setGeneratedCustomWorldProfile(agentDraftResultProfile);
setCustomWorldResultViewSource('agent-draft');
isAgentDraftResultAutoOpenSuppressedRef.current = false;
}
}, [
agentDraftResultProfile,
generatedCustomWorldProfile,
selectionStage,
setSelectionStage,
shouldAutoOpenAgentDraftResult,
]);
const openRpgAgentWorkspace = useCallback(
async (seedText = '') => {
if (isCreatingAgentSession) {
return;
}
setIsCreatingAgentSession(true);
setCreationTypeError(null);
isAgentDraftResultAutoOpenSuppressedRef.current = false;
invalidateAgentSessionSyncRequests();
setAgentSession(null);
setAgentOperation(null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
setPendingAgentUserMessage(null);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
try {
const { session } = await createRpgCreationSession(
seedText ? { seedText } : {},
);
isHydratingInitialAgentWorkspaceRef.current = false;
setAgentSession(session);
setAgentOperation(null);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
enterCreateTab?.();
onSessionOpened?.();
persistAgentUiState(session.sessionId, null);
setSelectionStage('agent-workspace');
} catch (error) {
setCreationTypeError(
resolveRpgCreationErrorMessage(error, '开启共创工作台失败。'),
);
} finally {
setIsCreatingAgentSession(false);
}
},
[
enterCreateTab,
invalidateAgentSessionSyncRequests,
isCreatingAgentSession,
onSessionOpened,
persistAgentUiState,
setSelectionStage,
],
);
const submitAgentMessage = useCallback(
async (payload: SendCustomWorldAgentMessageRequest) => {
if (!activeAgentSessionId || isStreamingAgentReply) {
return;
}
const optimisticUserMessage = buildOptimisticAgentMessage({
id: payload.clientMessageId,
role: 'user',
kind: 'chat',
text: payload.text.trim(),
});
const pendingMessagePayload: PendingAgentUserMessage = {
sessionId: activeAgentSessionId,
message: optimisticUserMessage,
};
setAgentOperation(null);
persistAgentUiState(activeAgentSessionId, null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(true);
setPendingAgentUserMessage(pendingMessagePayload);
const replyAbortController = new AbortController();
activeAgentReplyAbortControllerRef.current = replyAbortController;
setAgentSession((current) =>
mergePendingAgentUserMessageIntoSession(current, pendingMessagePayload),
);
try {
const nextSession = await streamRpgCreationMessage(
activeAgentSessionId,
payload,
{
onUpdate: (text) => {
if (replyAbortController.signal.aborted) {
return;
}
setStreamingAgentReplyText(text);
},
signal: replyAbortController.signal,
},
);
if (replyAbortController.signal.aborted) {
return;
}
const mergedNextSession = mergePendingAgentUserMessageIntoSession(
nextSession,
pendingMessagePayload,
);
setAgentSession(mergedNextSession);
setAgentOperation(null);
setStreamingAgentReplyText('');
const hasServerEchoedPendingMessage = nextSession.messages.some(
(message) => message.id === optimisticUserMessage.id,
);
setPendingAgentUserMessage(
hasServerEchoedPendingMessage ? null : pendingMessagePayload,
);
} catch (error) {
if (replyAbortController.signal.aborted) {
return;
}
const errorMessage = resolveRpgCreationErrorMessage(
error,
'发送共创消息失败。',
);
const warningMessage = buildOptimisticAgentMessage({
id: `message-error-${Date.now()}`,
role: 'assistant',
kind: 'warning',
text: errorMessage,
});
setAgentSession((current) =>
{
const mergedCurrentSession = mergePendingAgentUserMessageIntoSession(
current,
pendingMessagePayload,
);
return mergedCurrentSession
? {
...mergedCurrentSession,
messages: [...mergedCurrentSession.messages, warningMessage],
updatedAt: warningMessage.createdAt,
}
: current;
},
);
setPendingAgentUserMessage(null);
setStreamingAgentReplyText('');
persistAgentUiState(activeAgentSessionId, null);
} finally {
if (activeAgentReplyAbortControllerRef.current === replyAbortController) {
activeAgentReplyAbortControllerRef.current = null;
}
if (!replyAbortController.signal.aborted) {
setIsStreamingAgentReply(false);
}
}
},
[
activeAgentSessionId,
isStreamingAgentReply,
mergePendingAgentUserMessageIntoSession,
persistAgentUiState,
],
);
const executeAgentAction = useCallback(
async (payload: CustomWorldAgentActionRequest) => {
if (!activeAgentSessionId) {
return;
}
const isDraftFoundationAction = payload.action === 'draft_foundation';
if (isDraftFoundationAction) {
isAgentDraftResultAutoOpenSuppressedRef.current = false;
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldGenerationViewSource('agent-draft-foundation');
setCustomWorldResultViewSource(null);
setAgentDraftGenerationStartedAt(Date.now());
setSelectionStage('custom-world-generating');
}
try {
const { operation } = await executeRpgCreationAction(
activeAgentSessionId,
payload,
);
setAgentOperation(operation);
persistAgentUiState(
activeAgentSessionId,
operation.operationId,
isDraftFoundationAction ? 'agent-draft-foundation' : null,
);
} catch (error) {
const errorMessage = resolveRpgCreationErrorMessage(
error,
'执行共创操作失败。',
);
setAgentOperation(
createFailedAgentOperation({
type:
payload.action === 'draft_foundation'
? 'draft_foundation'
: payload.action,
phaseLabel: '执行操作失败',
error: errorMessage,
}),
);
persistAgentUiState(
activeAgentSessionId,
null,
isDraftFoundationAction ? 'agent-draft-foundation' : null,
);
}
},
[activeAgentSessionId, persistAgentUiState, setSelectionStage],
);
const setNormalizedGeneratedCustomWorldProfile = useCallback(
(profile: CustomWorldProfile | null) => {
setGeneratedCustomWorldProfile(
profile ? normalizeAgentBackedProfile(profile) : null,
);
},
[],
);
const resetSessionViewState = useCallback(() => {
setAgentOperation(null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
setPendingAgentUserMessage(null);
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
}, []);
const suppressAgentDraftResultAutoOpen = useCallback(() => {
isAgentDraftResultAutoOpenSuppressedRef.current = true;
}, []);
const releaseAgentDraftResultAutoOpenSuppression = useCallback(() => {
isAgentDraftResultAutoOpenSuppressedRef.current = false;
}, []);
return {
initialAgentSessionId: initialAgentUiStateRef.current.activeSessionId ?? null,
isCreatingAgentSession,
activeAgentSessionId,
activeAgentOperationId,
agentSession,
setAgentSession,
agentOperation,
setAgentOperation,
resetSessionViewState,
streamingAgentReplyText,
isStreamingAgentReply,
isLoadingAgentSession,
creationTypeError,
setCreationTypeError,
agentWorkspaceRestoreError,
customWorldError,
setCustomWorldError,
generatedCustomWorldProfile,
setGeneratedCustomWorldProfile: setNormalizedGeneratedCustomWorldProfile,
rawSetGeneratedCustomWorldProfile: setGeneratedCustomWorldProfile,
customWorldGenerationViewSource,
setCustomWorldGenerationViewSource,
customWorldResultViewSource,
setCustomWorldResultViewSource,
agentDraftGenerationStartedAt,
setAgentDraftGenerationStartedAt,
agentDraftSettingPreview,
agentDraftAnchorPreviewEntries,
agentDraftResultProfile,
agentDraftGenerationProgress,
isAgentDraftGenerationView,
isAgentDraftResultView,
isActiveGenerationRunning,
activeGenerationError,
isAgentDraftResultAutoOpenSuppressedRef,
suppressAgentDraftResultAutoOpen,
releaseAgentDraftResultAutoOpenSuppression,
persistAgentUiState,
syncAgentSessionSnapshot,
openRpgAgentWorkspace,
submitAgentMessage,
executeAgentAction,
};
}

View File

@@ -0,0 +1,278 @@
/** @vitest-environment jsdom */
import { act, render } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
import { WorldType, type CustomWorldProfile } from '../../types';
import {
executeRpgCreationAction,
upsertRpgWorldProfile,
} from '../../services/rpg-creation';
import { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave';
import { useRpgEntryLibraryDetail } from './useRpgEntryLibraryDetail';
vi.mock('../../services/rpg-creation', () => ({
executeRpgCreationAction: vi.fn(),
getRpgCreationOperation: vi.fn(),
upsertRpgWorldProfile: vi.fn(),
}));
vi.mock('../../services/rpg-entry', () => ({
deleteRpgEntryWorldProfile: vi.fn(),
getRpgEntryWorldGalleryDetail: vi.fn(),
listRpgEntryWorldLibrary: vi.fn(),
publishRpgEntryWorldProfile: vi.fn(),
unpublishRpgEntryWorldProfile: vi.fn(),
}));
function buildProfile(name: string): CustomWorldProfile {
return {
id: `profile-${name}`,
settingText: name,
name,
subtitle: name,
summary: name,
tone: '测试',
playerGoal: '测试',
templateWorldType: WorldType.WUXIA,
compatibilityTemplateWorldType: WorldType.WUXIA,
majorFactions: [],
coreConflicts: [],
attributeSchema: {
id: `schema-${name}`,
worldId: `profile-${name}`,
schemaVersion: 1,
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: name,
settingSummary: name,
tone: '测试',
conflictCore: '测试',
},
slots: [],
},
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
generationMode: 'full',
generationStatus: 'complete',
};
}
function buildSession(
overrides: Partial<CustomWorldAgentSessionSnapshot> = {},
): CustomWorldAgentSessionSnapshot {
return {
sessionId: 'agent-session-1',
currentTurn: 1,
anchorContent: {
worldPromise: null,
playerFantasy: null,
themeBoundary: null,
playerEntryPoint: null,
coreConflict: null,
keyRelationships: null,
hiddenLines: null,
iconicElements: null,
},
progressPercent: 20,
lastAssistantReply: '继续补齐世界草稿。',
stage: 'clarifying',
focusCardId: null,
creatorIntent: null,
creatorIntentReadiness: {
isReady: false,
completedKeys: [],
missingKeys: [],
},
anchorPack: null,
lockState: null,
draftProfile: null,
messages: [],
draftCards: [],
pendingClarifications: [],
suggestedActions: [],
recommendedReplies: [],
qualityFindings: [],
assetCoverage: {
roleAssets: [],
sceneAssets: [],
allRoleAssetsReady: false,
allSceneAssetsReady: false,
},
resultPreview: null,
updatedAt: '2026-04-25T00:00:00.000Z',
...overrides,
};
}
describe('RPG Agent 草稿恢复', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('作品摘要已有对象数量但 session 没有 draftProfile 时恢复 Agent 页面', async () => {
const syncAgentSessionSnapshot = vi.fn(async () =>
buildSession({
stage: 'clarifying',
draftProfile: null,
}),
);
const setSelectionStage = vi.fn();
const persistAgentUiState = vi.fn();
const setGeneratedCustomWorldProfile = vi.fn();
const setCustomWorldResultViewSource = vi.fn();
const suppressAgentDraftResultAutoOpen = vi.fn();
let openWork:
| ((work: CustomWorldWorkSummary) => Promise<void>)
| null = null;
function Harness() {
openWork = useRpgEntryLibraryDetail({
userId: 'user-1',
selectedDetailEntry: null,
setSelectedDetailEntry: vi.fn(),
savedCustomWorldEntries: [],
setSavedCustomWorldEntries: vi.fn(),
setGeneratedCustomWorldProfile,
setCustomWorldError: vi.fn(),
setCustomWorldAutoSaveError: vi.fn(),
setCustomWorldAutoSaveState: vi.fn(),
setCustomWorldGenerationViewSource: vi.fn(),
setCustomWorldResultViewSource,
setSelectionStage,
setPlatformTabToCreate: vi.fn(),
setPlatformError: vi.fn(),
appendBrowseHistoryEntry: vi.fn(async () => {}),
refreshCustomWorldWorks: vi.fn(async () => []),
refreshPublishedGallery: vi.fn(async () => []),
persistAgentUiState,
syncAgentSessionSnapshot,
buildDraftResultProfile: (session) =>
(session?.draftProfile as CustomWorldProfile | null) ?? null,
suppressAgentDraftResultAutoOpen,
releaseAgentDraftResultAutoOpenSuppression: vi.fn(),
resetAutoSaveTrackingToIdle: vi.fn(),
markAutoSavedProfile: vi.fn(),
}).handleOpenCreationWork;
return null;
}
render(<Harness />);
await act(async () => {
await openWork?.({
workId: 'draft:agent-session-1',
sourceType: 'agent_session',
status: 'draft',
title: '未生成草稿作品',
subtitle: '',
summary: '',
updatedAt: '2026-04-25T00:00:00.000Z',
stage: 'clarifying',
stageLabel: '澄清中',
playableNpcCount: 2,
landmarkCount: 3,
sessionId: 'agent-session-1',
canResume: true,
canEnterWorld: false,
});
});
expect(syncAgentSessionSnapshot).toHaveBeenCalledWith('agent-session-1');
expect(suppressAgentDraftResultAutoOpen).toHaveBeenCalled();
expect(persistAgentUiState).toHaveBeenCalledWith('agent-session-1', null);
expect(setGeneratedCustomWorldProfile).toHaveBeenLastCalledWith(null);
expect(setCustomWorldResultViewSource).toHaveBeenLastCalledWith(null);
expect(setSelectionStage).toHaveBeenLastCalledWith('agent-workspace');
expect(setSelectionStage).not.toHaveBeenCalledWith('custom-world-result');
});
it('Agent 结果页自动保存只刷新 session draftProfile不触发 sync_result_profile', async () => {
const oldProfile = buildProfile('旧前端快照');
const latestProfile = {
...buildProfile('服务端草稿快照'),
summary: '自动保存应保存这份 session 最新草稿。',
};
const latestSession = buildSession({
stage: 'object_refining',
draftProfile: latestProfile as unknown as Record<string, unknown>,
});
const syncAgentSessionSnapshot = vi.fn(async () => latestSession);
vi.mocked(upsertRpgWorldProfile).mockResolvedValue({
entry: {
ownerUserId: 'user-1',
profileId: latestProfile.id,
publicWorkCode: null,
authorPublicUserCode: null,
profile: latestProfile,
visibility: 'draft',
publishedAt: null,
updatedAt: '2026-04-25T00:00:00.000Z',
authorDisplayName: '测试玩家',
worldName: latestProfile.name,
subtitle: latestProfile.subtitle,
summaryText: latestProfile.summary,
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 0,
landmarkCount: 0,
},
entries: [],
});
function Harness() {
useRpgCreationResultAutosave({
selectionStage: 'custom-world-result',
activeAgentSessionId: 'agent-session-1',
agentSession: buildSession({
stage: 'object_refining',
draftProfile: oldProfile as unknown as Record<string, unknown>,
resultPreview: {
publishReady: false,
blockers: [],
qualityFindings: [],
sourceLabel: '旧预览',
} as never,
}),
generatedCustomWorldProfile: oldProfile,
isAgentDraftResultView: true,
userId: 'user-1',
setGeneratedCustomWorldProfile: vi.fn(),
setAgentOperation: vi.fn(),
setSavedCustomWorldEntries: vi.fn(),
setSelectedDetailEntry: vi.fn(),
refreshCustomWorldWorks: vi.fn(async () => []),
persistAgentUiState: vi.fn(),
syncAgentSessionSnapshot,
buildDraftResultProfile: (session) =>
(session?.draftProfile as CustomWorldProfile | null) ?? null,
});
return null;
}
vi.useFakeTimers();
render(<Harness />);
await act(async () => {
await vi.advanceTimersByTimeAsync(650);
});
vi.useRealTimers();
expect(syncAgentSessionSnapshot).toHaveBeenCalledWith('agent-session-1');
expect(upsertRpgWorldProfile).toHaveBeenCalledWith(latestProfile, {
sourceAgentSessionId: 'agent-session-1',
});
expect(
vi.mocked(executeRpgCreationAction).mock.calls.some(
([, payload]) => payload?.action === 'sync_result_profile',
),
).toBe(false);
});
});

View File

@@ -0,0 +1,382 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
PlatformBrowseHistoryEntry,
PlatformBrowseHistoryWriteEntry,
ProfileDashboardSummary,
ProfileSaveArchiveSummary,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { AuthUser } from '../../services/authService';
import { listRpgCreationWorks } from '../../services/rpg-creation/index';
import {
listRpgEntryWorldGallery,
listRpgEntryWorldLibrary,
listRpgProfileBrowseHistory,
listRpgProfileSaveArchives,
resumeRpgProfileSaveArchive,
upsertRpgProfileBrowseHistory,
} from '../../services/rpg-entry';
import type { CustomWorldProfile } from '../../types';
import type { PlatformHomeTab } from './RpgEntryHomeView';
import { resolveRpgEntryErrorMessage } from './rpgEntryShared';
type UseRpgEntryBootstrapParams = {
user: AuthUser | null | undefined;
canAccessProtectedData?: boolean | undefined;
getProfileDashboard: () => Promise<ProfileDashboardSummary | null>;
handleContinueGame: (
snapshot?: HydratedSavedGameSnapshot | null,
) => void;
hasInitialAgentSession: boolean;
};
export function useRpgEntryBootstrap(
params: UseRpgEntryBootstrapParams,
) {
const {
user,
canAccessProtectedData = Boolean(user),
getProfileDashboard,
handleContinueGame,
hasInitialAgentSession,
} = params;
const isAuthenticated = Boolean(user);
const canReadProtectedData = Boolean(user) && canAccessProtectedData;
const platformTabBootstrapUserIdRef = useRef<string | null | undefined>(
undefined,
);
const hasExplicitPlatformTabSelectionRef = useRef(false);
const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState<
CustomWorldLibraryEntry<CustomWorldProfile>[]
>([]);
const [customWorldWorkEntries, setCustomWorldWorkEntries] = useState<
CustomWorldWorkSummary[]
>([]);
const [publishedGalleryEntries, setPublishedGalleryEntries] = useState<
CustomWorldGalleryCard[]
>([]);
const [historyEntries, setHistoryEntries] = useState<
PlatformBrowseHistoryEntry[]
>([]);
const [saveEntries, setSaveEntries] = useState<ProfileSaveArchiveSummary[]>([]);
const [platformTab, setPlatformTabState] = useState<PlatformHomeTab>('home');
const [platformError, setPlatformError] = useState<string | null>(null);
const [dashboardError, setDashboardError] = useState<string | null>(null);
const [historyError, setHistoryError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [isLoadingPlatform, setIsLoadingPlatform] = useState(false);
const [isLoadingDashboard, setIsLoadingDashboard] = useState(false);
const [isResumingSaveWorldKey, setIsResumingSaveWorldKey] = useState<
string | null
>(null);
const [profileDashboard, setProfileDashboard] =
useState<ProfileDashboardSummary | null>(null);
const setPlatformTab = useCallback((nextTab: PlatformHomeTab) => {
// 区分“平台首屏默认落点”和“用户/流程显式切换”。
// 一旦显式切过 Tab就不能再被首屏异步请求回刷成首页或存档。
hasExplicitPlatformTabSelectionRef.current = true;
setPlatformTabState(nextTab);
}, []);
const refreshProfileDashboard = useCallback(async () => {
if (!user || !canReadProtectedData) {
setProfileDashboard(null);
setDashboardError(null);
setIsLoadingDashboard(false);
return;
}
setIsLoadingDashboard(true);
setDashboardError(null);
try {
setProfileDashboard(await getProfileDashboard());
} catch (error) {
setDashboardError(
resolveRpgEntryErrorMessage(error, '读取个人数据看板失败。'),
);
} finally {
setIsLoadingDashboard(false);
}
}, [canReadProtectedData, getProfileDashboard, user]);
const refreshCustomWorldWorks = useCallback(async () => {
if (!user || !canReadProtectedData) {
setCustomWorldWorkEntries([]);
return [];
}
const nextItems = await listRpgCreationWorks();
setCustomWorldWorkEntries(nextItems);
return nextItems;
}, [canReadProtectedData, user]);
const refreshPublishedGallery = useCallback(async () => {
const nextEntries = await listRpgEntryWorldGallery();
setPublishedGalleryEntries(nextEntries);
return nextEntries;
}, []);
const refreshSavedCustomWorldLibrary = useCallback(async () => {
if (!user || !canReadProtectedData) {
setSavedCustomWorldEntries([]);
return [];
}
const nextEntries = await listRpgEntryWorldLibrary();
setSavedCustomWorldEntries(nextEntries);
return nextEntries;
}, [canReadProtectedData, user]);
const appendBrowseHistoryEntry = useCallback(
async (entry: PlatformBrowseHistoryWriteEntry) => {
setHistoryError(null);
try {
const syncedEntries = await upsertRpgProfileBrowseHistory(entry);
setHistoryEntries(syncedEntries);
} catch (error) {
setHistoryError(
resolveRpgEntryErrorMessage(error, '写入浏览历史失败。'),
);
}
},
[],
);
const handleResumeSaveEntry = useCallback(
async (entry: ProfileSaveArchiveSummary) => {
if (!user || !canReadProtectedData || isResumingSaveWorldKey) {
return;
}
setIsResumingSaveWorldKey(entry.worldKey);
setSaveError(null);
try {
const resumedArchive = await resumeRpgProfileSaveArchive(entry.worldKey);
setSaveEntries((currentEntries) =>
currentEntries.map((currentEntry) =>
currentEntry.worldKey === resumedArchive.entry.worldKey
? resumedArchive.entry
: currentEntry,
),
);
handleContinueGame(resumedArchive.snapshot);
} catch (error) {
setSaveError(resolveRpgEntryErrorMessage(error, '恢复存档失败。'));
} finally {
setIsResumingSaveWorldKey(null);
}
},
[canReadProtectedData, handleContinueGame, isResumingSaveWorldKey, user],
);
useEffect(() => {
let isActive = true;
const nextPlatformBootstrapUserId = user?.id ?? null;
const shouldApplyInitialPlatformTab =
platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId;
if (shouldApplyInitialPlatformTab) {
// 在请求发出前先占位,避免首屏请求未完成时用户切了 Tab
// 返回结果又被误判成“还没初始化过”并强制跳回默认页。
platformTabBootstrapUserIdRef.current = nextPlatformBootstrapUserId;
}
void (async () => {
setHistoryEntries([]);
setHistoryError(null);
setSaveError(null);
setIsLoadingPlatform(true);
setPlatformError(null);
setIsLoadingDashboard(canReadProtectedData);
setDashboardError(null);
if (!canReadProtectedData) {
setSavedCustomWorldEntries([]);
setCustomWorldWorkEntries([]);
setSaveEntries([]);
setProfileDashboard(null);
}
try {
const [
libraryEntriesResult,
workEntriesResult,
galleryEntriesResult,
dashboardResult,
historyResult,
saveArchivesResult,
] = await Promise.allSettled([
canReadProtectedData
? listRpgEntryWorldLibrary()
: Promise.resolve([]),
canReadProtectedData
? listRpgCreationWorks()
: Promise.resolve([]),
listRpgEntryWorldGallery(),
canReadProtectedData ? getProfileDashboard() : Promise.resolve(null),
canReadProtectedData
? listRpgProfileBrowseHistory()
: Promise.resolve([]),
canReadProtectedData
? listRpgProfileSaveArchives()
: Promise.resolve([]),
]);
if (!isActive) {
return;
}
if (libraryEntriesResult.status === 'fulfilled') {
setSavedCustomWorldEntries(libraryEntriesResult.value);
} else {
setSavedCustomWorldEntries([]);
}
if (workEntriesResult.status === 'fulfilled') {
setCustomWorldWorkEntries(workEntriesResult.value);
} else {
setCustomWorldWorkEntries([]);
}
if (galleryEntriesResult.status === 'fulfilled') {
setPublishedGalleryEntries(galleryEntriesResult.value);
} else {
setPublishedGalleryEntries([]);
}
if (
(canReadProtectedData &&
libraryEntriesResult.status === 'rejected') ||
(canReadProtectedData &&
workEntriesResult.status === 'rejected') ||
galleryEntriesResult.status === 'rejected'
) {
const platformFailure =
libraryEntriesResult.status === 'rejected'
? libraryEntriesResult.reason
: workEntriesResult.status === 'rejected'
? workEntriesResult.reason
: galleryEntriesResult.status === 'rejected'
? galleryEntriesResult.reason
: null;
setPlatformError(
resolveRpgEntryErrorMessage(platformFailure, '读取平台数据失败。'),
);
}
if (dashboardResult.status === 'fulfilled') {
setProfileDashboard(dashboardResult.value);
} else if (canReadProtectedData) {
setProfileDashboard(null);
setDashboardError(
resolveRpgEntryErrorMessage(
dashboardResult.reason,
'读取个人数据看板失败。',
),
);
}
if (historyResult.status === 'fulfilled') {
setHistoryEntries(historyResult.value);
} else if (canReadProtectedData) {
setHistoryError(
resolveRpgEntryErrorMessage(historyResult.reason, '读取浏览历史失败。'),
);
}
if (saveArchivesResult.status === 'fulfilled') {
setSaveEntries(saveArchivesResult.value);
} else if (canReadProtectedData) {
setSaveEntries([]);
setSaveError(
resolveRpgEntryErrorMessage(
saveArchivesResult.reason,
'读取存档列表失败。',
),
);
}
if (
shouldApplyInitialPlatformTab &&
!hasInitialAgentSession &&
!hasExplicitPlatformTabSelectionRef.current
) {
setPlatformTabState(
isAuthenticated &&
canReadProtectedData &&
saveArchivesResult.status === 'fulfilled' &&
saveArchivesResult.value.length > 0
? 'saves'
: 'home',
);
}
} finally {
if (isActive) {
setIsLoadingPlatform(false);
setIsLoadingDashboard(false);
}
}
})();
return () => {
isActive = false;
};
}, [
canReadProtectedData,
getProfileDashboard,
hasInitialAgentSession,
isAuthenticated,
user,
]);
return {
isAuthenticated,
canReadProtectedData,
platformTab,
setPlatformTab,
savedCustomWorldEntries,
setSavedCustomWorldEntries,
customWorldWorkEntries,
setCustomWorldWorkEntries,
publishedGalleryEntries,
setPublishedGalleryEntries,
historyEntries,
setHistoryEntries,
saveEntries,
setSaveEntries,
platformError,
setPlatformError,
dashboardError,
setDashboardError,
historyError,
setHistoryError,
saveError,
setSaveError,
isLoadingPlatform,
isLoadingDashboard,
isResumingSaveWorldKey,
profileDashboard,
setProfileDashboard,
refreshProfileDashboard,
refreshCustomWorldWorks,
refreshPublishedGallery,
refreshSavedCustomWorldLibrary,
appendBrowseHistoryEntry,
handleResumeSaveEntry,
};
}
/**
* 兼容旧创作链命名,避免并行工作包在本轮迁移后断开导入。
*/
export const useRpgCreationPlatformBootstrap = useRpgEntryBootstrap;

View File

@@ -0,0 +1,46 @@
import { useCallback } from 'react';
import type { Character, CustomWorldProfile, GameState } from '../../types';
type UseRpgEntryCharacterSelectParams = {
gameState: GameState;
handleBackToWorldSelect: () => void;
setSelectionStage: (stage: 'platform') => void;
handleCharacterSelect: (character: NonNullable<GameState['playerCharacter']>) => void;
};
/**
* 统一角色选择页的返回与确认动作,保持主阶段路由器里只做装配。
*/
export function useRpgEntryCharacterSelect(
params: UseRpgEntryCharacterSelectParams,
) {
const {
gameState,
handleBackToWorldSelect,
setSelectionStage,
handleCharacterSelect,
} = params;
const customWorldProfile: CustomWorldProfile | null =
gameState.customWorldProfile;
const handleBack = useCallback(() => {
handleBackToWorldSelect();
setSelectionStage('platform');
}, [handleBackToWorldSelect, setSelectionStage]);
const handleConfirm = useCallback(
(character: Character) => {
handleCharacterSelect(character);
},
[handleCharacterSelect],
);
return {
worldType: gameState.worldType,
customWorldProfile,
handleBack,
handleConfirm,
};
}

View File

@@ -0,0 +1,519 @@
import { useCallback, useEffect, useState } from 'react';
import type {
CustomWorldAgentSessionSnapshot,
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
PlatformBrowseHistoryWriteEntry,
} from '../../../packages/shared/src/contracts/runtime';
import {
deleteRpgEntryWorldProfile,
getRpgEntryWorldGalleryDetail,
listRpgEntryWorldLibrary,
publishRpgEntryWorldProfile,
unpublishRpgEntryWorldProfile,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
import { ApiClientError } from '../../services/apiClient';
import type { CustomWorldProfile } from '../../types';
import {
normalizeRpgEntryAgentBackedProfile,
resolveRpgEntryErrorMessage,
} from './rpgEntryShared';
import type {
CustomWorldAutoSaveState,
CustomWorldGenerationViewSource,
CustomWorldResultViewSource,
SelectionStage,
} from './rpgEntryTypes';
type UseRpgEntryLibraryDetailParams = {
userId: string | null | undefined;
selectedDetailEntry: CustomWorldLibraryEntry<CustomWorldProfile> | null;
setSelectedDetailEntry: (
entry:
| CustomWorldLibraryEntry<CustomWorldProfile>
| null
| ((
current: CustomWorldLibraryEntry<CustomWorldProfile> | null,
) => CustomWorldLibraryEntry<CustomWorldProfile> | null),
) => void;
savedCustomWorldEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
setSavedCustomWorldEntries: (
entries: CustomWorldLibraryEntry<CustomWorldProfile>[],
) => void;
setGeneratedCustomWorldProfile: (
profile: CustomWorldProfile | null,
) => void;
setCustomWorldError: (error: string | null) => void;
setCustomWorldAutoSaveError: (error: string | null) => void;
setCustomWorldAutoSaveState: (state: CustomWorldAutoSaveState) => void;
setCustomWorldGenerationViewSource: (
source: CustomWorldGenerationViewSource,
) => void;
setCustomWorldResultViewSource: (
source: CustomWorldResultViewSource,
) => void;
setSelectionStage: (stage: SelectionStage) => void;
setPlatformTabToCreate: () => void;
setPlatformError: (error: string | null) => void;
appendBrowseHistoryEntry: (
entry: PlatformBrowseHistoryWriteEntry,
) => Promise<void>;
refreshCustomWorldWorks: () => Promise<unknown>;
refreshPublishedGallery: () => Promise<unknown>;
persistAgentUiState: (
sessionId: string | null,
operationId: string | null,
generationSource?: 'agent-draft-foundation' | null,
) => void;
syncAgentSessionSnapshot: (
sessionId: string,
) => Promise<CustomWorldAgentSessionSnapshot | null>;
buildDraftResultProfile: (
session: CustomWorldAgentSessionSnapshot | null,
) => CustomWorldProfile | null;
suppressAgentDraftResultAutoOpen: () => void;
releaseAgentDraftResultAutoOpenSuppression: () => void;
resetAutoSaveTrackingToIdle: () => void;
markAutoSavedProfile: (profile: CustomWorldProfile) => void;
};
const AGENT_RESULT_STAGES = new Set([
'object_refining',
'visual_refining',
'long_tail_review',
'ready_to_publish',
'published',
]);
function isMissingRpgEntryAgentSessionError(error: unknown) {
return (
error instanceof ApiClientError &&
error.status === 404 &&
error.code === 'NOT_FOUND'
);
}
/**
* 负责平台详情、创作作品入口和结果页打开路径。
* 平台壳层只消费“打开哪个面板”的结果,不再自己拼接恢复流程细节。
*/
export function useRpgEntryLibraryDetail(
params: UseRpgEntryLibraryDetailParams,
) {
const {
userId,
selectedDetailEntry,
setSelectedDetailEntry,
savedCustomWorldEntries,
setSavedCustomWorldEntries,
setGeneratedCustomWorldProfile,
setCustomWorldError,
setCustomWorldAutoSaveError,
setCustomWorldAutoSaveState,
setCustomWorldGenerationViewSource,
setCustomWorldResultViewSource,
setSelectionStage,
setPlatformTabToCreate,
setPlatformError,
appendBrowseHistoryEntry,
refreshCustomWorldWorks,
refreshPublishedGallery,
persistAgentUiState,
syncAgentSessionSnapshot,
buildDraftResultProfile,
suppressAgentDraftResultAutoOpen,
releaseAgentDraftResultAutoOpenSuppression,
resetAutoSaveTrackingToIdle,
markAutoSavedProfile,
} = params;
const [detailError, setDetailError] = useState<string | null>(null);
const [isDetailLoading, setIsDetailLoading] = useState(false);
const [isMutatingDetail, setIsMutatingDetail] = useState(false);
useEffect(() => {
if (!selectedDetailEntry) {
return;
}
const nextOwnedEntry = savedCustomWorldEntries.find(
(entry) =>
entry.ownerUserId === selectedDetailEntry.ownerUserId &&
entry.profileId === selectedDetailEntry.profileId,
);
if (nextOwnedEntry && nextOwnedEntry !== selectedDetailEntry) {
setSelectedDetailEntry(nextOwnedEntry);
}
}, [savedCustomWorldEntries, selectedDetailEntry, setSelectedDetailEntry]);
const openLibraryDetail = useCallback(
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
if (entry.visibility === 'published') {
void appendBrowseHistoryEntry({
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
worldName: entry.worldName,
subtitle: entry.subtitle,
summaryText: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
themeMode: entry.themeMode,
authorDisplayName: entry.authorDisplayName,
});
}
setSelectedDetailEntry(entry);
setDetailError(null);
setSelectionStage('detail');
},
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
);
const openGalleryDetail = useCallback(
async (entry: CustomWorldGalleryCard) => {
setSelectionStage('detail');
setIsDetailLoading(true);
setDetailError(null);
try {
const detailEntry = await getRpgEntryWorldGalleryDetail(
entry.ownerUserId,
entry.profileId,
);
setSelectedDetailEntry(detailEntry);
void appendBrowseHistoryEntry({
ownerUserId: detailEntry.ownerUserId,
profileId: detailEntry.profileId,
worldName: detailEntry.worldName,
subtitle: detailEntry.subtitle,
summaryText: detailEntry.summaryText,
coverImageSrc: detailEntry.coverImageSrc,
themeMode: detailEntry.themeMode,
authorDisplayName: detailEntry.authorDisplayName,
});
} catch (error) {
setSelectedDetailEntry(null);
setDetailError(
resolveRpgEntryErrorMessage(error, '读取作品详情失败。'),
);
} finally {
setIsDetailLoading(false);
}
},
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
);
const openSavedCustomWorldEditor = useCallback(
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
setSelectedDetailEntry(entry);
resetAutoSaveTrackingToIdle();
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
setCustomWorldError(null);
setGeneratedCustomWorldProfile(null);
const normalizedProfile = normalizeRpgEntryAgentBackedProfile(
entry.profile,
);
setGeneratedCustomWorldProfile(normalizedProfile);
markAutoSavedProfile(normalizedProfile);
setCustomWorldAutoSaveState('saved');
setCustomWorldAutoSaveError(null);
setCustomWorldError(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource('saved-profile');
setSelectionStage('custom-world-result');
},
[
markAutoSavedProfile,
setCustomWorldAutoSaveError,
setCustomWorldAutoSaveState,
setCustomWorldError,
setCustomWorldGenerationViewSource,
setCustomWorldResultViewSource,
setGeneratedCustomWorldProfile,
setSelectedDetailEntry,
setSelectionStage,
],
);
const handleOpenCreationWork = useCallback(
async (work: CustomWorldWorkSummary) => {
if (work.status === 'draft' && work.sessionId) {
setCustomWorldError(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
setGeneratedCustomWorldProfile(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
resetAutoSaveTrackingToIdle();
try {
const latestSession = await syncAgentSessionSnapshot(work.sessionId);
const nextProfile = buildDraftResultProfile(latestSession);
const shouldOpenAgentWorkspace =
!latestSession?.draftProfile ||
!latestSession.stage ||
!AGENT_RESULT_STAGES.has(latestSession.stage);
const shouldResumeFailedGenerationView =
!nextProfile &&
//u.test(`${work.stageLabel ?? ''}${work.summary ?? ''}`);
if (shouldResumeFailedGenerationView) {
// 生成过程中失败的草稿要回到生成过程页承接错误处理,避免误回 Agent 对话。
suppressAgentDraftResultAutoOpen();
persistAgentUiState(
work.sessionId,
null,
'agent-draft-foundation',
);
setGeneratedCustomWorldProfile(null);
setCustomWorldGenerationViewSource('agent-draft-foundation');
setCustomWorldResultViewSource(null);
setPlatformTabToCreate();
setSelectionStage('custom-world-generating');
return;
}
if (shouldOpenAgentWorkspace) {
// 还没有服务端草稿真相源时只能恢复 Agent对象数量等摘要字段不能决定结果页入口。
suppressAgentDraftResultAutoOpen();
persistAgentUiState(work.sessionId, null);
setGeneratedCustomWorldProfile(null);
setCustomWorldResultViewSource(null);
setPlatformTabToCreate();
setSelectionStage('agent-workspace');
return;
}
releaseAgentDraftResultAutoOpenSuppression();
if (!nextProfile) {
persistAgentUiState(
work.sessionId,
null,
'agent-draft-foundation',
);
setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。');
setPlatformTabToCreate();
setCustomWorldGenerationViewSource('agent-draft-foundation');
setSelectionStage('custom-world-generating');
return;
}
persistAgentUiState(work.sessionId, null);
setGeneratedCustomWorldProfile(
normalizeRpgEntryAgentBackedProfile(nextProfile),
);
setCustomWorldResultViewSource('agent-draft');
setPlatformTabToCreate();
setSelectionStage('custom-world-result');
return;
} catch (error) {
if (isMissingRpgEntryAgentSessionError(error)) {
// 失效会话不能继续保留在恢复状态里,否则刷新后会重复命中同一个坏 session。
persistAgentUiState(null, null);
setGeneratedCustomWorldProfile(null);
setCustomWorldResultViewSource(null);
await refreshCustomWorldWorks().catch(() => []);
setPlatformError(
'这份共创草稿已失效,已为你返回创作中心,请重新开始创作。',
);
} else {
setPlatformError(
resolveRpgEntryErrorMessage(error, '读取创作草稿失败。'),
);
}
setPlatformTabToCreate();
setSelectionStage('platform');
return;
}
}
if (!work.profileId) {
return;
}
try {
let matchedEntry = savedCustomWorldEntries.find(
(entry) => entry.profileId === work.profileId,
);
if (!matchedEntry && userId) {
const latestLibraryEntries = await listRpgEntryWorldLibrary();
setSavedCustomWorldEntries(latestLibraryEntries);
matchedEntry = latestLibraryEntries.find(
(entry) => entry.profileId === work.profileId,
);
}
if (matchedEntry) {
openLibraryDetail(matchedEntry);
return;
}
setPlatformError('未找到对应作品,请刷新后重试。');
} catch (error) {
setPlatformError(
resolveRpgEntryErrorMessage(error, '读取作品详情失败。'),
);
}
},
[
buildDraftResultProfile,
openLibraryDetail,
persistAgentUiState,
releaseAgentDraftResultAutoOpenSuppression,
resetAutoSaveTrackingToIdle,
savedCustomWorldEntries,
setCustomWorldAutoSaveError,
setCustomWorldAutoSaveState,
setCustomWorldError,
setCustomWorldGenerationViewSource,
setCustomWorldResultViewSource,
setGeneratedCustomWorldProfile,
setPlatformError,
setPlatformTabToCreate,
setSavedCustomWorldEntries,
setSelectionStage,
suppressAgentDraftResultAutoOpen,
syncAgentSessionSnapshot,
userId,
],
);
const handlePublishSelectedWorld = useCallback(async () => {
if (!selectedDetailEntry || isMutatingDetail) {
return;
}
setIsMutatingDetail(true);
setDetailError(null);
try {
const mutation = await publishRpgEntryWorldProfile(
selectedDetailEntry.profileId,
);
setSavedCustomWorldEntries(mutation.entries);
await refreshCustomWorldWorks().catch(() => []);
setSelectedDetailEntry(mutation.entry);
await refreshPublishedGallery().catch(() => []);
} catch (error) {
setDetailError(
resolveRpgEntryErrorMessage(error, '发布自定义世界失败。'),
);
} finally {
setIsMutatingDetail(false);
}
}, [
isMutatingDetail,
refreshCustomWorldWorks,
refreshPublishedGallery,
selectedDetailEntry,
setSavedCustomWorldEntries,
setSelectedDetailEntry,
]);
const handleUnpublishSelectedWorld = useCallback(async () => {
if (!selectedDetailEntry || isMutatingDetail) {
return;
}
setIsMutatingDetail(true);
setDetailError(null);
try {
const mutation = await unpublishRpgEntryWorldProfile(
selectedDetailEntry.profileId,
);
setSavedCustomWorldEntries(mutation.entries);
await refreshCustomWorldWorks().catch(() => []);
setSelectedDetailEntry(mutation.entry);
await refreshPublishedGallery().catch(() => []);
} catch (error) {
setDetailError(
resolveRpgEntryErrorMessage(error, '下架自定义世界失败。'),
);
} finally {
setIsMutatingDetail(false);
}
}, [
isMutatingDetail,
refreshCustomWorldWorks,
refreshPublishedGallery,
selectedDetailEntry,
setSavedCustomWorldEntries,
setSelectedDetailEntry,
]);
const handleDeleteSelectedWorld = useCallback(async () => {
if (!selectedDetailEntry || isMutatingDetail) {
return;
}
const confirmed = window.confirm(
`确认删除作品《${selectedDetailEntry.worldName}》吗?删除后会从你的作品列表和公开广场中移除。`,
);
if (!confirmed) {
return;
}
setIsMutatingDetail(true);
setDetailError(null);
try {
const entries = await deleteRpgEntryWorldProfile(
selectedDetailEntry.profileId,
);
setSavedCustomWorldEntries(entries);
await refreshCustomWorldWorks().catch(() => []);
setSelectedDetailEntry(null);
setPlatformTabToCreate();
setSelectionStage('platform');
await refreshPublishedGallery().catch(() => []);
} catch (error) {
setDetailError(
resolveRpgEntryErrorMessage(error, '删除自定义世界失败。'),
);
} finally {
setIsMutatingDetail(false);
}
}, [
isMutatingDetail,
refreshCustomWorldWorks,
refreshPublishedGallery,
selectedDetailEntry,
setPlatformTabToCreate,
setSavedCustomWorldEntries,
setSelectedDetailEntry,
setSelectionStage,
]);
const isSelectedWorldOwned = Boolean(
selectedDetailEntry &&
savedCustomWorldEntries.some(
(entry) =>
entry.ownerUserId === selectedDetailEntry.ownerUserId &&
entry.profileId === selectedDetailEntry.profileId,
),
);
return {
detailError,
setDetailError,
isDetailLoading,
isMutatingDetail,
isSelectedWorldOwned,
openLibraryDetail,
openGalleryDetail,
openSavedCustomWorldEditor,
handleOpenCreationWork,
handlePublishSelectedWorld,
handleUnpublishSelectedWorld,
handleDeleteSelectedWorld,
};
}
/**
* 兼容旧创作链命名,避免并行工作包在本轮迁移后断开导入。
*/
export const useRpgCreationDetailNavigation = useRpgEntryLibraryDetail;

View File

@@ -0,0 +1,55 @@
import { useCallback } from 'react';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { CustomWorldProfile } from '../../types';
import type { SelectionStage } from './rpgEntryTypes';
type UseRpgEntryNavigationParams = {
setSelectionStage: (stage: SelectionStage) => void;
setSelectedDetailEntry: (
entry: CustomWorldLibraryEntry<CustomWorldProfile> | null,
) => void;
};
/**
* 收口 RPG 入口壳层的阶段跳转,避免页面内散落大量匿名 setState 回调。
*/
export function useRpgEntryNavigation(
params: UseRpgEntryNavigationParams,
) {
const { setSelectionStage, setSelectedDetailEntry } = params;
const backToPlatformHome = useCallback(() => {
setSelectionStage('platform');
}, [setSelectionStage]);
const backToPlatformWithClearedDetail = useCallback(() => {
setSelectedDetailEntry(null);
setSelectionStage('platform');
}, [setSelectedDetailEntry, setSelectionStage]);
const openDetailStage = useCallback(() => {
setSelectionStage('detail');
}, [setSelectionStage]);
const openAgentWorkspaceStage = useCallback(() => {
setSelectionStage('agent-workspace');
}, [setSelectionStage]);
const openCustomWorldGenerationStage = useCallback(() => {
setSelectionStage('custom-world-generating');
}, [setSelectionStage]);
const openCustomWorldResultStage = useCallback(() => {
setSelectionStage('custom-world-result');
}, [setSelectionStage]);
return {
backToPlatformHome,
backToPlatformWithClearedDetail,
openDetailStage,
openAgentWorkspaceStage,
openCustomWorldGenerationStage,
openCustomWorldResultStage,
};
}

View File

@@ -0,0 +1,28 @@
import { useCallback } from 'react';
import type { ProfileSaveArchiveSummary } from '../../../packages/shared/src/contracts/runtime';
type UseRpgEntrySaveResumeParams = {
handleResumeSaveEntry: (entry: ProfileSaveArchiveSummary) => Promise<void>;
};
/**
* RPG 入口域里的“继续游戏”入口只负责转发恢复动作,
* 让壳层组件不直接知道具体的存档恢复实现细节。
*/
export function useRpgEntrySaveResume(
params: UseRpgEntrySaveResumeParams,
) {
const { handleResumeSaveEntry } = params;
const resumeSelectedSave = useCallback(
async (entry: ProfileSaveArchiveSummary) => {
await handleResumeSaveEntry(entry);
},
[handleResumeSaveEntry],
);
return {
resumeSelectedSave,
};
}