This commit is contained in:
2026-04-21 18:27:46 +08:00
parent 04bff9617d
commit 4372ab5be1
358 changed files with 30788 additions and 14737 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,504 @@
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 { 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"
/>
) : (
<img 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,156 @@
import { ArrowRight, X } from 'lucide-react';
export interface RpgEntryCreationTypeModalProps {
isOpen: boolean;
isBusy: boolean;
error: string | null;
onClose: () => void;
onSelectRpg: () => void;
}
type CreationGameTypeCard = {
id: 'rpg' | 'airp' | 'visual-novel';
title: string;
subtitle: string;
badge: string;
locked: boolean;
};
const CREATION_GAME_TYPES: CreationGameTypeCard[] = [
{
id: 'rpg',
title: '角色扮演 RPG',
subtitle: 'Agent 共创',
badge: '可创建',
locked: false,
},
{
id: 'airp',
title: 'AIRP',
subtitle: '敬请期待',
badge: '锁定',
locked: true,
},
{
id: 'visual-novel',
title: '视觉小说',
subtitle: '敬请期待',
badge: '锁定',
locked: true,
},
];
function CreationTypeCard(props: {
item: CreationGameTypeCard;
busy: boolean;
onSelect: () => void;
}) {
const { item, busy, onSelect } = props;
const disabled = item.locked || busy;
return (
<button
type="button"
disabled={disabled}
onClick={onSelect}
className={`platform-interactive-card relative overflow-hidden rounded-[1.65rem] border px-4 py-4 text-left ${
item.locked
? 'cursor-not-allowed border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] text-[var(--platform-text-soft)]'
: 'border-[var(--platform-cool-border)] bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_34%),linear-gradient(135deg,rgba(255,79,139,0.96),rgba(255,145,110,0.9))] text-white'
} ${busy && !item.locked ? 'opacity-70' : ''}`}
>
<div className="flex items-start justify-between gap-3">
<span
className={`platform-pill px-3 ${
item.locked
? 'platform-pill--neutral text-[var(--platform-text-soft)]'
: 'platform-pill--neutral border-white/30 bg-white/18 text-white'
}`}
>
{item.locked ? item.badge : busy ? '正在开启' : item.badge}
</span>
{item.locked ? (
<span className="text-lg leading-none text-white/45">·</span>
) : (
<ArrowRight className="h-4 w-4 text-white/80" />
)}
</div>
<div className="mt-8 text-xl font-black leading-tight text-inherit">
{item.title}
</div>
<div
className={`mt-2 text-sm ${
item.locked ? 'text-zinc-500' : 'text-zinc-200/82'
}`}
>
{item.subtitle}
</div>
</button>
);
}
/**
* RPG 入口创作类型弹层真实入口。
* 第三批收口后入口主链不再直接依赖旧 `PlatformCreationTypeModal` 命名。
*/
export function RpgEntryCreationTypeModal({
isOpen,
isBusy,
error,
onClose,
onSelectRpg,
}: RpgEntryCreationTypeModalProps) {
if (!isOpen) {
return null;
}
return (
<div className="platform-overlay fixed inset-0 z-[90] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4">
<div className="platform-modal-shell w-full max-w-3xl overflow-hidden rounded-[1.8rem]">
<div className="bg-transparent">
<div className="flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-xs text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="platform-icon-button disabled:cursor-not-allowed disabled:opacity-45"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="px-4 py-4 sm:px-5 sm:py-5">
<div className="grid gap-3 sm:grid-cols-3">
{CREATION_GAME_TYPES.map((item) => (
<CreationTypeCard
key={item.id}
item={item}
busy={isBusy}
onSelect={() => {
if (item.id === 'rpg') {
onSelectRpg();
}
}}
/>
))}
</div>
{error ? (
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
{error}
</div>
) : null}
</div>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
import { RpgEntryFlowShellImpl } from './RpgEntryFlowShellImpl';
import type { RpgEntryFlowShellProps } from './rpgEntryTypes';
import type { SelectionStage } from './rpgEntryTypes';
export type { RpgEntryFlowShellProps, SelectionStage };
/**
* RPG 入口域真实壳层入口。
* 入口主链已收口到 `rpg-entry` 命名根,不再保留旧入口脚本。
*/
export function RpgEntryFlowShell(props: RpgEntryFlowShellProps) {
return <RpgEntryFlowShellImpl {...props} />;
}
/**
* 兼容创作链已经接入的旧组件命名,避免本轮迁移扩大影响面。
*/
export const RpgCreationShell = RpgEntryFlowShell;
export default RpgEntryFlowShell;

View File

@@ -0,0 +1,718 @@
import { AnimatePresence, motion } from 'motion/react';
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { readCustomWorldAgentUiState } from '../../services/customWorldAgentUiState';
import { getRpgProfileDashboard } from '../../services/rpg-entry';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { CustomWorldCreationHub } from '../custom-world-home/CustomWorldCreationHub';
import { RpgEntryCreationTypeModal } from './RpgEntryCreationTypeModal';
import { RpgEntryHomeView } from './RpgEntryHomeView';
import {
buildCreationHubFallbackItems,
normalizeAgentBackedProfile,
resolveRpgCreationErrorMessage,
} from './rpgEntryShared';
import type { RpgEntryFlowShellProps } from './rpgEntryTypes';
import { RpgEntryWorldDetailView } from './RpgEntryWorldDetailView';
import { useRpgCreationAgentOperationPolling } from './useRpgCreationAgentOperationPolling';
import { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
import { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave';
import { useRpgCreationSessionController } from './useRpgCreationSessionController';
import { useRpgEntryBootstrap } from './useRpgEntryBootstrap';
import { useRpgEntryLibraryDetail } from './useRpgEntryLibraryDetail';
import { useRpgEntryNavigation } from './useRpgEntryNavigation';
const CustomWorldGenerationView = lazy(async () => {
const module = await import('../CustomWorldGenerationView');
return {
default: module.CustomWorldGenerationView,
};
});
const RpgCreationResultView = lazy(async () => {
const module = await import('../rpg-creation-result/RpgCreationResultView');
return {
default: module.RpgCreationResultView,
};
});
const CustomWorldAgentWorkspace = lazy(async () => {
const module = await import(
'../custom-world-agent/CustomWorldAgentWorkspace'
);
return {
default: module.CustomWorldAgentWorkspace,
};
});
function LazyPanelFallback({ label }: { label: string }) {
return (
<div className="flex h-full min-h-0 items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
{label}
</div>
</div>
);
}
export function RpgEntryFlowShellImpl({
selectionStage,
setSelectionStage,
hasSavedGame,
savedSnapshot,
handleContinueGame,
handleStartNewGame,
handleCustomWorldSelect,
}: RpgEntryFlowShellProps) {
const authUi = useAuthUi();
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
const [selectedDetailEntry, setSelectedDetailEntry] = useState<
CustomWorldLibraryEntry<CustomWorldProfile> | null
>(null);
const hasInitialAgentSession = Boolean(
readCustomWorldAgentUiState().activeSessionId,
);
const platformBootstrap = useRpgEntryBootstrap({
user: authUi?.user,
getProfileDashboard: getRpgProfileDashboard,
handleContinueGame,
hasInitialAgentSession,
});
const entryNavigation = useRpgEntryNavigation({
setSelectionStage,
setSelectedDetailEntry,
});
const enterCreateTab = useCallback(() => {
platformBootstrap.setPlatformTab('create');
}, [platformBootstrap]);
const sessionController = useRpgCreationSessionController({
userId: authUi?.user?.id,
openLoginModal: authUi?.openLoginModal,
selectionStage,
setSelectionStage,
enterCreateTab,
onSessionOpened: () => {
setShowCreationTypeModal(false);
},
});
useRpgCreationAgentOperationPolling({
activeAgentSessionId: sessionController.activeAgentSessionId,
activeAgentOperationId: sessionController.activeAgentOperationId,
userId: authUi?.user?.id,
setAgentOperation: sessionController.setAgentOperation,
persistAgentUiState: sessionController.persistAgentUiState,
syncAgentSessionSnapshot: sessionController.syncAgentSessionSnapshot,
});
const autosaveCoordinator = useRpgCreationResultAutosave({
selectionStage,
activeAgentSessionId: sessionController.activeAgentSessionId,
agentSession: sessionController.agentSession,
generatedCustomWorldProfile: sessionController.generatedCustomWorldProfile,
isAgentDraftResultView: sessionController.isAgentDraftResultView,
userId: authUi?.user?.id,
setGeneratedCustomWorldProfile:
sessionController.setGeneratedCustomWorldProfile,
setAgentOperation: sessionController.setAgentOperation,
setSavedCustomWorldEntries: platformBootstrap.setSavedCustomWorldEntries,
setSelectedDetailEntry,
refreshCustomWorldWorks: platformBootstrap.refreshCustomWorldWorks,
persistAgentUiState: sessionController.persistAgentUiState,
syncAgentSessionSnapshot: sessionController.syncAgentSessionSnapshot,
buildDraftResultProfile: (session) =>
rpgCreationPreviewAdapter.buildPreviewFromSession(session),
});
const detailNavigation = useRpgEntryLibraryDetail({
userId: authUi?.user?.id,
selectedDetailEntry,
setSelectedDetailEntry,
savedCustomWorldEntries: platformBootstrap.savedCustomWorldEntries,
setSavedCustomWorldEntries: platformBootstrap.setSavedCustomWorldEntries,
setGeneratedCustomWorldProfile:
sessionController.setGeneratedCustomWorldProfile,
setCustomWorldError: sessionController.setCustomWorldError,
setCustomWorldAutoSaveError: autosaveCoordinator.setCustomWorldAutoSaveError,
setCustomWorldAutoSaveState: autosaveCoordinator.setCustomWorldAutoSaveState,
setCustomWorldGenerationViewSource:
sessionController.setCustomWorldGenerationViewSource,
setCustomWorldResultViewSource:
sessionController.setCustomWorldResultViewSource,
setSelectionStage,
setPlatformTabToCreate: enterCreateTab,
setPlatformError: platformBootstrap.setPlatformError,
appendBrowseHistoryEntry: platformBootstrap.appendBrowseHistoryEntry,
refreshCustomWorldWorks: platformBootstrap.refreshCustomWorldWorks,
refreshPublishedGallery: platformBootstrap.refreshPublishedGallery,
persistAgentUiState: sessionController.persistAgentUiState,
syncAgentSessionSnapshot: sessionController.syncAgentSessionSnapshot,
buildDraftResultProfile: (session) =>
rpgCreationPreviewAdapter.buildPreviewFromSession(session),
suppressAgentDraftResultAutoOpen:
sessionController.suppressAgentDraftResultAutoOpen,
releaseAgentDraftResultAutoOpenSuppression:
sessionController.releaseAgentDraftResultAutoOpenSuppression,
resetAutoSaveTrackingToIdle: autosaveCoordinator.resetAutoSaveTrackingToIdle,
markAutoSavedProfile: autosaveCoordinator.markAutoSavedProfile,
});
const enterWorldCoordinator = useRpgCreationEnterWorld({
isAgentDraftResultView: sessionController.isAgentDraftResultView,
activeAgentSessionId: sessionController.activeAgentSessionId,
generatedCustomWorldProfile: sessionController.generatedCustomWorldProfile,
agentSessionProfile: sessionController.agentDraftResultProfile,
agentSession: sessionController.agentSession,
handleCustomWorldSelect,
executePublishWorld: () =>
autosaveCoordinator.executeAgentActionAndWait({
action: 'publish_world',
}),
syncAgentDraftResultProfile: autosaveCoordinator.syncAgentDraftResultProfile,
setGeneratedCustomWorldProfile:
sessionController.setGeneratedCustomWorldProfile,
});
const previewCustomWorldCharacters = useMemo(
() =>
sessionController.generatedCustomWorldProfile
? buildCustomWorldPlayableCharacters(
sessionController.generatedCustomWorldProfile,
)
: [],
[sessionController.generatedCustomWorldProfile],
);
const agentResultPreview = sessionController.agentSession?.resultPreview ?? null;
const agentResultPreviewBlockers = useMemo(
() => agentResultPreview?.blockers?.map((entry) => entry.message) ?? [],
[agentResultPreview],
);
const agentResultPreviewQualityFindings = useMemo(
() => agentResultPreview?.qualityFindings ?? [],
[agentResultPreview],
);
const agentResultPreviewSourceLabel = useMemo(() => {
if (!agentResultPreview?.source) {
return null;
}
if (agentResultPreview.source === 'published_profile') {
return '已发布世界';
}
if (agentResultPreview.source === 'session_preview') {
return '会话预览';
}
return '服务端预览';
}, [agentResultPreview]);
const featuredGalleryEntries = useMemo(
() => platformBootstrap.publishedGalleryEntries.slice(0, 6),
[platformBootstrap.publishedGalleryEntries],
);
const creationHubItems =
platformBootstrap.customWorldWorkEntries.length > 0
? platformBootstrap.customWorldWorkEntries
: buildCreationHubFallbackItems(platformBootstrap.savedCustomWorldEntries);
const resultViewError =
autosaveCoordinator.customWorldAutoSaveError ??
sessionController.customWorldError;
useEffect(() => {
if (
selectionStage === 'custom-world-result' &&
!sessionController.generatedCustomWorldProfile
) {
setSelectionStage(selectedDetailEntry ? 'detail' : 'platform');
}
}, [
selectedDetailEntry,
selectionStage,
sessionController.generatedCustomWorldProfile,
setSelectionStage,
]);
const runProtectedAction = useCallback(
(action: () => void) => {
if (!authUi?.requireAuth) {
action();
return;
}
authUi.requireAuth(action);
},
[authUi],
);
const openCreationTypePicker = useCallback(() => {
if (sessionController.isCreatingAgentSession) {
return;
}
if (!hasSavedGame) {
handleStartNewGame();
}
sessionController.setCreationTypeError(null);
setShowCreationTypeModal(true);
}, [
handleStartNewGame,
hasSavedGame,
sessionController,
]);
const leaveAgentWorkspace = useCallback(() => {
enterCreateTab();
sessionController.resetSessionViewState();
sessionController.setGeneratedCustomWorldProfile(null);
autosaveCoordinator.resetAutoSaveTrackingToIdle();
sessionController.persistAgentUiState(
sessionController.activeAgentSessionId,
null,
);
setSelectionStage('platform');
}, [
autosaveCoordinator,
enterCreateTab,
sessionController,
setSelectionStage,
]);
const leaveAgentDraftGeneration = useCallback(() => {
if (sessionController.isActiveGenerationRunning) {
return;
}
sessionController.setAgentDraftGenerationStartedAt(null);
sessionController.setCustomWorldGenerationViewSource(null);
setSelectionStage('agent-workspace');
}, [sessionController, setSelectionStage]);
const leaveAgentDraftResult = useCallback(() => {
sessionController.suppressAgentDraftResultAutoOpen();
sessionController.setGeneratedCustomWorldProfile(null);
sessionController.setCustomWorldError(null);
autosaveCoordinator.resetAutoSaveTrackingToIdle();
sessionController.setCustomWorldGenerationViewSource(null);
sessionController.setCustomWorldResultViewSource(null);
enterCreateTab();
setSelectionStage('platform');
}, [
autosaveCoordinator,
enterCreateTab,
sessionController,
setSelectionStage,
]);
const leaveCustomWorldResult = useCallback(() => {
sessionController.setGeneratedCustomWorldProfile(null);
sessionController.setCustomWorldError(null);
autosaveCoordinator.resetAutoSaveTrackingToIdle();
sessionController.setCustomWorldGenerationViewSource(null);
sessionController.setCustomWorldResultViewSource(null);
setSelectionStage(selectedDetailEntry ? 'detail' : 'platform');
}, [
autosaveCoordinator,
selectedDetailEntry,
sessionController,
setSelectionStage,
]);
const handleStartSelectedWorld = useCallback(() => {
if (!selectedDetailEntry) {
return;
}
runProtectedAction(() => {
handleCustomWorldSelect(selectedDetailEntry.profile);
});
}, [handleCustomWorldSelect, runProtectedAction, selectedDetailEntry]);
const creationHubContent = (
<CustomWorldCreationHub
items={creationHubItems}
loading={platformBootstrap.isLoadingPlatform}
error={
platformBootstrap.isLoadingPlatform
? null
: platformBootstrap.platformError ?? sessionController.creationTypeError
}
onBack={() => {
platformBootstrap.setPlatformTab('home');
}}
onRetry={() => {
platformBootstrap.setPlatformError(null);
void platformBootstrap.refreshCustomWorldWorks().catch((error) => {
platformBootstrap.setPlatformError(
resolveRpgCreationErrorMessage(error, '读取创作作品列表失败。'),
);
});
}}
onCreateNew={openCreationTypePicker}
onOpenDraft={(item) => {
runProtectedAction(() => {
void detailNavigation.handleOpenCreationWork(item);
});
}}
onEnterPublished={(profileId) => {
runProtectedAction(() => {
const matchedWork = creationHubItems.find(
(entry) => entry.profileId === profileId,
);
if (!matchedWork) {
return;
}
void detailNavigation.handleOpenCreationWork(matchedWork);
});
}}
/>
);
return (
<>
<AnimatePresence mode="wait">
{selectionStage === 'platform' && (
<motion.div
key="platform-home"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<RpgEntryHomeView
activeTab={platformBootstrap.platformTab}
onTabChange={platformBootstrap.setPlatformTab}
hasSavedGame={hasSavedGame}
savedSnapshot={savedSnapshot}
saveEntries={platformBootstrap.saveEntries}
saveError={platformBootstrap.saveError}
featuredEntries={featuredGalleryEntries}
latestEntries={platformBootstrap.publishedGalleryEntries}
myEntries={platformBootstrap.savedCustomWorldEntries}
historyEntries={platformBootstrap.historyEntries}
profileDashboard={platformBootstrap.profileDashboard}
isLoadingPlatform={platformBootstrap.isLoadingPlatform}
isLoadingDashboard={platformBootstrap.isLoadingDashboard}
isResumingSaveWorldKey={platformBootstrap.isResumingSaveWorldKey}
platformError={
platformBootstrap.isLoadingPlatform
? null
: platformBootstrap.platformError ??
sessionController.creationTypeError
}
dashboardError={
platformBootstrap.isLoadingDashboard
? null
: platformBootstrap.dashboardError
}
createTabContent={creationHubContent}
onContinueGame={handleContinueGame}
onResumeSave={(entry) => {
void platformBootstrap.handleResumeSaveEntry(entry);
}}
onOpenCreateWorld={openCreationTypePicker}
onOpenCreateTypePicker={openCreationTypePicker}
onOpenGalleryDetail={(entry) => {
runProtectedAction(() => {
void detailNavigation.openGalleryDetail(entry);
});
}}
onOpenLibraryDetail={(entry) => {
runProtectedAction(() => {
detailNavigation.openLibraryDetail(entry);
});
}}
onOpenProfileDashboardCard={() => {
if (platformBootstrap.dashboardError) {
void platformBootstrap.refreshProfileDashboard();
}
}}
/>
</motion.div>
)}
{selectionStage === 'detail' && (
<motion.div
key="platform-detail"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
{detailNavigation.isDetailLoading || !selectedDetailEntry ? (
<div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
{detailNavigation.detailError || '正在读取作品详情...'}
</div>
</div>
) : (
<RpgEntryWorldDetailView
entry={selectedDetailEntry}
isMutating={detailNavigation.isMutatingDetail}
error={detailNavigation.detailError}
onBack={() => {
detailNavigation.setDetailError(null);
entryNavigation.backToPlatformHome();
}}
onStartGame={handleStartSelectedWorld}
onContinueEdit={
detailNavigation.isSelectedWorldOwned
? () => {
runProtectedAction(() => {
detailNavigation.openSavedCustomWorldEditor(
selectedDetailEntry,
);
});
}
: null
}
onPublish={
selectedDetailEntry.visibility === 'draft' &&
detailNavigation.isSelectedWorldOwned
? () => {
runProtectedAction(() => {
void detailNavigation.handlePublishSelectedWorld();
});
}
: null
}
onUnpublish={
selectedDetailEntry.visibility === 'published' &&
detailNavigation.isSelectedWorldOwned
? () => {
runProtectedAction(() => {
void detailNavigation.handleUnpublishSelectedWorld();
});
}
: null
}
onDelete={
detailNavigation.isSelectedWorldOwned
? () => {
runProtectedAction(() => {
void detailNavigation.handleDeleteSelectedWorld();
});
}
: null
}
/>
)}
</motion.div>
)}
{selectionStage === 'agent-workspace' && (
<motion.div
key="agent-workspace"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={
<LazyPanelFallback label="正在加载 Agent 共创工作区..." />
}
>
{sessionController.agentSession ? (
<CustomWorldAgentWorkspace
session={sessionController.agentSession}
activeOperation={sessionController.agentOperation}
streamingReplyText={sessionController.streamingAgentReplyText}
isStreamingReply={sessionController.isStreamingAgentReply}
onBack={leaveAgentWorkspace}
onSubmitMessage={(payload) => {
void sessionController.submitAgentMessage(payload);
}}
onExecuteAction={(payload) => {
void sessionController.executeAgentAction(payload);
}}
/>
) : (
<div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
{sessionController.isLoadingAgentSession
? '正在准备 Agent 共创工作区...'
: sessionController.creationTypeError || '正在恢复创作工作区...'}
</div>
</div>
)}
</Suspense>
</motion.div>
)}
{selectionStage === 'custom-world-generating' && (
<motion.div
key="custom-world-generating"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载世界生成面板..." />}
>
<CustomWorldGenerationView
settingText={sessionController.agentDraftSettingPreview}
anchorEntries={sessionController.agentDraftAnchorPreviewEntries}
progress={sessionController.agentDraftGenerationProgress}
isGenerating={sessionController.isActiveGenerationRunning}
error={sessionController.activeGenerationError}
onBack={leaveAgentDraftGeneration}
onEditSetting={leaveAgentDraftGeneration}
onRetry={() => {
void sessionController.executeAgentAction({
action: 'draft_foundation',
});
}}
onInterrupt={undefined}
backLabel="返回工作区"
settingActionLabel={null}
retryLabel="重新生成草稿"
settingTitle="当前世界信息"
settingDescription={null}
progressTitle="世界草稿生成进度"
activeBadgeLabel="草稿编译中"
pausedBadgeLabel="草稿生成已暂停"
idleBadgeLabel="等待返回工作区"
/>
</Suspense>
</motion.div>
)}
{selectionStage === 'custom-world-result' &&
sessionController.generatedCustomWorldProfile && (
<motion.div
key="custom-world-result"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载世界编辑器..." />}
>
<RpgCreationResultView
profile={sessionController.generatedCustomWorldProfile}
previewCharacters={previewCustomWorldCharacters}
isGenerating={false}
progress={0}
progressLabel=""
error={resultViewError}
onProfileChange={(profile) => {
sessionController.setGeneratedCustomWorldProfile(
normalizeAgentBackedProfile(profile),
);
}}
onBack={
sessionController.isAgentDraftResultView
? () => {
void (async () => {
const currentProfile =
sessionController.generatedCustomWorldProfile;
if (!currentProfile) {
leaveAgentDraftResult();
return;
}
await autosaveCoordinator.syncAgentDraftResultProfile(
currentProfile,
);
leaveAgentDraftResult();
})().catch((error) => {
sessionController.setCustomWorldError(
resolveRpgCreationErrorMessage(
error,
'返回创作前同步草稿失败。',
),
);
});
}
: leaveCustomWorldResult
}
onEditSetting={undefined}
onRegenerate={undefined}
onContinueExpand={undefined}
onEnterWorld={() => {
runProtectedAction(() => {
void enterWorldCoordinator
.enterWorldFromCurrentResult()
.catch((error) => {
sessionController.setCustomWorldError(
resolveRpgCreationErrorMessage(
error,
'发布并进入世界失败。',
),
);
});
});
}}
readOnly={false}
compactAgentResultMode={sessionController.isAgentDraftResultView}
backLabel={
sessionController.isAgentDraftResultView
? '返回创作'
: undefined
}
editActionLabel="继续调整设定"
enterWorldActionLabel={
sessionController.isAgentDraftResultView &&
sessionController.agentSession?.stage !== 'published'
? '发布并进入世界'
: '进入世界'
}
publishReady={
sessionController.isAgentDraftResultView
? Boolean(agentResultPreview?.publishReady)
: true
}
publishBlockers={
sessionController.isAgentDraftResultView
? agentResultPreviewBlockers
: []
}
qualityFindings={
sessionController.isAgentDraftResultView
? agentResultPreviewQualityFindings
: []
}
previewSourceLabel={
sessionController.isAgentDraftResultView
? agentResultPreviewSourceLabel
: null
}
autoSaveState={autosaveCoordinator.customWorldAutoSaveState}
/>
</Suspense>
</motion.div>
)}
</AnimatePresence>
<RpgEntryCreationTypeModal
isOpen={showCreationTypeModal}
isBusy={sessionController.isCreatingAgentSession}
error={sessionController.creationTypeError}
onClose={() => {
if (sessionController.isCreatingAgentSession) {
return;
}
setShowCreationTypeModal(false);
}}
onSelectRpg={() => {
runProtectedAction(() => {
void sessionController.openRpgAgentWorkspace();
});
}}
/>
</>
);
}
export const RpgCreationShellImpl = RpgEntryFlowShellImpl;
export default RpgEntryFlowShellImpl;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,294 @@
import { ArrowLeft } from 'lucide-react';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import type { CustomWorldProfile } from '../../types';
import {
buildPlatformWorldTags,
describePlatformThemeLabel,
formatPlatformWorldTime,
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 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);
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 ? (
<img
src={coverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover opacity-38"
/>
) : null}
{leadPortrait ? (
<img
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>
</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,110 @@
import type {
CustomWorldAgentMessage,
CustomWorldAgentOperationRecord,
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldCreatorIntentFoundationText } from '../../services/customWorldCreatorIntent';
import type { CustomWorldProfile } from '../../types';
export function resolveRpgEntryErrorMessage(
error: unknown,
fallback: string,
) {
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: 100,
error: params.error,
};
}
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,39 @@
import type {
CustomWorldAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { CustomWorldProfile, GameState } from '../../types';
export type SelectionStage =
| 'platform'
| 'detail'
| 'agent-workspace'
| 'custom-world-generating'
| 'custom-world-result';
export type CustomWorldGenerationViewSource = 'agent-draft-foundation' | null;
export type CustomWorldResultViewSource = 'saved-profile' | 'agent-draft' | null;
export type CustomWorldAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
export type SyncedAgentDraftResult = {
session: CustomWorldAgentSessionSnapshot | null;
profile: CustomWorldProfile | null;
};
export type RpgEntryFlowShellProps = {
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
gameState: GameState;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
};
/**
* 兼容旧创作链入口的 props 命名,避免并行工作包在迁移期间断开引用。
*/
export type RpgCreationShellProps = RpgEntryFlowShellProps;

View File

@@ -0,0 +1,90 @@
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import type { CustomWorldProfile } from '../../types';
export type PlatformWorldCardLike =
| CustomWorldGalleryCard
| CustomWorldLibraryEntry<CustomWorldProfile>;
export function isLibraryWorldEntry(
entry: PlatformWorldCardLike,
): entry is CustomWorldLibraryEntry<CustomWorldProfile> {
return 'profile' in entry;
}
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 (!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 describePlatformThemeLabel(
themeMode: PlatformWorldCardLike['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,109 @@
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,
) => 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, 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,92 @@
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>;
syncAgentDraftResultProfile: (
profile: CustomWorldProfile,
) => Promise<{
profile: CustomWorldProfile | null;
session: CustomWorldAgentSessionSnapshot | null;
}>;
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
};
/**
* 统一“进入世界”前的最终同步策略。
* 非 Agent 草稿结果直接进入Agent 草稿结果必须先把当前结果页并回 session。
*/
export function useRpgCreationEnterWorld(
params: UseRpgCreationEnterWorldParams,
) {
const {
isAgentDraftResultView,
activeAgentSessionId,
generatedCustomWorldProfile,
agentSessionProfile,
agentSession,
handleCustomWorldSelect,
executePublishWorld,
syncAgentDraftResultProfile,
setGeneratedCustomWorldProfile,
} = params;
const enterWorldFromCurrentResult = useCallback(async () => {
if (!generatedCustomWorldProfile) {
return;
}
if (!isAgentDraftResultView || !activeAgentSessionId) {
handleCustomWorldSelect(generatedCustomWorldProfile);
return;
}
const latestResult = await syncAgentDraftResultProfile(
generatedCustomWorldProfile,
);
const latestProfile =
latestResult.profile ?? agentSessionProfile ?? generatedCustomWorldProfile;
setGeneratedCustomWorldProfile(latestProfile);
const latestSession = latestResult.session ?? agentSession;
const canEnterPublishedWorld =
latestSession?.stage === 'published' &&
latestSession.resultPreview?.canEnterWorld;
if (canEnterPublishedWorld) {
handleCustomWorldSelect(latestProfile);
return;
}
const publishedSession = await executePublishWorld();
const publishedProfile =
rpgCreationPreviewAdapter.buildPreviewFromSession(publishedSession) ??
latestProfile;
setGeneratedCustomWorldProfile(publishedProfile);
handleCustomWorldSelect(publishedProfile);
}, [
activeAgentSessionId,
agentSession,
agentSessionProfile,
executePublishWorld,
generatedCustomWorldProfile,
handleCustomWorldSelect,
isAgentDraftResultView,
setGeneratedCustomWorldProfile,
syncAgentDraftResultProfile,
]);
return {
enterWorldFromCurrentResult,
};
}

View File

@@ -0,0 +1,392 @@
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,
) => 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);
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;
}
},
[
generatedCustomWorldProfile,
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)
: '';
if (latestSessionProfileSignature === profileSignature) {
latestAgentResultSyncSignatureRef.current = profileSignature;
return {
session: agentSession,
profile: normalizeAgentBackedProfile(latestSessionProfile ?? profile),
} satisfies SyncedAgentDraftResult;
}
if (latestAgentResultSyncSignatureRef.current === profileSignature) {
return {
session: agentSession,
profile: normalizeAgentBackedProfile(latestSessionProfile ?? profile),
} satisfies SyncedAgentDraftResult;
}
const { operation } = await executeRpgCreationAction(
activeAgentSessionId,
{
action: 'sync_result_profile',
profile: normalizedProfile as unknown as Record<string, unknown>,
},
);
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);
const latestSession = await syncAgentSessionSnapshot(
activeAgentSessionId,
);
const latestProfile = normalizeAgentBackedProfile(
buildDraftResultProfile(latestSession) ?? profile,
);
if (latestProfile) {
setGeneratedCustomWorldProfile(latestProfile);
}
latestAgentResultSyncSignatureRef.current = profileSignature;
return {
session: latestSession,
profile: latestProfile,
} satisfies SyncedAgentDraftResult;
}
await new Promise((resolve) => window.setTimeout(resolve, 200));
}
throw new Error('同步结果页世界快照超时。');
},
[
activeAgentSessionId,
agentSession,
buildDraftResultProfile,
persistAgentUiState,
setAgentOperation,
setGeneratedCustomWorldProfile,
syncAgentSessionSnapshot,
],
);
const executeAgentActionAndWait = useCallback(
async (action: Parameters<typeof executeRpgCreationAction>[1]) => {
if (!activeAgentSessionId) {
return null;
}
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,570 @@
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;
};
export function useRpgCreationSessionController(
params: UseRpgCreationSessionControllerParams,
) {
const {
userId,
openLoginModal,
selectionStage,
setSelectionStage,
enterCreateTab,
onSessionOpened,
} = params;
const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState());
const hasAppliedInitialAgentWorkspaceRef = useRef(false);
const hasRequestedInitialAgentWorkspaceAuthRef = useRef(false);
const isAgentDraftResultAutoOpenSuppressedRef = useRef(false);
const [isCreatingAgentSession, setIsCreatingAgentSession] = useState(false);
const [activeAgentSessionId, setActiveAgentSessionId] = useState<
string | null
>(() => initialAgentUiStateRef.current.activeSessionId ?? null);
const [activeAgentOperationId, setActiveAgentOperationId] = useState<
string | null
>(() => initialAgentUiStateRef.current.activeOperationId ?? 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 [isLoadingAgentSession, setIsLoadingAgentSession] = useState(false);
const [creationTypeError, setCreationTypeError] = 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 persistAgentUiState = useCallback(
(nextSessionId: string | null, nextOperationId: string | null) => {
setActiveAgentSessionId(nextSessionId);
setActiveAgentOperationId(nextOperationId);
writeCustomWorldAgentUiState({
activeSessionId: nextSessionId,
activeOperationId: nextOperationId,
});
},
[],
);
const syncAgentSessionSnapshot = useCallback(async (sessionId: string) => {
const nextSession = await getRpgCreationSession(sessionId);
setAgentSession(nextSession);
return nextSession;
}, []);
useEffect(() => {
const initialAgentSessionId = initialAgentUiStateRef.current.activeSessionId;
if (!initialAgentSessionId || hasAppliedInitialAgentWorkspaceRef.current) {
return;
}
enterCreateTab?.();
if (!userId) {
if (!hasRequestedInitialAgentWorkspaceAuthRef.current) {
hasRequestedInitialAgentWorkspaceAuthRef.current = true;
openLoginModal?.(() => {
setSelectionStage('agent-workspace');
});
}
return;
}
hasAppliedInitialAgentWorkspaceRef.current = true;
setSelectionStage('agent-workspace');
}, [enterCreateTab, openLoginModal, setSelectionStage, userId]);
useEffect(() => {
if (!activeAgentSessionId) {
setAgentSession(null);
setAgentOperation(null);
setIsLoadingAgentSession(false);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
return;
}
if (!userId) {
setAgentSession(null);
setAgentOperation(null);
setIsLoadingAgentSession(false);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
return;
}
let cancelled = false;
setIsLoadingAgentSession(true);
void syncAgentSessionSnapshot(activeAgentSessionId)
.then(() => {
if (!cancelled) {
setCreationTypeError(null);
}
})
.catch((error) => {
if (cancelled) {
return;
}
setCreationTypeError(
resolveRpgCreationErrorMessage(error, '读取 Agent 共创工作区失败。'),
);
setAgentSession(null);
setAgentOperation(null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
persistAgentUiState(null, null);
enterCreateTab?.();
setSelectionStage('platform');
})
.finally(() => {
if (!cancelled) {
setIsLoadingAgentSession(false);
}
});
return () => {
cancelled = true;
};
}, [
activeAgentSessionId,
enterCreateTab,
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;
const timeoutId = window.setTimeout(() => {
void (async () => {
const latestSession = activeAgentSessionId
? await syncAgentSessionSnapshot(activeAgentSessionId).catch(
() => null,
)
: agentSession;
if (cancelled) {
return;
}
const draftResultProfile =
rpgCreationPreviewAdapter.buildPreviewFromSession(
latestSession ?? agentSession,
);
if (!draftResultProfile) {
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setSelectionStage('agent-workspace');
return;
}
setGeneratedCustomWorldProfile(
normalizeAgentBackedProfile(draftResultProfile),
);
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource('agent-draft');
setSelectionStage('custom-world-result');
})();
}, 900);
return () => {
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [
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;
try {
const { session } = await createRpgCreationSession(
seedText ? { seedText } : {},
);
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,
isCreatingAgentSession,
onSessionOpened,
persistAgentUiState,
setSelectionStage,
],
);
const submitAgentMessage = useCallback(
async (payload: SendCustomWorldAgentMessageRequest) => {
if (!activeAgentSessionId) {
return;
}
const optimisticUserMessage = buildOptimisticAgentMessage({
id: payload.clientMessageId,
role: 'user',
kind: 'chat',
text: payload.text.trim(),
});
setAgentOperation(null);
persistAgentUiState(activeAgentSessionId, null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(true);
setAgentSession((current) =>
current
? {
...current,
messages: [...current.messages, optimisticUserMessage],
updatedAt: optimisticUserMessage.createdAt,
}
: current,
);
try {
const nextSession = await streamRpgCreationMessage(
activeAgentSessionId,
payload,
{
onUpdate: (text) => {
setStreamingAgentReplyText(text);
},
},
);
setAgentSession(nextSession);
setAgentOperation(null);
setStreamingAgentReplyText('');
} catch (error) {
const errorMessage = resolveRpgCreationErrorMessage(
error,
'发送共创消息失败。',
);
setAgentSession((current) =>
current
? {
...current,
messages: [
...current.messages,
buildOptimisticAgentMessage({
id: `message-error-${Date.now()}`,
role: 'assistant',
kind: 'warning',
text: errorMessage,
}),
],
updatedAt: new Date().toISOString(),
}
: current,
);
setStreamingAgentReplyText('');
persistAgentUiState(activeAgentSessionId, null);
} finally {
setIsStreamingAgentReply(false);
}
},
[activeAgentSessionId, 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);
} catch (error) {
const errorMessage = resolveRpgCreationErrorMessage(
error,
'执行共创操作失败。',
);
setAgentOperation(
createFailedAgentOperation({
type:
payload.action === 'draft_foundation'
? 'draft_foundation'
: payload.action,
phaseLabel: '执行操作失败',
error: errorMessage,
}),
);
persistAgentUiState(activeAgentSessionId, null);
}
},
[activeAgentSessionId, persistAgentUiState, setSelectionStage],
);
const setNormalizedGeneratedCustomWorldProfile = useCallback(
(profile: CustomWorldProfile | null) => {
setGeneratedCustomWorldProfile(
profile ? normalizeAgentBackedProfile(profile) : null,
);
},
[],
);
const resetSessionViewState = useCallback(() => {
setAgentOperation(null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
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,
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,346 @@
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;
getProfileDashboard: () => Promise<ProfileDashboardSummary | null>;
handleContinueGame: (
snapshot?: HydratedSavedGameSnapshot | null,
) => void;
hasInitialAgentSession: boolean;
};
export function useRpgEntryBootstrap(
params: UseRpgEntryBootstrapParams,
) {
const { user, getProfileDashboard, handleContinueGame, hasInitialAgentSession } =
params;
const isAuthenticated = Boolean(user);
const platformTabBootstrapUserIdRef = useRef<string | null | undefined>(
undefined,
);
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, setPlatformTab] = 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 refreshProfileDashboard = useCallback(async () => {
if (!user) {
setProfileDashboard(null);
setDashboardError(null);
setIsLoadingDashboard(false);
return;
}
setIsLoadingDashboard(true);
setDashboardError(null);
try {
setProfileDashboard(await getProfileDashboard());
} catch (error) {
setDashboardError(
resolveRpgEntryErrorMessage(error, '读取个人数据看板失败。'),
);
} finally {
setIsLoadingDashboard(false);
}
}, [getProfileDashboard, user]);
const refreshCustomWorldWorks = useCallback(async () => {
if (!user) {
setCustomWorldWorkEntries([]);
return [];
}
const nextItems = await listRpgCreationWorks();
setCustomWorldWorkEntries(nextItems);
return nextItems;
}, [user]);
const refreshPublishedGallery = useCallback(async () => {
const nextEntries = await listRpgEntryWorldGallery();
setPublishedGalleryEntries(nextEntries);
return nextEntries;
}, []);
const refreshSavedCustomWorldLibrary = useCallback(async () => {
if (!user) {
setSavedCustomWorldEntries([]);
return [];
}
const nextEntries = await listRpgEntryWorldLibrary();
setSavedCustomWorldEntries(nextEntries);
return nextEntries;
}, [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 || 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);
}
},
[handleContinueGame, isResumingSaveWorldKey, user],
);
useEffect(() => {
let isActive = true;
void (async () => {
setHistoryEntries([]);
setHistoryError(null);
setSaveError(null);
setIsLoadingPlatform(true);
setPlatformError(null);
setIsLoadingDashboard(isAuthenticated);
setDashboardError(null);
if (!isAuthenticated) {
setSavedCustomWorldEntries([]);
setCustomWorldWorkEntries([]);
setSaveEntries([]);
setProfileDashboard(null);
}
try {
const [
libraryEntriesResult,
workEntriesResult,
galleryEntriesResult,
dashboardResult,
historyResult,
saveArchivesResult,
] = await Promise.allSettled([
isAuthenticated
? listRpgEntryWorldLibrary()
: Promise.resolve([]),
isAuthenticated
? listRpgCreationWorks()
: Promise.resolve([]),
listRpgEntryWorldGallery(),
isAuthenticated ? getProfileDashboard() : Promise.resolve(null),
isAuthenticated ? listRpgProfileBrowseHistory() : Promise.resolve([]),
isAuthenticated ? 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 (
(isAuthenticated && libraryEntriesResult.status === 'rejected') ||
(isAuthenticated && 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 (isAuthenticated) {
setProfileDashboard(null);
setDashboardError(
resolveRpgEntryErrorMessage(
dashboardResult.reason,
'读取个人数据看板失败。',
),
);
}
if (historyResult.status === 'fulfilled') {
setHistoryEntries(historyResult.value);
} else if (isAuthenticated) {
setHistoryError(
resolveRpgEntryErrorMessage(historyResult.reason, '读取浏览历史失败。'),
);
}
if (saveArchivesResult.status === 'fulfilled') {
setSaveEntries(saveArchivesResult.value);
} else if (isAuthenticated) {
setSaveEntries([]);
setSaveError(
resolveRpgEntryErrorMessage(
saveArchivesResult.reason,
'读取存档列表失败。',
),
);
}
const nextPlatformBootstrapUserId = user?.id ?? null;
if (
platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId
) {
platformTabBootstrapUserIdRef.current = nextPlatformBootstrapUserId;
if (!hasInitialAgentSession) {
setPlatformTab(
isAuthenticated &&
saveArchivesResult.status === 'fulfilled' &&
saveArchivesResult.value.length > 0
? 'saves'
: 'home',
);
}
}
} finally {
if (isActive) {
setIsLoadingPlatform(false);
setIsLoadingDashboard(false);
}
}
})();
return () => {
isActive = false;
};
}, [getProfileDashboard, hasInitialAgentSession, isAuthenticated, user]);
return {
isAuthenticated,
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,444 @@
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';
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,
) => void;
syncAgentSessionSnapshot: (
sessionId: string,
) => Promise<CustomWorldAgentSessionSnapshot | null>;
buildDraftResultProfile: (
session: CustomWorldAgentSessionSnapshot | null,
) => CustomWorldProfile | null;
suppressAgentDraftResultAutoOpen: () => void;
releaseAgentDraftResultAutoOpenSuppression: () => void;
resetAutoSaveTrackingToIdle: () => void;
markAutoSavedProfile: (profile: CustomWorldProfile) => void;
};
/**
* 负责平台详情、创作作品入口和结果页打开路径。
* 平台壳层只消费“打开哪个面板”的结果,不再自己拼接恢复流程细节。
*/
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);
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) {
persistAgentUiState(work.sessionId, null);
setCustomWorldError(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
setCustomWorldGenerationViewSource(null);
resetAutoSaveTrackingToIdle();
const shouldOpenAgentWorkspace =
work.playableNpcCount <= 0 && work.landmarkCount <= 0;
if (shouldOpenAgentWorkspace) {
// 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。
suppressAgentDraftResultAutoOpen();
setGeneratedCustomWorldProfile(null);
setCustomWorldResultViewSource(null);
setPlatformTabToCreate();
setSelectionStage('agent-workspace');
return;
}
releaseAgentDraftResultAutoOpenSuppression();
const latestSession = await syncAgentSessionSnapshot(work.sessionId);
const nextProfile = buildDraftResultProfile(latestSession);
if (!nextProfile) {
setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。');
setPlatformTabToCreate();
setSelectionStage('agent-workspace');
return;
}
setGeneratedCustomWorldProfile(
normalizeRpgEntryAgentBackedProfile(nextProfile),
);
setCustomWorldResultViewSource('agent-draft');
setPlatformTabToCreate();
setSelectionStage('custom-world-result');
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,
};
}