1
This commit is contained in:
@@ -53,6 +53,7 @@ function buildPlaceholderPuzzleWork(): PuzzleWorkSummary {
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
publishedAt: new Date(0).toISOString(),
|
||||
playCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to
|
||||
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
|
||||
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
|
||||
playCount: 8,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
},
|
||||
]}
|
||||
@@ -158,6 +159,7 @@ test('creation hub shows RPG public work code from published library entry', ()
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 3,
|
||||
landmarkCount: 4,
|
||||
likeCount: 0,
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
@@ -249,6 +251,7 @@ test('creation hub work code copy button copies without opening the card', async
|
||||
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
|
||||
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
|
||||
playCount: 8,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -65,6 +65,7 @@ test('creation hub renders puzzle works in the same unified list with puzzle tag
|
||||
updatedAt: new Date('2026-04-22T10:00:00.000Z').toISOString(),
|
||||
publishedAt: new Date('2026-04-22T10:05:00.000Z').toISOString(),
|
||||
playCount: 12,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
},
|
||||
]}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
||||
import { ArrowLeft, Copy, GitFork, Play, Share2 } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import {
|
||||
buildPlatformWorldTags,
|
||||
formatPlatformWorldTime,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorldCoverImage,
|
||||
resolvePlatformWorldStats,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
|
||||
export interface PlatformWorkDetailViewProps {
|
||||
entry: PlatformPublicGalleryCard;
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
onBack: () => void;
|
||||
onStart: () => void;
|
||||
onRemix: () => void;
|
||||
}
|
||||
|
||||
function formatCompactCount(value: number) {
|
||||
if (value >= 10000) {
|
||||
const normalized = value / 10000;
|
||||
return `${Number.isInteger(normalized) ? normalized.toFixed(0) : normalized.toFixed(1)}万`;
|
||||
}
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
function getSourceLabel(entry: PlatformPublicGalleryCard) {
|
||||
if ('sourceType' in entry && entry.sourceType === 'puzzle') {
|
||||
return '拼图';
|
||||
}
|
||||
if ('sourceType' in entry && entry.sourceType === 'big-fish') {
|
||||
return '大鱼吃小鱼';
|
||||
}
|
||||
return 'RPG';
|
||||
}
|
||||
|
||||
export function PlatformWorkDetailView({
|
||||
entry,
|
||||
isBusy,
|
||||
error,
|
||||
onBack,
|
||||
onStart,
|
||||
onRemix,
|
||||
}: PlatformWorkDetailViewProps) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
|
||||
'idle',
|
||||
);
|
||||
const tags = useMemo(
|
||||
() =>
|
||||
[
|
||||
getSourceLabel(entry),
|
||||
...buildPlatformWorldTags(entry).map((tag) => tag.trim()),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.slice(0, 4),
|
||||
[entry],
|
||||
);
|
||||
const stats = resolvePlatformWorldStats(entry);
|
||||
const statItems = [
|
||||
{ label: '改造次数', value: formatCompactCount(stats.remixCount) },
|
||||
{ label: '游玩次数', value: formatCompactCount(stats.playCount) },
|
||||
{ label: '点赞次数', value: formatCompactCount(stats.likeCount) },
|
||||
{
|
||||
label: '上线日期',
|
||||
value: formatPlatformWorldTime(stats.publishedAt),
|
||||
},
|
||||
];
|
||||
|
||||
const copyPublicWorkCode = () => {
|
||||
if (!publicWorkCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
void copyTextToClipboard(publicWorkCode).then((copied) => {
|
||||
setCopyState(copied ? 'copied' : 'failed');
|
||||
window.setTimeout(() => setCopyState('idle'), 1400);
|
||||
});
|
||||
};
|
||||
|
||||
const sharePublicWork = () => {
|
||||
if (!publicWorkCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shareText = `邀请你来玩《${entry.worldName}》\n作品号:${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
|
||||
void copyTextToClipboard(shareText).then((copied) => {
|
||||
setShareState(copied ? 'copied' : 'failed');
|
||||
window.setTimeout(() => setShareState('idle'), 1400);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-work-detail">
|
||||
<div className="platform-work-detail__topbar">
|
||||
<button
|
||||
type="button"
|
||||
className="platform-work-detail__icon-button"
|
||||
onClick={onBack}
|
||||
aria-label="返回"
|
||||
title="返回"
|
||||
>
|
||||
<ArrowLeft className="h-6 w-6" />
|
||||
</button>
|
||||
<div className="platform-work-detail__title">详情</div>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-work-detail__icon-button"
|
||||
onClick={sharePublicWork}
|
||||
disabled={!publicWorkCode}
|
||||
aria-label="分享"
|
||||
title="分享"
|
||||
>
|
||||
<Share2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="platform-work-detail__scroll">
|
||||
<section className="platform-work-detail__cover">
|
||||
{coverImage ? (
|
||||
<>
|
||||
<ResolvedAssetImage
|
||||
src={coverImage}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="platform-work-detail__cover-blur"
|
||||
/>
|
||||
<ResolvedAssetImage
|
||||
src={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="platform-work-detail__cover-image"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="platform-work-detail__cover-fallback" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="platform-work-detail__summary">
|
||||
<div className="platform-work-detail__meta-row">
|
||||
<div className="platform-work-detail__app-icon">
|
||||
{coverImage ? (
|
||||
<ResolvedAssetImage
|
||||
src={coverImage}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
entry.worldName.slice(0, 1)
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="platform-work-detail__name">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
<div className="platform-work-detail__author">
|
||||
{entry.authorDisplayName}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-work-detail__remix"
|
||||
onClick={onRemix}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<GitFork className="h-5 w-5" />
|
||||
Remix
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="platform-work-detail__stats">
|
||||
{statItems.map((item) => (
|
||||
<div key={item.label} className="platform-work-detail__stat">
|
||||
<div className="platform-work-detail__stat-label">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="platform-work-detail__stat-value">
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="platform-work-detail__body">
|
||||
<div className="platform-work-detail__chips">
|
||||
{tags.map((tag) => (
|
||||
<span key={tag} className="platform-work-detail__chip">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="platform-work-detail__copy">{entry.summaryText}</p>
|
||||
{publicWorkCode ? (
|
||||
<button
|
||||
type="button"
|
||||
className="platform-work-detail__code"
|
||||
onClick={copyPublicWorkCode}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
<span>{publicWorkCode}</span>
|
||||
{copyState !== 'idle' ? (
|
||||
<span>{copyState === 'copied' ? '已复制' : '复制失败'}</span>
|
||||
) : null}
|
||||
</button>
|
||||
) : null}
|
||||
{shareState !== 'idle' ? (
|
||||
<div className="platform-work-detail__toast">
|
||||
{shareState === 'copied' ? '分享内容已复制' : '分享失败'}
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="platform-work-detail__error">{error}</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="platform-work-detail__bottom">
|
||||
<button
|
||||
type="button"
|
||||
className="platform-work-detail__start"
|
||||
onClick={onStart}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Play className="h-5 w-5 fill-current" />
|
||||
启动
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export type CustomWorldRuntimeLaunchOptions = {
|
||||
|
||||
export type SelectionStage =
|
||||
| 'platform'
|
||||
| 'work-detail'
|
||||
| 'detail'
|
||||
| 'agent-workspace'
|
||||
| 'big-fish-agent-workspace'
|
||||
|
||||
@@ -28,6 +28,7 @@ const detailItem = {
|
||||
updatedAt: '2026-04-25T10:00:00.000Z',
|
||||
publishedAt: '2026-04-25T10:00:00.000Z',
|
||||
playCount: 7,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
} satisfies PuzzleWorkSummary;
|
||||
|
||||
|
||||
@@ -708,6 +708,7 @@ beforeEach(() => {
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
likeCount: 0,
|
||||
},
|
||||
entries: [],
|
||||
});
|
||||
@@ -1449,6 +1450,7 @@ test('clicking a public work while logged out routes through requireAuth', async
|
||||
authorDisplayName: '潮汐作者',
|
||||
playableNpcCount: 3,
|
||||
landmarkCount: 4,
|
||||
likeCount: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1543,6 +1545,7 @@ test('creation hub clears all private work shelves immediately after logout stat
|
||||
updatedAt: '2026-04-25T10:10:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
},
|
||||
],
|
||||
@@ -1583,6 +1586,7 @@ test('published puzzle works appear on home and category public shelves', async
|
||||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||||
playCount: 3,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
} satisfies PuzzleWorkSummary;
|
||||
|
||||
@@ -1666,6 +1670,7 @@ test('published puzzle detail returns to the source platform tab', async () => {
|
||||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||||
playCount: 3,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
} satisfies PuzzleWorkSummary;
|
||||
|
||||
@@ -1921,6 +1926,7 @@ test('puzzle draft card restores the bound agent session and opens the result vi
|
||||
updatedAt: '2026-04-22T12:10:00.000Z',
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: false,
|
||||
},
|
||||
],
|
||||
@@ -1967,6 +1973,7 @@ test('published puzzle work card restores its source session for editing', async
|
||||
updatedAt: '2026-04-25T12:10:00.000Z',
|
||||
publishedAt: '2026-04-25T12:10:00.000Z',
|
||||
playCount: 8,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
},
|
||||
],
|
||||
@@ -2007,6 +2014,7 @@ test('public code search opens a published puzzle by PZ code', async () => {
|
||||
updatedAt: '2026-04-25T12:10:00.000Z',
|
||||
publishedAt: '2026-04-25T12:10:00.000Z',
|
||||
playCount: 8,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
};
|
||||
|
||||
@@ -3185,6 +3193,7 @@ test('creation hub published work can open detail view before deleting from deta
|
||||
themeMode: 'tide' as const,
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
likeCount: 0,
|
||||
};
|
||||
|
||||
vi.mocked(listRpgCreationWorks)
|
||||
@@ -3272,6 +3281,7 @@ test('creation hub published work enters existing detail view', async () => {
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 3,
|
||||
landmarkCount: 4,
|
||||
likeCount: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -3345,6 +3355,7 @@ test('creation hub published work experience button enters world directly', asyn
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 3,
|
||||
landmarkCount: 4,
|
||||
likeCount: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -3421,6 +3432,7 @@ test('creation hub published work delete button removes the work directly from c
|
||||
themeMode: 'tide' as const,
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
likeCount: 0,
|
||||
};
|
||||
|
||||
vi.mocked(listRpgCreationWorks)
|
||||
|
||||
@@ -132,6 +132,7 @@ const puzzlePublicEntry = {
|
||||
summaryText: '一张用于公开分享的拼图作品。',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['奇幻'],
|
||||
likeCount: 12,
|
||||
visibility: 'published',
|
||||
publishedAt: '1777110165.990127Z',
|
||||
updatedAt: '2026-04-25T10:00:00.000Z',
|
||||
@@ -404,7 +405,7 @@ test('public gallery cards hide work code until detail is opened', async () => {
|
||||
screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
|
||||
).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /查看作品/u }));
|
||||
await user.click(screen.getByRole('button', { name: /奇幻拼图/u }));
|
||||
|
||||
expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Clock3,
|
||||
Coins,
|
||||
Copy,
|
||||
Heart,
|
||||
House,
|
||||
LogIn,
|
||||
MessageCircle,
|
||||
@@ -129,6 +130,18 @@ const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
|
||||
'profile',
|
||||
];
|
||||
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
|
||||
type MobileHomeChannel = 'recommend' | 'today' | 'category' | 'pc' | 'instant';
|
||||
|
||||
const MOBILE_HOME_CHANNELS: Array<{
|
||||
id: MobileHomeChannel;
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: 'recommend', label: '推荐' },
|
||||
{ id: 'today', label: '今日游戏' },
|
||||
{ id: 'category', label: '游戏分类' },
|
||||
{ id: 'pc', label: 'PC游戏' },
|
||||
{ id: 'instant', label: '即点即玩' },
|
||||
];
|
||||
|
||||
function usePlatformDesktopLayout() {
|
||||
const [isDesktopLayout, setIsDesktopLayout] = useState(() => {
|
||||
@@ -303,7 +316,6 @@ function WorldCard({
|
||||
className?: string;
|
||||
}) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
|
||||
const tags = [
|
||||
...new Set(
|
||||
buildPlatformWorldTags(entry)
|
||||
@@ -311,66 +323,79 @@ function WorldCard({
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, 3);
|
||||
const likeCount = getPlatformWorldLikeCount(entry);
|
||||
const cardLabel = `${entry.worldName},${formatCompactCount(likeCount)}点赞`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`platform-surface platform-interactive-card relative flex h-[15rem] w-[min(15.25rem,78vw)] shrink-0 flex-col overflow-hidden px-3.5 py-3.5 text-left ${className ?? ''}`}
|
||||
aria-label={cardLabel}
|
||||
className={`platform-public-work-card platform-surface platform-interactive-card relative flex w-[min(21rem,88vw)] shrink-0 flex-col overflow-hidden p-0 text-left ${className ?? ''}`}
|
||||
>
|
||||
{coverImage ? (
|
||||
<ResolvedAssetBackdrop
|
||||
src={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover opacity-40"
|
||||
/>
|
||||
) : null}
|
||||
{leadPortrait ? (
|
||||
<ResolvedAssetImage
|
||||
src={leadPortrait}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-2 right-2 h-24 w-24 object-contain opacity-25"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
||||
<div className="relative z-10 flex h-full flex-col">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<span className="platform-pill platform-pill--warm max-w-[8.5rem] truncate">
|
||||
<div className="platform-public-work-card__cover relative aspect-video overflow-hidden">
|
||||
{coverImage ? (
|
||||
<ResolvedAssetBackdrop
|
||||
src={coverImage}
|
||||
alt={entry.worldName}
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.02),rgba(0,0,0,0.18))]" />
|
||||
<div className="absolute left-3 top-3 flex min-w-0 max-w-[calc(100%-1.5rem)] flex-wrap gap-1.5">
|
||||
<span className="platform-pill platform-pill--warm max-w-[9rem] truncate px-2.5">
|
||||
{badge}
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--neutral px-2.5">
|
||||
<span className="platform-pill platform-pill--neutral max-w-[9rem] truncate px-2.5">
|
||||
{metaLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<div className="line-clamp-1 text-xl font-black text-[var(--platform-text-strong)]">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-1 line-clamp-1 text-[11px] tracking-[0.16em] text-[color:color-mix(in_srgb,var(--platform-text-base)_85%,transparent)]">
|
||||
{entry.subtitle}
|
||||
</div>
|
||||
|
||||
<div className="platform-public-work-card__body flex min-h-[7.25rem] flex-col gap-2 px-3.5 py-3">
|
||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="line-clamp-1 break-words text-base font-black leading-tight text-[var(--platform-text-strong)]">
|
||||
{entry.worldName}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2 line-clamp-2 text-xs leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_90%,transparent)]">
|
||||
{entry.summaryText || '等待补充世界摘要。'}
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-0.5 line-clamp-1 break-words text-[11px] font-medium text-[var(--platform-text-soft)]">
|
||||
{entry.subtitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{tags.length > 0 ? (
|
||||
tags.map((tag, index) => (
|
||||
<span
|
||||
key={`world-tag-${index}-${tag || 'empty'}`}
|
||||
className="platform-pill platform-pill--neutral px-2.5"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="platform-pill platform-pill--neutral px-2.5">
|
||||
{describePublicGalleryCardKind(entry)}
|
||||
<div className="platform-public-work-card__likes shrink-0 text-right">
|
||||
<div className="flex items-center justify-end gap-1 text-xs font-black text-[var(--platform-warm-text)]">
|
||||
<Heart className="h-3.5 w-3.5 fill-current" />
|
||||
<span>{formatCompactCount(likeCount)}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] font-semibold text-[var(--platform-text-soft)]">
|
||||
点赞
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="line-clamp-2 break-words text-xs leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_88%,transparent)]">
|
||||
{entry.summaryText || entry.subtitle || '等待补充世界摘要。'}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex min-w-0 flex-wrap gap-1.5">
|
||||
{tags.length > 0 ? (
|
||||
tags.map((tag, index) => (
|
||||
<span
|
||||
key={`world-tag-${index}-${tag || 'empty'}`}
|
||||
className="platform-pill platform-pill--neutral max-w-full px-2.5"
|
||||
>
|
||||
<span className="truncate">{tag}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="platform-pill platform-pill--neutral px-2.5">
|
||||
{describePublicGalleryCardKind(entry)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -740,6 +765,21 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
|
||||
: describePlatformThemeLabel(entry.themeMode);
|
||||
}
|
||||
|
||||
function getPlatformWorldLikeCount(entry: PlatformWorldCardLike) {
|
||||
return Math.max(0, Math.round(entry.likeCount ?? 0));
|
||||
}
|
||||
|
||||
function formatCompactCount(value: number) {
|
||||
const normalizedValue = Math.max(0, Math.round(value));
|
||||
if (normalizedValue >= 100000000) {
|
||||
return `${(normalizedValue / 100000000).toFixed(1)}亿`;
|
||||
}
|
||||
if (normalizedValue >= 10000) {
|
||||
return `${(normalizedValue / 10000).toFixed(1)}万`;
|
||||
}
|
||||
return `${normalizedValue}`;
|
||||
}
|
||||
|
||||
function formatSnapshotTime(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '刚刚保存';
|
||||
@@ -1435,6 +1475,8 @@ export function RpgEntryHomeView({
|
||||
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [mobileHomeChannel, setMobileHomeChannel] =
|
||||
useState<MobileHomeChannel>('recommend');
|
||||
const [visitedTabs, setVisitedTabs] = useState<Set<PlatformHomeTab>>(
|
||||
() => new Set([activeTab]),
|
||||
);
|
||||
@@ -1644,6 +1686,19 @@ export function RpgEntryHomeView({
|
||||
const desktopFeaturedGrid = featuredShelf.slice(0, 4);
|
||||
const desktopReleaseGrid = latestEntries.slice(0, 6);
|
||||
const desktopLibraryPreview = myEntries.slice(0, 2);
|
||||
const mobileFeedEntries = useMemo(() => {
|
||||
const entryMap = new Map<string, PlatformPublicGalleryCard>();
|
||||
const sourceEntries =
|
||||
mobileHomeChannel === 'recommend'
|
||||
? [...featuredShelf, ...latestEntries]
|
||||
: latestEntries;
|
||||
|
||||
sourceEntries.forEach((entry) => {
|
||||
entryMap.set(buildPublicGalleryCardKey(entry), entry);
|
||||
});
|
||||
|
||||
return Array.from(entryMap.values());
|
||||
}, [featuredShelf, latestEntries, mobileHomeChannel]);
|
||||
const categoryPageClass = isDesktopLayout
|
||||
? DESKTOP_PAGE_STAGE_CLASS
|
||||
: MOBILE_PAGE_STAGE_CLASS;
|
||||
@@ -1666,39 +1721,21 @@ export function RpgEntryHomeView({
|
||||
isSearching={isSearchingPublicCode}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openLeadPublicEntry}
|
||||
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-4 py-4 text-left`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
|
||||
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
|
||||
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
|
||||
<span className="platform-pill platform-pill--warm shrink-0">
|
||||
作品
|
||||
</span>
|
||||
<div className="platform-mobile-hero-secondary platform-pill platform-pill--neutral max-w-full px-3 text-[11px] tracking-[0.08em]">
|
||||
{leadPublicEntry
|
||||
? describePublicGalleryCardKind(leadPublicEntry)
|
||||
: '作品广场'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="break-all text-[clamp(1.6rem,7.4vw,1.92rem)] font-black leading-tight text-white">
|
||||
{leadPublicEntry?.worldName ?? '浏览玩家作品'}
|
||||
</div>
|
||||
<div className="mt-2 max-w-[28rem] break-all text-sm leading-6 text-zinc-200/88">
|
||||
{leadPublicEntry?.summaryText ||
|
||||
leadPublicEntry?.subtitle ||
|
||||
'从公开广场进入作品详情,挑一个世界开始游玩。'}
|
||||
</div>
|
||||
<div className="mt-4 flex min-w-0 items-center gap-2 text-sm font-semibold text-white/90">
|
||||
<span className="min-w-0 break-all">查看作品</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{MOBILE_HOME_CHANNELS.map((channel) => {
|
||||
const active = mobileHomeChannel === channel.id;
|
||||
return (
|
||||
<button
|
||||
key={channel.id}
|
||||
type="button"
|
||||
onClick={() => setMobileHomeChannel(channel.id)}
|
||||
className={`platform-mobile-home-channel shrink-0 ${active ? 'platform-mobile-home-channel--active' : ''}`}
|
||||
>
|
||||
{channel.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{platformError ? (
|
||||
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
|
||||
@@ -1706,45 +1743,28 @@ export function RpgEntryHomeView({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
<SectionHeader title="精选推荐" detail="为你挑选" />
|
||||
<section className="platform-mobile-home-feed">
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取精选作品..." />
|
||||
) : featuredShelf.length > 0 ? (
|
||||
<div className="flex min-w-0 gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{featuredShelf.map((entry: PlatformPublicGalleryCard) => (
|
||||
<EmptyShelf text="正在读取公开作品..." />
|
||||
) : mobileFeedEntries.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-3">
|
||||
{mobileFeedEntries.map((entry: PlatformPublicGalleryCard) => (
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:featured`}
|
||||
key={`${buildPublicGalleryCardKey(entry)}:mobile-feed:${mobileHomeChannel}`}
|
||||
entry={entry}
|
||||
badge="推荐"
|
||||
metaLabel={describePublicGalleryCardKind(entry)}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="公开广场暂时还没有精选作品。" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader title="最新发布" detail="玩家广场" />
|
||||
{isLoadingPlatform ? (
|
||||
<EmptyShelf text="正在读取最新发布..." />
|
||||
) : latestEntries.length > 0 ? (
|
||||
<div className="flex min-w-0 gap-3 overflow-x-auto pb-1 scrollbar-hide">
|
||||
{latestEntries.map((entry: PlatformPublicGalleryCard) => (
|
||||
<WorldCard
|
||||
key={`${buildPublicGalleryCardKey(entry)}:latest`}
|
||||
entry={entry}
|
||||
badge={describePublicGalleryCardKind(entry)}
|
||||
badge={
|
||||
mobileHomeChannel === 'recommend'
|
||||
? '推荐'
|
||||
: describePublicGalleryCardKind(entry)
|
||||
}
|
||||
metaLabel={entry.authorDisplayName}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
className="w-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyShelf text="公开广场暂时还没有新作品。" />
|
||||
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
@@ -1783,7 +1803,7 @@ export function RpgEntryHomeView({
|
||||
badge={activeCategoryGroup.tag}
|
||||
metaLabel={entry.authorDisplayName}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
className="h-[15rem] w-full min-w-0 sm:h-[16rem]"
|
||||
className="w-full min-w-0"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -2226,7 +2246,7 @@ export function RpgEntryHomeView({
|
||||
badge="推荐"
|
||||
metaLabel={describePublicGalleryCardKind(entry)}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
className="h-[16rem] w-full min-w-0"
|
||||
className="w-full min-w-0"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -2304,6 +2324,7 @@ export function RpgEntryHomeView({
|
||||
authorDisplayName: entry.authorDisplayName,
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
likeCount: 0,
|
||||
})
|
||||
}
|
||||
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
|
||||
@@ -2344,7 +2365,7 @@ export function RpgEntryHomeView({
|
||||
badge={describePublicGalleryCardKind(entry)}
|
||||
metaLabel={entry.authorDisplayName}
|
||||
onClick={() => onOpenGalleryDetail(entry)}
|
||||
className="h-[17rem] w-full min-w-0"
|
||||
className="w-full min-w-0"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
|
||||
import {
|
||||
@@ -30,6 +30,9 @@ export type PlatformPuzzleGalleryCard = {
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
themeTags: string[];
|
||||
playCount?: number;
|
||||
remixCount?: number;
|
||||
likeCount?: number;
|
||||
visibility: 'published';
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
@@ -47,6 +50,9 @@ export type PlatformBigFishGalleryCard = {
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
themeTags: string[];
|
||||
playCount?: number;
|
||||
remixCount?: number;
|
||||
likeCount?: number;
|
||||
visibility: 'published';
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
@@ -90,6 +96,9 @@ export function mapPuzzleWorkToPlatformGalleryCard(
|
||||
summaryText: work.summary,
|
||||
coverImageSrc: work.coverImageSrc,
|
||||
themeTags: work.themeTags,
|
||||
playCount: work.playCount ?? 0,
|
||||
remixCount: work.remixCount ?? 0,
|
||||
likeCount: work.likeCount ?? 0,
|
||||
visibility: 'published',
|
||||
publishedAt: work.publishedAt,
|
||||
updatedAt: work.updatedAt,
|
||||
@@ -111,12 +120,24 @@ export function mapBigFishWorkToPlatformGalleryCard(
|
||||
summaryText: work.summary,
|
||||
coverImageSrc: work.coverImageSrc,
|
||||
themeTags: ['大鱼', `${work.levelCount}级`],
|
||||
playCount: work.playCount ?? 0,
|
||||
remixCount: work.remixCount ?? 0,
|
||||
likeCount: work.likeCount ?? 0,
|
||||
visibility: 'published',
|
||||
publishedAt: work.updatedAt,
|
||||
publishedAt: work.publishedAt ?? work.updatedAt,
|
||||
updatedAt: work.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) {
|
||||
return {
|
||||
playCount: 'playCount' in entry ? (entry.playCount ?? 0) : 0,
|
||||
remixCount: 'remixCount' in entry ? (entry.remixCount ?? 0) : 0,
|
||||
likeCount: 'likeCount' in entry ? (entry.likeCount ?? 0) : 0,
|
||||
publishedAt: entry.publishedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) {
|
||||
if (entry.coverImageSrc) {
|
||||
return entry.coverImageSrc;
|
||||
|
||||
@@ -280,6 +280,7 @@ describe('RPG Agent 草稿恢复', () => {
|
||||
themeMode: 'tide',
|
||||
playableNpcCount: 0,
|
||||
landmarkCount: 0,
|
||||
likeCount: 0,
|
||||
},
|
||||
entries: [],
|
||||
});
|
||||
|
||||
@@ -161,6 +161,27 @@ export function useRpgEntryLibraryDetail(
|
||||
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
|
||||
);
|
||||
|
||||
const loadGalleryDetailEntry = useCallback(
|
||||
async (entry: CustomWorldGalleryCard) => {
|
||||
const detailEntry = await getRpgEntryWorldGalleryDetail(
|
||||
entry.ownerUserId,
|
||||
entry.profileId,
|
||||
);
|
||||
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,
|
||||
});
|
||||
return detailEntry;
|
||||
},
|
||||
[appendBrowseHistoryEntry],
|
||||
);
|
||||
|
||||
const openGalleryDetail = useCallback(
|
||||
async (entry: CustomWorldGalleryCard) => {
|
||||
setSelectionStage('detail');
|
||||
@@ -168,26 +189,13 @@ export function useRpgEntryLibraryDetail(
|
||||
setDetailError(null);
|
||||
|
||||
try {
|
||||
const detailEntry = await getRpgEntryWorldGalleryDetail(
|
||||
entry.ownerUserId,
|
||||
entry.profileId,
|
||||
);
|
||||
const detailEntry = await loadGalleryDetailEntry(entry);
|
||||
setSelectedDetailEntry(detailEntry);
|
||||
if (detailEntry.publicWorkCode?.trim()) {
|
||||
pushAppHistoryPath(
|
||||
buildPublicWorkDetailPath(detailEntry.publicWorkCode),
|
||||
);
|
||||
}
|
||||
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(
|
||||
@@ -197,7 +205,11 @@ export function useRpgEntryLibraryDetail(
|
||||
setIsDetailLoading(false);
|
||||
}
|
||||
},
|
||||
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
|
||||
[
|
||||
loadGalleryDetailEntry,
|
||||
setSelectedDetailEntry,
|
||||
setSelectionStage,
|
||||
],
|
||||
);
|
||||
|
||||
const openSavedCustomWorldEditor = useCallback(
|
||||
@@ -489,6 +501,7 @@ export function useRpgEntryLibraryDetail(
|
||||
isSelectedWorldOwned,
|
||||
openLibraryDetail,
|
||||
openGalleryDetail,
|
||||
loadGalleryDetailEntry,
|
||||
openSavedCustomWorldEditor,
|
||||
handleOpenCreationWork,
|
||||
handlePublishSelectedWorld,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {type Dispatch, type SetStateAction,useState} from 'react';
|
||||
import { type Dispatch, type SetStateAction, useState } from 'react';
|
||||
|
||||
import {
|
||||
generateCharacterPanelChatSuggestions,
|
||||
generateCharacterPanelChatSummary,
|
||||
streamCharacterPanelChatReply,
|
||||
} from '../../services/aiService';
|
||||
import type {StoryGenerationContext} from '../../services/aiTypes';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type {
|
||||
Character,
|
||||
CharacterChatRecord,
|
||||
@@ -47,12 +47,17 @@ export interface CharacterChatUi {
|
||||
sendDraft: () => void;
|
||||
}
|
||||
|
||||
export function getCharacterChatRecord(state: GameState, characterId: string): CharacterChatRecord {
|
||||
return state.characterChats[characterId] ?? {
|
||||
history: [],
|
||||
summary: '',
|
||||
updatedAt: null,
|
||||
};
|
||||
export function getCharacterChatRecord(
|
||||
state: GameState,
|
||||
characterId: string,
|
||||
): CharacterChatRecord {
|
||||
return (
|
||||
state.characterChats[characterId] ?? {
|
||||
history: [],
|
||||
summary: '',
|
||||
updatedAt: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function trimCharacterChatHistory(history: CharacterChatTurn[]) {
|
||||
@@ -66,7 +71,10 @@ export function buildLocalCharacterChatSummary(
|
||||
) {
|
||||
const latestTurns = history
|
||||
.slice(-4)
|
||||
.map(turn => `${turn.speaker === 'player' ? '玩家' : character.name}: ${turn.text}`)
|
||||
.map(
|
||||
(turn) =>
|
||||
`${turn.speaker === 'player' ? '玩家' : character.name}: ${turn.text}`,
|
||||
)
|
||||
.join(' ');
|
||||
|
||||
const currentSummary = latestTurns
|
||||
@@ -111,7 +119,9 @@ type CharacterChatTargetStatus = {
|
||||
affinity?: number | null;
|
||||
};
|
||||
|
||||
function buildTargetStatus(target: CharacterChatTarget): CharacterChatTargetStatus {
|
||||
function buildTargetStatus(
|
||||
target: CharacterChatTarget,
|
||||
): CharacterChatTargetStatus {
|
||||
return {
|
||||
roleLabel: target.roleLabel,
|
||||
hp: target.hp,
|
||||
@@ -129,9 +139,13 @@ export function useCharacterChatFlow({
|
||||
}: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
buildStoryContextFromState: (state: GameState) => StoryGenerationContext;
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: { currentStory?: null },
|
||||
) => StoryGenerationContext;
|
||||
}) {
|
||||
const [characterChatModal, setCharacterChatModal] = useState<CharacterChatModalState | null>(null);
|
||||
const [characterChatModal, setCharacterChatModal] =
|
||||
useState<CharacterChatModalState | null>(null);
|
||||
|
||||
const loadCharacterChatSuggestions = async (
|
||||
target: CharacterChatTarget,
|
||||
@@ -139,7 +153,7 @@ export function useCharacterChatFlow({
|
||||
summary: string,
|
||||
) => {
|
||||
if (!gameState.worldType || !gameState.playerCharacter) {
|
||||
setCharacterChatModal(current =>
|
||||
setCharacterChatModal((current) =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
@@ -151,7 +165,7 @@ export function useCharacterChatFlow({
|
||||
return;
|
||||
}
|
||||
|
||||
setCharacterChatModal(current =>
|
||||
setCharacterChatModal((current) =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
@@ -172,7 +186,7 @@ export function useCharacterChatFlow({
|
||||
buildTargetStatus(target),
|
||||
);
|
||||
|
||||
setCharacterChatModal(current =>
|
||||
setCharacterChatModal((current) =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
@@ -183,7 +197,7 @@ export function useCharacterChatFlow({
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate character chat suggestions:', error);
|
||||
setCharacterChatModal(current =>
|
||||
setCharacterChatModal((current) =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
@@ -213,7 +227,11 @@ export function useCharacterChatFlow({
|
||||
};
|
||||
|
||||
const sendCharacterChatDraft = async () => {
|
||||
if (!characterChatModal || !gameState.worldType || !gameState.playerCharacter) {
|
||||
if (
|
||||
!characterChatModal ||
|
||||
!gameState.worldType ||
|
||||
!gameState.playerCharacter
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -223,7 +241,10 @@ export function useCharacterChatFlow({
|
||||
}
|
||||
|
||||
const target = characterChatModal.target;
|
||||
const existingRecord = getCharacterChatRecord(gameState, target.character.id);
|
||||
const existingRecord = getCharacterChatRecord(
|
||||
gameState,
|
||||
target.character.id,
|
||||
);
|
||||
const baseMessages = trimCharacterChatHistory(characterChatModal.messages);
|
||||
const nextMessages = trimCharacterChatHistory([
|
||||
...baseMessages,
|
||||
@@ -233,12 +254,12 @@ export function useCharacterChatFlow({
|
||||
},
|
||||
]);
|
||||
|
||||
setCharacterChatModal(current =>
|
||||
setCharacterChatModal((current) =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
draft: '',
|
||||
messages: [...nextMessages, {speaker: 'character', text: ''}],
|
||||
messages: [...nextMessages, { speaker: 'character', text: '' }],
|
||||
suggestions: [],
|
||||
isSending: true,
|
||||
isLoadingSuggestions: true,
|
||||
@@ -261,12 +282,12 @@ export function useCharacterChatFlow({
|
||||
draft,
|
||||
buildTargetStatus(target),
|
||||
{
|
||||
onUpdate: text => {
|
||||
setCharacterChatModal(current =>
|
||||
onUpdate: (text) => {
|
||||
setCharacterChatModal((current) =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
messages: [...nextMessages, {speaker: 'character', text}],
|
||||
messages: [...nextMessages, { speaker: 'character', text }],
|
||||
}
|
||||
: current,
|
||||
);
|
||||
@@ -275,7 +296,7 @@ export function useCharacterChatFlow({
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to stream character panel chat reply:', error);
|
||||
setCharacterChatModal(current =>
|
||||
setCharacterChatModal((current) =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
@@ -283,10 +304,12 @@ export function useCharacterChatFlow({
|
||||
messages: baseMessages,
|
||||
isSending: false,
|
||||
isLoadingSuggestions: false,
|
||||
error: error instanceof Error ? error.message : '未知智能生成错误',
|
||||
suggestions: current.suggestions.length > 0
|
||||
? current.suggestions
|
||||
: buildLocalCharacterChatSuggestions(target.character),
|
||||
error:
|
||||
error instanceof Error ? error.message : '未知智能生成错误',
|
||||
suggestions:
|
||||
current.suggestions.length > 0
|
||||
? current.suggestions
|
||||
: buildLocalCharacterChatSuggestions(target.character),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
@@ -315,7 +338,11 @@ export function useCharacterChatFlow({
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to summarize character chat:', error);
|
||||
nextSummary = buildLocalCharacterChatSummary(target.character, finalMessages, existingRecord.summary);
|
||||
nextSummary = buildLocalCharacterChatSummary(
|
||||
target.character,
|
||||
finalMessages,
|
||||
existingRecord.summary,
|
||||
);
|
||||
}
|
||||
|
||||
const nextRecord: CharacterChatRecord = {
|
||||
@@ -324,10 +351,10 @@ export function useCharacterChatFlow({
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setGameState(current =>
|
||||
setGameState((current) =>
|
||||
buildCharacterChatRecordUpdate(current, target.character.id, nextRecord),
|
||||
);
|
||||
setCharacterChatModal(current =>
|
||||
setCharacterChatModal((current) =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
@@ -346,8 +373,14 @@ export function useCharacterChatFlow({
|
||||
modal: characterChatModal,
|
||||
openChat: openCharacterChat,
|
||||
closeChat: () => setCharacterChatModal(null),
|
||||
setDraft: (value: string) => setCharacterChatModal(current => (current ? {...current, draft: value} : current)),
|
||||
useSuggestion: (value: string) => setCharacterChatModal(current => (current ? {...current, draft: value} : current)),
|
||||
setDraft: (value: string) =>
|
||||
setCharacterChatModal((current) =>
|
||||
current ? { ...current, draft: value } : current,
|
||||
),
|
||||
useSuggestion: (value: string) =>
|
||||
setCharacterChatModal((current) =>
|
||||
current ? { ...current, draft: value } : current,
|
||||
),
|
||||
refreshSuggestions: () => {
|
||||
if (!characterChatModal) {
|
||||
return;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { isRpgRuntimeServerFunctionId } from '../../services/rpg-runtime';
|
||||
@@ -25,7 +22,12 @@ import {
|
||||
} from './storyChoiceRuntime';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
|
||||
type RuntimeStatsIncrements = Partial<Pick<GameState['runtimeStats'], 'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'>>;
|
||||
type RuntimeStatsIncrements = Partial<
|
||||
Pick<
|
||||
GameState['runtimeStats'],
|
||||
'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'
|
||||
>
|
||||
>;
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
@@ -63,6 +65,7 @@ type BuildStoryContextFromState = (
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
currentStory?: StoryMoment | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
@@ -115,7 +118,11 @@ export function createStoryChoiceActions({
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
setBattleReward: Dispatch<SetStateAction<BattleRewardSummary | null>>;
|
||||
buildResolvedChoiceState: (state: GameState, option: StoryOption, character: Character) => ResolvedChoiceState;
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
@@ -127,14 +134,24 @@ export function createStoryChoiceActions({
|
||||
buildStoryFromResponse: BuildStoryFromResponse;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
generateStoryForState: GenerateStoryForState;
|
||||
getAvailableOptionsForState: (state: GameState, character: Character) => StoryOption[] | null;
|
||||
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
getAvailableOptionsForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
buildNpcStory: BuildNpcStory;
|
||||
handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation;
|
||||
updateQuestLog: UpdateQuestLog;
|
||||
incrementRuntimeStats: IncrementRuntimeStats;
|
||||
getCampCompanionTravelScene?: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
|
||||
getCampCompanionTravelScene?: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => GameState['currentScenePreset'] | null;
|
||||
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
|
||||
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
|
||||
handleTreasureInteraction: (
|
||||
@@ -149,8 +166,12 @@ export function createStoryChoiceActions({
|
||||
) => { nextState: GameState; resultText: string } | null;
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption?: (option: StoryOption) => boolean;
|
||||
isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
|
||||
isNpcEncounter?: (encounter: GameState['currentEncounter']) => encounter is Encounter;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isNpcEncounter?: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
npcPreviewTalkFunctionId: string;
|
||||
fallbackCompanionName?: string;
|
||||
turnVisualMs: number;
|
||||
@@ -160,7 +181,10 @@ export function createStoryChoiceActions({
|
||||
if (!gameState.worldType || !character || isLoading) return;
|
||||
if (option.disabled) return;
|
||||
|
||||
if (currentStory?.deferredOptions?.length && isContinueAdventureOption(option)) {
|
||||
if (
|
||||
currentStory?.deferredOptions?.length &&
|
||||
isContinueAdventureOption(option)
|
||||
) {
|
||||
if (currentStory.deferredRuntimeState) {
|
||||
setGameState({
|
||||
...gameState,
|
||||
@@ -209,9 +233,9 @@ export function createStoryChoiceActions({
|
||||
}
|
||||
|
||||
if (
|
||||
option.functionId === npcPreviewTalkFunctionId
|
||||
&& isRegularNpcEncounter(gameState.currentEncounter)
|
||||
&& !gameState.npcInteractionActive
|
||||
option.functionId === npcPreviewTalkFunctionId &&
|
||||
isRegularNpcEncounter(gameState.currentEncounter) &&
|
||||
!gameState.npcInteractionActive
|
||||
) {
|
||||
setAiError(null);
|
||||
enterNpcInteraction(gameState.currentEncounter, option.actionText);
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import type {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
getCharacterById,
|
||||
} from '../../data/characterPresets';
|
||||
import { getCharacterById } from '../../data/characterPresets';
|
||||
import {
|
||||
buildNpcGiftModalState,
|
||||
buildNpcRecruitModalState,
|
||||
buildNpcTradeModalIntroText,
|
||||
} from '../../data/functionCatalog';
|
||||
import {
|
||||
buildNpcTradeTransactionActionText,
|
||||
} from '../../data/npcInteractions';
|
||||
import { buildNpcTradeTransactionActionText } from '../../data/npcInteractions';
|
||||
import { streamNpcRecruitDialogue } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type {
|
||||
@@ -52,6 +45,7 @@ type StoryNpcInteractionRuntime = {
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
currentStory?: StoryMoment | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
@@ -67,11 +61,16 @@ type StoryNpcInteractionRuntime = {
|
||||
streaming?: boolean,
|
||||
) => StoryMoment;
|
||||
generateStoryForState: GenerateStoryForState;
|
||||
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getTypewriterDelay: (char: string) => number;
|
||||
};
|
||||
|
||||
function buildOfflineRecruitDialogue(encounter: Encounter, releasedCompanionName?: string | null) {
|
||||
function buildOfflineRecruitDialogue(
|
||||
encounter: Encounter,
|
||||
releasedCompanionName?: string | null,
|
||||
) {
|
||||
const releaseLine = releasedCompanionName
|
||||
? `你:如果你愿意加入,我会先让${releasedCompanionName}暂时离队,把位置腾给你。`
|
||||
: '你:如果你愿意加入,我希望接下来能和你并肩行动。';
|
||||
@@ -92,10 +91,11 @@ function normalizeRecruitDialogue(
|
||||
const rawLines = dialogueText
|
||||
.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const refusalPattern = /拒绝|不加入|不答应|不能加入|不会加入|暂不加入|以后再说|改天再说|再考虑|观望|算了|不同路/u;
|
||||
const sanitizedLines = rawLines.filter(line => !refusalPattern.test(line));
|
||||
const refusalPattern =
|
||||
/拒绝|不加入|不答应|不能加入|不会加入|暂不加入|以后再说|改天再说|再考虑|观望|算了|不同路/u;
|
||||
const sanitizedLines = rawLines.filter((line) => !refusalPattern.test(line));
|
||||
const npcPrefix = `${encounter.npcName}:`;
|
||||
const playerPrefix = '你:';
|
||||
const releaseLine = releasedCompanionName
|
||||
@@ -108,14 +108,17 @@ function normalizeRecruitDialogue(
|
||||
`${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`,
|
||||
];
|
||||
|
||||
const workingLines = sanitizedLines.length > 0 ? sanitizedLines.slice(0, 5) : defaultLines.slice(0, 3);
|
||||
if (!workingLines.some(line => line.startsWith(playerPrefix))) {
|
||||
const workingLines =
|
||||
sanitizedLines.length > 0
|
||||
? sanitizedLines.slice(0, 5)
|
||||
: defaultLines.slice(0, 3);
|
||||
if (!workingLines.some((line) => line.startsWith(playerPrefix))) {
|
||||
const firstDefaultLine = defaultLines[0];
|
||||
if (firstDefaultLine) {
|
||||
workingLines.unshift(firstDefaultLine);
|
||||
}
|
||||
}
|
||||
if (!workingLines.some(line => line.startsWith(npcPrefix))) {
|
||||
if (!workingLines.some((line) => line.startsWith(npcPrefix))) {
|
||||
const secondDefaultLine = defaultLines[1];
|
||||
if (secondDefaultLine) {
|
||||
workingLines.push(secondDefaultLine);
|
||||
@@ -125,9 +128,9 @@ function normalizeRecruitDialogue(
|
||||
const acceptanceLine = `${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`;
|
||||
const lastWorkingLine = workingLines[workingLines.length - 1];
|
||||
if (
|
||||
workingLines.length === 0
|
||||
|| !lastWorkingLine?.startsWith(npcPrefix)
|
||||
|| refusalPattern.test(lastWorkingLine)
|
||||
workingLines.length === 0 ||
|
||||
!lastWorkingLine?.startsWith(npcPrefix) ||
|
||||
refusalPattern.test(lastWorkingLine)
|
||||
) {
|
||||
workingLines.push(acceptanceLine);
|
||||
} else {
|
||||
@@ -158,11 +161,16 @@ export function useStoryNpcInteractionFlow({
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
getNpcEncounterKey: (encounter: Encounter) => string;
|
||||
getResolvedNpcState: (state: GameState, encounter: Encounter) => GameState['npcStates'][string];
|
||||
getResolvedNpcState: (
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
) => GameState['npcStates'][string];
|
||||
updateNpcState: (
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
updater: (npcState: GameState['npcStates'][string]) => GameState['npcStates'][string],
|
||||
updater: (
|
||||
npcState: GameState['npcStates'][string],
|
||||
) => GameState['npcStates'][string],
|
||||
) => GameState;
|
||||
cloneInventoryItemForOwner: (
|
||||
item: InventoryItem,
|
||||
@@ -173,7 +181,9 @@ export function useStoryNpcInteractionFlow({
|
||||
}) {
|
||||
const [tradeModal, setTradeModal] = useState<TradeModalState | null>(null);
|
||||
const [giftModal, setGiftModal] = useState<GiftModalState | null>(null);
|
||||
const [recruitModal, setRecruitModal] = useState<RecruitModalState | null>(null);
|
||||
const [recruitModal, setRecruitModal] = useState<RecruitModalState | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const resolveRecruitmentOnServer = async (params: {
|
||||
encounter: Encounter;
|
||||
@@ -221,7 +231,10 @@ export function useStoryNpcInteractionFlow({
|
||||
runtime.setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve npc recruit action on the server:', error);
|
||||
console.error(
|
||||
'Failed to resolve npc recruit action on the server:',
|
||||
error,
|
||||
);
|
||||
runtime.setAiError(
|
||||
error instanceof Error ? error.message : 'NPC 招募执行失败',
|
||||
);
|
||||
@@ -245,9 +258,11 @@ export function useStoryNpcInteractionFlow({
|
||||
|
||||
const releasedCompanionName = releasedNpcId
|
||||
? (() => {
|
||||
const releasedCompanion = gameState.companions.find(item => item.npcId === releasedNpcId);
|
||||
const releasedCompanion = gameState.companions.find(
|
||||
(item) => item.npcId === releasedNpcId,
|
||||
);
|
||||
return releasedCompanion?.characterId
|
||||
? getCharacterById(releasedCompanion.characterId)?.name ?? null
|
||||
? (getCharacterById(releasedCompanion.characterId)?.name ?? null)
|
||||
: null;
|
||||
})()
|
||||
: null;
|
||||
@@ -261,7 +276,9 @@ export function useStoryNpcInteractionFlow({
|
||||
setRecruitModal(null);
|
||||
runtime.setAiError(null);
|
||||
runtime.setIsLoading(true);
|
||||
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true));
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true),
|
||||
);
|
||||
|
||||
let dialogueText = '';
|
||||
let streamedTargetText = '';
|
||||
@@ -269,20 +286,32 @@ export function useStoryNpcInteractionFlow({
|
||||
let streamCompleted = false;
|
||||
|
||||
const typewriterPromise = (async () => {
|
||||
while (!streamCompleted || displayedText.length < streamedTargetText.length) {
|
||||
while (
|
||||
!streamCompleted ||
|
||||
displayedText.length < streamedTargetText.length
|
||||
) {
|
||||
if (displayedText.length >= streamedTargetText.length) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 40));
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 40));
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextChar = streamedTargetText[displayedText.length];
|
||||
if (!nextChar) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 40));
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 40));
|
||||
continue;
|
||||
}
|
||||
displayedText += nextChar;
|
||||
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, displayedText, [], true));
|
||||
await new Promise(resolve => window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar)));
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildDialogueStoryMoment(
|
||||
encounter.npcName,
|
||||
displayedText,
|
||||
[],
|
||||
true,
|
||||
),
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar)),
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -294,12 +323,13 @@ export function useStoryNpcInteractionFlow({
|
||||
runtime.getStoryGenerationHostileNpcs(provisionalState),
|
||||
gameState.storyHistory,
|
||||
runtime.buildStoryContextFromState(provisionalState, {
|
||||
currentStory: runtime.currentStory,
|
||||
lastFunctionId: 'npc_recruit',
|
||||
}),
|
||||
actionText,
|
||||
recruitPromptSummary,
|
||||
{
|
||||
onUpdate: text => {
|
||||
onUpdate: (text) => {
|
||||
streamedTargetText = text;
|
||||
},
|
||||
},
|
||||
@@ -311,17 +341,30 @@ export function useStoryNpcInteractionFlow({
|
||||
streamCompleted = true;
|
||||
await typewriterPromise;
|
||||
console.error('Failed to stream recruit dialogue:', error);
|
||||
dialogueText = displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName);
|
||||
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
dialogueText =
|
||||
displayedText ||
|
||||
buildOfflineRecruitDialogue(encounter, releasedCompanionName);
|
||||
runtime.setAiError(
|
||||
error instanceof Error ? error.message : '未知智能生成错误',
|
||||
);
|
||||
}
|
||||
|
||||
const finalDialogueText = normalizeRecruitDialogue(
|
||||
encounter,
|
||||
dialogueText || displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName),
|
||||
dialogueText ||
|
||||
displayedText ||
|
||||
buildOfflineRecruitDialogue(encounter, releasedCompanionName),
|
||||
releasedCompanionName,
|
||||
);
|
||||
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, finalDialogueText, [], false));
|
||||
await new Promise(resolve => window.setTimeout(resolve, 260));
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildDialogueStoryMoment(
|
||||
encounter.npcName,
|
||||
finalDialogueText,
|
||||
[],
|
||||
false,
|
||||
),
|
||||
);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 260));
|
||||
await resolveRecruitmentOnServer({
|
||||
encounter,
|
||||
actionText,
|
||||
@@ -334,8 +377,8 @@ export function useStoryNpcInteractionFlow({
|
||||
mode: 'buy' | 'sell',
|
||||
): RuntimeNpcTradeItemView[] =>
|
||||
mode === 'buy'
|
||||
? gameState.runtimeNpcInteraction?.trade.buyItems ?? []
|
||||
: gameState.runtimeNpcInteraction?.trade.sellItems ?? [];
|
||||
? (gameState.runtimeNpcInteraction?.trade.buyItems ?? [])
|
||||
: (gameState.runtimeNpcInteraction?.trade.sellItems ?? []);
|
||||
|
||||
const findRuntimeTradeItem = (modal: TradeModalState) => {
|
||||
const itemId =
|
||||
@@ -360,27 +403,25 @@ export function useStoryNpcInteractionFlow({
|
||||
};
|
||||
|
||||
const openTradeModal = (encounter: Encounter, actionText: string) => {
|
||||
setTradeModal(
|
||||
{
|
||||
encounter,
|
||||
actionText,
|
||||
introText: buildNpcTradeModalIntroText(encounter),
|
||||
mode: 'buy',
|
||||
selectedNpcItemId:
|
||||
gameState.runtimeNpcInteraction?.trade.buyItems.find(
|
||||
(item) => item.canSubmit,
|
||||
)?.itemId ??
|
||||
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
|
||||
null,
|
||||
selectedPlayerItemId:
|
||||
gameState.runtimeNpcInteraction?.trade.sellItems.find(
|
||||
(item) => item.canSubmit,
|
||||
)?.itemId ??
|
||||
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
|
||||
null,
|
||||
selectedQuantity: 1,
|
||||
},
|
||||
);
|
||||
setTradeModal({
|
||||
encounter,
|
||||
actionText,
|
||||
introText: buildNpcTradeModalIntroText(encounter),
|
||||
mode: 'buy',
|
||||
selectedNpcItemId:
|
||||
gameState.runtimeNpcInteraction?.trade.buyItems.find(
|
||||
(item) => item.canSubmit,
|
||||
)?.itemId ??
|
||||
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
|
||||
null,
|
||||
selectedPlayerItemId:
|
||||
gameState.runtimeNpcInteraction?.trade.sellItems.find(
|
||||
(item) => item.canSubmit,
|
||||
)?.itemId ??
|
||||
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
|
||||
null,
|
||||
selectedQuantity: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const openGiftModal = (encounter: Encounter, actionText: string) => {
|
||||
@@ -390,17 +431,13 @@ export function useStoryNpcInteractionFlow({
|
||||
gameState.runtimeNpcInteraction?.gift.items[0]?.itemId ??
|
||||
null;
|
||||
|
||||
setGiftModal(
|
||||
buildNpcGiftModalState(
|
||||
encounter,
|
||||
actionText,
|
||||
selectedItemId,
|
||||
),
|
||||
);
|
||||
setGiftModal(buildNpcGiftModalState(encounter, actionText, selectedItemId));
|
||||
};
|
||||
|
||||
const openRecruitModal = (encounter: Encounter, actionText: string) => {
|
||||
setRecruitModal(buildNpcRecruitModalState(gameState, encounter, actionText));
|
||||
setRecruitModal(
|
||||
buildNpcRecruitModalState(gameState, encounter, actionText),
|
||||
);
|
||||
};
|
||||
|
||||
const clearNpcInteractionUi = () => {
|
||||
@@ -448,7 +485,10 @@ export function useStoryNpcInteractionFlow({
|
||||
runtime.setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve npc runtime action on the server:', error);
|
||||
console.error(
|
||||
'Failed to resolve npc runtime action on the server:',
|
||||
error,
|
||||
);
|
||||
runtime.setAiError(
|
||||
error instanceof Error ? error.message : 'NPC 交互执行失败',
|
||||
);
|
||||
@@ -514,50 +554,62 @@ export function useStoryNpcInteractionFlow({
|
||||
tradeModal,
|
||||
giftModal,
|
||||
recruitModal,
|
||||
setTradeMode: (mode: 'buy' | 'sell') => setTradeModal(current => {
|
||||
if (!current) return current;
|
||||
return {
|
||||
...current,
|
||||
mode,
|
||||
selectedNpcItemId:
|
||||
current.selectedNpcItemId ??
|
||||
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
|
||||
null,
|
||||
selectedPlayerItemId:
|
||||
current.selectedPlayerItemId ??
|
||||
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
|
||||
null,
|
||||
selectedQuantity: 1,
|
||||
};
|
||||
}),
|
||||
selectTradeNpcItem: (itemId: string) => setTradeModal(current => {
|
||||
if (!current) return current;
|
||||
return {
|
||||
...current,
|
||||
selectedNpcItemId: itemId,
|
||||
selectedQuantity: 1,
|
||||
};
|
||||
}),
|
||||
selectTradePlayerItem: (itemId: string) => setTradeModal(current => {
|
||||
if (!current) return current;
|
||||
return {
|
||||
...current,
|
||||
selectedPlayerItemId: itemId,
|
||||
selectedQuantity: 1,
|
||||
};
|
||||
}),
|
||||
setTradeQuantity: (quantity: number) => setTradeModal(current => current
|
||||
? {
|
||||
setTradeMode: (mode: 'buy' | 'sell') =>
|
||||
setTradeModal((current) => {
|
||||
if (!current) return current;
|
||||
return {
|
||||
...current,
|
||||
selectedQuantity: normalizeTradeQuantity(quantity),
|
||||
}
|
||||
: current),
|
||||
mode,
|
||||
selectedNpcItemId:
|
||||
current.selectedNpcItemId ??
|
||||
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
|
||||
null,
|
||||
selectedPlayerItemId:
|
||||
current.selectedPlayerItemId ??
|
||||
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
|
||||
null,
|
||||
selectedQuantity: 1,
|
||||
};
|
||||
}),
|
||||
selectTradeNpcItem: (itemId: string) =>
|
||||
setTradeModal((current) => {
|
||||
if (!current) return current;
|
||||
return {
|
||||
...current,
|
||||
selectedNpcItemId: itemId,
|
||||
selectedQuantity: 1,
|
||||
};
|
||||
}),
|
||||
selectTradePlayerItem: (itemId: string) =>
|
||||
setTradeModal((current) => {
|
||||
if (!current) return current;
|
||||
return {
|
||||
...current,
|
||||
selectedPlayerItemId: itemId,
|
||||
selectedQuantity: 1,
|
||||
};
|
||||
}),
|
||||
setTradeQuantity: (quantity: number) =>
|
||||
setTradeModal((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
selectedQuantity: normalizeTradeQuantity(quantity),
|
||||
}
|
||||
: current,
|
||||
),
|
||||
closeTradeModal: () => setTradeModal(null),
|
||||
confirmTrade,
|
||||
selectGiftItem: (itemId: string) => setGiftModal(current => current ? { ...current, selectedItemId: itemId } : current),
|
||||
selectGiftItem: (itemId: string) =>
|
||||
setGiftModal((current) =>
|
||||
current ? { ...current, selectedItemId: itemId } : current,
|
||||
),
|
||||
closeGiftModal: () => setGiftModal(null),
|
||||
confirmGift,
|
||||
selectRecruitRelease: (npcId: string) => setRecruitModal(current => current ? { ...current, selectedReleaseNpcId: npcId } : current),
|
||||
selectRecruitRelease: (npcId: string) =>
|
||||
setRecruitModal((current) =>
|
||||
current ? { ...current, selectedReleaseNpcId: npcId } : current,
|
||||
),
|
||||
closeRecruitModal: () => setRecruitModal(null),
|
||||
confirmRecruit: () => {
|
||||
if (!recruitModal) return;
|
||||
|
||||
@@ -55,6 +55,7 @@ type BuildStoryContextFromState = (
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
currentStory?: StoryMoment | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
@@ -161,7 +162,10 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
params.option,
|
||||
params.character,
|
||||
);
|
||||
if (resolvedChoice.optionKind === 'battle' || resolvedChoice.optionKind === 'escape') {
|
||||
if (
|
||||
resolvedChoice.optionKind === 'battle' ||
|
||||
resolvedChoice.optionKind === 'escape'
|
||||
) {
|
||||
throw new Error(
|
||||
`战斗与逃脱动作必须由后端结算,禁止进入本地 continuation:${params.option.functionId}`,
|
||||
);
|
||||
@@ -194,6 +198,7 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
observeSignsRequested:
|
||||
params.option.functionId === 'idle_observe_signs',
|
||||
recentActionResult: combatResolutionContextText,
|
||||
currentStory: params.currentStory,
|
||||
}),
|
||||
projectedAvailableOptions
|
||||
? { availableOptions: projectedAvailableOptions }
|
||||
@@ -239,11 +244,11 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
lastObserveSignsSceneId:
|
||||
params.option.functionId === 'idle_observe_signs'
|
||||
? (afterSequence.currentScenePreset?.id ?? null)
|
||||
: afterSequence.lastObserveSignsSceneId ?? null,
|
||||
: (afterSequence.lastObserveSignsSceneId ?? null),
|
||||
lastObserveSignsReport:
|
||||
params.option.functionId === 'idle_observe_signs'
|
||||
? response.storyText
|
||||
: afterSequence.lastObserveSignsReport ?? null,
|
||||
: (afterSequence.lastObserveSignsReport ?? null),
|
||||
storyHistory: nextHistory,
|
||||
},
|
||||
{},
|
||||
|
||||
@@ -22,6 +22,7 @@ export type ChoiceRuntimeController = {
|
||||
recentActionResult?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
currentStory?: StoryMoment | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AnimationState, type GameState, type StoryMoment } from '../../types';
|
||||
import { buildStoryContextFromState } from './storyContextBuilder';
|
||||
|
||||
function createState(overrides: Partial<GameState> = {}): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 3,
|
||||
runtimeMode: 'play',
|
||||
runtimePersistenceDisabled: false,
|
||||
playerHp: 30,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 12,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
animationState: AnimationState.IDLE,
|
||||
playerSkillCooldowns: {},
|
||||
currentScenePreset: {
|
||||
id: 'forest-trail',
|
||||
name: '林间小径',
|
||||
description: '风声穿过树梢。',
|
||||
},
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('storyContextBuilder', () => {
|
||||
it('keeps normal play context lightweight', () => {
|
||||
const context = buildStoryContextFromState(createState());
|
||||
|
||||
expect(context.runtimeSessionId).toBe('runtime-main');
|
||||
expect(context.runtimeSnapshot).toBeUndefined();
|
||||
});
|
||||
|
||||
it('attaches transient snapshot for disabled persistence runtime', () => {
|
||||
const state = createState({
|
||||
runtimeSessionId: 'runtime-preview',
|
||||
runtimePersistenceDisabled: true,
|
||||
});
|
||||
const currentStory: StoryMoment = {
|
||||
text: '断桥客站在风口,等你先开口。',
|
||||
options: [],
|
||||
};
|
||||
|
||||
const context = buildStoryContextFromState(state, { currentStory });
|
||||
|
||||
expect(context.runtimeSnapshot).toEqual({
|
||||
bottomTab: 'adventure',
|
||||
gameState: state,
|
||||
currentStory,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type { GameState } from '../../types';
|
||||
import type { GameState, StoryMoment } from '../../types';
|
||||
|
||||
export type StoryContextBuilderExtras = {
|
||||
pendingSceneEncounter?: boolean;
|
||||
@@ -9,8 +9,17 @@ export type StoryContextBuilderExtras = {
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
currentStory?: StoryMoment | null;
|
||||
};
|
||||
|
||||
function shouldAttachTransientRuntimeSnapshot(state: GameState) {
|
||||
return (
|
||||
state.runtimePersistenceDisabled === true ||
|
||||
state.runtimeMode === 'preview' ||
|
||||
state.runtimeMode === 'test'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时 story prompt context 的正式投影已经迁到 server-rs。
|
||||
* 前端只保留 session 与少量请求元信息,方便旧调用面继续复用同一个函数签名。
|
||||
@@ -22,6 +31,15 @@ export function buildStoryContextFromState(
|
||||
return {
|
||||
runtimeSessionId: state.runtimeSessionId ?? null,
|
||||
runtimeActionVersion: state.runtimeActionVersion,
|
||||
runtimeSnapshot: shouldAttachTransientRuntimeSnapshot(state)
|
||||
? {
|
||||
// 中文注释:禁存运行态不会写入正式 runtime_snapshot,
|
||||
// 聊天/续写请求需要携带本地临时快照供 server-rs 投影上下文。
|
||||
bottomTab: 'adventure',
|
||||
gameState: state,
|
||||
currentStory: extras.currentStory ?? null,
|
||||
}
|
||||
: undefined,
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
playerMana: state.playerMana,
|
||||
|
||||
@@ -24,6 +24,7 @@ type StoryInteractionCoordinatorParams = {
|
||||
lastFunctionId?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
currentStory?: StoryMoment | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
@@ -18,6 +18,7 @@ type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
currentStory?: StoryMoment | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
@@ -163,8 +164,11 @@ export async function generateStoryForStateWithCoordinator(params: {
|
||||
const context = params.choice
|
||||
? params.buildStoryContextFromState(params.state, {
|
||||
lastFunctionId: params.lastFunctionId,
|
||||
currentStory: params.currentStory,
|
||||
})
|
||||
: params.buildStoryContextFromState(params.state);
|
||||
: params.buildStoryContextFromState(params.state, {
|
||||
currentStory: params.currentStory,
|
||||
});
|
||||
const response = params.choice
|
||||
? await params.requestNextStep(
|
||||
worldType,
|
||||
|
||||
@@ -63,6 +63,7 @@ type BuildStoryContextExtras = {
|
||||
lastFunctionId?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
currentStory?: StoryMoment | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
};
|
||||
|
||||
@@ -259,7 +260,9 @@ export function createStoryNpcEncounterActions({
|
||||
},
|
||||
});
|
||||
|
||||
const buildPendingQuestOfferOptions = (encounter: Encounter): StoryOption[] => [
|
||||
const buildPendingQuestOfferOptions = (
|
||||
encounter: Encounter,
|
||||
): StoryOption[] => [
|
||||
buildNpcChatQuestOfferOption(
|
||||
encounter,
|
||||
NPC_CHAT_QUEST_OFFER_FUNCTION_IDS.view,
|
||||
@@ -336,8 +339,7 @@ export function createStoryNpcEncounterActions({
|
||||
? `你们刚结束一场切磋,${params.resultText}`
|
||||
: `你刚赢下这场交锋,${params.resultText}`,
|
||||
logLines,
|
||||
battleOutcome:
|
||||
params.battleMode === 'spar' ? 'spar_complete' : 'victory',
|
||||
battleOutcome: params.battleMode === 'spar' ? 'spar_complete' : 'victory',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -353,7 +355,10 @@ export function createStoryNpcEncounterActions({
|
||||
return false;
|
||||
}
|
||||
|
||||
const reopenedNpcState = getResolvedNpcState(params.nextState, params.encounter);
|
||||
const reopenedNpcState = getResolvedNpcState(
|
||||
params.nextState,
|
||||
params.encounter,
|
||||
);
|
||||
const baseStory = buildNpcStory(
|
||||
params.nextState,
|
||||
playerCharacter,
|
||||
@@ -365,7 +370,10 @@ export function createStoryNpcEncounterActions({
|
||||
);
|
||||
const fallbackChatOption =
|
||||
baseChatOptions[0] ??
|
||||
buildNpcChatOption(params.encounter, `继续和${params.encounter.npcName}对话`);
|
||||
buildNpcChatOption(
|
||||
params.encounter,
|
||||
`继续和${params.encounter.npcName}对话`,
|
||||
);
|
||||
const combatContext = buildNpcBattleChatCombatContext({
|
||||
battleMode: params.battleMode,
|
||||
resultText: params.resultText,
|
||||
@@ -487,7 +495,9 @@ export function createStoryNpcEncounterActions({
|
||||
);
|
||||
const restoredEncounter =
|
||||
state.sparReturnEncounter ??
|
||||
(state.currentEncounter?.kind === 'npc' ? state.currentEncounter : null) ??
|
||||
(state.currentEncounter?.kind === 'npc'
|
||||
? state.currentEncounter
|
||||
: null) ??
|
||||
activeBattleHostiles[0]?.encounter ??
|
||||
({
|
||||
id: battleNpcId,
|
||||
@@ -756,7 +766,8 @@ export function createStoryNpcEncounterActions({
|
||||
functionId: option.functionId,
|
||||
actionText: option.actionText,
|
||||
detailText: option.detailText ?? null,
|
||||
action: option.interaction?.kind === 'npc' ? option.interaction.action : null,
|
||||
action:
|
||||
option.interaction?.kind === 'npc' ? option.interaction.action : null,
|
||||
}));
|
||||
const isHostileChat =
|
||||
directive?.isHostileChat === true ||
|
||||
@@ -883,10 +894,9 @@ export function createStoryNpcEncounterActions({
|
||||
encounter: Encounter,
|
||||
playerCharacter: Character,
|
||||
) => {
|
||||
const resolvedStateOptions =
|
||||
collapseNpcChatOptions(
|
||||
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
|
||||
);
|
||||
const resolvedStateOptions = collapseNpcChatOptions(
|
||||
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
|
||||
);
|
||||
const currentStoryOptions = currentStory?.options ?? [];
|
||||
const currentChatOptions = currentStoryOptions.filter((option) =>
|
||||
isNpcChatOptionForEncounter(option, encounter),
|
||||
@@ -1105,7 +1115,9 @@ export function createStoryNpcEncounterActions({
|
||||
};
|
||||
};
|
||||
|
||||
const buildHostileNpcEscapeOptions = (character: Character): StoryOption[] => {
|
||||
const buildHostileNpcEscapeOptions = (
|
||||
character: Character,
|
||||
): StoryOption[] => {
|
||||
const currentScene = gameState.currentScenePreset;
|
||||
const worldType = gameState.worldType;
|
||||
const options: StoryOption[] = [];
|
||||
@@ -1120,34 +1132,24 @@ export function createStoryNpcEncounterActions({
|
||||
seenSceneIds.add(connection.sceneId);
|
||||
const targetScene = getScenePresetById(worldType, connection.sceneId);
|
||||
const targetSceneName =
|
||||
targetScene?.name ??
|
||||
connection.summary?.trim() ??
|
||||
connection.sceneId;
|
||||
targetScene?.name ?? connection.summary?.trim() ?? connection.sceneId;
|
||||
|
||||
options.push(
|
||||
buildHostileNpcEscapeOption(
|
||||
character,
|
||||
`逃往${targetSceneName}`,
|
||||
{
|
||||
targetSceneId: connection.sceneId,
|
||||
escapeTargetSceneId: connection.sceneId,
|
||||
escapeEntry: 'from_left',
|
||||
},
|
||||
),
|
||||
buildHostileNpcEscapeOption(character, `逃往${targetSceneName}`, {
|
||||
targetSceneId: connection.sceneId,
|
||||
escapeTargetSceneId: connection.sceneId,
|
||||
escapeEntry: 'from_left',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
options.push(
|
||||
buildHostileNpcEscapeOption(
|
||||
character,
|
||||
'逃回当前场景起点',
|
||||
{
|
||||
targetSceneId: currentScene.id,
|
||||
escapeTargetSceneId: currentScene.id,
|
||||
escapeReturnToSceneStart: true,
|
||||
escapeEntry: 'from_left',
|
||||
},
|
||||
),
|
||||
buildHostileNpcEscapeOption(character, '逃回当前场景起点', {
|
||||
targetSceneId: currentScene.id,
|
||||
escapeTargetSceneId: currentScene.id,
|
||||
escapeReturnToSceneStart: true,
|
||||
escapeEntry: 'from_left',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1326,6 +1328,7 @@ export function createStoryNpcEncounterActions({
|
||||
getStoryGenerationHostileNpcs(gameState),
|
||||
gameState.storyHistory,
|
||||
buildStoryContextFromState(gameState, {
|
||||
currentStory,
|
||||
lastFunctionId: 'npc_chat',
|
||||
...openingCampContext,
|
||||
encounterNpcStateOverride: npcState,
|
||||
@@ -1484,6 +1487,7 @@ export function createStoryNpcEncounterActions({
|
||||
getStoryGenerationHostileNpcs(gameState),
|
||||
gameState.storyHistory,
|
||||
buildStoryContextFromState(gameState, {
|
||||
currentStory,
|
||||
lastFunctionId: 'npc_chat',
|
||||
...openingCampContext,
|
||||
encounterNpcStateOverride: npcState,
|
||||
@@ -1594,17 +1598,17 @@ export function createStoryNpcEncounterActions({
|
||||
chatDirective.remainingTurns ??
|
||||
null,
|
||||
limitReason: chatDirective.limitReason ?? null,
|
||||
terminationMode: chatDirective.terminationMode ?? null,
|
||||
terminationReason:
|
||||
terminationMode: chatDirective.terminationMode ?? null,
|
||||
terminationReason:
|
||||
chatTurn.chatDirective?.terminationReason ??
|
||||
chatDirective.terminationReason ??
|
||||
null,
|
||||
isHostileChat: chatDirective.isHostileChat ?? false,
|
||||
closingMode:
|
||||
chatTurn.chatDirective?.closingMode ??
|
||||
chatDirective.closingMode ??
|
||||
'free',
|
||||
forceExitAfterTurn:
|
||||
isHostileChat: chatDirective.isHostileChat ?? false,
|
||||
closingMode:
|
||||
chatTurn.chatDirective?.closingMode ??
|
||||
chatDirective.closingMode ??
|
||||
'free',
|
||||
forceExitAfterTurn:
|
||||
chatTurn.chatDirective?.forceExit ??
|
||||
chatDirective.forceExitAfterTurn ??
|
||||
false,
|
||||
@@ -1615,9 +1619,7 @@ export function createStoryNpcEncounterActions({
|
||||
const pendingQuestIntroText =
|
||||
chatTurn.pendingQuestOffer?.introText?.trim() || '';
|
||||
if (shouldForceExitAfterTurn) {
|
||||
const closingDialogue = [
|
||||
...nextDialogue,
|
||||
];
|
||||
const closingDialogue = [...nextDialogue];
|
||||
const shouldUseHostileClosureOptions =
|
||||
shouldUseHostileNpcChatClosureOptions(
|
||||
resolvedChatDirective,
|
||||
@@ -1756,13 +1758,9 @@ export function createStoryNpcEncounterActions({
|
||||
return false;
|
||||
}
|
||||
|
||||
void handleNpcChatTurn(
|
||||
encounter,
|
||||
`我先结束这轮交谈,继续往前走。`,
|
||||
{
|
||||
forcePlayerExit: true,
|
||||
},
|
||||
);
|
||||
void handleNpcChatTurn(encounter, `我先结束这轮交谈,继续往前走。`, {
|
||||
forcePlayerExit: true,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -1814,7 +1812,10 @@ export function createStoryNpcEncounterActions({
|
||||
},
|
||||
} satisfies StoryOption);
|
||||
|
||||
if (!currentStory?.npcChatState && !npcState.firstMeaningfulContactResolved) {
|
||||
if (
|
||||
!currentStory?.npcChatState &&
|
||||
!npcState.firstMeaningfulContactResolved
|
||||
) {
|
||||
void startNpcInitiatedOpening(
|
||||
encounter,
|
||||
seedChatOption,
|
||||
|
||||
+375
@@ -790,6 +790,22 @@ body {
|
||||
filter: brightness(0.98);
|
||||
}
|
||||
|
||||
.platform-public-work-card {
|
||||
background: var(--platform-subpanel-fill);
|
||||
}
|
||||
|
||||
.platform-public-work-card__cover {
|
||||
background: color-mix(in srgb, var(--platform-panel-fill-soft) 86%, #000 14%);
|
||||
}
|
||||
|
||||
.platform-public-work-card__body {
|
||||
background: color-mix(in srgb, var(--platform-subpanel-fill) 92%, #000 8%);
|
||||
}
|
||||
|
||||
.platform-public-work-card__likes {
|
||||
min-width: 3.2rem;
|
||||
}
|
||||
|
||||
.platform-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1243,6 +1259,66 @@ body {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.platform-mobile-home-stage {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.platform-mobile-home-stage .platform-desktop-search {
|
||||
border-radius: 9999px;
|
||||
padding: 0.64rem 0.9rem;
|
||||
}
|
||||
|
||||
.platform-mobile-home-channelbar {
|
||||
margin-right: -0.25rem;
|
||||
padding-left: 0.08rem;
|
||||
}
|
||||
|
||||
.platform-mobile-home-channel {
|
||||
position: relative;
|
||||
min-height: 2rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.platform-mobile-home-channel--active {
|
||||
color: var(--platform-text-strong);
|
||||
}
|
||||
|
||||
.platform-mobile-home-channel--active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0.1rem;
|
||||
height: 0.16rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--platform-warm-text);
|
||||
}
|
||||
|
||||
.platform-mobile-home-feed {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.platform-public-work-card {
|
||||
width: 100%;
|
||||
border-radius: 0.9rem;
|
||||
background: color-mix(in srgb, var(--platform-subpanel-fill) 82%, #050506 18%);
|
||||
}
|
||||
|
||||
.platform-public-work-card__cover {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.platform-public-work-card__body {
|
||||
min-height: 5.4rem;
|
||||
padding: 0.68rem 0.76rem 0.72rem;
|
||||
background: color-mix(in srgb, var(--platform-subpanel-fill) 78%, #050506 22%);
|
||||
}
|
||||
|
||||
.platform-mobile-hero-secondary {
|
||||
display: none;
|
||||
}
|
||||
@@ -2959,6 +3035,305 @@ button {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.platform-work-detail {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #151515;
|
||||
color: #f7f7f7;
|
||||
}
|
||||
|
||||
.platform-work-detail__topbar {
|
||||
display: grid;
|
||||
grid-template-columns: 3rem minmax(0, 1fr) 3rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: calc(env(safe-area-inset-top, 0px) + 0.7rem) 1rem 0.75rem;
|
||||
background: #020202;
|
||||
}
|
||||
|
||||
.platform-work-detail__title {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.platform-work-detail__icon-button {
|
||||
display: inline-flex;
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: #f8f8f8;
|
||||
}
|
||||
|
||||
.platform-work-detail__icon-button:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.platform-work-detail__scroll {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.platform-work-detail__cover {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: clamp(17rem, 54vh, 28rem);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: #0c0c0c;
|
||||
}
|
||||
|
||||
.platform-work-detail__cover-blur {
|
||||
position: absolute;
|
||||
inset: -1rem;
|
||||
height: calc(100% + 2rem);
|
||||
width: calc(100% + 2rem);
|
||||
object-fit: cover;
|
||||
opacity: 0.55;
|
||||
filter: blur(24px);
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.platform-work-detail__cover::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: '';
|
||||
background:
|
||||
linear-gradient(180deg, rgb(0 0 0 / 5%), rgb(0 0 0 / 42%)),
|
||||
radial-gradient(circle at center, transparent 0 34%, rgb(0 0 0 / 36%) 100%);
|
||||
}
|
||||
|
||||
.platform-work-detail__cover-image {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-height: min(72vw, 24rem);
|
||||
width: min(58vw, 22rem);
|
||||
max-width: 82%;
|
||||
object-fit: contain;
|
||||
box-shadow: 0 1.6rem 4rem rgb(0 0 0 / 38%);
|
||||
}
|
||||
|
||||
.platform-work-detail__cover-fallback {
|
||||
aspect-ratio: 3 / 4;
|
||||
width: min(54vw, 20rem);
|
||||
border-radius: 0.8rem;
|
||||
background:
|
||||
linear-gradient(135deg, rgb(20 184 166 / 55%), rgb(250 204 21 / 34%)),
|
||||
#202020;
|
||||
}
|
||||
|
||||
.platform-work-detail__summary {
|
||||
border-bottom: 0.5rem solid #070707;
|
||||
background: #181818;
|
||||
padding: 1.4rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.platform-work-detail__meta-row {
|
||||
display: grid;
|
||||
grid-template-columns: 4.35rem minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.platform-work-detail__app-icon {
|
||||
display: flex;
|
||||
aspect-ratio: 1;
|
||||
width: 4.35rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border-radius: 1rem;
|
||||
background: #2b2b2b;
|
||||
color: #e7e7e7;
|
||||
font-size: 1.45rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.platform-work-detail__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: clamp(1.15rem, 5vw, 1.75rem);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.platform-work-detail__author {
|
||||
margin-top: 0.45rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: rgb(255 255 255 / 55%);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.platform-work-detail__remix {
|
||||
display: inline-flex;
|
||||
min-width: 5.2rem;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
border: 0;
|
||||
border-radius: 1rem;
|
||||
background: #12bfb1;
|
||||
color: #041918;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.platform-work-detail__remix:disabled,
|
||||
.platform-work-detail__start:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.platform-work-detail__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0;
|
||||
margin-top: 1.35rem;
|
||||
}
|
||||
|
||||
.platform-work-detail__stat {
|
||||
min-width: 0;
|
||||
border-left: 1px solid rgb(255 255 255 / 10%);
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.platform-work-detail__stat:first-child {
|
||||
border-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.platform-work-detail__stat-label {
|
||||
color: rgb(255 255 255 / 52%);
|
||||
font-size: clamp(0.68rem, 2.8vw, 0.82rem);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.platform-work-detail__stat-value {
|
||||
margin-top: 0.35rem;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: rgb(255 255 255 / 82%);
|
||||
font-size: clamp(1rem, 4.2vw, 1.35rem);
|
||||
font-weight: 900;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
.platform-work-detail__body {
|
||||
background: #181818;
|
||||
padding: 1.4rem 1rem 7rem;
|
||||
}
|
||||
|
||||
.platform-work-detail__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.platform-work-detail__chip,
|
||||
.platform-work-detail__code {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid rgb(255 255 255 / 18%);
|
||||
border-radius: 0.7rem;
|
||||
background: transparent;
|
||||
color: rgb(255 255 255 / 82%);
|
||||
padding: 0.42rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.platform-work-detail__copy {
|
||||
margin: 0;
|
||||
color: rgb(255 255 255 / 88%);
|
||||
font-size: clamp(1rem, 4vw, 1.2rem);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.platform-work-detail__code {
|
||||
margin-top: 1.3rem;
|
||||
}
|
||||
|
||||
.platform-work-detail__error,
|
||||
.platform-work-detail__toast {
|
||||
margin-top: 1rem;
|
||||
border-radius: 0.85rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.platform-work-detail__error {
|
||||
border: 1px solid rgb(248 113 113 / 38%);
|
||||
background: rgb(127 29 29 / 38%);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.platform-work-detail__toast {
|
||||
border: 1px solid rgb(45 212 191 / 26%);
|
||||
background: rgb(15 118 110 / 26%);
|
||||
color: #ccfbf1;
|
||||
}
|
||||
|
||||
.platform-work-detail__bottom {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
padding: 0.9rem 1rem calc(env(safe-area-inset-bottom, 0px) + 0.9rem);
|
||||
background: linear-gradient(180deg, rgb(24 24 24 / 12%), #181818 32%);
|
||||
}
|
||||
|
||||
.platform-work-detail__start {
|
||||
display: inline-flex;
|
||||
min-height: 3.4rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #10bdb1;
|
||||
color: #f7fffe;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 900;
|
||||
box-shadow: 0 1.2rem 2.5rem rgb(16 189 177 / 24%);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.platform-work-detail {
|
||||
border-radius: 1.2rem;
|
||||
}
|
||||
|
||||
.platform-work-detail__summary,
|
||||
.platform-work-detail__body,
|
||||
.platform-work-detail__bottom {
|
||||
padding-left: max(2rem, calc((100% - 58rem) / 2));
|
||||
padding-right: max(2rem, calc((100% - 58rem) / 2));
|
||||
}
|
||||
|
||||
.platform-work-detail__topbar {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
describe('appPageRoutes', () => {
|
||||
it('normalizes page paths for stable matching', () => {
|
||||
expect(normalizeAppPath('')).toBe('/');
|
||||
expect(normalizeAppPath('/CREATION/RPG/AGENT/')).toBe('/creation/rpg/agent');
|
||||
expect(normalizeAppPath('/CREATION/RPG/AGENT/')).toBe(
|
||||
'/creation/rpg/agent',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves platform entry stages from independent paths', () => {
|
||||
@@ -52,11 +54,11 @@ describe('appPageRoutes', () => {
|
||||
|
||||
it('builds and reads public work detail query routes', () => {
|
||||
expect(buildPublicWorkDetailPath('CW-00000001')).toBe(
|
||||
'/worlds/detail?work=CW-00000001',
|
||||
);
|
||||
expect(buildPublicWorkDetailUrl('CW-00000001', 'https://example.test')).toBe(
|
||||
'https://example.test/worlds/detail?work=CW-00000001',
|
||||
'/works/detail?work=CW-00000001',
|
||||
);
|
||||
expect(
|
||||
buildPublicWorkDetailUrl('CW-00000001', 'https://example.test'),
|
||||
).toBe('https://example.test/works/detail?work=CW-00000001');
|
||||
expect(readPublicWorkCodeFromLocationSearch('?work=CW-00000001')).toBe(
|
||||
'CW-00000001',
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user