This commit is contained in:
2026-04-19 20:33:18 +08:00
parent 692643136f
commit 67c584b4df
123 changed files with 11898 additions and 4082 deletions

View File

@@ -0,0 +1,150 @@
/* @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 { CharacterSelectionFlow } from './CharacterSelectionFlow';
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(
<CharacterSelectionFlow
worldType={WorldType.CUSTOM}
customWorldProfile={{} as CustomWorldProfile}
onBack={() => {}}
onConfirm={handleConfirm}
/>,
);
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

@@ -63,6 +63,21 @@ function getCharacterMeta(
};
}
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,
@@ -163,7 +178,15 @@ export function CharacterSelectionFlow({
() => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : ROLE_TEMPLATE_CHARACTERS),
[customWorldProfile],
);
const [selectedCharacterId, setSelectedCharacterId] = useState(selectionCharacters[0]?.id ?? '');
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);
@@ -173,11 +196,14 @@ export function CharacterSelectionFlow({
const [characterDraftError, setCharacterDraftError] = useState<string | null>(null);
const [characterSelectionDrafts, setCharacterSelectionDrafts] = useState<Record<string, CharacterSelectionDraft>>({});
const selectedCharacter = useMemo(
() => selectionCharacters.find(character => character.id === selectedCharacterId) ?? selectionCharacters[0] ?? null,
[selectedCharacterId, selectionCharacters],
const selectedCharacterEntry = useMemo(
() => selectionEntries.find(entry => entry.selectionKey === selectedCharacterKey) ?? selectionEntries[0] ?? null,
[selectedCharacterKey, selectionEntries],
);
const selectedCharacterDraft = selectedCharacter ? characterSelectionDrafts[selectedCharacter.id] ?? null : null;
const selectedCharacter = selectedCharacterEntry?.character ?? null;
const selectedCharacterDraft = selectedCharacterEntry
? characterSelectionDrafts[selectedCharacterEntry.selectionKey] ?? null
: null;
const selectedCharacterPreview = useMemo(
() => applyCharacterSelectionDraft(selectedCharacter, selectedCharacterDraft),
[selectedCharacter, selectedCharacterDraft],
@@ -203,21 +229,21 @@ export function CharacterSelectionFlow({
}, [syncCharacterCarousel]);
useEffect(() => {
const focusedCharacter = selectionCharacters[focusedCharacterIndex];
if (focusedCharacter && focusedCharacter.id !== selectedCharacterId) {
setSelectedCharacterId(focusedCharacter.id);
const focusedEntry = selectionEntries[focusedCharacterIndex];
if (focusedEntry && focusedEntry.selectionKey !== selectedCharacterKey) {
setSelectedCharacterKey(focusedEntry.selectionKey);
}
}, [focusedCharacterIndex, selectedCharacterId, selectionCharacters]);
}, [focusedCharacterIndex, selectedCharacterKey, selectionEntries]);
useEffect(() => {
if (selectionCharacters.length === 0) return;
if (!selectionCharacters.some(character => character.id === selectedCharacterId)) {
const firstCharacter = selectionCharacters[0];
if (firstCharacter) {
setSelectedCharacterId(firstCharacter.id);
if (selectionEntries.length === 0) return;
if (!selectionEntries.some(entry => entry.selectionKey === selectedCharacterKey)) {
const firstEntry = selectionEntries[0];
if (firstEntry) {
setSelectedCharacterKey(firstEntry.selectionKey);
}
}
}, [selectedCharacterId, selectionCharacters]);
}, [selectedCharacterKey, selectionEntries]);
const openCharacterDraftEditor = () => {
if (!selectedCharacterPreview) return;
@@ -228,7 +254,7 @@ export function CharacterSelectionFlow({
};
const saveCharacterDraft = () => {
if (!selectedCharacter) return;
if (!selectedCharacter || !selectedCharacterEntry) return;
const nextName = characterDraftName.trim();
const nextBackstory = characterDraftBackstory.trim();
@@ -243,7 +269,7 @@ export function CharacterSelectionFlow({
setCharacterSelectionDrafts(current => ({
...current,
[selectedCharacter.id]: {
[selectedCharacterEntry.selectionKey]: {
name: nextName,
backstory: nextBackstory,
},
@@ -278,17 +304,17 @@ export function CharacterSelectionFlow({
onScroll={syncCharacterCarousel}
className="character-carousel scrollbar-hide flex-[1_1_auto]"
>
{selectionCharacters.map((character, index) => {
const characterDraft = characterSelectionDrafts[character.id];
{selectionEntries.map(({ character, selectionKey }, index) => {
const characterDraft = characterSelectionDrafts[selectionKey];
const meta = getCharacterMeta(character, {name: characterDraft?.name});
const selected = character.id === selectedCharacter.id;
const selected = selectionKey === selectedCharacterKey;
return (
<button
key={character.id}
key={selectionKey}
type="button"
onClick={() => {
setSelectedCharacterId(character.id);
setSelectedCharacterKey(selectionKey);
scrollCarouselToIndex(characterCarouselRef.current, index, 'horizontal');
}}
data-carousel-card="true"

View File

@@ -104,7 +104,7 @@ export function GameShellMainContent({
isCharacterSelectionStage: boolean;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: () => void;
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
handleBackToWorldSelect: () => void;
@@ -132,15 +132,21 @@ export function GameShellMainContent({
resetForSaveAndExit: () => void;
handleSaveAndExit: () => void;
}) {
const isPlatformShell = !gameState.worldType;
return (
<div
className={`pixel-app-shell flex min-h-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : 'p-3 sm:p-4'}`}
className={`${isPlatformShell ? 'platform-main-shell' : 'pixel-app-shell'} flex min-h-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : 'p-3 sm:p-4'}`}
style={{
background: isCharacterSelectionStage
background: isPlatformShell
? 'transparent'
: isCharacterSelectionStage
? '#0d1016'
: `linear-gradient(rgba(10, 12, 18, 0.55), rgba(10, 12, 18, 0.55)), url("${UI_CHROME.appBackground}")`,
backgroundPosition: isCharacterSelectionStage ? undefined : 'center',
backgroundRepeat: isCharacterSelectionStage ? undefined : 'repeat',
backgroundPosition:
isPlatformShell || isCharacterSelectionStage ? undefined : 'center',
backgroundRepeat:
isPlatformShell || isCharacterSelectionStage ? undefined : 'repeat',
}}
>
<AnimatePresence mode="wait">

View File

@@ -21,6 +21,11 @@ const GameShellCanvasStage = lazy(async () => {
export function GameShellRuntime({session, story, entry, companions, audio}: GameShellProps) {
const authUi = useAuthUi();
const isPlatformShell = !session.gameState.worldType;
const platformThemeClass =
authUi?.platformTheme === 'dark'
? 'platform-theme--dark'
: 'platform-theme--light';
const {
gameState,
isLoading,
@@ -99,20 +104,25 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
});
useEffect(() => {
authUi?.setGlobalAccountActionsVisible(Boolean(gameState.playerCharacter));
authUi?.setGlobalAccountActionsVisible(false);
return () => {
authUi?.setGlobalAccountActionsVisible(true);
};
}, [authUi, gameState.playerCharacter]);
}, [authUi]);
return (
<div
className="fusion-pixel-app pixel-root-shell flex h-screen max-h-screen flex-col overflow-hidden font-sans text-zinc-100"
className={`${isPlatformShell ? `platform-ui-shell platform-theme ${platformThemeClass} text-[var(--platform-text-strong)]` : 'fusion-pixel-app pixel-root-shell text-zinc-100'} flex h-screen max-h-screen flex-col overflow-hidden font-sans`}
style={{
backgroundImage: `linear-gradient(rgba(8, 10, 14, 0.82), rgba(8, 10, 14, 0.82)), url("${UI_CHROME.appBackground}")`,
backgroundPosition: 'center',
backgroundRepeat: 'repeat',
background: isPlatformShell
? 'var(--platform-body-fill)'
: undefined,
backgroundImage: isPlatformShell
? undefined
: `linear-gradient(rgba(8, 10, 14, 0.82), rgba(8, 10, 14, 0.82)), url("${UI_CHROME.appBackground}")`,
backgroundPosition: isPlatformShell ? undefined : 'center',
backgroundRepeat: isPlatformShell ? undefined : 'repeat',
}}
>
<Suspense fallback={null}>

View File

@@ -1,6 +1,4 @@
import { X } from 'lucide-react';
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { ArrowRight, X } from 'lucide-react';
type PlatformCreationTypeModalProps = {
isOpen: boolean;
@@ -55,25 +53,27 @@ function CreationTypeCard(props: {
type="button"
disabled={disabled}
onClick={onSelect}
className={`relative overflow-hidden rounded-[1.65rem] border px-4 py-4 text-left transition ${
className={`platform-interactive-card relative overflow-hidden rounded-[1.65rem] border px-4 py-4 text-left ${
item.locked
? 'cursor-not-allowed border-white/8 bg-white/5 text-zinc-500'
: 'border-emerald-300/18 bg-[radial-gradient(circle_at_top_left,rgba(110,231,183,0.16),transparent_36%),linear-gradient(180deg,rgba(255,255,255,0.03),rgba(255,255,255,0.02))] text-white hover:border-emerald-300/35'
? '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={`rounded-full px-3 py-1 text-[10px] tracking-[0.18em] ${
className={`platform-pill px-3 ${
item.locked
? 'border border-white/8 bg-black/18 text-zinc-400'
: 'border border-emerald-300/20 bg-emerald-500/10 text-emerald-100'
? '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>
<span className="text-lg leading-none text-white/45">
{item.locked ? '·' : '→'}
</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}
@@ -101,21 +101,15 @@ export function PlatformCreationTypeModal({
}
return (
<div className="fixed inset-0 z-[90] flex items-end justify-center bg-black/72 p-3 backdrop-blur-sm sm:items-center sm:p-4">
<div
className="pixel-nine-slice w-full max-w-3xl"
style={getNineSliceStyle(UI_CHROME.modalPanel, {
paddingX: 18,
paddingY: 18,
})}
>
<div className="rounded-[1.8rem] bg-[linear-gradient(180deg,rgba(11,16,22,0.98),rgba(8,10,14,0.98))]">
<div className="flex items-start justify-between gap-3 border-b border-white/8 px-4 py-4 sm:px-5">
<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-white">
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-xs text-zinc-400">
<div className="mt-1 text-xs text-[var(--platform-text-base)]">
</div>
</div>
@@ -123,7 +117,7 @@ export function PlatformCreationTypeModal({
type="button"
onClick={onClose}
disabled={isBusy}
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="platform-icon-button disabled:cursor-not-allowed disabled:opacity-45"
>
<X className="h-4 w-4" />
</button>
@@ -146,7 +140,7 @@ export function PlatformCreationTypeModal({
</div>
{error ? (
<div className="mt-4 rounded-[1.25rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
{error}
</div>
) : null}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
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 { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import {
buildPlatformWorldTags,
describePlatformThemeLabel,
@@ -23,17 +24,17 @@ function ActionButton({
}) {
const toneClass =
tone === 'primary'
? 'border-sky-300/25 bg-sky-500/10 text-sky-100 hover:border-sky-300/45 hover:text-white'
? 'platform-button platform-button--primary'
: tone === 'danger'
? 'border-rose-400/25 bg-rose-500/10 text-rose-100 hover:border-rose-400/45 hover:text-white'
: 'border-white/10 bg-black/20 text-zinc-200 hover:border-white/20 hover:text-white';
? 'platform-button platform-button--danger'
: 'platform-button platform-button--secondary';
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`rounded-full border px-4 py-2 text-sm transition ${toneClass} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
className={`${toneClass} ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
>
{label}
</button>
@@ -81,30 +82,24 @@ export function PlatformWorldDetailView({
<button
type="button"
onClick={onBack}
className="rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
className="platform-button platform-button--ghost px-3 py-1.5 text-[11px]"
>
<ArrowLeft className="h-4 w-4" />
广
</button>
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300">
<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="pixel-nine-slice relative overflow-hidden"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 18,
paddingY: 16,
})}
>
<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"
style={{ imageRendering: 'pixelated' }}
/>
) : null}
{leadPortrait ? (
@@ -113,19 +108,18 @@ export function PlatformWorldDetailView({
alt=""
aria-hidden="true"
className="absolute bottom-0 right-2 h-32 w-32 object-contain opacity-25"
style={{ imageRendering: 'pixelated' }}
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.1),rgba(8,10,14,0.9))]" />
<div className="absolute inset-0 bg-[linear-gradient(125deg,rgba(255,31,111,0.78),rgba(255,138,115,0.52)_48%,rgba(255,255,255,0.08)_100%)]" />
<div className="relative z-10">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-amber-100">
<span className="platform-pill platform-pill--warm">
{describePlatformThemeLabel(entry.themeMode)}
</span>
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] text-zinc-100">
<span className="platform-pill platform-pill--neutral px-3">
{entry.authorDisplayName}
</span>
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] text-zinc-100">
<span className="platform-pill platform-pill--neutral px-3">
{entry.visibility === 'published'
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
: '仅自己可见'}
@@ -146,7 +140,7 @@ export function PlatformWorldDetailView({
{tags.map((tag, index) => (
<span
key={`world-detail-tag-${index}-${tag || 'empty'}`}
className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-100"
className="platform-pill platform-pill--neutral px-3"
>
{tag}
</span>
@@ -156,18 +150,12 @@ export function PlatformWorldDetailView({
</div>
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<div
className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="platform-surface platform-surface--soft px-4 py-3.5">
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
</div>
<div className="mt-3 grid grid-cols-2 gap-3 text-sm text-zinc-100 sm:grid-cols-4">
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
<div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div>
@@ -175,7 +163,7 @@ export function PlatformWorldDetailView({
{entry.playableNpcCount}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
<div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div>
@@ -183,7 +171,7 @@ export function PlatformWorldDetailView({
{entry.landmarkCount}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
<div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div>
@@ -191,7 +179,7 @@ export function PlatformWorldDetailView({
{entry.profile.majorFactions.length}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3">
<div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
</div>
@@ -209,7 +197,7 @@ export function PlatformWorldDetailView({
{previewCharacters.map((character, index) => (
<div
key={character.id || `preview-character-${index}`}
className="rounded-2xl border border-white/10 bg-black/20 px-3 py-3"
className="platform-subpanel rounded-2xl px-3 py-3"
>
<div className="line-clamp-1 text-sm font-bold text-white">
{character.title}
@@ -230,7 +218,7 @@ export function PlatformWorldDetailView({
{previewLandmarks.map((landmark, index) => (
<div
key={landmark.id || `preview-landmark-${index}`}
className="rounded-2xl border border-white/10 bg-black/20 px-3 py-3"
className="platform-subpanel rounded-2xl px-3 py-3"
>
<div className="line-clamp-1 text-sm font-bold text-white">
{landmark.name}
@@ -244,13 +232,7 @@ export function PlatformWorldDetailView({
</div>
</div>
<div
className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<div className="platform-surface platform-surface--soft px-4 py-3.5">
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
</div>

View File

@@ -17,15 +17,21 @@ import type { AuthUser } from '../../services/authService';
import {
clearProfileBrowseHistory,
deleteCustomWorldProfile,
getCustomWorldGalleryDetail,
getProfileDashboard,
listCustomWorldGallery,
listCustomWorldLibrary,
listProfileBrowseHistory,
listProfileSaveArchives,
resumeProfileSaveArchive,
upsertCustomWorldProfile,
upsertProfileBrowseHistory,
} from '../../services/storageService';
import type { GameState } from '../../types';
import { AuthUiContext } from '../auth/AuthUiContext';
import {
type PlatformSettingsSection,
AuthUiContext,
} from '../auth/AuthUiContext';
import {
PreGameSelectionFlow,
type SelectionStage,
@@ -48,7 +54,9 @@ vi.mock('../../services/storageService', () => ({
listCustomWorldGallery: vi.fn(),
listCustomWorldLibrary: vi.fn(),
listProfileBrowseHistory: vi.fn(),
listProfileSaveArchives: vi.fn(),
publishCustomWorldProfile: vi.fn(),
resumeProfileSaveArchive: vi.fn(),
syncProfileBrowseHistory: vi.fn(),
unpublishCustomWorldProfile: vi.fn(),
upsertProfileBrowseHistory: vi.fn(),
@@ -179,7 +187,32 @@ const mockAuthUser: AuthUser = {
wechatBound: false,
};
function TestWrapper({ withAuth = false }: { withAuth?: boolean } = {}) {
type TestAuthValue = {
user: AuthUser | null;
openLoginModal: (postLoginAction?: (() => void) | null) => void;
requireAuth: (action: () => void) => void;
openSettingsModal: (section?: PlatformSettingsSection) => void;
openAccountModal: () => void;
logout: () => Promise<void>;
setGlobalAccountActionsVisible: (visible: boolean) => void;
musicVolume: number;
setMusicVolume: (value: number) => void;
platformTheme: 'light' | 'dark';
setPlatformTheme: (theme: 'light' | 'dark') => void;
isHydratingSettings: boolean;
isPersistingSettings: boolean;
settingsError: string | null;
};
function TestWrapper({
withAuth = false,
authValue,
onContinueGame,
}: {
withAuth?: boolean;
authValue?: TestAuthValue;
onContinueGame?: (snapshot?: unknown) => void;
} = {}) {
const [selectionStage, setSelectionStage] =
useState<SelectionStage>('platform');
@@ -190,24 +223,36 @@ function TestWrapper({ withAuth = false }: { withAuth?: boolean } = {}) {
gameState={{} as GameState}
hasSavedGame={false}
savedSnapshot={null}
handleContinueGame={() => {}}
handleContinueGame={onContinueGame ?? (() => {})}
handleStartNewGame={() => {}}
handleCustomWorldSelect={() => {}}
/>
);
if (!withAuth) {
if (!withAuth && !authValue) {
return content;
}
return (
<AuthUiContext.Provider
value={{
user: mockAuthUser,
openAccountModal: () => {},
logout: async () => {},
setGlobalAccountActionsVisible: () => {},
}}
value={
authValue ?? {
user: mockAuthUser,
openLoginModal: () => {},
requireAuth: (action) => action(),
openSettingsModal: () => {},
openAccountModal: () => {},
logout: async () => {},
setGlobalAccountActionsVisible: () => {},
musicVolume: 0.42,
setMusicVolume: () => {},
platformTheme: 'light',
setPlatformTheme: () => {},
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
}
}
>
{content}
</AuthUiContext.Provider>
@@ -228,6 +273,27 @@ beforeEach(() => {
vi.mocked(listCustomWorldLibrary).mockResolvedValue([]);
vi.mocked(listCustomWorldGallery).mockResolvedValue([]);
vi.mocked(listProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(listProfileSaveArchives).mockResolvedValue([]);
vi.mocked(resumeProfileSaveArchive).mockResolvedValue({
entry: {
worldKey: 'custom:world-archive-1',
ownerUserId: null,
profileId: 'world-archive-1',
worldType: 'CUSTOM',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '回到旧灯塔继续推进调查。',
coverImageSrc: null,
lastPlayedAt: '2026-04-19T12:00:00.000Z',
},
snapshot: {
version: 2,
savedAt: '2026-04-19T12:00:00.000Z',
bottomTab: 'adventure',
currentStory: null,
gameState: {} as GameState,
},
});
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]);
@@ -309,6 +375,75 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and
).toBeTruthy();
});
test('clicking a public work while logged out routes through requireAuth', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
vi.mocked(listCustomWorldGallery).mockResolvedValue([
{
ownerUserId: 'author-1',
profileId: 'world-public-1',
visibility: 'published',
publishedAt: '2026-04-16T12:00:00.000Z',
updatedAt: '2026-04-16T12:00:00.000Z',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '最近公开发布的世界。',
coverImageSrc: null,
themeMode: 'tide',
authorDisplayName: '潮汐作者',
playableNpcCount: 3,
landmarkCount: 4,
},
]);
render(
<TestWrapper
authValue={{
user: null,
openLoginModal: () => {},
requireAuth,
openAccountModal: () => {},
logout: async () => {},
setGlobalAccountActionsVisible: () => {},
}}
/>,
);
const workCards = await screen.findAllByRole('button', {
name: //u,
});
await user.click(workCards[0]!);
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(getCustomWorldGalleryDetail).not.toHaveBeenCalled();
});
test('selecting RPG creation while logged out routes through requireAuth', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
render(
<TestWrapper
authValue={{
user: null,
openLoginModal: () => {},
requireAuth,
openAccountModal: () => {},
logout: async () => {},
setGlobalAccountActionsVisible: () => {},
}}
/>,
);
await user.click(screen.getByRole('button', { name: '创作' }));
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: / RPG/u }));
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(createCustomWorldAgentSession).not.toHaveBeenCalled();
});
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
const user = userEvent.setup();
@@ -472,40 +607,78 @@ test('existing draft sessions enter the legacy result layout directly', async ()
expect(screen.getByText('技能')).toBeTruthy();
});
test('profile tab loads server browse history and can clear it after confirmation', async () => {
test('authenticated users with save archives default into the saves tab', async () => {
const user = userEvent.setup();
vi.mocked(listProfileBrowseHistory).mockResolvedValue([
vi.mocked(listProfileSaveArchives).mockResolvedValue([
{
ownerUserId: 'author-1',
worldKey: 'custom:world-1',
ownerUserId: null,
profileId: 'world-1',
worldType: 'CUSTOM',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '最近浏览过的公开作品。',
summaryText: '回到旧灯塔继续推进调查。',
coverImageSrc: null,
themeMode: 'tide',
authorDisplayName: '潮汐作者',
visitedAt: '2026-04-16T12:00:00.000Z',
lastPlayedAt: '2026-04-19T12:00:00.000Z',
},
]);
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<TestWrapper withAuth />);
await user.click(screen.getByRole('button', { name: '我的' }));
expect(await screen.findByText('全部存档')).toBeTruthy();
expect(await screen.findByText('潮雾列岛')).toBeTruthy();
expect(screen.getByText('最近更新时间排序')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: '清空' }));
test('save tab can resume a selected archive directly into the game', async () => {
const user = userEvent.setup();
const handleContinueGame = vi.fn();
await waitFor(() => {
expect(clearProfileBrowseHistory).toHaveBeenCalledTimes(1);
vi.mocked(listProfileSaveArchives).mockResolvedValue([
{
worldKey: 'custom:world-1',
ownerUserId: null,
profileId: 'world-1',
worldType: 'CUSTOM',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '回到旧灯塔继续推进调查。',
coverImageSrc: null,
lastPlayedAt: '2026-04-19T12:00:00.000Z',
},
]);
vi.mocked(resumeProfileSaveArchive).mockResolvedValue({
entry: {
worldKey: 'custom:world-1',
ownerUserId: null,
profileId: 'world-1',
worldType: 'CUSTOM',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '回到旧灯塔继续推进调查。',
coverImageSrc: null,
lastPlayedAt: '2026-04-19T12:00:00.000Z',
},
snapshot: {
version: 2,
savedAt: '2026-04-19T12:00:00.000Z',
bottomTab: 'adventure',
currentStory: null,
gameState: {
worldType: 'CUSTOM',
} as GameState,
},
});
expect(
screen.getByText('你最近还没有浏览过作品详情,去首页逛一逛吧。'),
).toBeTruthy();
render(<TestWrapper withAuth onContinueGame={handleContinueGame} />);
await user.click(await screen.findByRole('button', { name: //u }));
await waitFor(() => {
expect(resumeProfileSaveArchive).toHaveBeenCalledWith('custom:world-1');
expect(handleContinueGame).toHaveBeenCalledTimes(1);
});
});
test('owned world detail can delete a work and return to the create tab list', async () => {
@@ -544,10 +717,10 @@ test('owned world detail can delete a work and return to the create tab list', a
]);
vi.mocked(deleteCustomWorldProfile).mockResolvedValue([]);
render(<TestWrapper />);
render(<TestWrapper withAuth />);
await user.click(screen.getByRole('button', { name: '创作' }));
await user.click(await screen.findByText('潮雾列岛'));
await user.click(await screen.findByRole('button', { name: //u }));
await user.click(await screen.findByRole('button', { name: '删除作品' }));
await waitFor(() => {

View File

@@ -10,8 +10,8 @@ import {
} from 'react';
import type {
CustomWorldAgentMessage,
CustomWorldAgentActionRequest,
CustomWorldAgentMessage,
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
SendCustomWorldAgentMessageRequest,
@@ -20,6 +20,7 @@ import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
ProfileDashboardSummary,
ProfileSaveArchiveSummary,
} from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
@@ -62,7 +63,9 @@ import {
listCustomWorldGallery,
listCustomWorldLibrary,
listProfileBrowseHistory,
listProfileSaveArchives,
publishCustomWorldProfile,
resumeProfileSaveArchive,
syncProfileBrowseHistory,
unpublishCustomWorldProfile,
upsertCustomWorldProfile,
@@ -115,7 +118,7 @@ type PreGameSelectionFlowProps = {
gameState: GameState;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: () => void;
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;
};
@@ -198,6 +201,9 @@ export function PreGameSelectionFlow({
const [historyEntries, setHistoryEntries] = useState<
PlatformBrowseHistoryEntry[]
>([]);
const [saveEntries, setSaveEntries] = useState<ProfileSaveArchiveSummary[]>(
[],
);
const [platformTab, setPlatformTab] = useState<PlatformHomeTab>('home');
const [selectedDetailEntry, setSelectedDetailEntry] =
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
@@ -225,10 +231,14 @@ export function PreGameSelectionFlow({
useState<ProfileDashboardSummary | null>(null);
const [dashboardError, setDashboardError] = useState<string | null>(null);
const [historyError, setHistoryError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [detailError, setDetailError] = useState<string | null>(null);
const [isLoadingPlatform, setIsLoadingPlatform] = useState(false);
const [isLoadingDashboard, setIsLoadingDashboard] = useState(false);
const [isClearingHistory, setIsClearingHistory] = useState(false);
const [isResumingSaveWorldKey, setIsResumingSaveWorldKey] = useState<
string | null
>(null);
const [isDetailLoading, setIsDetailLoading] = useState(false);
const [isMutatingDetail, setIsMutatingDetail] = useState(false);
const [customWorldAutoSaveState, setCustomWorldAutoSaveState] =
@@ -245,6 +255,9 @@ export function PreGameSelectionFlow({
const customWorldAutoSaveTimeoutRef = useRef<number | null>(null);
const lastAutoSavedProfileSignatureRef = useRef<string | null>(null);
const latestAutoSaveRequestIdRef = useRef(0);
const platformTabBootstrapUserIdRef = useRef<string | null | undefined>(
undefined,
);
const previewCustomWorldCharacters = useMemo(
() =>
@@ -258,6 +271,19 @@ export function PreGameSelectionFlow({
() => publishedGalleryEntries.slice(0, 6),
[publishedGalleryEntries],
);
const isAuthenticated = Boolean(authUi?.user);
const runProtectedAction = useCallback(
(action: () => void) => {
if (!authUi?.requireAuth) {
action();
return;
}
authUi.requireAuth(action);
},
[authUi],
);
const persistAgentUiState = useCallback(
(nextSessionId: string | null, nextOperationId: string | null) => {
@@ -278,6 +304,13 @@ export function PreGameSelectionFlow({
}, []);
const refreshProfileDashboard = useCallback(async () => {
if (!authUi?.user) {
setProfileDashboard(null);
setDashboardError(null);
setIsLoadingDashboard(false);
return;
}
setIsLoadingDashboard(true);
setDashboardError(null);
@@ -288,7 +321,7 @@ export function PreGameSelectionFlow({
} finally {
setIsLoadingDashboard(false);
}
}, []);
}, [authUi?.user]);
const appendBrowseHistoryEntry = useCallback(
async (entry: PlatformBrowseHistoryWriteEntry) => {
@@ -296,6 +329,10 @@ export function PreGameSelectionFlow({
setHistoryEntries(nextEntries);
setHistoryError(null);
if (!authUi?.user) {
return;
}
try {
const syncedEntries = await upsertProfileBrowseHistory(entry);
setHistoryEntries(syncedEntries);
@@ -341,10 +378,16 @@ export function PreGameSelectionFlow({
const localHistoryEntries = readPlatformBrowseHistory(authUi?.user);
setHistoryEntries(localHistoryEntries);
setHistoryError(null);
setSaveError(null);
setIsLoadingPlatform(true);
setPlatformError(null);
setIsLoadingDashboard(true);
setIsLoadingDashboard(isAuthenticated);
setDashboardError(null);
if (!isAuthenticated) {
setSavedCustomWorldEntries([]);
setSaveEntries([]);
setProfileDashboard(null);
}
try {
const [
@@ -352,23 +395,29 @@ export function PreGameSelectionFlow({
galleryEntriesResult,
dashboardResult,
historyResult,
saveArchivesResult,
] = await Promise.allSettled([
listCustomWorldLibrary(),
isAuthenticated ? listCustomWorldLibrary() : Promise.resolve([]),
listCustomWorldGallery(),
getProfileDashboard(),
(async () => {
let nextEntries = await listProfileBrowseHistory();
isAuthenticated ? getProfileDashboard() : Promise.resolve(null),
isAuthenticated
? (async () => {
let nextEntries = await listProfileBrowseHistory();
if (
hasPendingPlatformBrowseHistoryMigration(authUi?.user) &&
localHistoryEntries.length > 0
) {
nextEntries = await syncProfileBrowseHistory(localHistoryEntries);
markPlatformBrowseHistoryMigrated(authUi?.user);
}
if (
hasPendingPlatformBrowseHistoryMigration(authUi?.user) &&
localHistoryEntries.length > 0
) {
nextEntries = await syncProfileBrowseHistory(
localHistoryEntries,
);
markPlatformBrowseHistoryMigrated(authUi?.user);
}
return nextEntries;
})(),
return nextEntries;
})()
: Promise.resolve(localHistoryEntries),
isAuthenticated ? listProfileSaveArchives() : Promise.resolve([]),
]);
if (!isActive) {
return;
@@ -387,7 +436,7 @@ export function PreGameSelectionFlow({
}
if (
libraryEntriesResult.status === 'rejected' ||
(isAuthenticated && libraryEntriesResult.status === 'rejected') ||
galleryEntriesResult.status === 'rejected'
) {
const platformFailure =
@@ -403,7 +452,7 @@ export function PreGameSelectionFlow({
if (dashboardResult.status === 'fulfilled') {
setProfileDashboard(dashboardResult.value);
} else {
} else if (isAuthenticated) {
setProfileDashboard(null);
setDashboardError(
resolveErrorMessage(
@@ -415,11 +464,34 @@ export function PreGameSelectionFlow({
if (historyResult.status === 'fulfilled') {
setHistoryEntries(historyResult.value);
} else {
} else if (isAuthenticated) {
setHistoryError(
resolveErrorMessage(historyResult.reason, '读取浏览历史失败。'),
);
}
if (saveArchivesResult.status === 'fulfilled') {
setSaveEntries(saveArchivesResult.value);
} else if (isAuthenticated) {
setSaveEntries([]);
setSaveError(
resolveErrorMessage(saveArchivesResult.reason, '读取存档列表失败。'),
);
}
const nextPlatformBootstrapUserId = authUi?.user?.id ?? null;
if (platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId) {
platformTabBootstrapUserIdRef.current = nextPlatformBootstrapUserId;
if (!initialAgentUiStateRef.current.activeSessionId) {
setPlatformTab(
isAuthenticated &&
saveArchivesResult.status === 'fulfilled' &&
saveArchivesResult.value.length > 0
? 'saves'
: 'home',
);
}
}
} finally {
if (isActive) {
setIsLoadingPlatform(false);
@@ -431,7 +503,7 @@ export function PreGameSelectionFlow({
return () => {
isActive = false;
};
}, [authUi?.user]);
}, [authUi?.user, isAuthenticated]);
useEffect(() => {
if (
@@ -990,8 +1062,10 @@ export function PreGameSelectionFlow({
setIsClearingHistory(true);
setHistoryError(null);
try {
await clearProfileBrowseHistory();
clearPlatformBrowseHistory(authUi?.user);
if (authUi?.user) {
await clearProfileBrowseHistory();
}
setHistoryEntries([]);
} catch (error) {
setHistoryError(resolveErrorMessage(error, '清空浏览历史失败。'));
@@ -1000,6 +1074,34 @@ export function PreGameSelectionFlow({
}
};
const handleResumeSaveEntry = useCallback(
async (entry: ProfileSaveArchiveSummary) => {
if (!authUi?.user || isResumingSaveWorldKey) {
return;
}
setIsResumingSaveWorldKey(entry.worldKey);
setSaveError(null);
try {
const resumedArchive = await resumeProfileSaveArchive(entry.worldKey);
setSaveEntries((currentEntries) =>
currentEntries.map((currentEntry) =>
currentEntry.worldKey === resumedArchive.entry.worldKey
? resumedArchive.entry
: currentEntry,
),
);
handleContinueGame(resumedArchive.snapshot);
} catch (error) {
setSaveError(resolveErrorMessage(error, '恢复存档失败。'));
} finally {
setIsResumingSaveWorldKey(null);
}
},
[authUi?.user, handleContinueGame, isResumingSaveWorldKey],
);
const saveGeneratedCustomWorld = useCallback(
async (profile = generatedCustomWorldProfile) => {
if (!profile) {
@@ -1107,7 +1209,9 @@ export function PreGameSelectionFlow({
return;
}
handleCustomWorldSelect(selectedDetailEntry.profile);
runProtectedAction(() => {
handleCustomWorldSelect(selectedDetailEntry.profile);
});
};
const handlePublishSelectedWorld = async () => {
@@ -1208,6 +1312,8 @@ export function PreGameSelectionFlow({
onTabChange={setPlatformTab}
hasSavedGame={hasSavedGame}
savedSnapshot={savedSnapshot}
saveEntries={saveEntries}
saveError={saveError}
featuredEntries={featuredGalleryEntries}
latestEntries={publishedGalleryEntries}
myEntries={savedCustomWorldEntries}
@@ -1217,20 +1323,30 @@ export function PreGameSelectionFlow({
isLoadingPlatform={isLoadingPlatform}
isLoadingDashboard={isLoadingDashboard}
isClearingHistory={isClearingHistory}
isResumingSaveWorldKey={isResumingSaveWorldKey}
platformError={
isLoadingPlatform ? null : (platformError ?? creationTypeError)
}
dashboardError={isLoadingDashboard ? null : dashboardError}
onContinueGame={handleContinueGame}
onResumeSave={(entry) => {
void handleResumeSaveEntry(entry);
}}
onClearHistory={() => {
void handleClearBrowseHistory();
}}
onOpenCreateWorld={openCustomWorldCreator}
onOpenCreateTypePicker={openCreationTypePicker}
onOpenGalleryDetail={(entry) => {
void openGalleryDetail(entry);
runProtectedAction(() => {
void openGalleryDetail(entry);
});
}}
onOpenLibraryDetail={(entry) => {
runProtectedAction(() => {
openLibraryDetail(entry);
});
}}
onOpenLibraryDetail={openLibraryDetail}
onOpenProfileDashboardCard={() => {
if (dashboardError) {
void refreshProfileDashboard();
@@ -1266,23 +1382,41 @@ export function PreGameSelectionFlow({
onStartGame={handleStartSelectedWorld}
onContinueEdit={
isSelectedWorldOwned
? () => openSavedCustomWorldEditor(selectedDetailEntry)
? () => {
runProtectedAction(() => {
openSavedCustomWorldEditor(selectedDetailEntry);
});
}
: null
}
onPublish={
selectedDetailEntry.visibility === 'draft' &&
isSelectedWorldOwned
? handlePublishSelectedWorld
? () => {
runProtectedAction(() => {
void handlePublishSelectedWorld();
});
}
: null
}
onUnpublish={
selectedDetailEntry.visibility === 'published' &&
isSelectedWorldOwned
? handleUnpublishSelectedWorld
? () => {
runProtectedAction(() => {
void handleUnpublishSelectedWorld();
});
}
: null
}
onDelete={
isSelectedWorldOwned ? handleDeleteSelectedWorld : null
isSelectedWorldOwned
? () => {
runProtectedAction(() => {
void handleDeleteSelectedWorld();
});
}
: null
}
/>
)}
@@ -1409,7 +1543,9 @@ export function PreGameSelectionFlow({
onRegenerate={undefined}
onContinueExpand={undefined}
onEnterWorld={() => {
handleCustomWorldSelect(generatedCustomWorldProfile);
runProtectedAction(() => {
handleCustomWorldSelect(generatedCustomWorldProfile);
});
}}
readOnly={false}
backLabel={isAgentDraftResultView ? '返回创作' : undefined}
@@ -1433,7 +1569,9 @@ export function PreGameSelectionFlow({
setShowCreationTypeModal(false);
}}
onSelectRpg={() => {
void openRpgAgentWorkspace();
runProtectedAction(() => {
void openRpgAgentWorkspace();
});
}}
/>
</>

View File

@@ -47,7 +47,7 @@ export interface GameShellStoryProps {
export interface GameShellEntryProps {
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: () => void;
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
handleStartNewGame: () => void;
handleSaveAndExit: () => void;
handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void;