This commit is contained in:
2026-04-16 15:45:00 +08:00
parent 6363267bca
commit 91b63675eb
43 changed files with 5652 additions and 853 deletions

View File

@@ -1,21 +1,28 @@
import { type ReactNode, useDeferredValue, useEffect, useMemo, useState } from 'react';
import {
getCustomWorldSceneRelativePositionLabel,
} from '../data/customWorldSceneGraph';
type ReactNode,
useDeferredValue,
useEffect,
useMemo,
useState,
} from 'react';
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
import {
resolveCustomWorldCampSceneImage,
resolveCustomWorldLandmarkImageMap,
} from '../data/customWorldVisuals';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import { buildCustomWorldCreatorIntentDisplayText } from '../services/customWorldCreatorIntent';
import {
buildCustomWorldCreatorIntentDisplayText,
normalizeCustomWorldCreatorIntent,
} from '../services/customWorldCreatorIntent';
import { AnimationState, Character, CustomWorldProfile } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CharacterAnimator } from './CharacterAnimator';
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
export type ResultTab = 'world' | 'anchors' | 'playable' | 'story' | 'landmarks';
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
interface CustomWorldEntityCatalogProps {
profile: CustomWorldProfile;
@@ -28,11 +35,11 @@ interface CustomWorldEntityCatalogProps {
onDeleteLandmarks?: (ids: string[]) => void;
createActionLabel?: string;
onCreateAction?: () => void;
readOnly?: boolean;
}
const RESULT_TABS: Array<{ id: ResultTab; label: string }> = [
{ id: 'world', label: '世界' },
{ id: 'anchors', label: '锚点' },
{ id: 'playable', label: '可扮演角色' },
{ id: 'story', label: '场景角色' },
{ id: 'landmarks', label: '场景' },
@@ -50,11 +57,20 @@ function Section({
children: ReactNode;
}) {
return (
<div className="pixel-nine-slice pixel-panel" style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 14, paddingY: 12 })}>
<div
className="pixel-nine-slice pixel-panel"
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 14, paddingY: 12 })}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-bold tracking-[0.16em] text-white">{title}</div>
{subtitle ? <div className="mt-1 text-xs leading-6 text-zinc-500">{subtitle}</div> : null}
<div className="text-xs font-bold tracking-[0.16em] text-white">
{title}
</div>
{subtitle ? (
<div className="mt-1 text-xs leading-6 text-zinc-500">
{subtitle}
</div>
) : null}
</div>
{actions}
</div>
@@ -72,11 +88,12 @@ function SmallButton({
children: ReactNode;
tone?: 'default' | 'sky' | 'rose';
}) {
const toneClassName = tone === 'sky'
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
: tone === 'rose'
? 'border-rose-300/20 bg-rose-500/10 text-rose-100 hover:text-white'
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white';
const toneClassName =
tone === 'sky'
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
: tone === 'rose'
? 'border-rose-300/20 bg-rose-500/10 text-rose-100 hover:text-white'
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white';
return (
<button
@@ -102,7 +119,7 @@ function SearchBox({
<div className="rounded-2xl border border-white/10 bg-black/20 px-3 py-2">
<input
value={value}
onChange={event => onChange(event.target.value)}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className="w-full bg-transparent text-sm text-zinc-100 outline-none placeholder:text-zinc-500"
/>
@@ -122,7 +139,9 @@ function ImageFrame({
tone?: 'square' | 'landscape';
}) {
return (
<div className={`overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}>
<div
className={`overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),transparent_48%),linear-gradient(180deg,rgba(19,24,39,0.96),rgba(8,10,17,0.92))] ${tone === 'landscape' ? 'aspect-[16/9]' : 'aspect-square'}`}
>
{src ? (
<img src={src} alt={alt} className="h-full w-full object-cover" />
) : (
@@ -149,6 +168,7 @@ function CatalogCard({
isSelectionMode,
isSelected,
onClick,
disabled = false,
}: {
title: string;
description: string;
@@ -156,15 +176,19 @@ function CatalogCard({
isSelectionMode: boolean;
isSelected: boolean;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`w-full rounded-[1.4rem] border p-3 text-left transition-colors ${
isSelected
? 'border-rose-300/35 bg-rose-500/10'
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
: disabled
? 'border-white/10 bg-black/20'
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-black/28'
}`}
>
<div className="space-y-3">
@@ -172,7 +196,9 @@ function CatalogCard({
{media}
</div>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-base font-semibold text-white">{title}</div>
<div className="min-w-0 text-base font-semibold text-white">
{title}
</div>
{isSelectionMode ? (
<div
className={`shrink-0 rounded-full border px-2.5 py-1 text-[10px] ${
@@ -204,6 +230,96 @@ function getSearchPlaceholder(tab: ResultTab) {
return '搜索';
}
function compactTextList(values: Array<string | null | undefined>) {
return values.map((value) => value?.trim() ?? '').filter(Boolean);
}
function buildOpeningSceneSearchText(
profile: CustomWorldProfile,
campScene: ReturnType<typeof resolveCustomWorldCampScene>,
) {
return [
campScene.name,
campScene.description,
campScene.dangerLevel,
profile.playerGoal,
profile.summary,
'开局场景',
'开局归处',
].join(' ');
}
function buildStructuredFoundationEntries(profile: CustomWorldProfile) {
const creatorIntent = normalizeCustomWorldCreatorIntent(profile.creatorIntent);
const relationshipSeed = creatorIntent?.keyCharacters[0];
const relationshipText = relationshipSeed
? compactTextList([
relationshipSeed.name,
relationshipSeed.role,
relationshipSeed.relationToPlayer
? `与玩家:${relationshipSeed.relationToPlayer}`
: '',
relationshipSeed.hiddenHook
? `暗线:${relationshipSeed.hiddenHook}`
: '',
]).join(' · ')
: '';
const themeToneText = compactTextList([
creatorIntent?.themeKeywords.join('、') || '',
creatorIntent?.toneDirectives.join('、') || '',
]).join(' / ');
const playerOpeningText = compactTextList([
creatorIntent?.playerPremise || '',
creatorIntent?.openingSituation || '',
]).join('');
return [
{
id: 'world-hook',
label: '世界核心',
value:
creatorIntent?.worldHook ||
profile.anchorPack?.worldSummary ||
profile.summary,
},
{
id: 'player-opening',
label: '玩家开局',
value: playerOpeningText || profile.playerGoal,
},
{
id: 'theme-tone',
label: '主题气质',
value: themeToneText || profile.tone,
},
{
id: 'core-conflict',
label: '核心冲突',
value:
creatorIntent?.coreConflicts.join('') ||
profile.coreConflicts.join('') ||
profile.summary,
},
{
id: 'relationship-seed',
label: '关键关系',
value:
relationshipText ||
profile.playableNpcs[0]?.relationshipHooks.join('') ||
profile.storyNpcs[0]?.relationshipHooks.join('') ||
'待补充',
},
{
id: 'iconic-elements',
label: '标志元素',
value:
creatorIntent?.iconicElements.join('、') ||
profile.anchorPack?.motifDirectives.join('、') ||
'待补充',
},
];
}
type CatalogRole =
| CustomWorldProfile['playableNpcs'][number]
| CustomWorldProfile['storyNpcs'][number];
@@ -268,9 +384,12 @@ export function CustomWorldEntityCatalog({
onDeleteLandmarks,
createActionLabel,
onCreateAction,
readOnly = false,
}: CustomWorldEntityCatalogProps) {
const [searchDraft, setSearchDraft] = useState('');
const [bulkDeleteMode, setBulkDeleteMode] = useState<BulkDeleteTab | null>(null);
const [bulkDeleteMode, setBulkDeleteMode] = useState<BulkDeleteTab | null>(
null,
);
const [selectedBulkIds, setSelectedBulkIds] = useState<string[]>([]);
const deferredSearch = useDeferredValue(searchDraft.trim());
@@ -286,45 +405,101 @@ export function CustomWorldEntityCatalog({
() => resolveCustomWorldLandmarkImageMap(profile),
[profile],
);
const resolvedCampScene = useMemo(() => resolveCustomWorldCampScene(profile), [profile]);
const resolvedCampScene = useMemo(
() => resolveCustomWorldCampScene(profile),
[profile],
);
const resolvedCampImageSrc = useMemo(
() => resolveCustomWorldCampSceneImage(profile),
[profile],
);
const previewCharacterById = useMemo(
() => new Map(profile.playableNpcs.map((role, index) => [role.id, previewCharacters[index] ?? null])),
() =>
new Map(
profile.playableNpcs.map((role, index) => [
role.id,
previewCharacters[index] ?? null,
]),
),
[previewCharacters, profile.playableNpcs],
);
const filteredPlayable = useMemo(
() => profile.playableNpcs.filter(role =>
!deferredSearch
|| matchText(buildRoleSearchText(role), deferredSearch),
),
() =>
profile.playableNpcs.filter(
(role) =>
!deferredSearch ||
matchText(buildRoleSearchText(role), deferredSearch),
),
[deferredSearch, profile.playableNpcs],
);
const filteredStory = useMemo(
() => profile.storyNpcs.filter(npc =>
!deferredSearch
|| matchText(buildRoleSearchText(npc), deferredSearch),
),
() =>
profile.storyNpcs.filter(
(npc) =>
!deferredSearch ||
matchText(buildRoleSearchText(npc), deferredSearch),
),
[deferredSearch, profile.storyNpcs],
);
const filteredLandmarks = useMemo(
() => profile.landmarks.filter(landmark =>
!deferredSearch
|| matchText(
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
deferredSearch,
() =>
profile.landmarks.filter(
(landmark) =>
!deferredSearch ||
matchText(
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
deferredSearch,
),
),
),
[deferredSearch, landmarkById, profile.landmarks, storyNpcById],
);
const structuredFoundationEntries = useMemo(
() => buildStructuredFoundationEntries(profile),
[profile],
);
const filteredSceneEntries = useMemo(() => {
const openingSceneEntry = {
id: 'custom-world-opening-scene',
kind: 'camp' as const,
name: resolvedCampScene.name,
description: resolvedCampScene.description,
imageSrc: resolvedCampImageSrc,
searchText: buildOpeningSceneSearchText(profile, resolvedCampScene),
};
const landmarkEntries = filteredLandmarks.map((landmark) => ({
id: landmark.id,
kind: 'landmark' as const,
name: landmark.name,
description: landmark.description,
imageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
searchText: buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
}));
const allEntries = [openingSceneEntry, ...landmarkEntries];
if (!deferredSearch) {
return allEntries;
}
return allEntries.filter((entry) =>
matchText(entry.searchText, deferredSearch),
);
}, [
deferredSearch,
filteredLandmarks,
landmarkById,
landmarkImageById,
profile,
resolvedCampImageSrc,
resolvedCampScene,
storyNpcById,
]);
const creatorIntentSummary = useMemo(
() => buildCustomWorldCreatorIntentDisplayText(profile.creatorIntent).trim(),
() =>
buildCustomWorldCreatorIntentDisplayText(profile.creatorIntent).trim(),
[profile.creatorIntent],
);
const lockedCharacterNames = useMemo(
@@ -340,15 +515,15 @@ export function CustomWorldEntityCatalog({
const counts = {
world: 1,
anchors: 1,
playable: profile.playableNpcs.length,
story: profile.storyNpcs.length,
landmarks: profile.landmarks.length,
landmarks: profile.landmarks.length + 1,
} satisfies Record<ResultTab, number>;
const bulkDeleteTab: BulkDeleteTab | null =
activeTab === 'story' || activeTab === 'landmarks' ? activeTab : null;
const isBulkDeleteMode = bulkDeleteMode === bulkDeleteTab;
const isBulkDeleteMode =
bulkDeleteTab !== null && bulkDeleteMode === bulkDeleteTab;
useEffect(() => {
if (bulkDeleteMode && bulkDeleteMode !== activeTab) {
@@ -365,7 +540,7 @@ export function CustomWorldEntityCatalog({
if (!window.confirm(`确认删除可扮演角色「${name}」吗?`)) return;
onProfileChange({
...profile,
playableNpcs: profile.playableNpcs.filter(role => role.id !== id),
playableNpcs: profile.playableNpcs.filter((role) => role.id !== id),
});
};
@@ -411,14 +586,20 @@ export function CustomWorldEntityCatalog({
return (
<div className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide">
<div className="px-1 pb-1 text-center">
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500"></div>
<div className="mt-2 text-3xl font-black text-white sm:text-[2.2rem]">{profile.name}</div>
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-400">{profile.subtitle}</div>
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500">
</div>
<div className="mt-2 text-3xl font-black text-white sm:text-[2.2rem]">
{profile.name}
</div>
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-400">
{profile.subtitle}
</div>
</div>
<div className="sticky top-0 z-10 -mx-1 space-y-3 bg-[linear-gradient(180deg,rgba(8,10,17,0.98)_0%,rgba(8,10,17,0.95)_75%,rgba(8,10,17,0)_100%)] px-1 pb-3 pt-1">
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
{RESULT_TABS.map(tab => (
{RESULT_TABS.map((tab) => (
<div key={tab.id}>
<button
type="button"
@@ -426,16 +607,22 @@ export function CustomWorldEntityCatalog({
className={`rounded-full border px-3 py-2 text-left text-sm transition-colors ${activeTab === tab.id ? 'border-sky-300/25 bg-sky-500/12 text-white' : 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'}`}
>
<div className="font-semibold">{tab.label}</div>
<div className="mt-1 text-[10px] tracking-[0.16em] text-white/55">{counts[tab.id]}</div>
<div className="mt-1 text-[10px] tracking-[0.16em] text-white/55">
{counts[tab.id]}
</div>
</button>
</div>
))}
</div>
{activeTab !== 'world' && activeTab !== 'anchors' ? (
{activeTab !== 'world' ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<SearchBox value={searchDraft} onChange={setSearchDraft} placeholder={getSearchPlaceholder(activeTab)} />
<SearchBox
value={searchDraft}
onChange={setSearchDraft}
placeholder={getSearchPlaceholder(activeTab)}
/>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
{isBulkDeleteMode ? (
@@ -444,20 +631,25 @@ export function CustomWorldEntityCatalog({
{selectedBulkIds.length}
</div>
<SmallButton onClick={cancelBulkDelete}></SmallButton>
<SmallButton
onClick={confirmBulkDelete}
tone="rose"
>
<SmallButton onClick={confirmBulkDelete} tone="rose">
</SmallButton>
</>
) : (
<>
{createActionLabel && onCreateAction ? (
<SmallButton onClick={onCreateAction} tone="sky">{createActionLabel}</SmallButton>
{!readOnly && createActionLabel && onCreateAction ? (
<SmallButton onClick={onCreateAction} tone="sky">
{createActionLabel}
</SmallButton>
) : null}
{bulkDeleteTab && ((bulkDeleteTab === 'story' && onDeleteStoryNpcs) || (bulkDeleteTab === 'landmarks' && onDeleteLandmarks)) ? (
<SmallButton onClick={() => startBulkDelete(bulkDeleteTab)} tone="rose">
{!readOnly &&
bulkDeleteTab &&
((bulkDeleteTab === 'story' && onDeleteStoryNpcs) ||
(bulkDeleteTab === 'landmarks' && onDeleteLandmarks)) ? (
<SmallButton
onClick={() => startBulkDelete(bulkDeleteTab)}
tone="rose"
>
</SmallButton>
) : null}
@@ -470,52 +662,91 @@ export function CustomWorldEntityCatalog({
{activeTab === 'world' ? (
<>
<Section title="世界概述" actions={<SmallButton onClick={() => onEditTarget({ kind: 'world' })} tone="sky"></SmallButton>}>
<Section
title="世界概述"
actions={
readOnly ? (
<SmallButton
onClick={() => onEditTarget({ kind: 'world' })}
tone="sky"
>
</SmallButton>
) : (
<SmallButton
onClick={() => onEditTarget({ kind: 'world' })}
tone="sky"
>
</SmallButton>
)
}
>
<div className="space-y-3 text-sm leading-7 text-zinc-300">
<p>{profile.summary}</p>
<div className="rounded-2xl border border-amber-300/12 bg-amber-500/8 px-3 py-3 text-amber-100">线{profile.playerGoal}</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">{profile.tone}</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-zinc-400">{profile.settingText}</div>
<div className="rounded-2xl border border-amber-300/12 bg-amber-500/8 px-3 py-3 text-amber-100">
线{profile.playerGoal}
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3">
{profile.tone}
</div>
</div>
</Section>
{creatorIntentSummary ? (
<Section title="创作锚点" subtitle="这部分来自创作者输入AI 会围绕它继续展开世界。">
<div className="whitespace-pre-line rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-4 text-sm leading-7 text-sky-50/95">
{creatorIntentSummary}
</div>
</Section>
) : null}
<Section title="开局归处" subtitle="玩家进入自定义世界后的第一处落脚点,也会直接作为开场场景背景。">
<Section
title="原始设定"
subtitle="把开局最关键的 6 个原始锚点拆开看,后续精修会更顺。"
>
<div className="space-y-3">
<ImageFrame
src={resolvedCampImageSrc}
alt={resolvedCampScene.name}
fallbackLabel={resolvedCampScene.name.slice(0, 4) || '归处'}
tone="landscape"
/>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-6 text-zinc-200">
{resolvedCampScene.name}
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-sm leading-7 text-zinc-300">
{resolvedCampScene.description}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{structuredFoundationEntries.map((entry) => (
<div
key={entry.id}
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-4"
>
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
{entry.label}
</div>
<div className="mt-2 text-sm leading-7 text-zinc-100">
{entry.value || '待补充'}
</div>
</div>
))}
</div>
{profile.settingText ? (
<div className="whitespace-pre-line rounded-2xl border border-white/8 bg-black/20 px-4 py-4 text-sm leading-7 text-zinc-200">
{profile.settingText}
</div>
) : null}
{creatorIntentSummary && creatorIntentSummary !== profile.settingText ? (
<div className="whitespace-pre-line rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-4 text-sm leading-7 text-sky-50/95">
{creatorIntentSummary}
</div>
) : null}
</div>
</Section>
<Section title="档案规模" subtitle="结果页现在会同时维护场景角色归属和场景之间的相对位置连接关系。">
<Section
title="档案规模"
subtitle="结果页现在会同时维护场景角色归属和场景之间的相对位置连接关系。"
>
<div className="grid grid-cols-1 gap-2 text-center text-[11px] text-zinc-300 sm:grid-cols-3">
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
<div className="text-xl font-black text-white">{profile.playableNpcs.length}</div>
<div className="text-xl font-black text-white">
{profile.playableNpcs.length}
</div>
<div></div>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
<div className="text-xl font-black text-white">{profile.storyNpcs.length}</div>
<div className="text-xl font-black text-white">
{profile.storyNpcs.length}
</div>
<div></div>
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-2 py-3">
<div className="text-xl font-black text-white">{profile.landmarks.length}</div>
<div className="text-xl font-black text-white">
{profile.landmarks.length + 1}
</div>
<div></div>
</div>
</div>
@@ -526,128 +757,72 @@ export function CustomWorldEntityCatalog({
</>
) : null}
{activeTab === 'anchors' ? (
<div className="space-y-3">
<Section
title="创作者输入"
subtitle="这些内容来自创作者工作台,会作为 AI 继续展开世界的锚点。"
>
<div className="whitespace-pre-line rounded-2xl border border-sky-300/14 bg-sky-500/8 px-4 py-4 text-sm leading-7 text-sky-50/95">
{creatorIntentSummary || '当前还没有记录创作锚点。'}
</div>
</Section>
<Section title="关键势力">
<div className="space-y-2">
{profile.creatorIntent?.keyFactions.length ? (
profile.creatorIntent.keyFactions.map((entry) => (
<div
key={entry.id}
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
>
<div className="flex items-center justify-between gap-3">
<div className="font-semibold text-white">{entry.name || '未命名势力'}</div>
{entry.locked ? (
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
</div>
<div className="mt-1">{entry.publicGoal || '暂无目标说明'}</div>
{entry.tension ? <div className="mt-1 text-zinc-400">{entry.tension}</div> : null}
{entry.notes ? <div className="mt-1 text-zinc-500">{entry.notes}</div> : null}
</div>
))
) : (
<EmptyState title="当前没有关键势力锚点。" />
)}
</div>
</Section>
<Section title="关键角色">
<div className="space-y-2">
{profile.creatorIntent?.keyCharacters.length ? (
profile.creatorIntent.keyCharacters.map((entry) => (
<div
key={entry.id}
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
>
<div className="flex items-center justify-between gap-3">
<div className="font-semibold text-white">{entry.name || '未命名角色'}</div>
{entry.locked ? (
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
</div>
<div className="mt-1">{entry.role || '未填写身份'}</div>
{entry.publicMask ? <div className="mt-1 text-zinc-400">{entry.publicMask}</div> : null}
{entry.hiddenHook ? <div className="mt-1 text-zinc-400">线{entry.hiddenHook}</div> : null}
{entry.relationToPlayer ? <div className="mt-1 text-zinc-500">{entry.relationToPlayer}</div> : null}
</div>
))
) : (
<EmptyState title="当前没有关键角色锚点。" />
)}
</div>
</Section>
<Section title="关键地点">
<div className="space-y-2">
{profile.creatorIntent?.keyLandmarks.length ? (
profile.creatorIntent.keyLandmarks.map((entry) => (
<div
key={entry.id}
className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm leading-6 text-zinc-300"
>
<div className="flex items-center justify-between gap-3">
<div className="font-semibold text-white">{entry.name || '未命名地点'}</div>
{entry.locked ? (
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
</div>
<div className="mt-1">{entry.purpose || '未填写作用'}</div>
{entry.mood ? <div className="mt-1 text-zinc-400">{entry.mood}</div> : null}
{entry.secret ? <div className="mt-1 text-zinc-500">{entry.secret}</div> : null}
</div>
))
) : (
<EmptyState title="当前没有关键地点锚点。" />
)}
</div>
</Section>
</div>
) : null}
{activeTab === 'playable' ? (
<div className="space-y-3">
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{readOnly
? '当前是草稿结果预览,可先浏览角色结构,再回到工作区继续精修。'
: '可扮演角色支持新增、删除与更换外观模板。'}
</div>
{filteredPlayable.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的可扮演角色。" />
) : (
filteredPlayable.map(role => {
const previewCharacter = previewCharacterById.get(role.id) ?? null;
filteredPlayable.map((role) => {
const previewCharacter =
previewCharacterById.get(role.id) ?? null;
return (
<div key={role.id}>
<Section
title={role.name}
subtitle={role.title}
actions={(
<div className="flex items-center gap-2">
<SmallButton onClick={() => onEditTarget({ kind: 'playable', mode: 'edit', id: role.id })} tone="sky"></SmallButton>
<SmallButton onClick={() => removePlayable(role.id, role.name)} tone="rose"></SmallButton>
</div>
)}
actions={
readOnly ? (
<SmallButton
onClick={() =>
onEditTarget({
kind: 'playable',
mode: 'edit',
id: role.id,
})
}
tone="sky"
>
</SmallButton>
) : (
<div className="flex items-center gap-2">
<SmallButton
onClick={() =>
onEditTarget({
kind: 'playable',
mode: 'edit',
id: role.id,
})
}
tone="sky"
>
</SmallButton>
<SmallButton
onClick={() => removePlayable(role.id, role.name)}
tone="rose"
>
</SmallButton>
</div>
)
}
>
<div className="flex flex-col gap-3 sm:flex-row">
<div className="flex h-28 w-28 shrink-0 items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/35">
{previewCharacter ? (
<CharacterAnimator state={AnimationState.RUN} character={previewCharacter} className="h-full w-full" imageClassName="object-bottom" />
<CharacterAnimator
state={AnimationState.RUN}
character={previewCharacter}
className="h-full w-full"
imageClassName="object-bottom"
/>
) : null}
</div>
<div className="min-w-0 flex-1">
@@ -656,51 +831,86 @@ export function CustomWorldEntityCatalog({
</div>
) : null}
<div className="text-sm leading-6 text-zinc-300">{role.description}</div>
<div className="mt-2 text-xs leading-6 text-zinc-400">{role.backstory}</div>
<div className="text-sm leading-6 text-zinc-300">
{role.description}
</div>
<div className="mt-2 text-xs leading-6 text-zinc-400">
{role.backstory}
</div>
<div className="mt-3 rounded-xl border border-sky-300/12 bg-sky-500/8 px-3 py-2 text-xs leading-6 text-sky-50/95">
{role.backstoryReveal.publicSummary || '未填写'}
{role.backstoryReveal.publicSummary || '未填写'}
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">{role.role}</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">{role.initialAffinity}</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">{role.personality}</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">{role.combatStyle}</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{role.role}
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{role.initialAffinity}
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{role.personality}
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{role.combatStyle}
</div>
</div>
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{role.motivation}
</div>
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">{role.motivation}</div>
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400"></div>
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
</div>
<div className="mt-2 space-y-2">
{role.backstoryReveal.chapters.map(chapter => (
<div key={`${role.id}-${chapter.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{chapter.affinityRequired} · {chapter.title}{chapter.teaser}
{role.backstoryReveal.chapters.map((chapter) => (
<div
key={`${role.id}-${chapter.id}`}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
>
{chapter.affinityRequired} ·{' '}
{chapter.title}{chapter.teaser}
</div>
))}
</div>
</div>
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400"></div>
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
</div>
<div className="mt-2 space-y-2">
{role.skills.map(skill => (
<div key={`${role.id}-${skill.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{role.skills.map((skill) => (
<div
key={`${role.id}-${skill.id}`}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
>
{skill.name} · {skill.style}{skill.summary}
</div>
))}
</div>
</div>
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400"></div>
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
</div>
<div className="mt-2 space-y-2">
{role.initialItems.map(item => (
<div key={`${role.id}-${item.id}`} className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{item.name} x{item.quantity} · {item.category} · {item.rarity}{item.description}
{role.initialItems.map((item) => (
<div
key={`${role.id}-${item.id}`}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
>
{item.name} x{item.quantity} · {item.category} ·{' '}
{item.rarity}{item.description}
</div>
))}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{role.tags.map(tag => (
<span key={`${role.id}-${tag}`} className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
{role.tags.map((tag) => (
<span
key={`${role.id}-${tag}`}
className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300"
>
{tag}
</span>
))}
@@ -720,7 +930,7 @@ export function CustomWorldEntityCatalog({
{filteredStory.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景角色。" />
) : (
filteredStory.map(npc => (
filteredStory.map((npc) => (
<div key={npc.id}>
<CatalogCard
title={npc.name}
@@ -730,9 +940,19 @@ export function CustomWorldEntityCatalog({
onClick={() =>
isBulkDeleteMode
? toggleBulkSelected(npc.id)
: onEditTarget({ kind: 'story', mode: 'edit', id: npc.id })
: readOnly
? onEditTarget({
kind: 'story',
mode: 'edit',
id: npc.id,
})
: onEditTarget({
kind: 'story',
mode: 'edit',
id: npc.id,
})
}
media={(
media={
<CustomWorldNpcPortrait
npc={npc}
profile={profile}
@@ -741,7 +961,7 @@ export function CustomWorldEntityCatalog({
scale={2.18}
preferImageSrc
/>
)}
}
/>
</div>
))
@@ -751,29 +971,43 @@ export function CustomWorldEntityCatalog({
{activeTab === 'landmarks' ? (
<div className="space-y-3">
{filteredLandmarks.length === 0 ? (
{filteredSceneEntries.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景。" />
) : (
filteredLandmarks.map(landmark => (
<div key={landmark.id}>
filteredSceneEntries.map((scene) => (
<div key={scene.id}>
<CatalogCard
title={landmark.name}
description={landmark.description}
isSelectionMode={isBulkDeleteMode}
isSelected={selectedBulkIds.includes(landmark.id)}
onClick={() =>
isBulkDeleteMode
? toggleBulkSelected(landmark.id)
: onEditTarget({ kind: 'landmark', mode: 'edit', id: landmark.id })
title={scene.name}
description={
scene.kind === 'camp'
? `开局场景 · ${scene.description}`
: scene.description
}
media={(
isSelectionMode={scene.kind === 'landmark' && isBulkDeleteMode}
isSelected={
scene.kind === 'landmark' &&
selectedBulkIds.includes(scene.id)
}
onClick={() =>
scene.kind === 'camp'
? onEditTarget({ kind: 'world' })
: isBulkDeleteMode
? toggleBulkSelected(scene.id)
: onEditTarget({
kind: 'landmark',
mode: 'edit',
id: scene.id,
})
}
media={
<ImageFrame
src={landmarkImageById.get(landmark.id) ?? landmark.imageSrc}
alt={landmark.name}
fallbackLabel={landmark.name.slice(0, 4) || '场景'}
src={scene.imageSrc}
alt={scene.name}
fallbackLabel={scene.name.slice(0, 4) || '场景'}
tone="landscape"
/>
)}
}
disabled={scene.kind === 'camp' && isBulkDeleteMode}
/>
</div>
))

View File

@@ -1,8 +1,6 @@
import { motion } from 'motion/react';
import type {
CustomWorldGenerationProgress,
} from '../../packages/shared/src/contracts/runtime';
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
interface CustomWorldGenerationViewProps {
@@ -74,7 +72,9 @@ export function CustomWorldGenerationView({
? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}`
: '正在校准预计等待时间';
const elapsedText =
progress != null ? `已耗时 ${formatDuration(progress.elapsedMs)}` : '正在启动世界生成';
progress != null
? `已耗时 ${formatDuration(progress.elapsedMs)}`
: '正在启动世界生成';
return (
<div

View File

@@ -1,10 +1,16 @@
import { type ReactNode,useMemo, useState } from 'react';
import { type ReactNode, useMemo, useState } from 'react';
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
import { Character, CustomWorldProfile } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CustomWorldEntityCatalog, type ResultTab } from './CustomWorldEntityCatalog';
import { type CustomWorldEditorTarget,CustomWorldEntityEditorModal } from './CustomWorldEntityEditorModal';
import {
CustomWorldEntityCatalog,
type ResultTab,
} from './CustomWorldEntityCatalog';
import {
type CustomWorldEditorTarget,
CustomWorldEntityEditorModal,
} from './CustomWorldEntityEditorModal';
interface CustomWorldResultViewProps {
profile: CustomWorldProfile;
@@ -17,8 +23,13 @@ interface CustomWorldResultViewProps {
onEditSetting?: () => void;
onRegenerate?: () => void;
onContinueExpand?: () => void;
onSave: () => void;
onSave?: () => void;
onProfileChange: (profile: CustomWorldProfile) => void;
readOnly?: boolean;
backLabel?: string;
editActionLabel?: string;
regenerateActionLabel?: string;
saveActionLabel?: string;
}
function SmallButton({
@@ -48,7 +59,9 @@ function SmallButton({
);
}
function getCreateTargetByTab(activeTab: ResultTab): CustomWorldEditorTarget | null {
function getCreateTargetByTab(
activeTab: ResultTab,
): CustomWorldEditorTarget | null {
if (activeTab === 'playable') return { kind: 'playable', mode: 'create' };
if (activeTab === 'story') return { kind: 'story', mode: 'create' };
if (activeTab === 'landmarks') return { kind: 'landmark', mode: 'create' };
@@ -82,7 +95,10 @@ function removeStoryNpcsFromProfile(
} satisfies CustomWorldProfile;
}
function removeLandmarksFromProfile(profile: CustomWorldProfile, ids: string[]) {
function removeLandmarksFromProfile(
profile: CustomWorldProfile,
ids: string[],
) {
const idSet = new Set(ids);
const nextLandmarks = profile.landmarks.filter(
(landmark) => !idSet.has(landmark.id),
@@ -115,12 +131,24 @@ export function CustomWorldResultView({
onContinueExpand,
onSave,
onProfileChange,
readOnly = false,
backLabel = '返回',
editActionLabel = '修改设定',
regenerateActionLabel = '重新生成',
saveActionLabel = '保存到我的作品',
}: CustomWorldResultViewProps) {
const [editorTarget, setEditorTarget] = useState<CustomWorldEditorTarget | null>(null);
const [editorTarget, setEditorTarget] =
useState<CustomWorldEditorTarget | null>(null);
const [activeTab, setActiveTab] = useState<ResultTab>('world');
const createTarget = useMemo(() => getCreateTargetByTab(activeTab), [activeTab]);
const createLabel = useMemo(() => getCreateLabelByTab(activeTab), [activeTab]);
const createTarget = useMemo(
() => getCreateTargetByTab(activeTab),
[activeTab],
);
const createLabel = useMemo(
() => getCreateLabelByTab(activeTab),
[activeTab],
);
const onRegenerate = () => {
if (isGenerating || !triggerRegenerate) return;
@@ -151,7 +179,7 @@ export function CustomWorldResultView({
disabled={isGenerating}
className={`self-start rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'opacity-45' : ''}`}
>
{backLabel}
</button>
</div>
@@ -165,15 +193,22 @@ export function CustomWorldResultView({
onProfileChange={onProfileChange}
onDeleteStoryNpcs={handleDeleteStoryNpcs}
onDeleteLandmarks={handleDeleteLandmarks}
createActionLabel={createLabel}
onCreateAction={createTarget ? () => setEditorTarget(createTarget) : undefined}
createActionLabel={readOnly ? undefined : createLabel}
onCreateAction={
readOnly || !createTarget
? undefined
: () => setEditorTarget(createTarget)
}
readOnly={readOnly}
/>
</div>
{isGenerating && (
<div className="mt-3 rounded-2xl border border-sky-400/18 bg-sky-500/10 px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">{progressLabel}</div>
<div className="text-sm font-semibold text-white">
{progressLabel}
</div>
<div className="text-xs text-sky-100">{Math.round(progress)}%</div>
</div>
<div className="mt-3 h-3 overflow-hidden rounded-full border border-white/10 bg-black/35">
@@ -199,28 +234,41 @@ export function CustomWorldResultView({
) : null}
<div className="flex items-center justify-end gap-3">
{onEditSetting ? (
<SmallButton onClick={onEditSetting}></SmallButton>
<SmallButton onClick={onEditSetting}>{editActionLabel}</SmallButton>
) : null}
{triggerRegenerate ? (
<SmallButton onClick={onRegenerate} tone="sky"></SmallButton>
<SmallButton onClick={onRegenerate} tone="sky">
{regenerateActionLabel}
</SmallButton>
) : null}
{profile.generationStatus === 'key_only' && onContinueExpand ? (
<SmallButton onClick={onContinueExpand} tone="sky" disabled={isGenerating}>
<SmallButton
onClick={onContinueExpand}
tone="sky"
disabled={isGenerating}
>
</SmallButton>
) : null}
<button
type="button"
onClick={onSave}
disabled={isGenerating}
className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'opacity-55' : ''}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 16, paddingY: 10 })}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white"></span>
<span className="text-white/60"></span>
</div>
</button>
{onSave ? (
<button
type="button"
onClick={onSave}
disabled={isGenerating}
className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'opacity-55' : ''}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 16,
paddingY: 10,
})}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white">
{saveActionLabel}
</span>
<span className="text-white/60"></span>
</div>
</button>
) : null}
</div>
</div>

View File

@@ -55,12 +55,12 @@ export function CustomWorldAgentDraftDrawer({
</div>
</div>
<div className="mt-2 space-y-2">
{group.items.map((card) => {
{group.items.map((card, index) => {
const isActive = activeCardId === card.id;
return (
<button
key={card.id}
key={card.id || `${group.kind}-card-${index}`}
type="button"
onClick={() => onSelectCard(card.id)}
className={`w-full rounded-[1.2rem] border px-3 py-3 text-left transition ${

View File

@@ -7,15 +7,19 @@ function readLockedItems(lockState: Record<string, unknown> | null) {
return [];
}
return Object.entries(lockState)
return [...new Set(
Object.entries(lockState)
.flatMap(([key, value]) =>
Array.isArray(value)
? value.map((item) => `${key}:${String(item)}`)
? value
.map((item) => String(item).trim())
.filter(Boolean)
.map((item) => `${key}:${item}`)
: typeof value === 'string' && value.trim()
? [`${key}:${value.trim()}`]
: [],
)
.slice(0, 8);
)].slice(0, 8);
}
export function CustomWorldAgentLockBar({
@@ -30,9 +34,9 @@ export function CustomWorldAgentLockBar({
</div>
{lockedItems.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{lockedItems.map((item) => (
{lockedItems.map((item, index) => (
<span
key={item}
key={`locked-item-${index}-${item}`}
className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100"
>
{item}

View File

@@ -5,6 +5,7 @@ type CustomWorldAgentQuickActionsProps = {
disabled: boolean;
canDraftFoundation: boolean;
showEntityActions?: boolean;
showSummaryAction?: boolean;
onRequestSummary: () => void;
onDraftFoundation: () => void;
onGenerateCharacter?: () => void;
@@ -45,6 +46,7 @@ export function CustomWorldAgentQuickActions({
disabled,
canDraftFoundation,
showEntityActions = false,
showSummaryAction = true,
onRequestSummary,
onDraftFoundation,
onGenerateCharacter,
@@ -70,12 +72,14 @@ export function CustomWorldAgentQuickActions({
</div>
<div className="mt-3 flex flex-col gap-2">
<QuickActionButton
label={summaryAction?.label ?? '总结当前设定'}
onClick={onRequestSummary}
disabled={disabled}
tone="sky"
/>
{showSummaryAction ? (
<QuickActionButton
label={summaryAction?.label ?? '总结当前设定'}
onClick={onRequestSummary}
disabled={disabled}
tone="sky"
/>
) : null}
{draftAction && canDraftFoundation ? (
<QuickActionButton
label={draftAction.label}

View File

@@ -0,0 +1,65 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { afterEach, expect, test, vi } from 'vitest';
import { CustomWorldAgentThread } from './CustomWorldAgentThread';
afterEach(() => {
vi.restoreAllMocks();
});
test('filters empty recommended replies and avoids duplicate key warnings', () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
render(
<CustomWorldAgentThread
messages={[
{
id: '',
role: 'assistant',
kind: 'summary',
text: '先把世界骨架收出来。',
createdAt: '2026-04-16T10:00:00.000Z',
relatedOperationId: null,
},
{
id: '',
role: 'user',
kind: 'chat',
text: '继续。',
createdAt: '2026-04-16T10:01:00.000Z',
relatedOperationId: null,
},
]}
recommendedReplies={[
'',
'继续补充冲突',
'继续补充冲突',
' 先确定玩家身份 ',
]}
onRecommendedReply={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '继续补充冲突' })).toBeTruthy();
expect(screen.getByRole('button', { name: '先确定玩家身份' })).toBeTruthy();
expect(screen.queryByRole('button', { name: /^\s*$/u })).toBeNull();
expect(screen.getAllByRole('button')).toHaveLength(2);
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

@@ -26,9 +26,16 @@ export function CustomWorldAgentThread({
onRecommendedReply,
}: CustomWorldAgentThreadProps) {
const bottomRef = useRef<HTMLDivElement | null>(null);
const lastAssistantMessageId = [...messages]
.reverse()
.find((message) => message.role === 'assistant')?.id;
const visibleRecommendedReplies = [
...new Set(
recommendedReplies.map((reply) => reply.trim()).filter(Boolean),
),
].slice(0, 3);
const lastAssistantMessageIndex = messages.reduce(
(lastIndex, message, index) =>
message.role === 'assistant' ? index : lastIndex,
-1,
);
useEffect(() => {
bottomRef.current?.scrollIntoView({
@@ -45,13 +52,13 @@ export function CustomWorldAgentThread({
</div>
) : (
<div className="space-y-3">
{messages.map((message) => {
{messages.map((message, index) => {
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
return (
<div
key={message.id}
key={message.id || `message-${index}`}
className={`flex ${
isUser ? 'justify-end' : 'justify-start'
}`}
@@ -70,12 +77,12 @@ export function CustomWorldAgentThread({
{formatMessageTime(message.createdAt)}
</div>
{!isUser &&
message.id === lastAssistantMessageId &&
recommendedReplies.length > 0 ? (
index === lastAssistantMessageIndex &&
visibleRecommendedReplies.length > 0 ? (
<div className="mt-3 flex flex-col gap-2">
{recommendedReplies.slice(0, 3).map((reply) => (
{visibleRecommendedReplies.map((reply, replyIndex) => (
<button
key={reply}
key={`recommended-reply-${replyIndex}-${reply}`}
type="button"
onClick={() => onRecommendedReply?.(reply)}
className="rounded-2xl border border-white/10 bg-white/5 px-3 py-2 text-left text-xs leading-5 text-zinc-200 transition hover:border-emerald-300/25 hover:text-white"

View File

@@ -212,7 +212,11 @@ const baseSession: CustomWorldAgentSessionSnapshot = {
targetId: null,
},
],
recommendedReplies: ['现在开始生成草稿', '先总结一下当前设定', '我还想再补充一点'],
recommendedReplies: [
'现在开始生成草稿',
'先总结一下当前设定',
'我还想再补充一点',
],
qualityFindings: [],
assetCoverage: {
roleAssets: [
@@ -268,6 +272,10 @@ test('workspace loads detail, saves edits, opens generate actions, and reflects
);
});
expect(screen.getByText('卡片详情')).toBeTruthy();
expect(screen.queryByPlaceholderText('输入消息')).toBeNull();
expect(screen.queryByText('当前底稿已经可以继续精修。')).toBeNull();
await user.click(screen.getByRole('button', { name: '编辑设定' }));
const summaryInput = screen.getByLabelText('摘要');
await user.clear(summaryInput);
@@ -297,7 +305,9 @@ test('workspace loads detail, saves edits, opens generate actions, and reflects
);
});
const [generateCharacterButton] = screen.getAllByRole('button', { name: '新增角色' });
const [generateCharacterButton] = screen.getAllByRole('button', {
name: '新增角色',
});
await user.click(generateCharacterButton!);
expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '生成角色' }));
@@ -309,7 +319,9 @@ test('workspace loads detail, saves edits, opens generate actions, and reflects
anchorCardIds: ['character-1'],
});
const [generateLandmarkButton] = screen.getAllByRole('button', { name: '新增场景' });
const [generateLandmarkButton] = screen.getAllByRole('button', {
name: '新增场景',
});
await user.click(generateLandmarkButton!);
expect(screen.getByRole('button', { name: '生成场景' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '生成场景' }));

View File

@@ -3,7 +3,7 @@ import { expect, test } from 'vitest';
import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace';
test('custom world agent workspace renders progress labels, action button and recommended replies', () => {
test('custom world agent workspace renders draft workspace instead of chat after draft cards appear', () => {
const html = renderToStaticMarkup(
<CustomWorldAgentWorkspace
session={{
@@ -87,10 +87,11 @@ test('custom world agent workspace renders progress labels, action button and re
/>,
);
expect(html).toContain('首轮草稿会先确认这 6 项信息');
expect(html).toContain('世界核心');
expect(html).toContain('玩家开局');
expect(html).toContain('现在开始生成草稿');
expect(html).toContain('开始生成草稿');
expect(html).toContain('欢迎。当前底稿已经可以继续精修。');
expect(html).toContain('卡片详情');
expect(html).toContain('快捷动作');
expect(html).toContain('草稿抽屉');
expect(html).not.toContain('首轮草稿会先确认这 6 项信息');
expect(html).not.toContain('现在开始生成草稿');
expect(html).not.toContain('欢迎。当前底稿已经可以继续精修。');
expect(html).not.toContain('输入消息');
});

View File

@@ -7,8 +7,8 @@ import type {
CustomWorldDraftCardDetail,
SendCustomWorldAgentMessageRequest,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldRoleAssetStudioModal } from '../CustomWorldRoleAssetStudioModal';
import { getCustomWorldAgentCardDetail } from '../../services/aiService';
import { CustomWorldRoleAssetStudioModal } from '../CustomWorldRoleAssetStudioModal';
import { CustomWorldAgentComposer } from './CustomWorldAgentComposer';
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
import { CustomWorldAgentDraftDrawer } from './CustomWorldAgentDraftDrawer';
@@ -149,7 +149,8 @@ function resolveRoleAssetTarget(
: [],
imageSrc: toText(role.imageSrc) || undefined,
generatedVisualAssetId: toText(role.generatedVisualAssetId) || undefined,
generatedAnimationSetId: toText(role.generatedAnimationSetId) || undefined,
generatedAnimationSetId:
toText(role.generatedAnimationSetId) || undefined,
animationMap: toRecord(role.animationMap) ?? undefined,
} satisfies WorkspaceRoleAssetTarget,
roleKind: playableRole ? ('playable' as const) : ('story' as const),
@@ -352,7 +353,8 @@ export function CustomWorldAgentWorkspace({
}
const isBusy =
activeOperation?.status === 'queued' || activeOperation?.status === 'running';
activeOperation?.status === 'queued' ||
activeOperation?.status === 'running';
const canStartDraft =
session.creatorIntentReadiness.isReady &&
session.stage === 'foundation_review';
@@ -360,9 +362,10 @@ export function CustomWorldAgentWorkspace({
!session.creatorIntentReadiness.isReady &&
session.creatorIntentReadiness.completedKeys.includes('world_hook');
const showDraftWorkspace =
(session.stage === 'object_refining' || session.stage === 'visual_refining') &&
session.draftCards.length > 0;
const selectedCard = session.draftCards.find((card) => card.id === selectedCardId) ?? null;
session.stage !== 'foundation_review' && session.draftCards.length > 0;
const showAgentConversation = !showDraftWorkspace;
const selectedCard =
session.draftCards.find((card) => card.id === selectedCardId) ?? null;
const recommendedReplies = buildRecommendedReplies(session);
const selectedRoleAssetContext = resolveRoleAssetTarget(
session,
@@ -424,27 +427,30 @@ export function CustomWorldAgentWorkspace({
return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-[1500px] flex-col gap-3">
<CustomWorldAgentHeader onBack={onBack} />
<CustomWorldAgentReadinessBar
completedKeys={session.creatorIntentReadiness.completedKeys}
isReady={canStartDraft}
busy={isBusy}
onStartDraft={() => {
onExecuteAction({
action: 'draft_foundation',
});
}}
/>
{!showDraftWorkspace ? <CustomWorldAgentHeader onBack={onBack} /> : null}
{!showDraftWorkspace ? (
<CustomWorldAgentReadinessBar
completedKeys={session.creatorIntentReadiness.completedKeys}
isReady={canStartDraft}
busy={isBusy}
onStartDraft={() => {
onExecuteAction({
action: 'draft_foundation',
});
}}
/>
) : null}
<CustomWorldAgentOperationBanner operation={activeOperation} />
{showDraftWorkspace ? (
<div className="grid min-h-0 flex-1 gap-3 xl:grid-cols-[18rem_minmax(0,1fr)_24rem]">
<div className="grid min-h-0 flex-1 gap-3 xl:grid-cols-[20rem_minmax(0,1fr)]">
<div className="flex min-h-0 flex-col gap-3 xl:overflow-hidden">
<CustomWorldAgentQuickActions
suggestedActions={session.suggestedActions}
disabled={isBusy}
canDraftFoundation={canStartDraft}
showEntityActions
showSummaryAction={false}
showRoleAssetAction={selectedCard?.kind === 'character'}
onRequestSummary={submitSummaryRequest}
onDraftFoundation={() => {
@@ -464,13 +470,11 @@ export function CustomWorldAgentWorkspace({
onFocusSuggestedAction={(action) => {
if (action?.targetId) {
setSelectedCardId(action.targetId);
setDetailModalOpen(true);
return;
}
if (session.draftCards[0]) {
setSelectedCardId(session.draftCards[0].id);
setDetailModalOpen(true);
}
}}
/>
@@ -480,13 +484,12 @@ export function CustomWorldAgentWorkspace({
activeCardId={selectedCardId}
onSelectCard={(cardId) => {
setSelectedCardId(cardId);
setDetailModalOpen(true);
}}
/>
</div>
</div>
<div className="hidden min-h-0 xl:block xl:overflow-y-auto">
<div className="min-h-0 xl:overflow-y-auto">
<CustomWorldAgentDraftDetailPanel
detail={detail}
loading={detailLoading}
@@ -526,38 +529,25 @@ export function CustomWorldAgentWorkspace({
}}
/>
</div>
<div className="flex min-h-0 flex-col gap-3">
<div className="h-[18rem] min-h-[18rem] xl:min-h-0 xl:flex-1">
</div>
) : (
<>
{showAgentConversation ? (
<>
<CustomWorldAgentThread
messages={session.messages}
recommendedReplies={recommendedReplies}
onRecommendedReply={handleRecommendedReply}
/>
</div>
<CustomWorldAgentComposer
disabled={isBusy}
onSubmit={onSubmitMessage}
onSummaryClick={submitSummaryRequest}
onAutoCompleteClick={() => setAutoCompleteConfirmOpen(true)}
showAutoComplete={showAutoCompleteButton}
/>
</div>
</div>
) : (
<>
<CustomWorldAgentThread
messages={session.messages}
recommendedReplies={recommendedReplies}
onRecommendedReply={handleRecommendedReply}
/>
<CustomWorldAgentComposer
disabled={isBusy}
onSubmit={onSubmitMessage}
onSummaryClick={submitSummaryRequest}
onAutoCompleteClick={() => setAutoCompleteConfirmOpen(true)}
showAutoComplete={showAutoCompleteButton}
/>
<CustomWorldAgentComposer
disabled={isBusy}
onSubmit={onSubmitMessage}
onSummaryClick={submitSummaryRequest}
onAutoCompleteClick={() => setAutoCompleteConfirmOpen(true)}
showAutoComplete={showAutoCompleteButton}
/>
</>
) : null}
</>
)}
@@ -671,13 +661,13 @@ export function CustomWorldAgentWorkspace({
}
visualPointCost={
selectedRoleAssetContext.assetSummary?.status === 'missing'
? selectedRoleAssetContext.assetSummary?.nextPointCost ?? 20
? (selectedRoleAssetContext.assetSummary?.nextPointCost ?? 20)
: 20
}
animationPointCost={
selectedRoleAssetContext.assetSummary?.status === 'missing'
? 60
: selectedRoleAssetContext.assetSummary?.nextPointCost ?? 60
: (selectedRoleAssetContext.assetSummary?.nextPointCost ?? 60)
}
syncBusy={
activeOperation?.type === 'sync_role_assets' &&

View File

@@ -70,7 +70,9 @@ function WorldCard({
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
const tags = buildPlatformWorldTags(entry);
const tags = [
...new Set(buildPlatformWorldTags(entry).map((tag) => tag.trim()).filter(Boolean)),
].slice(0, 3);
return (
<button
@@ -120,9 +122,9 @@ function WorldCard({
</div>
<div className="mt-3 flex flex-wrap gap-2">
{tags.length > 0 ? (
tags.map((tag) => (
tags.map((tag, index) => (
<span
key={tag}
key={`world-tag-${index}-${tag || 'empty'}`}
className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-100"
>
{tag}

View File

@@ -66,7 +66,9 @@ export function PlatformWorldDetailView({
3,
);
const previewLandmarks = entry.profile.landmarks.slice(0, 3);
const tags = buildPlatformWorldTags(entry);
const tags = [
...new Set(buildPlatformWorldTags(entry).map((tag) => tag.trim()).filter(Boolean)),
].slice(0, 3);
return (
<div className="flex h-full min-h-0 flex-col">
@@ -133,9 +135,9 @@ export function PlatformWorldDetailView({
{entry.summaryText || '等待补充世界摘要。'}
</div>
<div className="mt-4 flex flex-wrap gap-2">
{tags.map((tag) => (
{tags.map((tag, index) => (
<span
key={tag}
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"
>
{tag}
@@ -189,9 +191,9 @@ export function PlatformWorldDetailView({
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
{previewCharacters.map((character) => (
{previewCharacters.map((character, index) => (
<div
key={character.id}
key={character.id || `preview-character-${index}`}
className="rounded-2xl border border-white/10 bg-black/20 px-3 py-3"
>
<div className="line-clamp-1 text-sm font-bold text-white">
@@ -210,9 +212,9 @@ export function PlatformWorldDetailView({
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
{previewLandmarks.map((landmark) => (
{previewLandmarks.map((landmark, index) => (
<div
key={landmark.id}
key={landmark.id || `preview-landmark-${index}`}
className="rounded-2xl border border-white/10 bg-black/20 px-3 py-3"
>
<div className="line-clamp-1 text-sm font-bold text-white">

View File

@@ -8,11 +8,14 @@ import { beforeEach, expect, test, vi } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import {
createCustomWorldAgentSession,
executeCustomWorldAgentAction,
getCustomWorldAgentOperation,
getCustomWorldAgentSession,
} from '../../services/aiService';
import {
listCustomWorldGallery,
listCustomWorldLibrary,
upsertCustomWorldProfile,
} from '../../services/storageService';
import type { GameState } from '../../types';
import {
@@ -41,11 +44,23 @@ vi.mock('../../services/storageService', () => ({
vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
CustomWorldAgentWorkspace: ({
session,
onExecuteAction,
}: {
session: CustomWorldAgentSessionSnapshot | null;
onExecuteAction: (payload: { action: string }) => void;
}) => (
<div className="agent-workspace-mock">
Agent工作区{session?.sessionId ?? 'missing-session'}
<button
type="button"
onClick={() => {
onExecuteAction({
action: 'draft_foundation',
});
}}
>
稿
</button>
</div>
),
}));
@@ -117,9 +132,51 @@ beforeEach(() => {
window.sessionStorage.clear();
vi.mocked(listCustomWorldLibrary).mockResolvedValue([]);
vi.mocked(listCustomWorldGallery).mockResolvedValue([]);
vi.mocked(upsertCustomWorldProfile).mockResolvedValue({
entry: {
ownerUserId: 'user-1',
profileId: 'agent-draft-custom-world-agent-session-1',
profile: {
id: 'agent-draft-custom-world-agent-session-1',
name: '潮雾列岛',
} as never,
visibility: 'draft',
publishedAt: null,
updatedAt: '2026-04-14T12:00:00.000Z',
authorDisplayName: '玩家',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '第一版世界底稿已经整理完成。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 1,
landmarkCount: 1,
},
entries: [],
});
vi.mocked(createCustomWorldAgentSession).mockResolvedValue({
session: mockSession,
});
vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({
operation: {
operationId: 'operation-draft-foundation-1',
type: 'draft_foundation',
status: 'queued',
phaseLabel: '已接收请求',
phaseDetail: '正在准备生成世界底稿。',
progress: 10,
error: null,
},
});
vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({
operationId: 'operation-draft-foundation-1',
type: 'draft_foundation',
status: 'running',
phaseLabel: '生成世界底稿',
phaseDetail: '正在根据已确认锚点编译第一版世界结构。',
progress: 38,
error: null,
});
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(mockSession);
});
@@ -151,3 +208,168 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and
await screen.findByText('Agent工作区custom-world-agent-session-1'),
).toBeTruthy();
});
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
const user = userEvent.setup();
render(<TestWrapper />);
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(
await screen.findByText('Agent工作区custom-world-agent-session-1'),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: '开始生成草稿' }));
await waitFor(() => {
expect(executeCustomWorldAgentAction).toHaveBeenCalledWith(
'custom-world-agent-session-1',
{
action: 'draft_foundation',
},
);
});
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(screen.queryByText(/Agent/u)).toBeNull();
expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0);
});
test('existing draft sessions enter the legacy result layout directly', async () => {
const user = userEvent.setup();
vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({
operationId: 'operation-draft-foundation-1',
type: 'draft_foundation',
status: 'completed',
phaseLabel: '世界底稿已生成',
phaseDetail: '第一版世界底稿和 4 张草稿卡已经整理完成。',
progress: 100,
error: null,
});
vi.mocked(getCustomWorldAgentSession).mockResolvedValue({
...mockSession,
stage: 'object_refining',
creatorIntent: {
sourceMode: 'card',
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
themeKeywords: ['海雾', '旧航路'],
toneDirectives: ['压抑', '悬疑'],
openingSituation: '首夜就有陌生船只闯入禁航区。',
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: ['会移动的海雾'],
forbiddenDirectives: [],
rawSettingText: '',
},
draftProfile: {
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '旧航路引路人',
role: '关键同行者',
publicIdentity: '最熟悉旧航路的人。',
publicMask: '看上去像可靠旧友。',
currentPressure: '他必须在两股势力间站队。',
hiddenHook: '暗中替沉船商盟引路。',
relationToPlayer: '旧友兼潜在背叛者',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
storyNpcs: [
{
id: 'story-1',
name: '顾潮音',
title: '守灯会值夜人',
role: '场景关键角色',
publicIdentity: '负责夜间巡灯与封锁。',
publicMask: '对外一直冷静克制。',
currentPressure: '她知道更多禁航区真相。',
hiddenHook: '曾亲眼见过失控海雾吞船。',
relationToPlayer: '最早愿意交换线索的人',
threadIds: ['thread-1'],
summary: '她总像比所有人更早知道海雾会往哪一侧压下来。',
},
],
landmarks: [
{
id: 'landmark-1',
name: '回潮旧灯塔',
purpose: '观察雾潮与往来船只',
mood: '潮湿、压抑、风声不止',
importance: '开局核心场景',
characterIds: ['story-1'],
threadIds: ['thread-1'],
summary: '旧灯塔是整片群岛最先看见异动的地方。',
},
],
factions: [],
threads: [],
chapters: [],
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
openingSituation: '首夜就有陌生船只闯入禁航区。',
iconicElements: ['会移动的海雾'],
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
},
draftCards: [
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
status: 'warning',
linkedIds: ['playable-1', 'story-1', 'landmark-1'],
warningCount: 0,
},
],
});
render(<TestWrapper />);
await user.click(screen.getByRole('button', { name: '创作' }));
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: / RPG/u }));
await waitFor(
async () => {
expect(await screen.findByText('世界档案')).toBeTruthy();
expect(
screen.getByRole('button', {
name: /||/u,
}),
).toBeTruthy();
},
{ timeout: 2500 },
);
expect(screen.queryByText(/Agent/u)).toBeNull();
expect(screen.queryByRole('button', { name: /^/u })).toBeNull();
expect(screen.getByText(//u)).toBeTruthy();
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: //u }));
expect(await screen.findByText(//u)).toBeTruthy();
expect(
screen.getByRole('button', { name: /AI/u }),
).toBeTruthy();
expect(screen.getByText('技能')).toBeTruthy();
});

View File

@@ -31,16 +31,17 @@ import {
getCustomWorldAgentSession,
sendCustomWorldAgentMessage,
} from '../../services/aiService';
import {
readCustomWorldAgentUiState,
writeCustomWorldAgentUiState,
} from '../../services/customWorldAgentUiState';
import { buildCustomWorldProfileFromAgentDraft } from '../../services/customWorldAgentDraftResult';
import {
buildAgentDraftFoundationGenerationProgress,
buildAgentDraftFoundationSettingText,
isDraftFoundationOperation,
isDraftFoundationOperationRunning,
} from '../../services/customWorldAgentGenerationProgress';
import {
readCustomWorldAgentUiState,
writeCustomWorldAgentUiState,
} from '../../services/customWorldAgentUiState';
import {
buildCustomWorldCreatorIntentDisplayText,
buildCustomWorldCreatorIntentGenerationText,
@@ -61,7 +62,7 @@ import {
type GameState,
} from '../../types';
import { PlatformCreationTypeModal } from './PlatformCreationTypeModal';
import { type PlatformHomeTab,PlatformHomeView } from './PlatformHomeView';
import { type PlatformHomeTab, PlatformHomeView } from './PlatformHomeView';
import { PlatformWorldDetailView } from './PlatformWorldDetailView';
const CustomWorldGenerationView = lazy(async () => {
@@ -106,6 +107,9 @@ type CustomWorldGenerationViewSource =
| 'agent-draft-foundation'
| null;
type CustomWorldResultViewSource = 'classic' | 'agent-draft' | null;
type CustomWorldAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
type PreGameSelectionFlowProps = {
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
@@ -270,11 +274,21 @@ export function PreGameSelectionFlow({
const [isMutatingDetail, setIsMutatingDetail] = useState(false);
const [customWorldProgress, setCustomWorldProgress] =
useState<CustomWorldGenerationProgress | null>(null);
const [customWorldAutoSaveState, setCustomWorldAutoSaveState] =
useState<CustomWorldAutoSaveState>('idle');
const [customWorldAutoSaveError, setCustomWorldAutoSaveError] = useState<
string | null
>(null);
const [customWorldGenerationViewSource, setCustomWorldGenerationViewSource] =
useState<CustomWorldGenerationViewSource>(null);
const [customWorldResultViewSource, setCustomWorldResultViewSource] =
useState<CustomWorldResultViewSource>(null);
const [agentDraftGenerationStartedAt, setAgentDraftGenerationStartedAt] =
useState<number | null>(null);
const customWorldAbortControllerRef = useRef<AbortController | null>(null);
const customWorldAutoSaveTimeoutRef = useRef<number | null>(null);
const lastAutoSavedProfileSignatureRef = useRef<string | null>(null);
const latestAutoSaveRequestIdRef = useRef(0);
const previewCustomWorldCharacters = useMemo(
() =>
@@ -307,34 +321,6 @@ export function PreGameSelectionFlow({
return nextSession;
}, []);
const refreshPlatformData = useCallback(async () => {
setIsLoadingPlatform(true);
setPlatformError(null);
try {
const [libraryEntries, galleryEntries] = await Promise.all([
listCustomWorldLibrary(),
listCustomWorldGallery(),
]);
setSavedCustomWorldEntries(libraryEntries);
setPublishedGalleryEntries(galleryEntries);
if (selectedDetailEntry) {
const nextOwnedEntry = libraryEntries.find(
(entry) =>
entry.ownerUserId === selectedDetailEntry.ownerUserId &&
entry.profileId === selectedDetailEntry.profileId,
);
if (nextOwnedEntry) {
setSelectedDetailEntry(nextOwnedEntry);
}
}
} catch (error) {
setPlatformError(resolveErrorMessage(error, '读取平台数据失败。'));
} finally {
setIsLoadingPlatform(false);
}
}, [selectedDetailEntry]);
useEffect(() => {
if (hasAppliedInitialAgentWorkspaceRef.current) {
return;
@@ -397,6 +383,9 @@ export function PreGameSelectionFlow({
useEffect(
() => () => {
customWorldAbortControllerRef.current?.abort();
if (customWorldAutoSaveTimeoutRef.current !== null) {
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
}
},
[],
);
@@ -512,6 +501,72 @@ export function PreGameSelectionFlow({
syncAgentSessionSnapshot,
]);
useEffect(() => {
if (
!isDraftFoundationOperationRunning(agentOperation) ||
agentDraftGenerationStartedAt
) {
return;
}
setAgentDraftGenerationStartedAt(Date.now());
}, [agentDraftGenerationStartedAt, agentOperation]);
useEffect(() => {
if (
selectionStage !== 'custom-world-generating' ||
customWorldGenerationViewSource !== 'agent-draft-foundation' ||
!isDraftFoundationOperation(agentOperation) ||
agentOperation.status !== 'completed'
) {
return;
}
let cancelled = false;
const timeoutId = window.setTimeout(() => {
void (async () => {
const latestSession = activeAgentSessionId
? await syncAgentSessionSnapshot(activeAgentSessionId).catch(
() => null,
)
: agentSession;
if (cancelled) {
return;
}
const draftResultProfile = buildCustomWorldProfileFromAgentDraft(
latestSession ?? agentSession,
);
if (!draftResultProfile) {
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setSelectionStage('agent-workspace');
return;
}
setGeneratedCustomWorldProfile(draftResultProfile);
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource('agent-draft');
setSelectionStage('custom-world-result');
})();
}, 900);
return () => {
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [
activeAgentSessionId,
agentOperation,
customWorldGenerationViewSource,
agentSession,
selectionStage,
setSelectionStage,
syncAgentSessionSnapshot,
]);
const customWorldSettingPreview = useMemo(() => {
if (customWorldCreatorIntent.sourceMode === 'freeform') {
return customWorldCreatorIntent.rawSettingText.trim();
@@ -531,6 +586,51 @@ export function PreGameSelectionFlow({
() => buildAgentDraftFoundationSettingText(agentSession),
[agentSession],
);
const agentDraftResultProfile = useMemo(
() => buildCustomWorldProfileFromAgentDraft(agentSession),
[agentSession],
);
const shouldAutoOpenAgentDraftResult = useMemo(
() =>
Boolean(
agentDraftResultProfile &&
agentSession &&
(agentSession.stage === 'object_refining' ||
agentSession.stage === 'visual_refining' ||
agentSession.stage === 'long_tail_review' ||
agentSession.stage === 'ready_to_publish' ||
agentSession.stage === 'published') &&
agentSession.draftCards.length > 0,
),
[agentDraftResultProfile, agentSession],
);
useEffect(() => {
if (!shouldAutoOpenAgentDraftResult || !agentDraftResultProfile) {
return;
}
if (selectionStage === 'agent-workspace') {
setGeneratedCustomWorldProfile(agentDraftResultProfile);
setCustomWorldResultViewSource('agent-draft');
setSelectionStage('custom-world-result');
return;
}
if (
selectionStage === 'custom-world-result' &&
!generatedCustomWorldProfile
) {
setGeneratedCustomWorldProfile(agentDraftResultProfile);
setCustomWorldResultViewSource('agent-draft');
}
}, [
agentDraftResultProfile,
generatedCustomWorldProfile,
selectionStage,
setSelectionStage,
shouldAutoOpenAgentDraftResult,
]);
const agentDraftGenerationProgress = useMemo(
() =>
@@ -543,25 +643,38 @@ export function PreGameSelectionFlow({
const isAgentDraftGenerationView =
customWorldGenerationViewSource === 'agent-draft-foundation';
const isAgentDraftResultView = customWorldResultViewSource === 'agent-draft';
const activeGenerationSettingText = isAgentDraftGenerationView
? agentDraftSettingPreview
: customWorldSettingPreview;
const activeGenerationProgress = isAgentDraftGenerationView
? agentDraftGenerationProgress
: customWorldProgress;
const isActiveGenerationRunning = isAgentDraftGenerationView
? isDraftFoundationOperationRunning(agentOperation)
: isGeneratingCustomWorld;
const activeGenerationError =
isAgentDraftGenerationView &&
isDraftFoundationOperation(agentOperation) &&
agentOperation.status === 'failed'
const activeGenerationError = isAgentDraftGenerationView
? isDraftFoundationOperation(agentOperation) &&
agentOperation.status === 'failed'
? agentOperation.error || agentOperation.phaseDetail
: customWorldError;
: null
: customWorldError;
const leaveCustomWorldResult = () => {
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
setCustomWorldProgress(null);
setCustomWorldGenerationViewSource(null);
setSelectionStage(selectedDetailEntry ? 'detail' : 'platform');
setCustomWorldResultViewSource(null);
setSelectionStage(
isAgentDraftResultView
? 'agent-workspace'
: selectedDetailEntry
? 'detail'
: 'platform',
);
};
const leaveCustomWorldGeneration = () => {
@@ -570,8 +683,11 @@ export function PreGameSelectionFlow({
}
setCustomWorldError(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
setCustomWorldProgress(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
setSelectionStage('platform');
};
@@ -596,6 +712,12 @@ export function PreGameSelectionFlow({
const { session } = await createCustomWorldAgentSession({});
setAgentSession(session);
setAgentOperation(null);
setGeneratedCustomWorldProfile(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
persistAgentUiState(session.sessionId, null);
setShowCreationTypeModal(false);
setPlatformTab('create');
@@ -639,6 +761,20 @@ export function PreGameSelectionFlow({
return;
}
const isDraftFoundationAction = payload.action === 'draft_foundation';
if (isDraftFoundationAction) {
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
setCustomWorldProgress(null);
setCustomWorldGenerationViewSource('agent-draft-foundation');
setCustomWorldResultViewSource(null);
setAgentDraftGenerationStartedAt(Date.now());
setSelectionStage('custom-world-generating');
}
try {
const { operation } = await executeCustomWorldAgentAction(
activeAgentSessionId,
@@ -665,10 +801,44 @@ export function PreGameSelectionFlow({
const leaveAgentWorkspace = () => {
setPlatformTab('create');
setAgentOperation(null);
setGeneratedCustomWorldProfile(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
persistAgentUiState(activeAgentSessionId, null);
setSelectionStage('platform');
};
const leaveAgentDraftGeneration = () => {
if (isDraftFoundationOperationRunning(agentOperation)) {
return;
}
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setSelectionStage('agent-workspace');
};
const leaveAgentDraftResult = () => {
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
setCustomWorldProgress(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
setPlatformTab('create');
setSelectionStage('platform');
};
const retryAgentDraftGeneration = () => {
void executeAgentAction({
action: 'draft_foundation',
});
};
const openCustomWorldCreator = () => {
if (isGeneratingCustomWorld) {
return;
@@ -683,7 +853,11 @@ export function PreGameSelectionFlow({
setPlatformError(null);
setDetailError(null);
setCustomWorldError(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
setCustomWorldProgress(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
setCustomWorldCreatorIntent(
createEmptyCustomWorldCreatorIntent('freeform'),
);
@@ -710,7 +884,11 @@ export function PreGameSelectionFlow({
}
setCustomWorldError(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
setCustomWorldProgress(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
setShowCustomWorldModal(true);
};
@@ -740,26 +918,98 @@ export function PreGameSelectionFlow({
}
};
const saveGeneratedCustomWorld = async () => {
const saveGeneratedCustomWorld = useCallback(
async (profile = generatedCustomWorldProfile) => {
if (!profile) {
return null;
}
const profileSignature = JSON.stringify(profile);
const requestId = latestAutoSaveRequestIdRef.current + 1;
latestAutoSaveRequestIdRef.current = requestId;
setCustomWorldAutoSaveState('saving');
setCustomWorldAutoSaveError(null);
try {
const mutation = await upsertCustomWorldProfile(profile);
if (latestAutoSaveRequestIdRef.current !== requestId) {
return mutation;
}
lastAutoSavedProfileSignatureRef.current = profileSignature;
setSavedCustomWorldEntries(mutation.entries);
setSelectedDetailEntry((current) => {
if (!current || current.profileId === mutation.entry.profileId) {
return mutation.entry;
}
return current;
});
setCustomWorldAutoSaveState('saved');
setCustomWorldAutoSaveError(null);
return mutation;
} catch (error) {
if (latestAutoSaveRequestIdRef.current !== requestId) {
return null;
}
setCustomWorldAutoSaveState('error');
setCustomWorldAutoSaveError(
resolveErrorMessage(error, '保存自定义世界失败。'),
);
return null;
}
},
[generatedCustomWorldProfile],
);
useEffect(() => {
if (!generatedCustomWorldProfile) {
setCustomWorldAutoSaveState('idle');
setCustomWorldAutoSaveError(null);
lastAutoSavedProfileSignatureRef.current = null;
if (customWorldAutoSaveTimeoutRef.current !== null) {
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
customWorldAutoSaveTimeoutRef.current = null;
}
return;
}
try {
const mutation = await upsertCustomWorldProfile(
generatedCustomWorldProfile,
);
setSavedCustomWorldEntries(mutation.entries);
setSelectedDetailEntry(mutation.entry);
await refreshPlatformData();
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldProgress(null);
setSelectionStage('platform');
} catch (error) {
setCustomWorldError(resolveErrorMessage(error, '保存自定义世界失败。'));
if (
selectionStage !== 'custom-world-result' ||
isGeneratingCustomWorld
) {
return;
}
};
const nextSignature = JSON.stringify(generatedCustomWorldProfile);
if (nextSignature === lastAutoSavedProfileSignatureRef.current) {
return;
}
setCustomWorldAutoSaveState('saving');
if (customWorldAutoSaveTimeoutRef.current !== null) {
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
}
const profileToSave = generatedCustomWorldProfile;
customWorldAutoSaveTimeoutRef.current = window.setTimeout(() => {
void saveGeneratedCustomWorld(profileToSave);
customWorldAutoSaveTimeoutRef.current = null;
}, 600);
return () => {
if (customWorldAutoSaveTimeoutRef.current !== null) {
window.clearTimeout(customWorldAutoSaveTimeoutRef.current);
customWorldAutoSaveTimeoutRef.current = null;
}
};
}, [
generatedCustomWorldProfile,
isGeneratingCustomWorld,
saveGeneratedCustomWorld,
selectionStage,
]);
const openSavedCustomWorldEditor = (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
@@ -770,6 +1020,9 @@ export function PreGameSelectionFlow({
setSelectedDetailEntry(entry);
setGeneratedCustomWorldProfile(entry.profile);
lastAutoSavedProfileSignatureRef.current = JSON.stringify(entry.profile);
setCustomWorldAutoSaveState('saved');
setCustomWorldAutoSaveError(null);
setCustomWorldCreatorIntent(
entry.profile.creatorIntent ??
({
@@ -780,6 +1033,8 @@ export function PreGameSelectionFlow({
setCustomWorldGenerationMode(entry.profile.generationMode ?? 'full');
setCustomWorldError(null);
setCustomWorldProgress(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource('classic');
setSelectionStage('custom-world-result');
};
@@ -844,6 +1099,9 @@ export function PreGameSelectionFlow({
...mergedProfile,
id: generatedCustomWorldProfile.id,
});
lastAutoSavedProfileSignatureRef.current = null;
setCustomWorldAutoSaveState('idle');
setCustomWorldAutoSaveError(null);
setCustomWorldProgress(null);
setCustomWorldError(null);
} catch (error) {
@@ -899,7 +1157,11 @@ export function PreGameSelectionFlow({
customWorldAbortControllerRef.current?.abort();
customWorldAbortControllerRef.current = abortController;
setCustomWorldError(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
setCustomWorldProgress(null);
setCustomWorldGenerationViewSource('classic');
setCustomWorldResultViewSource(null);
setShowCustomWorldModal(false);
setSelectionStage('custom-world-generating');
setIsGeneratingCustomWorld(true);
@@ -929,8 +1191,13 @@ export function PreGameSelectionFlow({
}
: profile,
);
lastAutoSavedProfileSignatureRef.current = null;
setCustomWorldAutoSaveState('idle');
setCustomWorldAutoSaveError(null);
setCustomWorldProgress(null);
setCustomWorldError(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource('classic');
setSelectionStage('custom-world-result');
} catch (error) {
if (abortController.signal.aborted) {
@@ -1019,6 +1286,17 @@ export function PreGameSelectionFlow({
entry.profileId === selectedDetailEntry.profileId,
),
);
const resultViewSaveActionLabel =
customWorldAutoSaveState === 'saving'
? '自动保存中'
: customWorldAutoSaveState === 'saved'
? '已保存到我的作品'
: customWorldAutoSaveState === 'error'
? '重新保存到我的作品'
: '保存到我的作品';
const resultViewError =
customWorldAutoSaveError ??
(isAgentDraftResultView ? null : customWorldError);
return (
<>
@@ -1156,16 +1434,61 @@ export function PreGameSelectionFlow({
fallback={<LazyPanelFallback label="正在加载世界生成面板..." />}
>
<CustomWorldGenerationView
settingText={customWorldSettingPreview}
progress={customWorldProgress}
isGenerating={isGeneratingCustomWorld}
error={customWorldError}
onBack={leaveCustomWorldGeneration}
onEditSetting={editCustomWorldSetting}
onRetry={() => {
void createCustomWorld();
}}
onInterrupt={interruptCustomWorldGeneration}
settingText={activeGenerationSettingText}
progress={activeGenerationProgress}
isGenerating={isActiveGenerationRunning}
error={activeGenerationError}
onBack={
isAgentDraftGenerationView
? leaveAgentDraftGeneration
: leaveCustomWorldGeneration
}
onEditSetting={
isAgentDraftGenerationView
? leaveAgentDraftGeneration
: editCustomWorldSetting
}
onRetry={
isAgentDraftGenerationView
? retryAgentDraftGeneration
: () => {
void createCustomWorld();
}
}
onInterrupt={
isAgentDraftGenerationView
? undefined
: interruptCustomWorldGeneration
}
backLabel={
isAgentDraftGenerationView ? '返回工作区' : undefined
}
settingActionLabel={
isAgentDraftGenerationView ? '回到工作区' : undefined
}
retryLabel={
isAgentDraftGenerationView ? '重新生成草稿' : undefined
}
settingTitle={
isAgentDraftGenerationView ? '当前共创设定' : undefined
}
settingDescription={
isAgentDraftGenerationView
? '这批锚点会被整理成第一版世界底稿与草稿卡。'
: undefined
}
progressTitle={
isAgentDraftGenerationView ? '世界草稿生成进度' : undefined
}
activeBadgeLabel={
isAgentDraftGenerationView ? '草稿编译中' : undefined
}
pausedBadgeLabel={
isAgentDraftGenerationView ? '草稿生成已暂停' : undefined
}
idleBadgeLabel={
isAgentDraftGenerationView ? '等待返回工作区' : undefined
}
/>
</Suspense>
</motion.div>
@@ -1186,22 +1509,52 @@ export function PreGameSelectionFlow({
<CustomWorldResultView
profile={generatedCustomWorldProfile}
previewCharacters={previewCustomWorldCharacters}
isGenerating={isGeneratingCustomWorld}
progress={customWorldProgress?.overallProgress ?? 0}
progressLabel={customWorldProgress?.phaseLabel ?? ''}
error={customWorldError}
isGenerating={
isAgentDraftResultView ? false : isGeneratingCustomWorld
}
progress={
isAgentDraftResultView
? 0
: (customWorldProgress?.overallProgress ?? 0)
}
progressLabel={
isAgentDraftResultView
? ''
: (customWorldProgress?.phaseLabel ?? '')
}
error={resultViewError}
onProfileChange={setGeneratedCustomWorldProfile}
onBack={leaveCustomWorldResult}
onEditSetting={editCustomWorldSetting}
onRegenerate={() => {
void createCustomWorld();
}}
onContinueExpand={() => {
void continueExpandCustomWorld();
}}
onBack={
isAgentDraftResultView
? leaveAgentDraftResult
: leaveCustomWorldResult
}
onEditSetting={
isAgentDraftResultView ? undefined : editCustomWorldSetting
}
onRegenerate={
isAgentDraftResultView
? retryAgentDraftGeneration
: () => {
void createCustomWorld();
}
}
onContinueExpand={
isAgentDraftResultView
? undefined
: () => {
void continueExpandCustomWorld();
}
}
onSave={() => {
void saveGeneratedCustomWorld();
}}
readOnly={false}
backLabel={isAgentDraftResultView ? '返回创作' : undefined}
regenerateActionLabel={
isAgentDraftResultView ? '重新生成草稿' : undefined
}
saveActionLabel={resultViewSaveActionLabel}
/>
</Suspense>
</motion.div>

View File

@@ -421,8 +421,11 @@ export function resolveEncounterRecruitCharacter(
return getCharacterById(resolveFallbackRecruitTemplateCharacterId(source));
}
export function getCharacterEquipment(character: Character) {
const runtimeProfile = getRuntimeCustomWorldProfile();
export function getCharacterEquipment(
character: Character,
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
) {
const runtimeProfile = customWorldProfile;
if (runtimeProfile) {
const starterEquipment = buildCustomWorldStarterEquipmentItems(character, runtimeProfile);
const toRarityLabel = (rarity: InventoryItem['rarity'] | undefined) => ({
@@ -492,9 +495,13 @@ export function getCharacterEquipment(character: Character) {
];
}
export function getInventoryItems(character: Character, worldType: WorldType | null) {
if (worldType === WorldType.CUSTOM && getRuntimeCustomWorldProfile()) {
return buildCustomWorldStarterInventoryItems(character).map(item => ({
export function getInventoryItems(
character: Character,
worldType: WorldType | null,
customWorldProfile: CustomWorldProfile | null = getRuntimeCustomWorldProfile(),
) {
if (worldType === WorldType.CUSTOM && customWorldProfile) {
return buildCustomWorldStarterInventoryItems(character, customWorldProfile).map(item => ({
category: item.category,
name: item.name,
quantity: item.quantity,

View File

@@ -1,5 +1,9 @@
import {isRecord, readStoredJson, writeStoredJson} from '../persistence/storage';
import {generateWorldAttributeSchema} from '../services/attributeSchemaGenerator';
import {
isRecord,
readStoredJson,
writeStoredJson,
} from '../persistence/storage';
import { generateWorldAttributeSchema } from '../services/attributeSchemaGenerator';
import { buildFallbackCustomWorldCampScene } from '../services/customWorldCamp';
import {
buildCustomWorldAnchorPackFromIntent,
@@ -26,16 +30,23 @@ import {
CustomWorldRoleInitialItem,
CustomWorldRoleSkill,
EquipmentSlotId,
ItemAttributeResonance,
ItemRarity,
ItemStatProfile,
ItemUseProfile,
KnowledgeFact,
RoleAttributeProfile,
SceneNarrativeResidue,
ThemePack,
ThreadContract,
WorldType,
WorldStoryGraph,
} from '../types';
import {
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS,
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
} from './affinityLevels';
import {coerceWorldAttributeSchema} from './attributeValidation';
import { coerceWorldAttributeSchema } from './attributeValidation';
import {
type CustomWorldLandmarkDraft,
normalizeCustomWorldLandmarks,
@@ -48,11 +59,30 @@ const MIN_CUSTOM_WORLD_AFFINITY = -40;
const MAX_CUSTOM_WORLD_AFFINITY = 90;
const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18;
const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6;
const ITEM_RARITIES = new Set<ItemRarity>(['common', 'uncommon', 'rare', 'epic', 'legendary']);
const ITEM_RARITIES = new Set<ItemRarity>([
'common',
'uncommon',
'rare',
'epic',
'legendary',
]);
const EQUIPMENT_SLOTS = new Set<EquipmentSlotId>(['weapon', 'armor', 'relic']);
const ANIMATION_STATES = new Set<AnimationState>(Object.values(AnimationState));
const CUSTOM_WORLD_NPC_VISUAL_RACES = new Set<CustomWorldNpcVisualRace>(['human', 'elf', 'orc', 'goblin']);
const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES = new Set<CustomWorldNpcVisualGearType>(['cloth', 'leather', 'metal', 'melee', 'magic', 'ranged']);
const CUSTOM_WORLD_NPC_VISUAL_RACES = new Set<CustomWorldNpcVisualRace>([
'human',
'elf',
'orc',
'goblin',
]);
const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES =
new Set<CustomWorldNpcVisualGearType>([
'cloth',
'leather',
'metal',
'melee',
'magic',
'ranged',
]);
const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = new Set([
'武器',
'护甲',
@@ -65,7 +95,12 @@ const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = new Set([
]);
const CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES =
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = ['表层来意', '旧事裂痕', '隐藏执念', '最终底牌'] as const;
const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = [
'表层来意',
'旧事裂痕',
'隐藏执念',
'最终底牌',
] as const;
type CustomWorldRoleFallbackSource = {
name: string;
@@ -92,25 +127,43 @@ function toText(value: unknown, fallback = '') {
function toStringArray(value: unknown) {
return Array.isArray(value)
? value
.filter((item): item is string => typeof item === 'string')
.map(item => item.trim())
.filter(Boolean)
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean)
: [];
}
function toOptionalNumber(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined;
}
function toOptionalInteger(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) ? Math.round(value) : undefined;
return typeof value === 'number' && Number.isFinite(value)
? Math.round(value)
: undefined;
}
function preserveStructuredRecord<T>(value: unknown): T | null {
return isRecord(value) ? (value as T) : null;
}
function preserveStructuredRecordArray<T>(value: unknown): T[] | null {
return Array.isArray(value)
? (value.filter((entry): entry is Record<string, unknown> => isRecord(entry)) as T[])
: null;
}
function normalizeInitialAffinity(value: unknown, fallback: number) {
const resolved = typeof value === 'number' && Number.isFinite(value)
? Math.round(value)
: fallback;
return Math.max(MIN_CUSTOM_WORLD_AFFINITY, Math.min(MAX_CUSTOM_WORLD_AFFINITY, resolved));
const resolved =
typeof value === 'number' && Number.isFinite(value)
? Math.round(value)
: fallback;
return Math.max(
MIN_CUSTOM_WORLD_AFFINITY,
Math.min(MAX_CUSTOM_WORLD_AFFINITY, resolved),
);
}
function truncateText(value: string, maxLength: number) {
@@ -125,7 +178,7 @@ function splitNarrativeSentences(text: string) {
if (!normalized) return [];
const matches = normalized.match(/[^!?]+[!?]?/gu);
return (matches ?? [normalized]).map(item => item.trim()).filter(Boolean);
return (matches ?? [normalized]).map((item) => item.trim()).filter(Boolean);
}
function normalizeRoleItemCategory(value: unknown, fallback = '材料') {
@@ -143,12 +196,17 @@ function normalizeRoleItemCategory(value: unknown, fallback = '材料') {
return fallback;
}
function buildFallbackBackstoryReveal(source: CustomWorldRoleFallbackSource): CharacterBackstoryRevealConfig {
const normalizedBackstory = source.backstory.trim() || `${source.name}对自己的过去仍有保留。`;
function buildFallbackBackstoryReveal(
source: CustomWorldRoleFallbackSource,
): CharacterBackstoryRevealConfig {
const normalizedBackstory =
source.backstory.trim() || `${source.name}对自己的过去仍有保留。`;
const backstorySentences = splitNarrativeSentences(normalizedBackstory);
const backstoryLead = backstorySentences[0] ?? normalizedBackstory;
const backstoryDetail = backstorySentences.slice(0, 2).join('') || normalizedBackstory;
const publicSummary = source.description.trim() || truncateText(normalizedBackstory, 42);
const backstoryDetail =
backstorySentences.slice(0, 2).join('') || normalizedBackstory;
const publicSummary =
source.description.trim() || truncateText(normalizedBackstory, 42);
const fallbackContents = [
source.description.trim() || backstoryLead,
backstoryDetail,
@@ -163,17 +221,28 @@ function buildFallbackBackstoryReveal(source: CustomWorldRoleFallbackSource): Ch
return {
publicSummary,
privateChatUnlockAffinity: DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
chapters: CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map((affinityRequired, index) => ({
id: `saved-backstory-${index + 1}`,
title: CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? `背景片段${index + 1}`,
affinityRequired,
teaser: truncateText(fallbackContents[index] ?? normalizedBackstory, 22),
content: truncateText(fallbackContents[index] ?? normalizedBackstory, 72),
contextSnippet: truncateText(
`${source.name}的背景正在向“${fallbackContents[index] ?? normalizedBackstory}”这条线索展开。`,
48,
),
}) satisfies CharacterBackstoryChapter),
chapters: CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map(
(affinityRequired, index) =>
({
id: `saved-backstory-${index + 1}`,
title:
CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ??
`背景片段${index + 1}`,
affinityRequired,
teaser: truncateText(
fallbackContents[index] ?? normalizedBackstory,
22,
),
content: truncateText(
fallbackContents[index] ?? normalizedBackstory,
72,
),
contextSnippet: truncateText(
`${source.name}的背景正在向“${fallbackContents[index] ?? normalizedBackstory}”这条线索展开。`,
48,
),
}) satisfies CharacterBackstoryChapter,
),
};
}
@@ -193,21 +262,38 @@ function normalizeBackstoryReveal(
return {
publicSummary: toText(value.publicSummary, fallback.publicSummary),
privateChatUnlockAffinity:
typeof value.privateChatUnlockAffinity === 'number' && Number.isFinite(value.privateChatUnlockAffinity)
? normalizeInitialAffinity(value.privateChatUnlockAffinity, DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY)
typeof value.privateChatUnlockAffinity === 'number' &&
Number.isFinite(value.privateChatUnlockAffinity)
? normalizeInitialAffinity(
value.privateChatUnlockAffinity,
DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
)
: fallback.privateChatUnlockAffinity,
chapters: CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map((defaultAffinity, index) => {
const rawChapter = rawChapters[index];
const fallbackChapter = fallback.chapters[index];
return {
id: rawChapter ? toText(rawChapter.id, fallbackChapter?.id) : fallbackChapter?.id ?? `saved-backstory-${index + 1}`,
title: rawChapter ? toText(rawChapter.title, fallbackChapter?.title) : fallbackChapter?.title ?? `背景片段${index + 1}`,
affinityRequired: fallbackChapter?.affinityRequired ?? defaultAffinity,
teaser: rawChapter ? toText(rawChapter.teaser, fallbackChapter?.teaser) : fallbackChapter?.teaser ?? '',
content: rawChapter ? toText(rawChapter.content, fallbackChapter?.content) : fallbackChapter?.content ?? '',
contextSnippet: rawChapter ? toText(rawChapter.contextSnippet, fallbackChapter?.contextSnippet) : fallbackChapter?.contextSnippet ?? '',
} satisfies CharacterBackstoryChapter;
}),
chapters: CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.map(
(defaultAffinity, index) => {
const rawChapter = rawChapters[index];
const fallbackChapter = fallback.chapters[index];
return {
id: rawChapter
? toText(rawChapter.id, fallbackChapter?.id)
: (fallbackChapter?.id ?? `saved-backstory-${index + 1}`),
title: rawChapter
? toText(rawChapter.title, fallbackChapter?.title)
: (fallbackChapter?.title ?? `背景片段${index + 1}`),
affinityRequired:
fallbackChapter?.affinityRequired ?? defaultAffinity,
teaser: rawChapter
? toText(rawChapter.teaser, fallbackChapter?.teaser)
: (fallbackChapter?.teaser ?? ''),
content: rawChapter
? toText(rawChapter.content, fallbackChapter?.content)
: (fallbackChapter?.content ?? ''),
contextSnippet: rawChapter
? toText(rawChapter.contextSnippet, fallbackChapter?.contextSnippet)
: (fallbackChapter?.contextSnippet ?? ''),
} satisfies CharacterBackstoryChapter;
},
),
} satisfies CharacterBackstoryRevealConfig;
}
@@ -217,19 +303,28 @@ function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) {
{
id: 'saved-role-skill-1',
name: `${nameSeed}起手`,
summary: truncateText(source.combatStyle || `${source.name}擅长稳住局面。`, 36),
summary: truncateText(
source.combatStyle || `${source.name}擅长稳住局面。`,
36,
),
style: '起手压制',
},
{
id: 'saved-role-skill-2',
name: `${nameSeed}变招`,
summary: truncateText(source.personality || `${source.name}习惯在周旋中找破绽。`, 36),
summary: truncateText(
source.personality || `${source.name}习惯在周旋中找破绽。`,
36,
),
style: '机动周旋',
},
{
id: 'saved-role-skill-3',
name: `${nameSeed}底牌`,
summary: truncateText(source.motivation || `${source.name}会在关键时刻亮出压箱手段。`, 36),
summary: truncateText(
source.motivation || `${source.name}会在关键时刻亮出压箱手段。`,
36,
),
style: '爆发终结',
},
] satisfies CustomWorldRoleSkill[];
@@ -241,18 +336,23 @@ function normalizeRoleSkills(
) {
const normalized = Array.isArray(value)
? value
.filter(isRecord)
.map((entry, index) => ({
id: toText(entry.id, `saved-role-skill-${index + 1}`),
name: toText(entry.name),
summary: toText(entry.summary, toText(entry.description)),
style: toText(entry.style, toText(entry.category, '常用')),
} satisfies CustomWorldRoleSkill))
.filter(entry => entry.name)
.slice(0, 3)
.filter(isRecord)
.map(
(entry, index) =>
({
id: toText(entry.id, `saved-role-skill-${index + 1}`),
name: toText(entry.name),
summary: toText(entry.summary, toText(entry.description)),
style: toText(entry.style, toText(entry.category, '常用')),
}) satisfies CustomWorldRoleSkill,
)
.filter((entry) => entry.name)
.slice(0, 3)
: [];
return normalized.length > 0 ? normalized : buildFallbackRoleSkills(fallbackSource);
return normalized.length > 0
? normalized
: buildFallbackRoleSkills(fallbackSource);
}
function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
@@ -264,7 +364,10 @@ function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
category: '武器',
quantity: 1,
rarity: 'rare',
description: truncateText(source.combatStyle || `${source.name}随身携带的主要作战物件。`, 36),
description: truncateText(
source.combatStyle || `${source.name}随身携带的主要作战物件。`,
36,
),
tags: source.tags.slice(0, 2),
},
{
@@ -273,7 +376,10 @@ function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: truncateText(source.personality || `${source.name}为长期行动准备的基础补给。`, 36),
description: truncateText(
source.personality || `${source.name}为长期行动准备的基础补给。`,
36,
),
tags: source.relationshipHooks.slice(0, 2),
},
{
@@ -282,7 +388,12 @@ function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: truncateText(source.backstory || source.motivation || `${source.name}不愿随意交出的信物。`, 36),
description: truncateText(
source.backstory ||
source.motivation ||
`${source.name}不愿随意交出的信物。`,
36,
),
tags: [...source.tags, ...source.relationshipHooks].slice(0, 3),
},
] satisfies CustomWorldRoleInitialItem[];
@@ -294,23 +405,29 @@ function normalizeRoleInitialItems(
) {
const normalized = Array.isArray(value)
? value
.filter(isRecord)
.map((entry, index) => ({
id: toText(entry.id, `saved-role-item-${index + 1}`),
name: toText(entry.name),
category: normalizeRoleItemCategory(entry.category),
quantity:
typeof entry.quantity === 'number' && Number.isFinite(entry.quantity)
? Math.max(1, Math.min(99, Math.round(entry.quantity)))
: 1,
rarity: typeof entry.rarity === 'string' && ITEM_RARITIES.has(entry.rarity as ItemRarity)
? entry.rarity as ItemRarity
: 'rare',
description: toText(entry.description),
tags: toStringArray(entry.tags),
} satisfies CustomWorldRoleInitialItem))
.filter(entry => entry.name)
.slice(0, 3)
.filter(isRecord)
.map(
(entry, index) =>
({
id: toText(entry.id, `saved-role-item-${index + 1}`),
name: toText(entry.name),
category: normalizeRoleItemCategory(entry.category),
quantity:
typeof entry.quantity === 'number' &&
Number.isFinite(entry.quantity)
? Math.max(1, Math.min(99, Math.round(entry.quantity)))
: 1,
rarity:
typeof entry.rarity === 'string' &&
ITEM_RARITIES.has(entry.rarity as ItemRarity)
? (entry.rarity as ItemRarity)
: 'rare',
description: toText(entry.description),
tags: toStringArray(entry.tags),
}) satisfies CustomWorldRoleInitialItem,
)
.filter((entry) => entry.name)
.slice(0, 3)
: [];
return normalized.length > 0
@@ -319,17 +436,24 @@ function normalizeRoleInitialItems(
}
function normalizeEquipmentSlot(value: unknown) {
return typeof value === 'string' && EQUIPMENT_SLOTS.has(value as EquipmentSlotId)
? value as EquipmentSlotId
return typeof value === 'string' &&
EQUIPMENT_SLOTS.has(value as EquipmentSlotId)
? (value as EquipmentSlotId)
: null;
}
function normalizeCustomWorldNpcVisualGear(value: unknown): CustomWorldNpcVisualGear | null {
function normalizeCustomWorldNpcVisualGear(
value: unknown,
): CustomWorldNpcVisualGear | null {
if (!isRecord(value)) return null;
const type = typeof value.type === 'string' && CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES.has(value.type as CustomWorldNpcVisualGearType)
? value.type as CustomWorldNpcVisualGearType
: null;
const type =
typeof value.type === 'string' &&
CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES.has(
value.type as CustomWorldNpcVisualGearType,
)
? (value.type as CustomWorldNpcVisualGearType)
: null;
const file = toText(value.file);
if (!type || !file) return null;
@@ -341,12 +465,16 @@ function normalizeCustomWorldNpcVisualGear(value: unknown): CustomWorldNpcVisual
};
}
function normalizeCustomWorldNpcVisual(value: unknown): CustomWorldNpcVisual | undefined {
function normalizeCustomWorldNpcVisual(
value: unknown,
): CustomWorldNpcVisual | undefined {
if (!isRecord(value)) return undefined;
const race = typeof value.race === 'string' && CUSTOM_WORLD_NPC_VISUAL_RACES.has(value.race as CustomWorldNpcVisualRace)
? value.race as CustomWorldNpcVisualRace
: null;
const race =
typeof value.race === 'string' &&
CUSTOM_WORLD_NPC_VISUAL_RACES.has(value.race as CustomWorldNpcVisualRace)
? (value.race as CustomWorldNpcVisualRace)
: null;
if (!race) return undefined;
@@ -357,8 +485,14 @@ function normalizeCustomWorldNpcVisual(value: unknown): CustomWorldNpcVisual | u
hairColorIndex: Math.max(1, toOptionalInteger(value.hairColorIndex) ?? 1),
hairStyleFrame: Math.max(0, toOptionalInteger(value.hairStyleFrame) ?? 0),
facialHairEnabled: Boolean(value.facialHairEnabled),
facialHairColorIndex: Math.max(1, toOptionalInteger(value.facialHairColorIndex) ?? 1),
facialHairStyleFrame: Math.max(0, toOptionalInteger(value.facialHairStyleFrame) ?? 0),
facialHairColorIndex: Math.max(
1,
toOptionalInteger(value.facialHairColorIndex) ?? 1,
),
facialHairStyleFrame: Math.max(
0,
toOptionalInteger(value.facialHairStyleFrame) ?? 0,
),
headgear: normalizeCustomWorldNpcVisualGear(value.headgear),
mainHand: normalizeCustomWorldNpcVisualGear(value.mainHand),
offHand: normalizeCustomWorldNpcVisualGear(value.offHand),
@@ -407,9 +541,9 @@ function normalizeGeneratedAnimationMap(value: unknown) {
});
return entries.length > 0
? Object.fromEntries(entries) as Partial<
? (Object.fromEntries(entries) as Partial<
Record<AnimationState, CharacterAnimationConfig>
>
>)
: undefined;
}
@@ -423,7 +557,9 @@ function normalizeItemStatProfile(value: unknown): ItemStatProfile | null {
incomingDamageMultiplier: toOptionalNumber(value.incomingDamageMultiplier),
};
return Object.values(profile).some(entry => entry !== undefined) ? profile : null;
return Object.values(profile).some((entry) => entry !== undefined)
? profile
: null;
}
function normalizeItemUseProfile(value: unknown): ItemUseProfile | null {
@@ -435,10 +571,15 @@ function normalizeItemUseProfile(value: unknown): ItemUseProfile | null {
cooldownReduction: toOptionalNumber(value.cooldownReduction),
};
return Object.values(profile).some(entry => entry !== undefined) ? profile : null;
return Object.values(profile).some((entry) => entry !== undefined)
? profile
: null;
}
function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayableNpc | null {
function normalizePlayableNpc(
value: unknown,
index: number,
): CustomWorldPlayableNpc | null {
if (!isRecord(value)) return null;
const name = toText(value.name);
@@ -456,13 +597,14 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
personality: toText(value.personality),
motivation: toText(value.motivation, toText(value.description)),
combatStyle: toText(value.combatStyle),
relationshipHooks: relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3),
relationshipHooks:
relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3),
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
} satisfies CustomWorldRoleFallbackSource;
return {
id: toText(value.id, `saved-playable-${index + 1}`),
name,
return {
id: toText(value.id, `saved-playable-${index + 1}`),
name,
title,
role,
description: fallbackSource.description,
@@ -470,21 +612,37 @@ function normalizePlayableNpc(value: unknown, index: number): CustomWorldPlayabl
personality: fallbackSource.personality,
motivation: fallbackSource.motivation,
combatStyle: fallbackSource.combatStyle,
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_PLAYABLE_INITIAL_AFFINITY),
relationshipHooks: fallbackSource.relationshipHooks,
tags: fallbackSource.tags,
backstoryReveal: normalizeBackstoryReveal(value.backstoryReveal, fallbackSource),
skills: normalizeRoleSkills(value.skills, fallbackSource),
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
imageSrc: toText(value.imageSrc) || undefined,
generatedVisualAssetId: toText(value.generatedVisualAssetId) || undefined,
generatedAnimationSetId: toText(value.generatedAnimationSetId) || undefined,
animationMap: normalizeGeneratedAnimationMap(value.animationMap),
templateCharacterId: toText(value.templateCharacterId) || undefined,
};
}
initialAffinity: normalizeInitialAffinity(
value.initialAffinity,
DEFAULT_PLAYABLE_INITIAL_AFFINITY,
),
relationshipHooks: fallbackSource.relationshipHooks,
tags: fallbackSource.tags,
backstoryReveal: normalizeBackstoryReveal(
value.backstoryReveal,
fallbackSource,
),
skills: normalizeRoleSkills(value.skills, fallbackSource),
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
imageSrc: toText(value.imageSrc) || undefined,
generatedVisualAssetId: toText(value.generatedVisualAssetId) || undefined,
generatedAnimationSetId: toText(value.generatedAnimationSetId) || undefined,
animationMap: normalizeGeneratedAnimationMap(value.animationMap),
attributeProfile:
preserveStructuredRecord<RoleAttributeProfile>(value.attributeProfile) ??
undefined,
narrativeProfile:
preserveStructuredRecord<CustomWorldPlayableNpc['narrativeProfile']>(
value.narrativeProfile,
) ?? undefined,
templateCharacterId: toText(value.templateCharacterId) || undefined,
};
}
function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null {
function normalizeStoryNpc(
value: unknown,
index: number,
): CustomWorldNpc | null {
if (!isRecord(value)) return null;
const name = toText(value.name);
@@ -502,13 +660,14 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
personality: toText(value.personality),
motivation: toText(value.motivation),
combatStyle: toText(value.combatStyle),
relationshipHooks: relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3),
relationshipHooks:
relationshipHooks.length > 0 ? relationshipHooks : tags.slice(0, 3),
tags: tags.length > 0 ? tags : relationshipHooks.slice(0, 5),
} satisfies CustomWorldRoleFallbackSource;
return {
id: toText(value.id, `saved-story-${index + 1}`),
name,
return {
id: toText(value.id, `saved-story-${index + 1}`),
name,
title,
role,
description: fallbackSource.description,
@@ -516,28 +675,43 @@ function normalizeStoryNpc(value: unknown, index: number): CustomWorldNpc | null
personality: fallbackSource.personality,
motivation: fallbackSource.motivation,
combatStyle: fallbackSource.combatStyle,
initialAffinity: normalizeInitialAffinity(value.initialAffinity, DEFAULT_STORY_NPC_INITIAL_AFFINITY),
relationshipHooks: fallbackSource.relationshipHooks,
tags: fallbackSource.tags,
backstoryReveal: normalizeBackstoryReveal(value.backstoryReveal, fallbackSource),
skills: normalizeRoleSkills(value.skills, fallbackSource),
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
imageSrc: toText(value.imageSrc) || undefined,
generatedVisualAssetId: toText(value.generatedVisualAssetId) || undefined,
generatedAnimationSetId: toText(value.generatedAnimationSetId) || undefined,
animationMap: normalizeGeneratedAnimationMap(value.animationMap),
visual: normalizeCustomWorldNpcVisual(value.visual),
};
}
initialAffinity: normalizeInitialAffinity(
value.initialAffinity,
DEFAULT_STORY_NPC_INITIAL_AFFINITY,
),
relationshipHooks: fallbackSource.relationshipHooks,
tags: fallbackSource.tags,
backstoryReveal: normalizeBackstoryReveal(
value.backstoryReveal,
fallbackSource,
),
skills: normalizeRoleSkills(value.skills, fallbackSource),
initialItems: normalizeRoleInitialItems(value.initialItems, fallbackSource),
imageSrc: toText(value.imageSrc) || undefined,
generatedVisualAssetId: toText(value.generatedVisualAssetId) || undefined,
generatedAnimationSetId: toText(value.generatedAnimationSetId) || undefined,
animationMap: normalizeGeneratedAnimationMap(value.animationMap),
attributeProfile:
preserveStructuredRecord<RoleAttributeProfile>(value.attributeProfile) ??
undefined,
narrativeProfile:
preserveStructuredRecord<CustomWorldNpc['narrativeProfile']>(
value.narrativeProfile,
) ?? undefined,
visual: normalizeCustomWorldNpcVisual(value.visual),
};
}
function normalizeItem(value: unknown, index: number): CustomWorldItem | null {
if (!isRecord(value)) return null;
const name = toText(value.name);
const category = toText(value.category);
const rarity = typeof value.rarity === 'string' && ITEM_RARITIES.has(value.rarity as ItemRarity)
? value.rarity as ItemRarity
: null;
const rarity =
typeof value.rarity === 'string' &&
ITEM_RARITIES.has(value.rarity as ItemRarity)
? (value.rarity as ItemRarity)
: null;
if (!name || !category || !rarity) return null;
return {
@@ -549,15 +723,25 @@ function normalizeItem(value: unknown, index: number): CustomWorldItem | null {
tags: toStringArray(value.tags),
iconSrc: toText(value.iconSrc) || undefined,
sourcePath: toText(value.sourcePath) || undefined,
origin: value.origin === 'generated' || value.origin === 'catalog' ? value.origin : undefined,
origin:
value.origin === 'generated' || value.origin === 'catalog'
? value.origin
: undefined,
equipmentSlotId: normalizeEquipmentSlot(value.equipmentSlotId),
statProfile: normalizeItemStatProfile(value.statProfile),
useProfile: normalizeItemUseProfile(value.useProfile),
value: toOptionalNumber(value.value),
attributeResonance:
preserveStructuredRecord<ItemAttributeResonance>(
value.attributeResonance,
) ?? undefined,
};
}
function normalizeLandmark(value: unknown, index: number): CustomWorldLandmark | null {
function normalizeLandmark(
value: unknown,
index: number,
): CustomWorldLandmark | null {
if (!isRecord(value)) return null;
const name = toText(value.name);
@@ -569,6 +753,10 @@ function normalizeLandmark(value: unknown, index: number): CustomWorldLandmark |
description: toText(value.description),
dangerLevel: toText(value.dangerLevel),
imageSrc: toText(value.imageSrc) || undefined,
narrativeResidues:
preserveStructuredRecordArray<SceneNarrativeResidue>(
value.narrativeResidues,
) ?? undefined,
sceneNpcIds: [],
connections: [],
};
@@ -578,7 +766,12 @@ function normalizeCampScene(
value: unknown,
fallbackProfile: Pick<
CustomWorldProfile,
'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
| 'name'
| 'summary'
| 'tone'
| 'playerGoal'
| 'settingText'
| 'templateWorldType'
>,
) {
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
@@ -655,6 +848,12 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
const summary = toText(value.summary);
const tone = toText(value.tone);
const playerGoal = toText(value.playerGoal);
const majorFactions = toStringArray(value.majorFactions);
const coreConflicts = toStringArray(value.coreConflicts);
const resolvedCoreConflicts =
coreConflicts.length > 0
? coreConflicts
: [summary || playerGoal || settingText || name];
const camp = normalizeCampScene(value.camp, {
name,
summary,
@@ -670,18 +869,18 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
summary,
tone,
playerGoal,
majorFactions: [],
coreConflicts: [summary || playerGoal || settingText || name],
majorFactions,
coreConflicts: resolvedCoreConflicts,
});
const storyNpcs = Array.isArray(value.storyNpcs)
? value.storyNpcs
.map((entry, index) => normalizeStoryNpc(entry, index))
.filter((entry): entry is CustomWorldNpc => Boolean(entry))
.map((entry, index) => normalizeStoryNpc(entry, index))
.filter((entry): entry is CustomWorldNpc => Boolean(entry))
: [];
const landmarkDrafts = Array.isArray(value.landmarks)
? value.landmarks
.map((entry, index) => normalizeLandmarkDraft(entry, index))
.filter((entry): entry is CustomWorldLandmarkDraft => Boolean(entry))
.map((entry, index) => normalizeLandmarkDraft(entry, index))
.filter((entry): entry is CustomWorldLandmarkDraft => Boolean(entry))
: [];
const normalizedProfile = {
@@ -694,27 +893,34 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
playerGoal,
templateWorldType,
compatibilityTemplateWorldType,
majorFactions: [],
coreConflicts: [summary || playerGoal || settingText || name],
attributeSchema: coerceWorldAttributeSchema(value.attributeSchema, generatedAttributeSchema),
majorFactions,
coreConflicts: resolvedCoreConflicts,
attributeSchema: coerceWorldAttributeSchema(
value.attributeSchema,
generatedAttributeSchema,
),
playableNpcs: Array.isArray(value.playableNpcs)
? value.playableNpcs
.map((entry, index) => normalizePlayableNpc(entry, index))
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
.map((entry, index) => normalizePlayableNpc(entry, index))
.filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry))
: [],
storyNpcs,
items: Array.isArray(value.items)
? value.items
.map((entry, index) => normalizeItem(entry, index))
.filter((entry): entry is CustomWorldItem => Boolean(entry))
.map((entry, index) => normalizeItem(entry, index))
.filter((entry): entry is CustomWorldItem => Boolean(entry))
: [],
camp,
landmarks: normalizeCustomWorldLandmarks({
landmarks: landmarkDrafts,
storyNpcs,
}),
themePack: null,
storyGraph: null,
themePack: preserveStructuredRecord<ThemePack>(value.themePack),
storyGraph: preserveStructuredRecord<WorldStoryGraph>(value.storyGraph),
knowledgeFacts:
preserveStructuredRecordArray<KnowledgeFact>(value.knowledgeFacts),
threadContracts:
preserveStructuredRecordArray<ThreadContract>(value.threadContracts),
creatorIntent: normalizeCustomWorldCreatorIntent(value.creatorIntent),
anchorPack:
value.anchorPack && typeof value.anchorPack === 'object'
@@ -733,9 +939,12 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
? value.generationMode
: 'full',
generationStatus:
value.generationStatus === 'key_only' || value.generationStatus === 'complete'
value.generationStatus === 'key_only' ||
value.generationStatus === 'complete'
? value.generationStatus
: 'complete',
scenarioPackId: toText(value.scenarioPackId) || null,
campaignPackId: toText(value.campaignPackId) || null,
} satisfies CustomWorldProfile;
return {
@@ -747,9 +956,15 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
};
}
export function normalizeCustomWorldProfileRecord(
value: unknown,
): CustomWorldProfile | null {
return normalizeProfile(value);
}
function writeProfiles(profiles: CustomWorldProfile[]) {
const normalizedProfiles = profiles
.map(profile => normalizeProfile(profile))
.map((profile) => normalizeProfile(profile))
.filter((profile): profile is CustomWorldProfile => Boolean(profile))
.slice(0, MAX_SAVED_CUSTOM_WORLDS);
@@ -772,13 +987,17 @@ export function readSavedCustomWorldProfiles() {
return (
readStoredJson({
key: CUSTOM_WORLD_LIBRARY_STORAGE_KEY,
parse: value => {
if (!isRecord(value) || value.version !== CUSTOM_WORLD_LIBRARY_VERSION || !Array.isArray(value.profiles)) {
parse: (value) => {
if (
!isRecord(value) ||
value.version !== CUSTOM_WORLD_LIBRARY_VERSION ||
!Array.isArray(value.profiles)
) {
return null;
}
return value.profiles
.map(profile => normalizeProfile(profile))
.map((profile) => normalizeProfile(profile))
.filter((profile): profile is CustomWorldProfile => Boolean(profile))
.slice(0, MAX_SAVED_CUSTOM_WORLDS);
},
@@ -789,7 +1008,9 @@ export function readSavedCustomWorldProfiles() {
export function upsertSavedCustomWorldProfile(profile: CustomWorldProfile) {
const nextProfiles = [
profile,
...readSavedCustomWorldProfiles().filter(savedProfile => savedProfile.id !== profile.id),
...readSavedCustomWorldProfiles().filter(
(savedProfile) => savedProfile.id !== profile.id,
),
];
return writeProfiles(nextProfiles);
}

View File

@@ -385,6 +385,7 @@ export function normalizeCustomWorldLandmarks(params: {
description: landmark.description,
dangerLevel: landmark.dangerLevel,
imageSrc: landmark.imageSrc,
narrativeResidues: landmark.narrativeResidues,
sceneNpcIds: resolveSceneNpcIdsForLandmark(
landmark,
storyNpcs,
@@ -407,6 +408,7 @@ export function syncCustomWorldLandmarkConnections(
return normalizeCustomWorldLandmarks({
landmarks: landmarks.map((landmark) => ({
...landmark,
narrativeResidues: landmark.narrativeResidues,
sceneNpcIds: landmark.sceneNpcIds,
connections: landmark.connections.map((connection) => ({
targetLandmarkId: connection.targetLandmarkId,

View File

@@ -1,4 +1,4 @@
import { Character, EquipmentLoadout, EquipmentSlotId, GameState, InventoryItem, ItemRarity } from '../types';
import { Character, CustomWorldProfile, EquipmentLoadout, EquipmentSlotId, GameState, InventoryItem, ItemRarity } from '../types';
import { normalizeBuildRole, normalizeBuildTags } from './buildTags';
import type { CharacterEquipmentItem } from './characterPresets';
import { getCharacterEquipment, getCharacterMaxHp, getCharacterMaxMana } from './characterPresets';
@@ -201,9 +201,12 @@ export function isInventoryItemEquippable(item: InventoryItem) {
return getEquipmentSlotFromItem(item) !== null;
}
export function buildInitialEquipmentLoadout(character: Character) {
export function buildInitialEquipmentLoadout(
character: Character,
customWorldProfile: CustomWorldProfile | null = null,
) {
const loadout = createEmptyEquipmentLoadout();
const starterEquipment = getCharacterEquipment(character);
const starterEquipment = getCharacterEquipment(character, customWorldProfile);
starterEquipment.forEach((equipmentItem, index) => {
const inferredSlot = inferSlotFromText(`${equipmentItem.slot} ${equipmentItem.item}`)

View File

@@ -439,12 +439,19 @@ function mergeInventory(items: InventoryItem[]) {
function buildCharacterInventory(
character: Character,
worldType: WorldType | null,
customWorldProfile = getRuntimeCustomWorldProfile(),
) {
if (worldType === WorldType.CUSTOM && getRuntimeCustomWorldProfile()) {
return sortInventoryItems(buildCustomWorldStarterInventoryItems(character));
if (worldType === WorldType.CUSTOM && customWorldProfile) {
return sortInventoryItems(
buildCustomWorldStarterInventoryItems(character, customWorldProfile),
);
}
const packItems = getInventoryItems(character, worldType).map((item) =>
const packItems = getInventoryItems(
character,
worldType,
customWorldProfile,
).map((item) =>
buildInventoryItem('player', item.category, item.name, item.quantity),
);
return sortInventoryItems(mergeInventory(packItems));
@@ -453,10 +460,17 @@ function buildCharacterInventory(
function buildCharacterNpcInventory(
character: Character,
worldType: WorldType | null,
customWorldProfile = getRuntimeCustomWorldProfile(),
) {
if (worldType === WorldType.CUSTOM && getRuntimeCustomWorldProfile()) {
const starterEquipment = buildCustomWorldStarterEquipmentItems(character);
const starterInventory = buildCustomWorldStarterInventoryItems(character);
if (worldType === WorldType.CUSTOM && customWorldProfile) {
const starterEquipment = buildCustomWorldStarterEquipmentItems(
character,
customWorldProfile,
);
const starterInventory = buildCustomWorldStarterInventoryItems(
character,
customWorldProfile,
);
return sortInventoryItems(
mergeInventory([
...(Object.values(starterEquipment).filter(Boolean) as InventoryItem[]),
@@ -465,7 +479,7 @@ function buildCharacterNpcInventory(
);
}
const equipmentItems = getCharacterEquipment(character).map((item) =>
const equipmentItems = getCharacterEquipment(character, customWorldProfile).map((item) =>
buildInventoryItem(
`npc-${character.id}`,
item.slot,
@@ -1503,8 +1517,9 @@ export function removeInventoryItem(
export function buildInitialPlayerInventory(
character: Character,
worldType: WorldType | null,
customWorldProfile = getRuntimeCustomWorldProfile(),
) {
return buildCharacterInventory(character, worldType);
return buildCharacterInventory(character, worldType, customWorldProfile);
}
function buildMonsterPresetInventory(
@@ -1547,7 +1562,11 @@ export function buildInitialNpcState(
? (() => {
const character = getCharacterById(encounter.characterId);
return character
? buildCharacterNpcInventory(character, worldType)
? buildCharacterNpcInventory(
character,
worldType,
state?.customWorldProfile ?? getRuntimeCustomWorldProfile(),
)
: buildRoleInventory(encounter, worldType, state);
})()
: encounter.monsterPresetId

View File

@@ -51,11 +51,13 @@ function createCharacter(): Character {
function createOption(
functionId: string,
actionText = functionId,
interaction?: StoryOption['interaction'],
): StoryOption {
return {
functionId,
actionText,
text: actionText,
interaction,
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
@@ -196,14 +198,21 @@ describe('storyCampCompanion', () => {
});
it('uses AI follow-up options when the camp follow-up request succeeds and falls back on errors', async () => {
const baseOptions = [createOption('npc_chat', '继续交谈')];
const baseOptions = [
createOption('npc_chat', '继续交谈', {
kind: 'npc',
npcId: 'camp-companion',
action: 'chat',
}),
createOption('camp_travel_home_scene', '前往旧地点'),
];
const generateNextStep = vi
.fn()
.mockResolvedValueOnce({
storyText: '继续营地交谈',
options: [
createOption('npc_trade', '先看对方带来的东西'),
createOption('npc_chat', '继续交谈'),
createOption('npc_chat', '顺着刚才的话继续问下去'),
createOption('camp_travel_home_scene', '先回云河渡'),
],
})
.mockRejectedValueOnce(new Error('llm failed'));
@@ -258,9 +267,20 @@ describe('storyCampCompanion', () => {
openingCampDialogue: '你们刚交换完第一轮判断。',
}),
);
expect(resolvedOptions.map((option) => option.functionId)).toEqual([
'npc_trade',
'npc_chat',
expect(resolvedOptions).toEqual([
expect.objectContaining({
functionId: 'npc_chat',
actionText: '顺着刚才的话继续问下去',
interaction: {
kind: 'npc',
npcId: 'camp-companion',
action: 'chat',
},
}),
expect.objectContaining({
functionId: 'camp_travel_home_scene',
actionText: '先回云河渡',
}),
]);
expect(fallbackOptions).toBe(baseOptions);
} finally {

View File

@@ -26,6 +26,7 @@ import type {
StoryOption,
WorldType,
} from '../../types';
import { resolveStoryResponseOptions } from './storyResponseOptions';
type BuildNpcStory = (
state: GameState,
@@ -182,7 +183,11 @@ export function createCampCompanionStoryHelpers(params: {
},
);
return sortStoryOptionsByPriority(response.options);
return resolveStoryResponseOptions({
responseOptions: response.options,
availableOptions: baseOptions,
getSanitizedOptions: () => sortStoryOptionsByPriority(baseOptions),
});
} catch (error) {
console.error('Failed to infer opening camp follow-up options:', error);
return baseOptions;

View File

@@ -7,12 +7,14 @@ function createOption(
functionId: string,
actionText: string,
priority = 0,
interaction?: StoryOption['interaction'],
): StoryOption {
return {
functionId,
actionText,
text: actionText,
priority,
interaction,
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
@@ -52,6 +54,41 @@ describe('storyResponseOptions', () => {
]);
});
it('preserves interaction metadata when AI rewrites provided npc options', () => {
const availableOptions = [
createOption('npc_chat', '继续交谈', 3, {
kind: 'npc',
npcId: 'npc-camp',
action: 'chat',
}),
createOption('camp_travel_home_scene', '前往旧地点', 1),
];
const responseOptions = [
createOption('npc_chat', '顺着你刚才那句提醒继续追问', 3),
createOption('camp_travel_home_scene', '先回云河渡', 1),
];
const resolved = resolveStoryResponseOptions({
responseOptions,
availableOptions,
getSanitizedOptions: () => {
throw new Error('available options branch should not sanitize');
},
});
expect(resolved[0]).toEqual(
expect.objectContaining({
functionId: 'npc_chat',
actionText: '顺着你刚才那句提醒继续追问',
interaction: {
kind: 'npc',
npcId: 'npc-camp',
action: 'chat',
},
}),
);
});
it('falls back to available options when the response omits them entirely', () => {
const availableOptions = [
createOption('npc_chat', '继续交谈', 2),

View File

@@ -8,6 +8,65 @@ type ResolveStoryResponseOptionsParams = {
getSanitizedOptions: () => StoryOption[];
};
function cloneStoryOption(option: StoryOption): StoryOption {
return {
...option,
visuals: {
...option.visuals,
monsterChanges: option.visuals.monsterChanges.map((change) => ({
...change,
})),
},
interaction: option.interaction ? { ...option.interaction } : undefined,
goalAffordance: option.goalAffordance
? { ...option.goalAffordance }
: option.goalAffordance,
};
}
function rewriteOptionsFromBaseOptions(
responseOptions: StoryOption[],
baseOptions: StoryOption[],
) {
if (responseOptions.length === 0) {
return baseOptions.map(cloneStoryOption);
}
const optionBuckets = new Map<string, StoryOption[]>();
const consumedOptions = new Set<StoryOption>();
baseOptions.forEach((option) => {
const bucket = optionBuckets.get(option.functionId) ?? [];
bucket.push(option);
optionBuckets.set(option.functionId, bucket);
});
const resolved: StoryOption[] = [];
responseOptions.forEach((option) => {
const bucket = optionBuckets.get(option.functionId);
const matchedOption = bucket?.shift();
if (!matchedOption) return;
consumedOptions.add(matchedOption);
const rewrittenText = option.actionText.trim() || matchedOption.actionText;
resolved.push({
...cloneStoryOption(matchedOption),
actionText: rewrittenText,
text: rewrittenText || matchedOption.text || matchedOption.actionText,
});
});
if (resolved.length === baseOptions.length) {
return resolved;
}
const remainingOptions = baseOptions.filter(
(option) => !consumedOptions.has(option),
);
return [...resolved, ...remainingOptions.map(cloneStoryOption)];
}
export function resolveStoryResponseOptions({
responseOptions,
availableOptions = null,
@@ -16,13 +75,13 @@ export function resolveStoryResponseOptions({
}: ResolveStoryResponseOptionsParams) {
if (availableOptions) {
return sortStoryOptionsByPriority(
responseOptions.length > 0 ? responseOptions : availableOptions,
rewriteOptionsFromBaseOptions(responseOptions, availableOptions),
);
}
if (optionCatalog) {
return sortStoryOptionsByPriority(
responseOptions.length > 0 ? responseOptions : optionCatalog,
rewriteOptionsFromBaseOptions(responseOptions, optionCatalog),
);
}

View File

@@ -0,0 +1,385 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useMemo } from 'react';
import { afterEach, expect, test } from 'vitest';
import {
buildCustomWorldPlayableCharacters,
setRuntimeCharacterOverrides,
} from '../data/characterPresets';
import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary';
import { WorldType } from '../types';
import { useGameFlow } from './useGameFlow';
function buildBackstoryReveal(label: string) {
return {
publicSummary: `${label}的公开背景`,
privateChatUnlockAffinity: 40,
chapters: [
{
id: `${label}-surface`,
title: '表层来意',
affinityRequired: 15,
teaser: `${label}先只肯说表面的来意。`,
content: `${label}表面上只愿意谈当前局势。`,
contextSnippet: `${label}表面上还在收着话。`,
},
{
id: `${label}-scar`,
title: '旧事裂痕',
affinityRequired: 30,
teaser: `${label}背后还有一段旧伤。`,
content: `${label}曾在旧案里留下无法轻易揭开的伤口。`,
contextSnippet: `${label}和旧案之间有一段没说开的裂痕。`,
},
{
id: `${label}-hidden`,
title: '隐藏执念',
affinityRequired: 60,
teaser: `${label}真正想追的不是表面那件事。`,
content: `${label}真正挂着的是旧案里还没结的那条线。`,
contextSnippet: `${label}真正执念指向旧案深处。`,
},
{
id: `${label}-final`,
title: '最终底牌',
affinityRequired: 90,
teaser: `${label}手里还压着最后一张牌。`,
content: `${label}手里还握着能直接证明真相的关键证据。`,
contextSnippet: `${label}最后的底牌足以改写局势。`,
},
],
};
}
function buildSavedProfile() {
const profile = normalizeCustomWorldProfileRecord({
id: 'saved-runtime-profile',
settingText: '被海雾吞没的旧航路群岛',
name: '回潮群岛',
subtitle: '旧灯塔与断续潮路',
summary: '围绕旧灯塔、假航灯和沉船旧案展开的结果页世界。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船夜与封航记录被改动的真相。',
templateWorldType: WorldType.WUXIA,
compatibilityTemplateWorldType: WorldType.WUXIA,
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['封航争夺', '沉船真相'],
attributeSchema: {
id: 'schema:test',
worldId: 'CUSTOM',
schemaVersion: 1,
schemaName: '测试',
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: '回潮群岛',
settingSummary: '潮雾旧航路',
tone: '压抑',
conflictCore: '沉船真相',
},
slots: [],
},
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '旧航路引路人',
role: '关键同行者',
description: '最熟悉旧潮路的人。',
backstory: '他在沉船夜里带着半支船队逃出过假航灯。',
personality: '表面沉稳,心里一直在算退路。',
motivation: '想赶在封航前查清真相。',
combatStyle: '借潮路换位,先拉扯再压近。',
initialAffinity: 18,
relationshipHooks: ['旧友', '沉船旧案'],
tags: ['潮路', '引路'],
backstoryReveal: buildBackstoryReveal('沈砺'),
skills: [
{
id: 'skill-playable-1',
name: '潮行引路',
summary: '踩着旧潮阶切线前压,替队伍打开角度。',
style: '机动周旋',
},
{
id: 'skill-playable-2',
name: '回雾折返',
summary: '借海雾遮住身位,再从侧线拉开。',
style: '起手压制',
},
{
id: 'skill-playable-3',
name: '旧图定标',
summary: '用旧潮图锁定退路和突入口。',
style: '爆发终结',
},
],
initialItems: [
{
id: 'item-playable-1',
name: '旧潮短刃',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '专门在湿滑甲板上近身换位用的短刃。',
tags: ['潮路', '近战'],
},
{
id: 'item-playable-2',
name: '雾盐药包',
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '压住寒潮后遗症的随身药包。',
tags: ['补给'],
},
{
id: 'item-playable-3',
name: '旧潮图残页',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '足够指向沉船夜另一条线的残页。',
tags: ['线索', '真相'],
},
],
templateCharacterId: 'archer-hero',
},
],
storyNpcs: [
{
id: 'story-1',
name: '顾潮音',
title: '守灯会值夜人',
role: '场景关键角色',
description: '夜里巡灯与封锁禁航区的人。',
backstory: '她在第一次海雾吞船那夜守到了最后一盏灯。',
personality: '冷静克制,但提到旧灯册会明显变调。',
motivation: '想守住灯塔记录,也想找到谁先改动了禁航信号。',
combatStyle: '借塔顶视角与风向压制,再用灯火错位扰乱。',
initialAffinity: 8,
relationshipHooks: ['禁航记录', '灯塔值夜'],
tags: ['守灯会', '灯塔'],
backstoryReveal: buildBackstoryReveal('顾潮音'),
skills: [
{
id: 'skill-story-1',
name: '夜潮灯语',
summary: '借灯语与潮声干扰对方判断。',
style: '起手压制',
},
{
id: 'skill-story-2',
name: '禁航暗潮',
summary: '封住错误航线,把人逼回她熟悉的区域。',
style: '机动周旋',
},
{
id: 'skill-story-3',
name: '回声巡线',
summary: '借塔顶回声迅速锁定异动方向。',
style: '爆发终结',
},
],
initialItems: [
{
id: 'item-story-1',
name: '值夜灯尺',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '兼作警械和测灯尺的长柄器具。',
tags: ['守灯会'],
},
],
narrativeProfile: {
publicMask: '守灯会值夜人,对外总像比别人更冷静一步。',
firstContactMask: '想进塔就按规矩来,今晚谁都别想乱闯禁航区。',
visibleLine: '她表面上只是在守灯和封线。',
hiddenLine: '她真正盯着的是那本被改过的原始灯册。',
contradiction: '越强调规矩,越像在掩住自己手里那份没上报的记录。',
debtOrBurden: '她扛着第一次海雾吞船夜里没能及时点亮备用灯的旧责。',
taboo: '最忌讳别人把那夜的失踪当成单纯天灾。',
immediatePressure: '新的封航令马上要落下来,她必须先把旧灯册转出去。',
relatedThreadIds: ['thread-visible-1'],
relatedScarIds: ['scar-1'],
reactionHooks: ['原始灯册', '封灯令'],
},
},
],
items: [],
camp: {
name: '回潮暂栖所',
description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。',
dangerLevel: 'low',
},
landmarks: [
{
id: 'landmark-1',
name: '回潮旧灯塔',
description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。',
dangerLevel: 'high',
sceneNpcIds: ['story-1'],
connections: [
{
targetLandmarkId: 'landmark-2',
relativePosition: 'forward',
summary: '沿着旧潮阶继续前压到雾栈尽头。',
},
],
narrativeResidues: [
{
id: 'residue-1',
title: '潮痕',
visibleClue: '塔壁上有一圈不该出现在高处的潮痕。',
linkedFactIds: ['fact-1'],
linkedThreadIds: ['thread-visible-1'],
},
],
},
{
id: 'landmark-2',
name: '雾栈尽头',
description: '旧栈桥尽头只剩断索、潮板和被抹掉的船名。',
dangerLevel: 'high',
sceneNpcIds: [],
connections: [
{
targetLandmarkId: 'landmark-1',
relativePosition: 'back',
summary: '退回灯塔还能重新整理路线。',
},
],
},
],
scenarioPackId: 'scenario-pack:tide',
campaignPackId: 'campaign-pack:tide',
generationMode: 'full',
generationStatus: 'complete',
});
if (!profile) {
throw new Error('failed to build saved custom world profile');
}
return profile;
}
function readSnapshot() {
const raw = screen.getByTestId('state-snapshot').textContent ?? '{}';
return JSON.parse(raw) as {
worldType: string | null;
currentScene: string;
profileName: string | null;
activeScenarioPackId: string | null;
activeCampaignPackId: string | null;
currentScenePresetId: string | null;
currentScenePresetName: string | null;
currentSceneConnectedIds: string[];
firstLandmarkResidueTitle: string | null;
playerCharacterName: string | null;
playerInventoryNames: string[];
playerEquipment: {
weapon: string | null;
armor: string | null;
relic: string | null;
};
};
}
function GameFlowHarness() {
const profile = useMemo(() => buildSavedProfile(), []);
const playableCharacters = useMemo(
() => buildCustomWorldPlayableCharacters(profile),
[profile],
);
const selectedCharacter = playableCharacters[0] ?? null;
const { gameState, handleCustomWorldSelect, handleCharacterSelect } =
useGameFlow();
const snapshot = {
worldType: gameState.worldType,
currentScene: gameState.currentScene,
profileName: gameState.customWorldProfile?.name ?? null,
activeScenarioPackId: gameState.activeScenarioPackId ?? null,
activeCampaignPackId: gameState.activeCampaignPackId ?? null,
currentScenePresetId: gameState.currentScenePreset?.id ?? null,
currentScenePresetName: gameState.currentScenePreset?.name ?? null,
currentSceneConnectedIds: gameState.currentScenePreset?.connectedSceneIds ?? [],
firstLandmarkResidueTitle:
gameState.customWorldProfile?.landmarks[0]?.narrativeResidues?.[0]
?.title ?? null,
playerCharacterName: gameState.playerCharacter?.name ?? null,
playerInventoryNames: gameState.playerInventory.map((item) => item.name),
playerEquipment: {
weapon: gameState.playerEquipment.weapon?.name ?? null,
armor: gameState.playerEquipment.armor?.name ?? null,
relic: gameState.playerEquipment.relic?.name ?? null,
},
};
return (
<div>
<button
type="button"
onClick={() => handleCustomWorldSelect(profile)}
>
</button>
<button
type="button"
onClick={() => {
if (selectedCharacter) {
handleCharacterSelect(selectedCharacter);
}
}}
>
</button>
<pre data-testid="state-snapshot">{JSON.stringify(snapshot)}</pre>
</div>
);
}
afterEach(() => {
setRuntimeCustomWorldProfile(null);
setRuntimeCharacterOverrides(null);
});
test('saved custom world result settings flow into game state after entering the world', async () => {
const user = userEvent.setup();
render(<GameFlowHarness />);
await user.click(screen.getByRole('button', { name: '选择世界' }));
await waitFor(() => {
expect(readSnapshot().worldType).toBe(WorldType.CUSTOM);
});
expect(readSnapshot().profileName).toBe('回潮群岛');
expect(readSnapshot().currentScenePresetId).toBe('custom-scene-camp');
expect(readSnapshot().currentScenePresetName).toBe('回潮暂栖所');
expect(readSnapshot().currentSceneConnectedIds).toContain(
'custom-scene-landmark-1',
);
expect(readSnapshot().firstLandmarkResidueTitle).toBe('潮痕');
expect(readSnapshot().activeScenarioPackId).toBe('scenario-pack:tide');
expect(readSnapshot().activeCampaignPackId).toBe('campaign-pack:tide');
await user.click(screen.getByRole('button', { name: '确认角色' }));
await waitFor(() => {
expect(readSnapshot().currentScene).toBe('Story');
});
expect(readSnapshot().playerCharacterName).toBe('沈砺');
expect(readSnapshot().playerInventoryNames).toContain('旧潮短刃');
expect(readSnapshot().playerInventoryNames).toContain('旧潮图残页');
expect(readSnapshot().playerEquipment.weapon).toBe('旧潮短刃');
expect(readSnapshot().playerEquipment.relic).toBe('旧潮图残页');
expect(readSnapshot().playerEquipment.armor).toBeTruthy();
});

View File

@@ -15,13 +15,102 @@ import { createInitialGameRuntimeStats } from '../data/runtimeStats';
import { ensureSceneEncounterPreview,RESOLVED_ENTITY_X_METERS } from '../data/sceneEncounterPreviews';
import { getScenePreset,getWorldCampScenePreset } from '../data/scenePresets';
import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine';
import { AnimationState, Character, CustomWorldProfile, Encounter, GameState, SceneNpc, WorldType } from '../types';
import { AnimationState, Character, CustomWorldProfile, Encounter, EquipmentLoadout, GameState, InventoryItem, SceneNpc, WorldType } from '../types';
import type { BottomTab } from '../types/navigation';
const PLAYER_BASE_MAX_HP = 180;
export type {BottomTab} from '../types/navigation';
function mergeStarterInventoryItems<T extends { category: string; name: string }>(
explicitItems: T[],
fallbackItems: T[],
) {
const merged = new Map<string, T>();
[...explicitItems, ...fallbackItems].forEach((item) => {
merged.set(`${item.category}:${item.name}`, item);
});
return [...merged.values()];
}
function normalizeExplicitStarterCategory(category: string) {
const normalized = category.trim();
return normalized === '专属物' ? '专属物品' : normalized;
}
function inferExplicitStarterSlot(category: string) {
const normalized = normalizeExplicitStarterCategory(category);
if (normalized === '武器') return 'weapon' as const;
if (normalized === '护甲') return 'armor' as const;
if (
normalized === '饰品' ||
normalized === '稀有品' ||
normalized === '专属物品'
) {
return 'relic' as const;
}
return null;
}
function buildExplicitCustomWorldRoleStarterState(
profile: CustomWorldProfile,
character: Character,
) {
const role =
profile.playableNpcs.find((entry) => entry.id === character.id) ??
profile.storyNpcs.find((entry) => entry.id === character.id) ??
profile.playableNpcs.find(
(entry) => entry.templateCharacterId === character.id,
) ??
profile.playableNpcs.find((entry) => entry.name === character.name) ??
profile.storyNpcs.find((entry) => entry.name === character.name) ??
null;
const inventory = role
? role.initialItems.map((item, index) => {
const category = normalizeExplicitStarterCategory(item.category);
return {
id: `custom-role-item:${role.id}:${index + 1}`,
category,
name: item.name,
quantity: Math.max(1, item.quantity),
rarity: item.rarity,
tags: [...item.tags],
description: item.description,
equipmentSlotId: inferExplicitStarterSlot(category),
runtimeMetadata: {
origin: 'ai_compiled' as const,
generationChannel: 'discovery' as const,
seedKey: `${role.id}:${index + 1}`,
relationAnchor: {
type: 'npc' as const,
npcId: role.id,
npcName: role.name,
roleText: role.role,
},
sourceReason: `${role.name}在自定义世界开局时自带的初始物品。`,
},
} satisfies InventoryItem;
})
: [];
const equipment: EquipmentLoadout = createEmptyEquipmentLoadout();
inventory.forEach((item) => {
const slot = item.equipmentSlotId;
if (!slot || equipment[slot]) {
return;
}
equipment[slot] = item;
});
return {
inventory,
equipment,
};
}
function createInitialCampEncounter(
worldType: WorldType | null,
playerCharacter: Character,
@@ -169,23 +258,47 @@ export function useGameFlow() {
setBottomTab('adventure');
setIsMapOpen(false);
const initialScenePreset = gameState.worldType
? getWorldCampScenePreset(gameState.worldType) ?? getScenePreset(gameState.worldType, 0)
: null;
const initialEncounter = createInitialCampEncounter(gameState.worldType, character);
const initialNpcState = initialEncounter
? buildInitialNpcState(initialEncounter, gameState.worldType, gameState)
: null;
const initialEquipment = buildInitialEquipmentLoadout(character);
const playerMaxHp = getCharacterMaxHp(
character,
gameState.worldType,
gameState.customWorldProfile,
);
setGameState(prev =>
ensureSceneEncounterPreview(
applyEquipmentLoadoutToState({
{
const resolvedWorldType = prev.worldType;
const resolvedCustomWorldProfile = prev.customWorldProfile;
const initialScenePreset = resolvedWorldType
? getWorldCampScenePreset(resolvedWorldType) ?? getScenePreset(resolvedWorldType, 0)
: null;
const initialEncounter = createInitialCampEncounter(
resolvedWorldType,
character,
);
const initialNpcState = initialEncounter
? buildInitialNpcState(initialEncounter, resolvedWorldType, prev)
: null;
const initialEquipment = buildInitialEquipmentLoadout(
character,
resolvedCustomWorldProfile,
);
const explicitStarterItems =
resolvedWorldType === WorldType.CUSTOM
? buildExplicitCustomWorldRoleStarterState(
resolvedCustomWorldProfile!,
character,
)
: null;
const mergedStarterEquipment = {
weapon:
explicitStarterItems?.equipment.weapon ?? initialEquipment.weapon,
armor:
explicitStarterItems?.equipment.armor ?? initialEquipment.armor,
relic:
explicitStarterItems?.equipment.relic ?? initialEquipment.relic,
};
const playerMaxHp = getCharacterMaxHp(
character,
resolvedWorldType,
resolvedCustomWorldProfile,
);
return ensureSceneEncounterPreview(
applyEquipmentLoadoutToState({
...prev,
playerCharacter: character,
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
@@ -218,10 +331,17 @@ export function useGameFlow() {
activeBuildBuffs: [],
activeCombatEffects: [],
playerCurrency: getInitialPlayerCurrency(
gameState.worldType,
gameState.customWorldProfile,
resolvedWorldType,
resolvedCustomWorldProfile,
),
playerInventory: mergeStarterInventoryItems(
explicitStarterItems?.inventory ?? [],
buildInitialPlayerInventory(
character,
resolvedWorldType,
resolvedCustomWorldProfile,
),
),
playerInventory: buildInitialPlayerInventory(character, gameState.worldType),
playerEquipment: createEmptyEquipmentLoadout(),
npcStates: initialEncounter && initialNpcState
? {
@@ -238,8 +358,9 @@ export function useGameFlow() {
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
}, initialEquipment),
),
}, mergedStarterEquipment),
);
},
);
};

View File

@@ -1877,11 +1877,15 @@ export function buildCustomWorldRoleBatchPrompt(params: {
'- 名称必须与批次名单完全一致,不得增删改名。',
'- 只补全 backstory、personality、motivation、combatStyle 这 4 个字段,不要输出 backstoryReveal、skills、initialItems。',
'- 必须严格沿用框架中的 title、role、description、initialAffinity、relationshipHooks、tags 所表达的角色定位,不要改名,不要改阵营。',
'- backstory 必须写出角色和当前世界的具体关系,至少落到一个势力、一个地点、一个正在发生的局势变化,不要只写抽象气质或泛泛成长史。',
'- personality 不能只写单个形容词,要体现角色在这个世界里的处事习惯、应对压力的方式和与人相处的锋面。',
'- motivation 必须是“此刻正在推动角色行动”的现实目标,而不是空泛理想;它要和玩家目标、核心冲突或开局处境形成直接拉扯。',
'- combatStyle 要体现角色为什么会这样战斗,它最好能反映其身份、经历、所属势力或长期栖身的场景环境。',
roleType === 'story'
? '- 怪物型场景角色要在 backstory 或 combatStyle 中直接写出怪物特征、栖息环境或攻击方式。'
: '- 可扮演角色要体现成长空间、协作价值和明确的入队理由。',
'- 所有生成文本都必须使用中文。',
'- 每个字符串尽量简洁backstory/personality/motivation/combatStyle 控制在 1040 个汉字内。',
'- 每个字符串尽量简洁但不能空泛backstory/personality/motivation/combatStyle 控制在 1856 个汉字内。',
'- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。',
].join('\n');
}
@@ -1933,6 +1937,11 @@ export function buildCustomWorldRoleBatchPrompt(params: {
'- 名称必须与批次名单完全一致,不得增删改名。',
'- 这一阶段只补全 backstoryReveal、skills、initialItems不要重复输出 title、role、description、backstory、personality、motivation、combatStyle、initialAffinity、relationshipHooks、tags。',
'- 背景章节、技能和初始物品必须严格围绕框架中的角色定位来写,不要改变阵营、身份或出现场景。',
'- backstoryReveal 的 4 章必须形成明显递进:第 1 章写表层来意与第一印象,第 2 章写旧伤或代价,第 3 章写角色真正隐瞒的线索,第 4 章写最终底牌或不可回避的真相。',
'- 每一章都必须紧贴当前世界设定,至少落到具体势力、地点、事件、制度、禁忌或关系链中的一项,不要写成可套用到任何世界的空泛心情。',
'- teaser 必须像“继续相处后能戳到的钩子”content 必须像“真正解锁后得到的新信息”contextSnippet 必须可直接被后续剧情复用,三者不要只是同一句话改写。',
'- skills 不只是职业标签,要体现角色的个人经历、所属阵营、地理环境或禁忌系统影响,尽量写出这个世界独有的招式语感。',
'- initialItems 不只是常规装备清单,至少要有一件能反映角色背景、关系或任务压力的私人物件。',
`- backstoryReveal.chapters 必须恰好 4 章affinityRequired 固定使用 ${CUSTOM_WORLD_BACKSTORY_CHAPTER_AFFINITIES.join('、')}`,
'- 每个角色必须提供恰好 3 个 skills 和恰好 3 个 initialItems。',
'- initialItems.category 只能使用:武器、护甲、饰品、消耗品、材料、稀有品、专属物品。',
@@ -1940,7 +1949,7 @@ export function buildCustomWorldRoleBatchPrompt(params: {
? '- 怪物型角色仍然放进 storyNpcs并在 role、description、backstory、combatStyle、tags、backstoryReveal、skills 或 initialItems 中明确写出怪物特征、栖息环境、攻击方式或异形外观。'
: '- 可扮演角色要保持明确的成长空间、协作价值和入队理由。',
'- 所有生成文本都必须使用中文。',
'- 每个字符串尽量简洁backstoryReveal.publicSummary 控制在 10 到 28 个汉字内backstoryReveal.content 控制在 12 到 36 个汉字内skills.summary 和 initialItems.description 控制在 8 到 24 个汉字内。',
'- 每个字符串尽量简洁但要有信息量backstoryReveal.publicSummary 控制在 14 到 36 个汉字内backstoryReveal.teaser 控制在 12 到 28 个汉字内backstoryReveal.content 控制在 20 到 64 个汉字内contextSnippet 控制在 12 到 36 个汉字内skills.summary 和 initialItems.description 控制在 1232 个汉字内。',
'- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。',
].join('\n');
}

View File

@@ -0,0 +1,607 @@
import { afterEach, expect, test } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../packages/shared/src/contracts/customWorldAgent';
import { buildCustomWorldRuntimeCharacters } from '../data/characterPresets';
import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
import { getScenePresetsByWorld } from '../data/scenePresets';
import { WorldType } from '../types';
import { buildCustomWorldProfileFromAgentDraft } from './customWorldAgentDraftResult';
afterEach(() => {
setRuntimeCustomWorldProfile(null);
});
const session: CustomWorldAgentSessionSnapshot = {
sessionId: 'session-1',
stage: 'object_refining',
focusCardId: null,
creatorIntent: {
sourceMode: 'card',
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
themeKeywords: ['海雾', '旧航路'],
toneDirectives: ['压抑', '悬疑'],
openingSituation: '首夜就有陌生船只闯入禁航区。',
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: ['会移动的海雾'],
forbiddenDirectives: [],
rawSettingText: '',
},
creatorIntentReadiness: {
isReady: true,
completedKeys: [],
missingKeys: [],
},
anchorPack: {},
lockState: {},
draftProfile: {
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '旧航路引路人',
role: '关键同行者',
publicIdentity: '最熟悉旧航路的人。',
publicMask: '看上去像可靠旧友。',
currentPressure: '他必须在两股势力间站队。',
hiddenHook: '暗中替沉船商盟引路。',
relationToPlayer: '旧友兼潜在背叛者',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
storyNpcs: [
{
id: 'story-1',
name: '顾潮音',
title: '守灯会值夜人',
role: '场景关键角色',
publicIdentity: '负责夜间巡灯与封锁。',
publicMask: '对外一直冷静克制。',
currentPressure: '她知道更多禁航区真相。',
hiddenHook: '曾亲眼见过失控海雾吞船。',
relationToPlayer: '最早愿意交换线索的人',
threadIds: ['thread-1'],
summary: '她总像比所有人更早知道海雾会往哪一侧压下来。',
},
],
landmarks: [
{
id: 'landmark-1',
name: '回潮旧灯塔',
purpose: '观察雾潮与往来船只',
mood: '潮湿、压抑、风声不止',
importance: '开局核心场景',
characterIds: ['story-1'],
threadIds: ['thread-1'],
summary: '旧灯塔是整片群岛最先看见异动的地方。',
},
],
factions: [],
threads: [],
chapters: [],
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
openingSituation: '首夜就有陌生船只闯入禁航区。',
iconicElements: ['会移动的海雾'],
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
},
messages: [],
draftCards: [],
pendingClarifications: [],
suggestedActions: [],
recommendedReplies: [],
qualityFindings: [],
assetCoverage: {
roleAssets: [],
sceneAssets: [],
allRoleAssetsReady: false,
allSceneAssetsReady: false,
},
updatedAt: '2026-04-15T10:00:00.000Z',
};
function buildBackstoryReveal(label: string) {
return {
publicSummary: `${label}的公开背景`,
privateChatUnlockAffinity: 40,
chapters: [
{
id: `${label}-surface`,
title: '表层来意',
affinityRequired: 15,
teaser: `${label}先只肯说表面的来意。`,
content: `${label}表面上只愿意谈当前局势。`,
contextSnippet: `${label}表面上还在收着话。`,
},
{
id: `${label}-scar`,
title: '旧事裂痕',
affinityRequired: 30,
teaser: `${label}背后还有一段旧伤。`,
content: `${label}曾在旧案里留下无法轻易揭开的伤口。`,
contextSnippet: `${label}和旧案之间有一段没说开的裂痕。`,
},
{
id: `${label}-hidden`,
title: '隐藏执念',
affinityRequired: 60,
teaser: `${label}真正想追的不是表面那件事。`,
content: `${label}真正挂着的是旧案里还没结的那条线。`,
contextSnippet: `${label}真正执念指向旧案深处。`,
},
{
id: `${label}-final`,
title: '最终底牌',
affinityRequired: 90,
teaser: `${label}手里还压着最后一张牌。`,
content: `${label}手里还握着能直接证明真相的关键证据。`,
contextSnippet: `${label}最后的底牌足以改写局势。`,
},
],
};
}
function buildLegacyResultProfile() {
return {
id: 'legacy-profile-1',
settingText: '被海雾吞没的旧航路群岛',
name: '旧版完整结果',
subtitle: '直接展示',
summary: '优先使用服务端编译好的旧版 profile。',
tone: '压抑',
playerGoal: '查明真相',
templateWorldType: WorldType.WUXIA,
compatibilityTemplateWorldType: WorldType.WUXIA,
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['争夺航路控制权', '沉船真相'],
attributeSchema: {
id: 'schema:test',
worldId: 'CUSTOM',
schemaVersion: 1,
schemaName: '测试',
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: '旧版完整结果',
settingSummary: '测试',
tone: '测试',
conflictCore: '测试',
},
slots: [],
},
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '旧航路引路人',
role: '关键同行者',
description: '最熟悉旧航路的人。',
backstory: '曾在沉船夜里带着半支船队逃出海雾。',
personality: '表面沉稳,心里一直在算退路。',
motivation: '想赶在守灯会封航前查清真相。',
combatStyle: '借地形和潮路换位,先拉扯再压近。',
initialAffinity: 18,
relationshipHooks: ['旧友', '沉船旧案'],
tags: ['潮路', '引路'],
backstoryReveal: buildBackstoryReveal('沈砺'),
skills: [
{
id: 'skill-playable-1',
name: '潮行引路',
summary: '踩着旧潮阶切线前压,替队伍打开角度。',
style: '机动周旋',
},
{
id: 'skill-playable-2',
name: '回雾折返',
summary: '借海雾遮住身位,再从侧线拉开。',
style: '起手压制',
},
{
id: 'skill-playable-3',
name: '旧图定标',
summary: '用旧潮图锁定退路和突入口。',
style: '爆发终结',
},
],
initialItems: [
{
id: 'item-playable-1',
name: '旧潮短刃',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '专门在湿滑甲板上近身换位用的短刃。',
tags: ['潮路', '近战'],
},
{
id: 'item-playable-2',
name: '雾盐药包',
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '压住寒潮后遗症的随身药包。',
tags: ['补给'],
},
{
id: 'item-playable-3',
name: '旧潮图残页',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '只剩半页,但足够指向沉船夜的另一条线。',
tags: ['线索', '真相'],
},
],
attributeProfile: {
schemaId: 'schema:test',
values: { axis_a: 48, axis_b: 72, axis_c: 78 },
topTraits: ['浪步', '舟识'],
evidence: [
{
slotId: 'axis_b',
reason: '长期依赖潮路换位与切线。',
},
],
},
narrativeProfile: {
publicMask: '像个只想把旧路再走通一次的熟路人。',
firstContactMask: '先别问太深,至少今晚这条路我还认得。',
visibleLine: '他明面上只想护着队伍别再走错一次潮线。',
hiddenLine: '真正让他回来的是沉船夜里被人卖掉的那条航线。',
contradiction: '嘴上说只想带路,实际每一步都在找能对上旧案的证据。',
debtOrBurden: '背着半支船队没能活着回来的旧债。',
taboo: '最忌讳别人轻描淡写地提起那晚的失踪名单。',
immediatePressure: '守灯会封航在即,他必须赶在封口前找到证据。',
relatedThreadIds: ['thread-visible-1'],
relatedScarIds: ['scar-1'],
reactionHooks: ['沉船夜', '封航记录'],
},
templateCharacterId: 'archer-hero',
},
],
storyNpcs: [
{
id: 'story-1',
name: '顾潮音',
title: '守灯会值夜人',
role: '场景关键角色',
description: '夜里巡灯与封锁禁航区的人。',
backstory: '在失控海雾第一次吞船那夜,她是最后一个还留在灯塔顶的人。',
personality: '冷静克制,但提到旧灯册时会显得过分警觉。',
motivation: '想守住灯塔记录,也想找到谁先改动了禁航信号。',
combatStyle: '借塔顶视角和风向压制,再用灯火错位扰乱。',
initialAffinity: 8,
relationshipHooks: ['禁航记录', '灯塔值夜'],
tags: ['守灯会', '灯塔'],
backstoryReveal: buildBackstoryReveal('顾潮音'),
skills: [
{
id: 'skill-story-1',
name: '夜潮灯语',
summary: '借灯语与潮声干扰对方判断。',
style: '起手压制',
},
{
id: 'skill-story-2',
name: '禁航暗潮',
summary: '封住错误航线,把人逼回她熟悉的区域。',
style: '机动周旋',
},
{
id: 'skill-story-3',
name: '回声巡线',
summary: '借塔顶回声迅速锁定异动方向。',
style: '爆发终结',
},
],
initialItems: [
{
id: 'item-story-1',
name: '值夜灯尺',
category: '武器',
quantity: 1,
rarity: 'rare',
description: '兼作警械和测灯尺的长柄器具。',
tags: ['守灯会'],
},
{
id: 'item-story-2',
name: '防潮火折',
category: '消耗品',
quantity: 2,
rarity: 'uncommon',
description: '在潮雾里也能点亮的值夜火折。',
tags: ['值夜'],
},
{
id: 'item-story-3',
name: '封灯令副本',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '一份被她私下截留下来的封灯令副本。',
tags: ['证据', '灯册'],
},
],
imageSrc: '/custom/npcs/gu-chaoyin.png',
attributeProfile: {
schemaId: 'schema:test',
values: { axis_a: 54, axis_c: 82, axis_f: 61 },
topTraits: ['舟识', '回澜'],
evidence: [
{
slotId: 'axis_c',
reason: '长期依赖值夜观察和读灯判断局势。',
},
],
},
narrativeProfile: {
publicMask: '守灯会值夜人,对外总像比别人更冷静一步。',
firstContactMask: '想进塔就按规矩来,今晚谁都别想乱闯禁航区。',
visibleLine: '她表面上只是在守灯和封线。',
hiddenLine: '她真正盯着的是那本被改过的原始灯册。',
contradiction: '越强调规矩,越像在掩住自己手里那份没上报的记录。',
debtOrBurden: '她扛着第一次海雾吞船夜里没能及时点亮备用灯的旧责。',
taboo: '最忌讳别人把那夜的失踪当成单纯天灾。',
immediatePressure: '新的封航令马上要落下来,她必须先把旧灯册转出去。',
relatedThreadIds: ['thread-visible-1'],
relatedScarIds: ['scar-1'],
reactionHooks: ['原始灯册', '封灯令'],
},
visual: {
race: 'human',
bodyColor: 'blue',
headIndex: 2,
hairColorIndex: 3,
hairStyleFrame: 4,
facialHairEnabled: false,
facialHairColorIndex: 0,
facialHairStyleFrame: 0,
offHand: {
type: 'magic',
file: 'lantern.png',
frameIndex: 1,
},
},
},
],
items: [
{
id: 'item-world-1',
name: '潮雾罗盘',
category: '饰品',
rarity: 'rare',
description: '会在假航灯附近偏转的旧罗盘。',
tags: ['线索', '潮雾'],
attributeResonance: {
resonanceVector: { axis_c: 0.88, axis_e: 0.31 },
explanation: '它会把持有者的判断力牵到潮雾最异常的地方。',
},
},
],
camp: {
name: '回潮暂栖所',
description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。',
dangerLevel: 'low',
imageSrc: '/custom/camp/huichao.png',
},
landmarks: [
{
id: 'landmark-1',
name: '回潮旧灯塔',
description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。',
dangerLevel: 'high',
imageSrc: '/custom/scenes/lighthouse.png',
sceneNpcIds: ['story-1'],
connections: [
{
targetLandmarkId: 'landmark-2',
relativePosition: 'forward',
summary: '沿着旧潮阶继续前压到雾栈尽头。',
},
],
narrativeResidues: [
{
id: 'residue-1',
title: '潮痕',
visibleClue: '塔壁上有一圈不该出现在高处的潮痕。',
linkedFactIds: ['fact-1'],
linkedThreadIds: ['thread-visible-1'],
},
],
},
{
id: 'landmark-2',
name: '雾栈尽头',
description: '旧栈桥尽头只剩断索、潮板和被抹掉的船名。',
dangerLevel: 'high',
imageSrc: '/custom/scenes/pier.png',
sceneNpcIds: [],
connections: [
{
targetLandmarkId: 'landmark-1',
relativePosition: 'back',
summary: '退回灯塔还能重新整理路线。',
},
],
},
],
themePack: {
id: 'theme-pack:tide',
displayName: '潮雾悬疑',
toneRange: ['压抑', '潮湿', '悬疑'],
institutionLexicon: ['守灯会', '航运公会'],
tabooLexicon: ['假航灯', '封灯令'],
artifactClasses: ['旧潮图', '灯册', '罗盘'],
actorArchetypes: ['引路人', '值夜人'],
conflictForms: ['封航争夺', '旧案追查'],
clueForms: ['灯册残页', '潮痕'],
namingPatterns: ['潮', '雾', '灯'],
revealStyles: ['试探式回应'],
},
storyGraph: {
visibleThreads: [
{
id: 'thread-visible-1',
title: '封航争夺',
visibility: 'visible',
summary: '守灯会与航运公会正在争夺旧航路的解释权。',
conflictType: '控制权争夺',
stakes: '谁能定义禁航区,就能决定谁能活着穿过去。',
involvedFactionIds: ['faction-guard', 'faction-guild'],
involvedActorIds: ['playable-1', 'story-1'],
relatedLocationIds: ['landmark-1', 'landmark-2'],
},
],
hiddenThreads: [
{
id: 'thread-hidden-1',
title: '沉船旧案',
visibility: 'hidden',
summary: '沉船夜的航灯与灯册被人动过手脚。',
conflictType: '真相遮蔽',
stakes: '真相一旦坐实,守灯会内部会先崩。',
involvedFactionIds: ['faction-guard'],
involvedActorIds: ['playable-1', 'story-1'],
relatedLocationIds: ['landmark-1'],
},
],
scars: [
{
id: 'scar-1',
title: '沉船夜',
pastEvent: '假航灯把整支船队引进了死潮区。',
publicResidue: '每逢潮夜,灯塔下总有人提起那晚的失踪名单。',
hiddenTruth: '禁航记录和灯册都在事后被篡改过。',
relatedActorIds: ['playable-1', 'story-1'],
relatedLocationIds: ['landmark-1'],
},
],
motifs: [
{
id: 'motif-1',
label: '假航灯',
semanticRole: 'technology',
lexicalHints: ['假灯', '偏航', '禁航记录'],
},
],
},
knowledgeFacts: [
{
id: 'fact-1',
title: '高处潮痕',
content: '回潮旧灯塔的高处潮痕说明那晚海面高度异常。',
ownerActorIds: ['story-1'],
relatedThreadIds: ['thread-visible-1'],
relatedScarIds: ['scar-1'],
sourceType: 'scene',
visibility: 'discoverable',
sayability: 'indirect',
},
],
threadContracts: [
{
id: 'contract-1',
threadId: 'thread-visible-1',
issuerActorId: 'story-1',
narrativeType: 'investigation',
currentStepId: 'contract-step-1',
visibleStage: 1,
steps: [
{
id: 'contract-step-1',
title: '查灯塔',
revealText: '先查清灯塔顶上的高处潮痕。',
completionSignalIds: ['inspect_scene:landmark-1'],
optionalFactIds: ['fact-1'],
},
],
followupThreadIds: ['thread-hidden-1'],
},
],
scenarioPackId: 'scenario-pack:tide',
campaignPackId: 'campaign-pack:tide',
generationMode: 'fast',
generationStatus: 'key_only',
};
}
function buildProfileFromEmbeddedLegacyResult() {
return buildCustomWorldProfileFromAgentDraft({
...session,
draftProfile: {
...session.draftProfile,
legacyResultProfile: buildLegacyResultProfile(),
},
});
}
test('adapts agent draft profile into legacy custom world result profile', () => {
const profile = buildCustomWorldProfileFromAgentDraft(session);
expect(profile?.name).toBe('潮雾列岛');
expect(profile?.generationStatus).toBe('key_only');
expect(profile?.playableNpcs[0]?.name).toBe('沈砺');
expect(profile?.storyNpcs[0]?.name).toBe('顾潮音');
expect(profile?.landmarks[0]?.name).toBe('回潮旧灯塔');
});
test('prefers embedded legacy result profile without dropping compiled runtime fields', () => {
const profile = buildProfileFromEmbeddedLegacyResult();
expect(profile?.name).toBe('旧版完整结果');
expect(profile?.majorFactions).toEqual(['守灯会', '航运公会']);
expect(profile?.coreConflicts).toEqual(['争夺航路控制权', '沉船真相']);
expect(profile?.themePack?.id).toBe('theme-pack:tide');
expect(profile?.storyGraph?.visibleThreads[0]?.id).toBe('thread-visible-1');
expect(profile?.knowledgeFacts?.[0]?.id).toBe('fact-1');
expect(profile?.threadContracts?.[0]?.id).toBe('contract-1');
expect(profile?.scenarioPackId).toBe('scenario-pack:tide');
expect(profile?.campaignPackId).toBe('campaign-pack:tide');
expect(profile?.playableNpcs[0]?.attributeProfile?.schemaId).toBe(
'schema:test',
);
expect(profile?.storyNpcs[0]?.narrativeProfile?.publicMask).toContain(
'守灯会值夜人',
);
expect(profile?.items[0]?.attributeResonance?.explanation).toContain('潮雾');
expect(profile?.landmarks[0]?.narrativeResidues?.[0]?.title).toBe('潮痕');
});
test('embedded legacy result profile keeps result-page settings in runtime characters and scenes', () => {
const profile = buildProfileFromEmbeddedLegacyResult();
expect(profile).toBeTruthy();
setRuntimeCustomWorldProfile(profile);
const runtimeCharacters = buildCustomWorldRuntimeCharacters(profile);
const leadCharacter = runtimeCharacters.find(
(character) => character.id === 'playable-1',
);
const lighthouseScene = getScenePresetsByWorld(WorldType.CUSTOM).find(
(scene) => scene.name === '回潮旧灯塔',
);
const guardNpc = lighthouseScene?.npcs.find((npc) => npc.id === 'story-1');
expect(leadCharacter?.skills[0]?.name).toBe('潮行引路');
expect(leadCharacter?.backstoryReveal?.publicSummary).toBe('沈砺的公开背景');
expect(lighthouseScene?.connections[0]?.summary).toBe(
'沿着旧潮阶继续前压到雾栈尽头。',
);
expect(lighthouseScene?.narrativeResidues?.[0]?.title).toBe('潮痕');
expect(guardNpc?.narrativeProfile?.publicMask).toBe(
'守灯会值夜人,对外总像比别人更冷静一步。',
);
});

View File

@@ -0,0 +1,242 @@
import type { CustomWorldAgentSessionSnapshot } from '../../packages/shared/src/contracts/customWorldAgent';
import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary';
import { type CustomWorldProfile, WorldType } from '../types';
import { buildAgentDraftFoundationSettingText } from './customWorldAgentGenerationProgress';
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter((item): item is Record<string, unknown> => isRecord(item))
: [];
}
function toStringArray(value: unknown, max = 8) {
if (!Array.isArray(value)) {
return [];
}
return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice(
0,
max,
);
}
function inferTemplateWorldType(settingText: string) {
return /[]/u.test(settingText)
? WorldType.XIANXIA
: WorldType.WUXIA;
}
function buildCharacterSummaryText(record: Record<string, unknown>) {
return (
toText(record.summary) ||
toText(record.publicIdentity) ||
toText(record.publicMask) ||
toText(record.currentPressure) ||
toText(record.relationToPlayer)
);
}
function buildCharacterBackstoryText(record: Record<string, unknown>) {
return [
toText(record.publicIdentity),
toText(record.currentPressure),
toText(record.hiddenHook) ? `暗线 ${toText(record.hiddenHook)}` : '',
]
.filter(Boolean)
.join('');
}
function buildRelationshipHooks(record: Record<string, unknown>) {
return [
toText(record.relationToPlayer),
toText(record.currentPressure),
toText(record.hiddenHook) ? `暗线 ${toText(record.hiddenHook)}` : '',
].filter(Boolean);
}
function buildCharacterTags(
record: Record<string, unknown>,
roleKind: 'playable' | 'story',
) {
const threadIds = toStringArray(record.threadIds, 4);
return [...threadIds, roleKind === 'playable' ? '草稿主角' : '草稿角色'];
}
type AdaptedDraftCharacter = {
id: string;
name: string;
title: string;
role: string;
description: string;
backstory: string;
personality: string;
motivation: string;
combatStyle: string;
initialAffinity: number;
relationshipHooks: string[];
tags: string[];
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, unknown>;
};
function adaptDraftCharacters(value: unknown, roleKind: 'playable' | 'story') {
return toRecordArray(value)
.map((record, index) => {
const name = toText(record.name);
if (!name) {
return null;
}
const title =
toText(record.title) ||
toText(record.role) ||
(roleKind === 'playable' ? '关键角色' : '场景角色');
const role = toText(record.role) || title;
const description = buildCharacterSummaryText(record);
const relationshipHooks = buildRelationshipHooks(record);
return {
id: toText(record.id) || `${roleKind}-draft-${index + 1}`,
name,
title,
role,
description,
backstory: buildCharacterBackstoryText(record),
personality:
toText(record.publicMask) ||
toText(record.publicIdentity) ||
description,
motivation:
toText(record.relationToPlayer) ||
toText(record.currentPressure) ||
toText(record.hiddenHook),
combatStyle: role,
initialAffinity: roleKind === 'playable' ? 18 : 6,
relationshipHooks,
tags: buildCharacterTags(record, roleKind),
imageSrc: toText(record.imageSrc) || undefined,
generatedVisualAssetId:
toText(record.generatedVisualAssetId) || undefined,
generatedAnimationSetId:
toText(record.generatedAnimationSetId) || undefined,
animationMap: isRecord(record.animationMap)
? record.animationMap
: undefined,
} satisfies AdaptedDraftCharacter;
})
.filter(Boolean) as AdaptedDraftCharacter[];
}
type AdaptedDraftLandmark = {
id: string;
name: string;
description: string;
dangerLevel: string;
imageSrc?: string;
sceneNpcIds: string[];
connections: never[];
};
function adaptDraftLandmarks(value: unknown, storyNpcIdSet: Set<string>) {
return toRecordArray(value)
.map((record, index) => {
const name = toText(record.name);
if (!name) {
return null;
}
return {
id: toText(record.id) || `landmark-draft-${index + 1}`,
name,
description:
toText(record.description) ||
toText(record.summary) ||
[toText(record.purpose), toText(record.mood)]
.filter(Boolean)
.join(''),
dangerLevel:
toText(record.dangerLevel) ||
toText(record.importance) ||
toText(record.mood),
imageSrc: toText(record.imageSrc) || undefined,
sceneNpcIds: toStringArray(record.characterIds).filter((id) =>
storyNpcIdSet.has(id),
),
connections: [],
} satisfies AdaptedDraftLandmark;
})
.filter(Boolean) as AdaptedDraftLandmark[];
}
export function buildCustomWorldProfileFromAgentDraft(
session: CustomWorldAgentSessionSnapshot | null | undefined,
): CustomWorldProfile | null {
if (!session || !isRecord(session.draftProfile)) {
return null;
}
const draftProfile = session.draftProfile;
const legacyResultProfile = normalizeCustomWorldProfileRecord(
draftProfile.legacyResultProfile,
);
if (legacyResultProfile) {
return legacyResultProfile;
}
const settingText = buildAgentDraftFoundationSettingText(session);
const templateWorldType = inferTemplateWorldType(settingText);
const playableNpcs = adaptDraftCharacters(
draftProfile.playableNpcs,
'playable',
);
const storyNpcs = adaptDraftCharacters(draftProfile.storyNpcs, 'story');
const storyNpcIdSet = new Set(
storyNpcs.map((entry) => toText(entry.id)).filter(Boolean),
);
const normalized = normalizeCustomWorldProfileRecord({
id: `agent-draft-${session.sessionId}`,
settingText,
name: toText(draftProfile.name) || '未命名世界底稿',
subtitle: toText(draftProfile.subtitle) || '第一版世界底稿',
summary:
toText(draftProfile.summary) ||
settingText ||
'第一版世界底稿已经整理完成。',
tone: toText(draftProfile.tone) || '整体气质仍可继续精修',
playerGoal: toText(draftProfile.playerGoal) || '先站稳开局,再判断下一步',
templateWorldType,
compatibilityTemplateWorldType: templateWorldType,
majorFactions: toStringArray(draftProfile.majorFactions, 6),
coreConflicts: toStringArray(draftProfile.coreConflicts, 6),
playableNpcs,
storyNpcs,
landmarks: adaptDraftLandmarks(draftProfile.landmarks, storyNpcIdSet),
camp: isRecord(draftProfile.camp)
? {
name: toText(draftProfile.camp.name),
description: toText(draftProfile.camp.description),
dangerLevel:
toText(draftProfile.camp.dangerLevel) ||
toText(draftProfile.camp.mood),
imageSrc: toText(draftProfile.camp.imageSrc) || undefined,
}
: undefined,
creatorIntent: session.creatorIntent,
anchorPack: session.anchorPack,
lockState: session.lockState,
generationMode: 'fast',
generationStatus: 'key_only',
});
return normalized;
}

View File

@@ -116,9 +116,9 @@ test('marks all legacy progress steps complete when draft foundation finishes',
test('builds readable draft setting text from creator intent first', () => {
const settingText = buildAgentDraftFoundationSettingText(baseSession);
expect(settingText).toContain('世界核心');
expect(settingText).toContain('玩家开局');
expect(settingText).toContain('标志素');
expect(settingText).toContain('世界核心命题');
expect(settingText).toContain('玩家身份');
expect(settingText).toContain('标志性要素');
});
test('falls back to latest user message when creator intent is unavailable', () => {

View File

@@ -87,11 +87,7 @@ function buildAgentDraftFoundationSteps(
detail: step.detail,
completed: isCompleted ? 1 : 0,
total: 1,
status: isCompleted
? 'completed'
: isActive
? 'active'
: 'pending',
status: isCompleted ? 'completed' : isActive ? 'active' : 'pending',
} satisfies CustomWorldGenerationStep;
});
}
@@ -113,10 +109,7 @@ function resolveEstimatedRemainingMs(
const elapsedMs = Math.max(0, nowMs - startedAtMs);
const progressFraction = progress / 100;
return Math.max(
0,
Math.round(elapsedMs / progressFraction - elapsedMs),
);
return Math.max(0, Math.round(elapsedMs / progressFraction - elapsedMs));
}
export function isDraftFoundationOperation(
@@ -184,19 +177,19 @@ export function buildAgentDraftFoundationSettingText(
);
if (creatorIntent) {
const displayText =
buildCustomWorldCreatorIntentDisplayText(creatorIntent).trim();
const generationText =
buildCustomWorldCreatorIntentGenerationText(creatorIntent).trim();
if (displayText) {
return displayText;
}
const displayText =
buildCustomWorldCreatorIntentDisplayText(creatorIntent).trim();
if (generationText) {
return generationText;
}
if (displayText) {
return displayText;
}
if (creatorIntent.rawSettingText.trim()) {
return creatorIntent.rawSettingText.trim();
}