Integrate role asset studio into custom world agent flow

This commit is contained in:
2026-04-14 20:16:41 +08:00
parent 0981d6ee1b
commit bc2999ffb9
118 changed files with 31211 additions and 1232 deletions

View File

@@ -0,0 +1,131 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { AdventurePanel } from './AdventurePanel';
import { AnimationState, type Character, type StoryMoment, type StoryOption, WorldType } from '../types';
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '测试主角',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 8,
spirit: 9,
},
personality: 'calm',
skills: [],
adventureOpenings: {},
} as Character;
}
function createOption(functionId: string, actionText: string): StoryOption {
return {
functionId,
actionText,
text: actionText,
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
};
}
function renderPanel(currentStory: StoryMoment, displayedOptions: StoryOption[]) {
return renderToStaticMarkup(
<AdventurePanel
aiError={null}
currentStory={currentStory}
isLoading={false}
displayedOptions={displayedOptions}
hideOptions={false}
canRefreshOptions={false}
onRefreshOptions={() => undefined}
onChoice={() => undefined}
onOpenCharacter={() => undefined}
onOpenInventory={() => undefined}
playerCharacter={createCharacter()}
worldType={WorldType.WUXIA}
quests={[]}
questUi={{
acknowledgeQuestCompletion: () => undefined,
claimQuestReward: () => null,
}}
goalStack={{
northStarGoal: null,
activeGoal: null,
immediateStepGoal: null,
supportGoals: [],
}}
goalPulse={null}
onDismissGoalPulse={() => undefined}
battleRewardUi={{
reward: null,
dismiss: () => undefined,
}}
playerHp={100}
playerMaxHp={100}
playerMana={20}
playerMaxMana={20}
playerSkillCooldowns={{}}
inBattle={false}
currentNpcBattleMode={null}
statistics={{
playTimeMs: 0,
hostileNpcsDefeated: 0,
questsAccepted: 0,
questsCompleted: 0,
questsTurnedIn: 0,
itemsUsed: 0,
scenesTraveled: 0,
currentSceneName: '竹林古道',
playerCurrency: 0,
inventoryItemCount: 0,
inventoryStackCount: 0,
activeCompanionCount: 0,
rosterCompanionCount: 0,
}}
musicVolume={0.6}
onMusicVolumeChange={() => undefined}
onSaveAndExit={() => undefined}
/>,
);
}
test('adventure panel recognizes story_continue_adventure by function id instead of action text', () => {
const continueOption = createOption('story_continue_adventure', '查看后续');
const currentStory: StoryMoment = {
text: '你们交换完这一轮判断。',
options: [continueOption],
deferredOptions: [createOption('idle_explore_forward', '继续向前探索')],
};
const html = renderPanel(currentStory, [continueOption]);
expect(html).toContain('剧情推理完成,继续后显示新的冒险选项');
});
test('adventure panel does not show deferred hint for non-continue options with the same text', () => {
const misleadingOption = createOption('npc_chat', '查看后续');
const currentStory: StoryMoment = {
text: '你们交换完这一轮判断。',
options: [misleadingOption],
deferredOptions: [createOption('idle_explore_forward', '继续向前探索')],
};
const html = renderPanel(currentStory, [misleadingOption]);
expect(html).not.toContain('剧情推理完成,继续后显示新的冒险选项');
});

View File

@@ -14,8 +14,8 @@ interface CustomWorldResultViewProps {
progressLabel: string;
error: string | null;
onBack: () => void;
onEditSetting: () => void;
onRegenerate: () => void;
onEditSetting?: () => void;
onRegenerate?: () => void;
onContinueExpand?: () => void;
onSave: () => void;
onProfileChange: (profile: CustomWorldProfile) => void;
@@ -122,7 +122,7 @@ export function CustomWorldResultView({
const createTarget = useMemo(() => getCreateTargetByTab(activeTab), [activeTab]);
const createLabel = useMemo(() => getCreateLabelByTab(activeTab), [activeTab]);
const onRegenerate = () => {
if (isGenerating) return;
if (isGenerating || !triggerRegenerate) return;
const confirmed = window.confirm(
`确认重新生成“${profile.name}”吗?\n\n重新生成会重新生成当前世界中的所有信息包括你修改和新增的所有内容。`,
@@ -198,8 +198,12 @@ export function CustomWorldResultView({
</div>
) : null}
<div className="flex items-center justify-end gap-3">
<SmallButton onClick={onEditSetting}></SmallButton>
<SmallButton onClick={onRegenerate} tone="sky"></SmallButton>
{onEditSetting ? (
<SmallButton onClick={onEditSetting}></SmallButton>
) : null}
{triggerRegenerate ? (
<SmallButton onClick={onRegenerate} tone="sky"></SmallButton>
) : null}
{profile.generationStatus === 'key_only' && onContinueExpand ? (
<SmallButton onClick={onContinueExpand} tone="sky" disabled={isGenerating}>

View File

@@ -4,13 +4,17 @@ import {
ImagePlus,
RefreshCcw,
} from 'lucide-react';
import { type ChangeEvent, type ReactNode, useMemo, useState } from 'react';
import {
type ChangeEvent,
type ReactNode,
useEffect,
useMemo,
useState,
} from 'react';
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
import {
AnimationState,
type CustomWorldNpc,
type CustomWorldPlayableNpc,
} from '../types';
import { CharacterAnimator } from './CharacterAnimator';
import {
@@ -29,7 +33,23 @@ import {
publishCharacterVisualAsset,
} from './asset-studio/characterAssetWorkflowPersistence';
type EditableCustomWorldRole = CustomWorldPlayableNpc | CustomWorldNpc;
type EditableCustomWorldRole = {
id: string;
name: string;
title: string;
role: string;
description?: string;
backstory?: string;
personality?: string;
motivation?: string;
combatStyle?: string;
tags?: string[];
templateCharacterId?: string;
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, unknown>;
};
type CustomWorldAiActionConfig = {
animation: AnimationState;
@@ -298,7 +318,7 @@ function buildRoleCharacterBrief(
role.personality ? `角色性格:${role.personality}` : '',
role.motivation ? `角色动机:${role.motivation}` : '',
role.combatStyle ? `战斗风格:${role.combatStyle}` : '',
role.tags.length > 0 ? `角色标签:${role.tags.join('、')}` : '',
role.tags && role.tags.length > 0 ? `角色标签:${role.tags.join('、')}` : '',
templateLabel ? `参考模板:${templateLabel}` : '',
]
.filter(Boolean)
@@ -319,13 +339,35 @@ export function CustomWorldRoleAssetStudioModal({
role,
roleKind,
onApply,
onPublishSuccess,
onClose,
syncBusy = false,
visualPointCost = 20,
animationPointCost = 60,
priorityTier = roleKind === 'playable' ? 'hero' : 'featured',
}: {
role: EditableCustomWorldRole;
roleKind: 'playable' | 'story';
onApply: (nextRole: EditableCustomWorldRole) => void;
onApply?: (nextRole: EditableCustomWorldRole) => void;
onPublishSuccess?: (
payload: {
roleId: string;
portraitPath: string;
generatedVisualAssetId: string;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
},
options?: {
closeAfterSync?: boolean;
},
) => void;
onClose: () => void;
syncBusy?: boolean;
visualPointCost?: number;
animationPointCost?: number;
priorityTier?: 'hero' | 'featured' | 'supporting';
}) {
const [workingRole, setWorkingRole] = useState<EditableCustomWorldRole>(role);
const [sourceMode, setSourceMode] =
useState<Exclude<CharacterVisualSourceMode, 'upload'>>(
role.imageSrc ? 'image-to-image' : 'text-to-image',
@@ -351,42 +393,66 @@ export function CustomWorldRoleAssetStudioModal({
const [isGeneratingAnimations, setIsGeneratingAnimations] = useState(false);
const [isApplyingAnimations, setIsApplyingAnimations] = useState(false);
useEffect(() => {
setWorkingRole(role);
}, [role]);
const selectedTemplate =
roleKind === 'playable' && 'templateCharacterId' in role && role.templateCharacterId
roleKind === 'playable' && workingRole.templateCharacterId
? ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === role.templateCharacterId,
(character) => character.id === workingRole.templateCharacterId,
) ?? null
: null;
const characterBriefText = useMemo(
() =>
buildRoleCharacterBrief(
role,
workingRole,
selectedTemplate
? `${selectedTemplate.name} / ${selectedTemplate.title}`
: undefined,
),
[role, selectedTemplate],
[workingRole, selectedTemplate],
);
const effectiveReferenceImages =
referenceImageDataUrls.length > 0
? referenceImageDataUrls
: role.imageSrc
? [role.imageSrc]
: workingRole.imageSrc
? [workingRole.imageSrc]
: [];
const selectedVisualDraft =
visualDrafts.find((draft) => draft.id === selectedVisualDraftId) ?? null;
const previewImageSrc =
selectedVisualDraft?.imageSrc ??
role.imageSrc ??
workingRole.imageSrc ??
selectedTemplate?.portrait ??
'';
const selectedActionConfig =
CORE_ACTIONS.find((item) => item.animation === selectedAnimation) ??
CORE_ACTIONS[0];
const appliedActionCount = CORE_ACTIONS.filter(
(item) => role.animationMap?.[item.animation]?.basePath,
(item) =>
Boolean(
(workingRole.animationMap as Record<string, { basePath?: string }> | null)
?.[item.animation]?.basePath,
),
).length;
const visualCandidateCount = priorityTier === 'supporting' ? 1 : 2;
const confirmPointSpend = (params: {
kindLabel: string;
points: number;
description: string;
}) => {
if (typeof window === 'undefined' || typeof window.confirm !== 'function') {
return true;
}
return window.confirm(
`${params.kindLabel}预计消耗 ${params.points} 积分。\n${params.description}`,
);
};
const handleReferenceImageUpload = async (
event: ChangeEvent<HTMLInputElement>,
) => {
@@ -406,6 +472,16 @@ export function CustomWorldRoleAssetStudioModal({
};
const handleGenerateVisuals = async () => {
if (
!confirmPointSpend({
kindLabel: '主图候选生成',
points: visualPointCost,
description: '这次是主图候选抽卡,不是最终发布。',
})
) {
return;
}
setIsGeneratingVisuals(true);
setVisualStatus(null);
@@ -418,12 +494,12 @@ export function CustomWorldRoleAssetStudioModal({
}
const result = await generateCharacterVisualCandidates({
characterId: role.id,
characterId: workingRole.id,
sourceMode,
promptText: visualPromptText,
characterBriefText,
referenceImageDataUrls: effectiveReferenceImages,
candidateCount: 3,
candidateCount: visualCandidateCount,
imageModel: 'wan2.7-image-pro',
size: '1024*1536',
});
@@ -450,7 +526,7 @@ export function CustomWorldRoleAssetStudioModal({
try {
const result = await publishCharacterVisualAsset({
characterId: role.id,
characterId: workingRole.id,
sourceMode,
promptText: visualPromptText,
selectedPreviewSource: selectedVisualDraft.imageSrc,
@@ -460,13 +536,25 @@ export function CustomWorldRoleAssetStudioModal({
updateCharacterOverride: false,
});
onApply(
mergeRole(role, {
imageSrc: result.portraitPath,
const nextRole = mergeRole(workingRole, {
imageSrc: result.portraitPath,
generatedVisualAssetId: result.assetId,
generatedAnimationSetId: undefined,
animationMap: undefined,
});
setWorkingRole(nextRole);
onApply?.(nextRole);
onPublishSuccess?.(
{
roleId: workingRole.id,
portraitPath: result.portraitPath,
generatedVisualAssetId: result.assetId,
generatedAnimationSetId: undefined,
animationMap: undefined,
}),
generatedAnimationSetId: null,
animationMap: null,
},
{
closeAfterSync: false,
},
);
setDraftAnimations({});
setAnimationStatus(null);
@@ -481,18 +569,18 @@ export function CustomWorldRoleAssetStudioModal({
};
const generateActionClip = async (config: CustomWorldAiActionConfig) => {
if (!role.imageSrc || !role.generatedVisualAssetId) {
if (!workingRole.imageSrc || !workingRole.generatedVisualAssetId) {
throw new Error('请先应用主图,再生成动作。');
}
const result = await generateCharacterAnimationDraft({
characterId: role.id,
characterId: workingRole.id,
strategy: 'image-to-video',
animation: config.animation,
promptText: animationPromptText,
characterBriefText,
actionTemplateId: config.templateId,
visualSource: role.imageSrc,
visualSource: workingRole.imageSrc,
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
frameCount: config.frameCount,
@@ -525,6 +613,16 @@ export function CustomWorldRoleAssetStudioModal({
return;
}
if (
!confirmPointSpend({
kindLabel: '动作草稿生成',
points: animationPointCost,
description: '这次是动作草稿试片,不是最终发布。',
})
) {
return;
}
setIsGeneratingAnimations(true);
setAnimationStatus(null);
@@ -545,6 +643,16 @@ export function CustomWorldRoleAssetStudioModal({
};
const handleGenerateAllAnimations = async () => {
if (
!confirmPointSpend({
kindLabel: '核心动作生成',
points: animationPointCost,
description: '这次会生成核心动作草稿,发布前仍可继续调整。',
})
) {
return;
}
setIsGeneratingAnimations(true);
setAnimationStatus(null);
@@ -570,7 +678,7 @@ export function CustomWorldRoleAssetStudioModal({
};
const handleApplyAnimations = async () => {
if (!role.generatedVisualAssetId) {
if (!workingRole.generatedVisualAssetId) {
setAnimationStatus('请先应用主图,再应用动作。');
return;
}
@@ -601,22 +709,37 @@ export function CustomWorldRoleAssetStudioModal({
]),
);
const result = await publishCharacterAnimationAssets({
characterId: role.id,
visualAssetId: role.generatedVisualAssetId,
characterId: workingRole.id,
visualAssetId: workingRole.generatedVisualAssetId,
animations: payload,
updateCharacterOverride: false,
});
onApply(
mergeRole(role, {
const nextRole = mergeRole(workingRole, {
generatedAnimationSetId: result.animationSetId,
animationMap: {
...(role.animationMap ?? {}),
...((workingRole.animationMap ?? {}) as Record<string, unknown>),
...(result.animationMap as NonNullable<
EditableCustomWorldRole['animationMap']
>),
},
}),
});
setWorkingRole(nextRole);
onApply?.(nextRole);
onPublishSuccess?.(
{
roleId: workingRole.id,
portraitPath: workingRole.imageSrc ?? previewImageSrc,
generatedVisualAssetId: workingRole.generatedVisualAssetId ?? '',
generatedAnimationSetId: result.animationSetId,
animationMap: (nextRole.animationMap ?? null) as Record<
string,
unknown
> | null,
},
{
closeAfterSync: true,
},
);
setAnimationStatus('核心动作已应用到当前角色。');
} catch (error) {
@@ -637,7 +760,8 @@ export function CustomWorldRoleAssetStudioModal({
isGeneratingVisuals ||
isApplyingVisual ||
isGeneratingAnimations ||
isApplyingAnimations
isApplyingAnimations ||
syncBusy
}
>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1.05fr)_minmax(22rem,0.95fr)]">
@@ -695,18 +819,21 @@ export function CustomWorldRoleAssetStudioModal({
</div>
</Field>
<div className="flex flex-wrap gap-3">
<StatusBadge tone="amber">
{visualPointCost}
</StatusBadge>
<ActionButton
icon={<ImagePlus className="h-4 w-4" />}
label={isGeneratingVisuals ? '生成中...' : '生成主图候选'}
onClick={() => void handleGenerateVisuals()}
disabled={isGeneratingVisuals}
disabled={isGeneratingVisuals || syncBusy}
tone="sky"
/>
<ActionButton
icon={<CheckCircle2 className="h-4 w-4" />}
label={isApplyingVisual ? '应用中...' : '应用主图'}
onClick={() => void handleApplyVisual()}
disabled={isApplyingVisual || !selectedVisualDraft}
disabled={isApplyingVisual || !selectedVisualDraft || syncBusy}
tone="green"
/>
</div>
@@ -723,7 +850,7 @@ export function CustomWorldRoleAssetStudioModal({
{previewImageSrc ? (
<img
src={previewImageSrc}
alt={role.name}
alt={workingRole.name}
className="max-h-[28rem] w-full object-contain"
/>
) : selectedTemplate ? (
@@ -811,7 +938,9 @@ export function CustomWorldRoleAssetStudioModal({
: `生成${selectedActionConfig?.label ?? '当前'}动作`
}
onClick={() => void handleGenerateSingleAnimation()}
disabled={isGeneratingAnimations || !role.imageSrc}
disabled={
isGeneratingAnimations || !workingRole.imageSrc || syncBusy
}
tone="sky"
/>
<ActionButton
@@ -820,16 +949,21 @@ export function CustomWorldRoleAssetStudioModal({
isGeneratingAnimations ? '生成中...' : '生成核心动作'
}
onClick={() => void handleGenerateAllAnimations()}
disabled={isGeneratingAnimations || !role.imageSrc}
disabled={
isGeneratingAnimations || !workingRole.imageSrc || syncBusy
}
/>
<ActionButton
icon={<CheckCircle2 className="h-4 w-4" />}
label={isApplyingAnimations ? '应用中...' : '应用动作'}
onClick={() => void handleApplyAnimations()}
disabled={isApplyingAnimations}
disabled={isApplyingAnimations || syncBusy}
tone="green"
/>
</div>
<div className="text-xs text-zinc-500">
稿 {animationPointCost}
</div>
{animationStatus ? (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{animationStatus}
@@ -843,7 +977,7 @@ export function CustomWorldRoleAssetStudioModal({
<StatusBadge tone="amber">
{appliedActionCount}/{CORE_ACTIONS.length}
</StatusBadge>
{role.generatedVisualAssetId ? (
{workingRole.generatedVisualAssetId ? (
<StatusBadge tone="green"></StatusBadge>
) : (
<StatusBadge tone="zinc"></StatusBadge>
@@ -853,7 +987,12 @@ export function CustomWorldRoleAssetStudioModal({
{CORE_ACTIONS.map((item) => {
const hasDraft = Boolean(draftAnimations[item.animation]);
const isApplied = Boolean(
role.animationMap?.[item.animation]?.basePath,
(
workingRole.animationMap as Record<
string,
{ basePath?: string }
> | null
)?.[item.animation]?.basePath,
);
return (
<div
@@ -896,17 +1035,22 @@ export function CustomWorldRoleAssetStudioModal({
state={selectedAnimation}
character={{
...selectedTemplate,
id: role.id,
name: role.name,
title: role.title,
portrait: role.imageSrc || selectedTemplate.portrait,
generatedVisualAssetId: role.generatedVisualAssetId,
id: workingRole.id,
name: workingRole.name,
title: workingRole.title,
portrait:
workingRole.imageSrc || selectedTemplate.portrait,
generatedVisualAssetId:
workingRole.generatedVisualAssetId ?? undefined,
generatedAnimationSetId:
role.generatedAnimationSetId,
animationMap: role.animationMap
workingRole.generatedAnimationSetId ?? undefined,
animationMap: workingRole.animationMap
? {
...(selectedTemplate.animationMap ?? {}),
...role.animationMap,
...(workingRole.animationMap as Record<
string,
unknown
>),
}
: selectedTemplate.animationMap,
}}
@@ -916,7 +1060,7 @@ export function CustomWorldRoleAssetStudioModal({
) : previewImageSrc ? (
<img
src={previewImageSrc}
alt={role.name}
alt={workingRole.name}
className="max-h-[16rem] w-full object-contain"
/>
) : (
@@ -934,14 +1078,20 @@ export function CustomWorldRoleAssetStudioModal({
<div className="space-y-5">
<Section title="当前角色档案">
<div className="rounded-3xl border border-white/10 bg-black/20 p-4">
<div className="text-lg font-semibold text-white">{role.name}</div>
<div className="text-lg font-semibold text-white">
{workingRole.name}
</div>
<div className="mt-1 text-sm text-zinc-400">
{role.title} / {role.role}
{workingRole.title} / {workingRole.role}
</div>
<div className="mt-4 space-y-3 text-sm leading-7 text-zinc-300">
{role.description ? <div>{role.description}</div> : null}
{role.combatStyle ? <div>{role.combatStyle}</div> : null}
{role.tags.length > 0 ? <div>{role.tags.join('、')}</div> : null}
{workingRole.description ? <div>{workingRole.description}</div> : null}
{workingRole.combatStyle ? (
<div>{workingRole.combatStyle}</div>
) : null}
{workingRole.tags && workingRole.tags.length > 0 ? (
<div>{workingRole.tags.join('、')}</div>
) : null}
</div>
</div>
<Field label="自动提示词依据">
@@ -959,7 +1109,7 @@ export function CustomWorldRoleAssetStudioModal({
<div className="grid gap-3">
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<div className="text-sm text-zinc-200"></div>
{role.generatedVisualAssetId ? (
{workingRole.generatedVisualAssetId ? (
<StatusBadge tone="green"></StatusBadge>
) : (
<StatusBadge tone="zinc"></StatusBadge>
@@ -976,7 +1126,11 @@ export function CustomWorldRoleAssetStudioModal({
)}
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-zinc-500">
{priorityTier === 'hero'
? '主角级'
: priorityTier === 'featured'
? '重点角色'
: '支撑角色'}
</div>
</div>
</Section>

View File

@@ -47,7 +47,7 @@ type AuthStatus =
const allowDevGuestAutoAuth =
import.meta.env.DEV &&
import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST === 'true';
import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST !== 'false';
export function AuthGate({ children }: AuthGateProps) {
const [status, setStatus] = useState<AuthStatus>('checking');

View File

@@ -0,0 +1,46 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { CustomWorldAgentClarificationPanel } from './CustomWorldAgentClarificationPanel';
test('clarification panel shows pending questions and ready state', () => {
const pendingHtml = renderToStaticMarkup(
<CustomWorldAgentClarificationPanel
readiness={{
isReady: false,
completedKeys: ['world_hook'],
missingKeys: ['player_premise', 'core_conflict'],
}}
pendingClarifications={[
{
id: 'player_premise',
label: '玩家身份与开局',
question: '玩家是谁,故事开场时卡在什么处境里?',
targetKey: 'player_premise',
priority: 2,
},
]}
/>,
);
const readyHtml = renderToStaticMarkup(
<CustomWorldAgentClarificationPanel
readiness={{
isReady: true,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'relationship_seed',
'iconic_element',
],
missingKeys: [],
}}
pendingClarifications={[]}
/>,
);
expect(pendingHtml).toContain('待补充问题');
expect(pendingHtml).toContain('玩家是谁,故事开场时卡在什么处境里');
expect(readyHtml).toContain('最小锚点已齐备,可以进入下一阶段');
});

View File

@@ -0,0 +1,64 @@
import type {
CreatorIntentReadiness,
CustomWorldPendingClarification,
} from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentClarificationPanelProps = {
pendingClarifications: CustomWorldPendingClarification[];
readiness: CreatorIntentReadiness;
};
export function CustomWorldAgentClarificationPanel({
pendingClarifications,
readiness,
}: CustomWorldAgentClarificationPanelProps) {
if (readiness.isReady) {
return (
<section className="rounded-[1.5rem] border border-emerald-300/18 bg-emerald-500/8 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.2em] text-emerald-100/80">
</div>
<div className="mt-2 text-lg font-semibold text-white">
</div>
</section>
);
}
return (
<section className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-2 text-lg font-semibold text-white">
1 3
</div>
</div>
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[11px] text-sky-100">
{pendingClarifications.length}
</span>
</div>
<div className="mt-4 space-y-2">
{pendingClarifications.slice(0, 3).map((item, index) => (
<div
key={item.id}
className="rounded-[1.15rem] border border-white/8 bg-white/5 px-3 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="text-sm font-semibold text-white">
{index + 1}. {item.label}
</div>
<div className="text-[11px] text-zinc-500">P{item.priority}</div>
</div>
<div className="mt-2 text-sm leading-6 text-zinc-300">
{item.question}
</div>
</div>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,102 @@
import type { RefObject } from 'react';
import { useState } from 'react';
import type { SendCustomWorldAgentMessageRequest } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentComposerProps = {
disabled: boolean;
onSubmit: (payload: SendCustomWorldAgentMessageRequest) => void;
textareaRef?: RefObject<HTMLTextAreaElement | null>;
onSummaryClick?: () => void;
onAutoCompleteClick?: () => void;
showAutoComplete?: boolean;
};
function createClientMessageId() {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return crypto.randomUUID();
}
return `client-message-${Date.now()}`;
}
export function CustomWorldAgentComposer({
disabled,
onSubmit,
textareaRef,
onSummaryClick,
onAutoCompleteClick,
showAutoComplete = true,
}: CustomWorldAgentComposerProps) {
const [text, setText] = useState('');
const submit = () => {
const nextText = text.trim();
if (!nextText || disabled) {
return;
}
onSubmit({
clientMessageId: createClientMessageId(),
text: nextText,
focusCardId: null,
selectedCardIds: [],
});
setText('');
};
return (
<div className="rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
<div className="flex flex-col gap-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={onSummaryClick}
disabled={disabled}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
{showAutoComplete ? (
<button
type="button"
onClick={onAutoCompleteClick}
disabled={disabled}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
) : null}
</div>
<textarea
ref={textareaRef}
value={text}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
submit();
}
}}
rows={2}
disabled={disabled}
placeholder="输入消息"
className="w-full resize-none rounded-[1.35rem] border border-white/10 bg-black/30 px-4 py-2.5 text-sm leading-6 text-white outline-none transition focus:border-emerald-300/35 disabled:cursor-not-allowed disabled:opacity-60"
/>
<div className="flex justify-end">
<button
type="button"
onClick={submit}
disabled={disabled || !text.trim()}
className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-4 py-2 text-sm font-medium text-emerald-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { expect, test } from 'vitest';
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
import { CustomWorldGenerateEntityModal } from './CustomWorldGenerateEntityModal';
const CHARACTER_DETAIL: CustomWorldDraftCardDetail = {
id: 'character-1',
kind: 'character',
title: '沈砺',
sections: [
{
id: 'name',
label: '角色名',
value: '沈砺',
},
{
id: 'publicMask',
label: '外显身份',
value: '守灯会里最熟悉旧航道的人。',
},
{
id: 'summary',
label: '角色摘要',
value: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
linkedIds: ['thread-1'],
locked: false,
editable: true,
editableSectionIds: ['name', 'publicMask', 'summary'],
warningMessages: [],
assetStatus: 'missing',
assetStatusLabel: '待生成主图',
};
function DetailInteractionHarness() {
const [editMode, setEditMode] = useState(false);
const [generateMode, setGenerateMode] = useState<'character' | 'landmark' | null>(
null,
);
const [savedPayload, setSavedPayload] = useState<string>('');
return (
<>
<CustomWorldAgentDraftDetailPanel
detail={CHARACTER_DETAIL}
loading={false}
editMode={editMode}
onClose={() => {}}
onStartEdit={() => {
setEditMode(true);
}}
onCancelEdit={() => {
setEditMode(false);
}}
onSave={(sections) => {
setSavedPayload(JSON.stringify(sections));
setEditMode(false);
}}
onGenerateCharacter={() => {
setGenerateMode('character');
}}
onGenerateLandmark={() => {
setGenerateMode('landmark');
}}
onOpenRoleAssetStudio={() => {}}
/>
<CustomWorldGenerateEntityModal
open={generateMode !== null}
mode={generateMode ?? 'character'}
anchorCardTitle={CHARACTER_DETAIL.title}
onClose={() => {
setGenerateMode(null);
}}
onSubmit={() => {
setGenerateMode(null);
}}
/>
<div data-testid="saved-payload">{savedPayload}</div>
</>
);
}
test('draft detail panel supports edit save and opening generate modals', async () => {
const user = userEvent.setup();
render(<DetailInteractionHarness />);
await user.click(screen.getByRole('button', { name: '编辑设定' }));
const summaryInput = screen.getByLabelText('角色摘要');
await user.clear(summaryInput);
await user.type(summaryInput, '他像旧友,也像最早知道航道秘密的人。');
await user.click(screen.getByRole('button', { name: '保存' }));
expect(screen.getByTestId('saved-payload').textContent).toContain(
'他像旧友,也像最早知道航道秘密的人。',
);
await user.click(screen.getByRole('button', { name: '新增角色' }));
expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy();
expect(screen.getByText('当前参考卡')).toBeTruthy();
const closeButtons = screen.getAllByRole('button', { name: '关闭' });
await user.click(closeButtons[closeButtons.length - 1]!);
expect(screen.getByRole('button', { name: '角色资产' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '新增场景' }));
expect(screen.getByRole('button', { name: '生成场景' })).toBeTruthy();
});

View File

@@ -0,0 +1,45 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
test('draft detail panel renders sections and warnings', () => {
const html = renderToStaticMarkup(
<CustomWorldAgentDraftDetailPanel
detail={{
id: 'thread-1',
kind: 'thread',
title: '谁掌握航道解释权',
sections: [
{
id: 'thread-type',
label: '线程类型',
value: '明线',
},
{
id: 'thread-conflict',
label: '冲突内容',
value: '守灯会与沉船商盟正在争夺航道解释权。',
},
],
linkedIds: ['character-1', 'landmark-1'],
locked: false,
editable: true,
editableSectionIds: ['title', 'summary', 'conflictType', 'stakes'],
warningMessages: ['这条线还缺少更明确的地点挂点。'],
}}
loading={false}
onClose={() => {}}
onStartEdit={() => {}}
onGenerateCharacter={() => {}}
onGenerateLandmark={() => {}}
/>,
);
expect(html).toContain('谁掌握航道解释权');
expect(html).toContain('线程类型');
expect(html).toContain('守灯会与沉船商盟');
expect(html).toContain('继续精修');
expect(html).toContain('编辑设定');
expect(html).toContain('新增角色');
});

View File

@@ -0,0 +1,204 @@
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldDraftEditPanel } from './CustomWorldDraftEditPanel';
type CustomWorldAgentDraftDetailPanelProps = {
detail: CustomWorldDraftCardDetail | null;
loading: boolean;
busy?: boolean;
editMode?: boolean;
onClose: () => void;
onStartEdit?: () => void;
onCancelEdit?: () => void;
onSave?: (
sections: Array<{
sectionId: string;
value: string;
}>,
) => void;
onGenerateCharacter?: () => void;
onGenerateLandmark?: () => void;
onOpenRoleAssetStudio?: () => void;
};
function resolveKindLabel(kind: CustomWorldDraftCardDetail['kind']) {
if (kind === 'world') return '世界总卡';
if (kind === 'camp') return '营地';
if (kind === 'faction') return '势力';
if (kind === 'character') return '角色';
if (kind === 'landmark') return '地点';
if (kind === 'thread') return '线程';
if (kind === 'chapter') return '第一幕';
return '草稿卡';
}
function ActionButton(props: {
label: string;
onClick?: () => void;
disabled?: boolean;
tone?: 'default' | 'sky';
}) {
const { label, onClick, disabled = false, tone = 'default' } = props;
if (!onClick) {
return null;
}
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`rounded-full border px-3 py-1.5 text-[11px] transition ${
tone === 'sky'
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'
} disabled:cursor-not-allowed disabled:opacity-45`}
>
{label}
</button>
);
}
export function CustomWorldAgentDraftDetailPanel({
detail,
loading,
busy = false,
editMode = false,
onClose,
onStartEdit,
onCancelEdit,
onSave,
onGenerateCharacter,
onGenerateLandmark,
onOpenRoleAssetStudio,
}: CustomWorldAgentDraftDetailPanelProps) {
return (
<section className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-2 text-lg font-semibold text-white">
{loading ? '正在读取' : detail?.title || '选择一张草稿卡'}
</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] text-zinc-300 transition hover:text-white"
>
</button>
</div>
{loading ? (
<div className="mt-4 rounded-[1.15rem] border border-white/8 bg-white/5 px-4 py-5 text-sm leading-7 text-zinc-300">
</div>
) : detail ? (
<div className="mt-4 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[11px] text-sky-100">
{resolveKindLabel(detail.kind)}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-300">
{detail.linkedIds.length}
</span>
{detail.editable ? (
<span className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[11px] text-emerald-100">
</span>
) : null}
{detail.kind === 'character' && detail.assetStatusLabel ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100">
{detail.assetStatusLabel}
</span>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{!editMode && detail.editable ? (
<ActionButton
label="编辑设定"
onClick={onStartEdit}
disabled={busy}
/>
) : null}
{!editMode && detail.kind === 'character' ? (
<ActionButton
label="角色资产"
onClick={onOpenRoleAssetStudio}
disabled={busy}
tone="sky"
/>
) : null}
{!editMode ? (
<>
<ActionButton
label="新增角色"
onClick={onGenerateCharacter}
disabled={busy}
tone="sky"
/>
<ActionButton
label="新增场景"
onClick={onGenerateLandmark}
disabled={busy}
tone="sky"
/>
</>
) : null}
</div>
{editMode && onSave && onCancelEdit ? (
<CustomWorldDraftEditPanel
detail={detail}
disabled={busy}
onSave={onSave}
onCancel={onCancelEdit}
/>
) : (
<div className="space-y-2">
{detail.sections.map((section) => (
<div
key={section.id}
className="rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3"
>
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
{section.label}
</div>
<div className="mt-2 whitespace-pre-wrap text-sm leading-7 text-zinc-100">
{section.value}
</div>
</div>
))}
</div>
)}
{detail.warningMessages.length > 0 ? (
<div className="rounded-[1.15rem] border border-amber-300/20 bg-amber-500/10 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.18em] text-amber-100">
</div>
<div className="mt-3 space-y-2">
{detail.warningMessages.map((message, index) => (
<div
key={`${detail.id}-warning-${index}`}
className="text-sm leading-7 text-amber-50"
>
{message}
</div>
))}
</div>
</div>
) : null}
</div>
) : (
<div className="mt-4 rounded-[1.15rem] border border-dashed border-white/10 bg-black/22 px-4 py-5 text-sm leading-7 text-zinc-400">
稿稿
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,115 @@
import type { CustomWorldDraftCardSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentDraftDrawerProps = {
draftCards: CustomWorldDraftCardSummary[];
activeCardId?: string | null;
onSelectCard: (cardId: string) => void;
};
const DRAWER_KIND_ORDER: CustomWorldDraftCardSummary['kind'][] = [
'world',
'chapter',
'thread',
'faction',
'character',
'landmark',
'camp',
];
function resolveGroupLabel(kind: CustomWorldDraftCardSummary['kind']) {
if (kind === 'world') return '世界总卡';
if (kind === 'chapter') return '第一幕';
if (kind === 'thread') return '世界线程';
if (kind === 'faction') return '势力';
if (kind === 'character') return '关键角色';
if (kind === 'landmark') return '关键地点';
if (kind === 'camp') return '营地';
return '草稿卡';
}
export function CustomWorldAgentDraftDrawer({
draftCards,
activeCardId,
onSelectCard,
}: CustomWorldAgentDraftDrawerProps) {
const groupedCards = DRAWER_KIND_ORDER.map((kind) => ({
kind,
items: draftCards.filter((card) => card.kind === kind),
})).filter((group) => group.items.length > 0);
return (
<div className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
稿
</div>
{groupedCards.length > 0 ? (
<div className="mt-3 space-y-4">
{groupedCards.map((group) => (
<section key={group.kind}>
<div className="flex items-center justify-between gap-3">
<div className="text-[11px] tracking-[0.18em] text-zinc-400">
{resolveGroupLabel(group.kind)}
</div>
<div className="text-[11px] text-zinc-500">
{group.items.length}
</div>
</div>
<div className="mt-2 space-y-2">
{group.items.map((card) => {
const isActive = activeCardId === card.id;
return (
<button
key={card.id}
type="button"
onClick={() => onSelectCard(card.id)}
className={`w-full rounded-[1.2rem] border px-3 py-3 text-left transition ${
isActive
? 'border-sky-300/30 bg-sky-500/10'
: 'border-white/8 bg-white/5 hover:border-white/14'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="text-sm font-semibold text-white">
{card.title}
</div>
{card.warningCount > 0 ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-100">
{card.warningCount}
</span>
) : null}
</div>
<div className="mt-1 text-[11px] text-zinc-400">
{card.subtitle}
</div>
<div className="mt-2 text-sm leading-6 text-zinc-300">
{card.summary}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-200">
{card.linkedIds.length}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-2.5 py-1 text-[10px] text-zinc-200">
{card.status === 'warning' ? '待精修' : '建议稿'}
</span>
{card.kind === 'character' && card.assetStatusLabel ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
{card.assetStatusLabel}
</span>
) : null}
</div>
</button>
);
})}
</div>
</section>
))}
</div>
) : (
<div className="mt-3 text-sm leading-7 text-zinc-400">
稿
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,18 @@
type CustomWorldAgentHeaderProps = {
onBack: () => void;
};
export function CustomWorldAgentHeader({ onBack }: CustomWorldAgentHeaderProps) {
return (
<div className="flex items-center justify-between gap-3 rounded-[1.5rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
<button
type="button"
onClick={onBack}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:text-white"
>
</button>
<div className="text-sm font-semibold text-white">Agent</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { CustomWorldAgentIntentSummaryPanel } from './CustomWorldAgentIntentSummaryPanel';
test('intent summary panel shows collected custom world anchors', () => {
const html = renderToStaticMarkup(
<CustomWorldAgentIntentSummaryPanel
creatorIntent={{
sourceMode: 'freeform',
rawSettingText: '',
worldHook: '一个被潮雾切开的列岛世界。',
themeKeywords: ['海岛'],
toneDirectives: ['冷峻'],
playerPremise: '玩家是失职返乡的守灯人。',
openingSituation: '开局站在即将熄灭的旧灯塔上。',
coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
keyCharacters: [],
iconicElements: ['潮雾钟声'],
}}
readiness={{
isReady: false,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'iconic_element',
],
missingKeys: ['relationship_seed'],
}}
/>,
);
expect(html).toContain('已收集锚点');
expect(html).toContain('世界一句话');
expect(html).toContain('一个被潮雾切开的列岛世界');
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
expect(html).toContain('5/6');
});

View File

@@ -0,0 +1,99 @@
import type { CreatorIntentReadiness } from '../../../packages/shared/src/contracts/customWorldAgent';
import {
evaluateCustomWorldCreatorIntentReadiness,
hasMeaningfulCustomWorldCreatorIntent,
normalizeCustomWorldCreatorIntent,
} from '../../services/customWorldCreatorIntent';
type CustomWorldAgentIntentSummaryPanelProps = {
creatorIntent: Record<string, unknown> | null;
readiness: CreatorIntentReadiness;
};
export function CustomWorldAgentIntentSummaryPanel({
creatorIntent,
readiness,
}: CustomWorldAgentIntentSummaryPanelProps) {
const intent = normalizeCustomWorldCreatorIntent(creatorIntent);
const resolvedReadiness =
readiness ?? evaluateCustomWorldCreatorIntentReadiness(intent);
const items = [
{
label: '世界一句话',
value: intent?.worldHook || '',
ready: resolvedReadiness.completedKeys.includes('world_hook'),
},
{
label: '玩家身份',
value: intent?.playerPremise || '',
ready: Boolean(intent?.playerPremise),
},
{
label: '开局处境',
value: intent?.openingSituation || '',
ready: Boolean(intent?.openingSituation),
},
{
label: '核心冲突',
value: intent?.coreConflicts.join('、') || '',
ready: resolvedReadiness.completedKeys.includes('core_conflict'),
},
{
label: '主题气质',
value:
[...(intent?.themeKeywords ?? []), ...(intent?.toneDirectives ?? [])]
.filter(Boolean)
.join('、') || '',
ready: resolvedReadiness.completedKeys.includes('theme_and_tone'),
},
{
label: '标志性要素',
value: intent?.iconicElements.join('、') || '',
ready: resolvedReadiness.completedKeys.includes('iconic_element'),
},
];
return (
<section className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-2 text-lg font-semibold text-white">
{resolvedReadiness.isReady ? '创作输入已齐备' : '继续收世界骨架'}
</div>
</div>
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-300">
{resolvedReadiness.completedKeys.length}/6
</span>
</div>
{hasMeaningfulCustomWorldCreatorIntent(intent) ? (
<div className="mt-4 grid gap-2 sm:grid-cols-2">
{items.map((item) => (
<div
key={item.label}
className={`rounded-[1.15rem] border px-3 py-3 ${
item.ready
? 'border-emerald-300/18 bg-emerald-500/8'
: 'border-white/8 bg-white/5'
}`}
>
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
{item.label}
</div>
<div className="mt-2 text-sm leading-6 text-zinc-100">
{item.value || '待补充'}
</div>
</div>
))}
</div>
) : (
<div className="mt-4 rounded-[1.15rem] border border-dashed border-white/10 bg-black/22 px-4 py-5 text-sm text-zinc-400">
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,90 @@
import { X } from 'lucide-react';
type CustomWorldAgentLauncherModalProps = {
isOpen: boolean;
seedText: string;
isBusy: boolean;
error: string | null;
onClose: () => void;
onSeedTextChange: (value: string) => void;
onConfirm: () => void;
};
export function CustomWorldAgentLauncherModal({
isOpen,
seedText,
isBusy,
error,
onClose,
onSeedTextChange,
onConfirm,
}: CustomWorldAgentLauncherModalProps) {
if (!isOpen) {
return null;
}
return (
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm">
<div className="flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] border border-white/10 bg-[#11161f] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div>
<div className="text-base font-semibold text-white">
Agent
</div>
<div className="mt-1 text-xs text-zinc-400">
</div>
</div>
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<label className="block">
<div className="mb-2 text-sm font-medium text-zinc-200">
Seed Text
</div>
<textarea
value={seedText}
onChange={(event) => onSeedTextChange(event.target.value)}
rows={7}
placeholder="例:一个被潮雾切碎的列岛世界,灯塔守望者、沉船秘术与旧盟约残片正在重新苏醒。"
className="w-full rounded-3xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40"
/>
</label>
{error ? (
<div className="mt-4 rounded-3xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{error}
</div>
) : null}
</div>
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
<button
type="button"
onClick={onConfirm}
disabled={isBusy}
className="rounded-2xl bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-45"
>
{isBusy ? '处理中...' : '开始共创'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
type CustomWorldAgentLockBarProps = {
lockState: Record<string, unknown> | null;
};
function readLockedItems(lockState: Record<string, unknown> | null) {
if (!lockState) {
return [];
}
return Object.entries(lockState)
.flatMap(([key, value]) =>
Array.isArray(value)
? value.map((item) => `${key}:${String(item)}`)
: typeof value === 'string' && value.trim()
? [`${key}:${value.trim()}`]
: [],
)
.slice(0, 8);
}
export function CustomWorldAgentLockBar({
lockState,
}: CustomWorldAgentLockBarProps) {
const lockedItems = readLockedItems(lockState);
return (
<div className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
{lockedItems.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{lockedItems.map((item) => (
<span
key={item}
className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100"
>
{item}
</span>
))}
</div>
) : (
<div className="mt-3 text-sm leading-7 text-zinc-400">
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { useEffect, useState } from 'react';
import type { CustomWorldAgentOperationRecord } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentOperationBannerProps = {
operation: CustomWorldAgentOperationRecord | null;
};
export function CustomWorldAgentOperationBanner({
operation,
}: CustomWorldAgentOperationBannerProps) {
const [visibleOperation, setVisibleOperation] =
useState<CustomWorldAgentOperationRecord | null>(operation);
useEffect(() => {
setVisibleOperation(operation);
if (operation?.status !== 'completed') {
return;
}
const timeoutId = window.setTimeout(() => {
setVisibleOperation((current) =>
current?.operationId === operation.operationId ? null : current,
);
}, 1200);
return () => window.clearTimeout(timeoutId);
}, [operation]);
if (!visibleOperation) {
return null;
}
const isFailed = visibleOperation.status === 'failed';
const isRunning =
visibleOperation.status === 'running' || visibleOperation.status === 'queued';
return (
<div
className={`rounded-[1.4rem] border px-4 py-4 ${
isFailed
? 'border-rose-400/20 bg-[#111318]/95'
: isRunning
? 'border-emerald-300/20 bg-[#111318]/95'
: 'border-emerald-300/20 bg-[#111318]/95'
}`}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
{visibleOperation.phaseLabel}
</div>
<div className="text-xs text-zinc-300">
{Math.max(0, Math.min(100, Math.round(visibleOperation.progress)))}%
</div>
</div>
{visibleOperation.error ? (
<div className="mt-2 text-sm text-zinc-200">
{visibleOperation.error}
</div>
) : null}
<div className="mt-3 h-2 overflow-hidden rounded-full bg-white/10">
<div
className={`h-full rounded-full transition-[width] duration-300 ${
isFailed ? 'bg-rose-300' : 'bg-emerald-300'
}`}
style={{
width: `${Math.max(8, Math.min(100, visibleOperation.progress))}%`,
}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
import type { CustomWorldSuggestedAction } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentQuickActionsProps = {
suggestedActions: CustomWorldSuggestedAction[];
disabled: boolean;
canDraftFoundation: boolean;
showEntityActions?: boolean;
onRequestSummary: () => void;
onDraftFoundation: () => void;
onGenerateCharacter?: () => void;
onGenerateLandmark?: () => void;
onGenerateRoleAssets?: () => void;
showRoleAssetAction?: boolean;
onFocusSuggestedAction: (action?: CustomWorldSuggestedAction) => void;
};
function QuickActionButton(props: {
label: string;
onClick: () => void;
disabled: boolean;
tone?: 'default' | 'sky' | 'amber';
}) {
const { label, onClick, disabled, tone = 'default' } = props;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`rounded-[1.1rem] border px-4 py-3 text-left text-sm transition disabled:cursor-not-allowed disabled:opacity-45 ${
tone === 'amber'
? 'border-amber-300/20 bg-amber-500/10 text-amber-100 hover:text-white'
: tone === 'sky'
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
: 'border-white/10 bg-black/20 text-zinc-200 hover:text-white'
}`}
>
{label}
</button>
);
}
export function CustomWorldAgentQuickActions({
suggestedActions,
disabled,
canDraftFoundation,
showEntityActions = false,
onRequestSummary,
onDraftFoundation,
onGenerateCharacter,
onGenerateLandmark,
onGenerateRoleAssets,
showRoleAssetAction = false,
onFocusSuggestedAction,
}: CustomWorldAgentQuickActionsProps) {
const summaryAction = suggestedActions.find(
(action) => action.type === 'request_summary',
);
const draftAction = suggestedActions.find(
(action) => action.type === 'draft_foundation',
);
const refinementActions = suggestedActions.filter(
(action) =>
action.type !== 'request_summary' && action.type !== 'draft_foundation',
);
return (
<div className="rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-3 flex flex-col gap-2">
<QuickActionButton
label={summaryAction?.label ?? '总结当前设定'}
onClick={onRequestSummary}
disabled={disabled}
tone="sky"
/>
{draftAction && canDraftFoundation ? (
<QuickActionButton
label={draftAction.label}
onClick={onDraftFoundation}
disabled={disabled}
tone="amber"
/>
) : null}
{showEntityActions && onGenerateCharacter ? (
<QuickActionButton
label="新增角色"
onClick={onGenerateCharacter}
disabled={disabled}
/>
) : null}
{showEntityActions && onGenerateLandmark ? (
<QuickActionButton
label="新增场景"
onClick={onGenerateLandmark}
disabled={disabled}
/>
) : null}
{showRoleAssetAction && onGenerateRoleAssets ? (
<QuickActionButton
label="生成角色主图与动作"
onClick={onGenerateRoleAssets}
disabled={disabled}
tone="amber"
/>
) : null}
{refinementActions.length > 0 ? (
refinementActions.slice(0, 2).map((action) => (
<QuickActionButton
key={action.id}
label={action.label}
onClick={() => onFocusSuggestedAction(action)}
disabled={disabled}
/>
))
) : !draftAction || !canDraftFoundation ? (
<QuickActionButton
label={showEntityActions ? '继续精修当前草稿' : '继续补充锚点'}
onClick={() => onFocusSuggestedAction()}
disabled={disabled}
/>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentSummaryPanelProps = {
session: CustomWorldAgentSessionSnapshot;
};
function readSummaryText(
draftProfile: Record<string, unknown> | null,
fallback: string,
) {
const title =
typeof draftProfile?.title === 'string' ? draftProfile.title.trim() : '';
const summary =
typeof draftProfile?.summary === 'string'
? draftProfile.summary.trim()
: '';
return {
title: title || '世界摘要待整理',
summary: summary || fallback,
};
}
export function CustomWorldAgentSummaryPanel({
session,
}: CustomWorldAgentSummaryPanelProps) {
const pendingCount = session.pendingClarifications.length;
const { title, summary } = readSummaryText(
session.draftProfile,
'第一阶段先收住世界锚点,后续阶段再把这里整理成更完整的世界底稿摘要。',
);
return (
<div className="rounded-[1.75rem] border border-white/10 bg-black/20 px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
</div>
<div className="mt-2 text-lg font-semibold text-white">
{title}
</div>
</div>
<div className="flex flex-wrap gap-2">
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-300">
{session.messages.length}
</span>
<span className="rounded-full border border-sky-300/20 bg-sky-500/10 px-3 py-1 text-[11px] text-sky-100">
{pendingCount}
</span>
</div>
</div>
<div className="mt-3 text-sm leading-7 text-zinc-300">
{summary}
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { useEffect, useRef } from 'react';
import type { CustomWorldAgentMessage } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldAgentThreadProps = {
messages: CustomWorldAgentMessage[];
recommendedReplies?: string[];
onRecommendedReply?: (text: string) => void;
};
function formatMessageTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
export function CustomWorldAgentThread({
messages,
recommendedReplies = [],
onRecommendedReply,
}: CustomWorldAgentThreadProps) {
const bottomRef = useRef<HTMLDivElement | null>(null);
const lastAssistantMessageId = [...messages]
.reverse()
.find((message) => message.role === 'assistant')?.id;
useEffect(() => {
bottomRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end',
});
}, [messages]);
return (
<div className="flex min-h-[20rem] flex-1 flex-col overflow-y-auto rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
{messages.length === 0 ? (
<div className="m-auto text-sm text-zinc-400">
</div>
) : (
<div className="space-y-3">
{messages.map((message) => {
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
return (
<div
key={message.id}
className={`flex ${
isUser ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 break-words sm:max-w-[82%] ${
isUser
? 'border border-white/10 bg-white/10 text-zinc-50'
: isSystem
? 'border border-amber-300/16 bg-amber-500/10 text-amber-50'
: 'border border-white/10 bg-white/6 text-zinc-100'
}`}
>
<div className="whitespace-pre-wrap">{message.text}</div>
<div className="mt-2 text-[11px] text-zinc-400">
{formatMessageTime(message.createdAt)}
</div>
{!isUser &&
message.id === lastAssistantMessageId &&
recommendedReplies.length > 0 ? (
<div className="mt-3 flex flex-col gap-2">
{recommendedReplies.slice(0, 3).map((reply) => (
<button
key={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"
>
{reply}
</button>
))}
</div>
) : null}
</div>
</div>
);
})}
<div ref={bottomRef} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,471 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest';
import type {
CustomWorldAgentSessionSnapshot,
CustomWorldDraftCardDetail,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import { getCustomWorldAgentCardDetail } from '../../services/aiService';
import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace';
vi.mock('../../services/aiService', () => ({
getCustomWorldAgentCardDetail: vi.fn(),
}));
vi.mock('../CustomWorldRoleAssetStudioModal', () => ({
CustomWorldRoleAssetStudioModal: ({
role,
onPublishSuccess,
}: {
role: { name: string };
onPublishSuccess?: (
payload: {
roleId: string;
portraitPath: string;
generatedVisualAssetId: string;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
},
options?: { closeAfterSync?: boolean },
) => void;
}) => (
<div>
<div>{role.name}</div>
<button
type="button"
onClick={() =>
onPublishSuccess?.(
{
roleId: 'character-1',
portraitPath: '/generated/character-1.png',
generatedVisualAssetId: 'visual-character-1',
generatedAnimationSetId: 'animation-set-character-1',
animationMap: {
idle: { basePath: '/generated/character-1/idle' },
run: { basePath: '/generated/character-1/run' },
attack: { basePath: '/generated/character-1/attack' },
hurt: { basePath: '/generated/character-1/hurt' },
die: { basePath: '/generated/character-1/die' },
},
},
{
closeAfterSync: true,
},
)
}
>
</button>
</div>
),
}));
const detailById: Record<string, CustomWorldDraftCardDetail> = {
'world-foundation': {
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
sections: [
{
id: 'title',
label: '标题',
value: '潮雾列岛',
},
{
id: 'summary',
label: '摘要',
value: '这是第一版世界底稿。',
},
],
linkedIds: ['thread-1', 'character-1'],
locked: false,
editable: true,
editableSectionIds: ['title', 'summary'],
warningMessages: [],
},
'character-1': {
id: 'character-1',
kind: 'character',
title: '沈砺',
sections: [
{
id: 'name',
label: '角色名',
value: '沈砺',
},
{
id: 'summary',
label: '角色摘要',
value: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
linkedIds: ['thread-1'],
locked: false,
editable: true,
editableSectionIds: ['name', 'summary'],
warningMessages: [],
assetStatus: 'missing',
assetStatusLabel: '待生成主图',
},
'character-2': {
id: 'character-2',
kind: 'character',
title: '顾潮音',
sections: [
{
id: 'name',
label: '角色名',
value: '顾潮音',
},
{
id: 'summary',
label: '角色摘要',
value: '她总像比所有人更早知道海雾会往哪一侧压下来。',
},
],
linkedIds: ['thread-1'],
locked: false,
editable: true,
editableSectionIds: ['name', 'summary'],
warningMessages: [],
assetStatus: 'missing',
assetStatusLabel: '待生成主图',
},
};
const baseSession: CustomWorldAgentSessionSnapshot = {
sessionId: 'custom-world-agent-session-1',
stage: 'object_refining',
focusCardId: 'world-foundation',
creatorIntent: {},
creatorIntentReadiness: {
isReady: true,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'relationship_seed',
'iconic_element',
],
missingKeys: [],
},
anchorPack: {},
lockState: {},
draftProfile: {
name: '潮雾列岛',
storyNpcs: [
{
id: 'character-1',
name: '沈砺',
title: '守灯会旧友',
role: '航道向导',
publicMask: '守灯会里最熟悉旧航道的人。',
hiddenHook: '暗地里正在为沉船商盟引路。',
relationToPlayer: '旧友兼宿敌',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
},
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'summary',
text: '当前底稿已经可以继续精修。',
createdAt: new Date().toISOString(),
relatedOperationId: null,
},
],
draftCards: [
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与航道争夺',
summary: '世界总卡已经生成。',
status: 'warning',
linkedIds: ['thread-1', 'character-1'],
warningCount: 1,
},
{
id: 'character-1',
kind: 'character',
title: '沈砺',
subtitle: '守灯会旧友',
summary: '他最了解旧航道,也最可能先背叛。',
status: 'suggested',
linkedIds: ['thread-1'],
warningCount: 0,
},
],
pendingClarifications: [],
suggestedActions: [
{
id: 'request-summary',
type: 'request_summary',
label: '总结当前世界底稿',
targetId: null,
},
],
recommendedReplies: ['现在开始生成草稿', '先总结一下当前设定', '我还想再补充一点'],
qualityFindings: [],
assetCoverage: {
roleAssets: [
{
roleId: 'character-1',
roleName: '沈砺',
roleKind: 'story',
priorityTier: 'featured',
portraitPath: null,
generatedVisualAssetId: null,
generatedAnimationSetId: null,
status: 'missing',
missingAnimations: ['idle', 'run', 'attack', 'hurt', 'die'],
nextPointCost: 20,
},
],
sceneAssets: [],
allRoleAssetsReady: false,
allSceneAssetsReady: false,
},
updatedAt: '2026-04-14T10:00:00.000Z',
};
beforeEach(() => {
vi.mocked(getCustomWorldAgentCardDetail).mockImplementation(
async (_sessionId, cardId): Promise<CustomWorldDraftCardDetail> =>
detailById[cardId] ?? detailById['world-foundation']!,
);
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
});
test('workspace loads detail, saves edits, opens generate actions, and reflects updated drawer cards', async () => {
const user = userEvent.setup();
const onExecuteAction = vi.fn();
const { rerender } = render(
<CustomWorldAgentWorkspace
session={baseSession}
activeOperation={null}
onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
await waitFor(() => {
expect(getCustomWorldAgentCardDetail).toHaveBeenCalledWith(
baseSession.sessionId,
'world-foundation',
);
});
await user.click(screen.getByRole('button', { name: '编辑设定' }));
const summaryInput = screen.getByLabelText('摘要');
await user.clear(summaryInput);
await user.type(summaryInput, '这是更新后的世界摘要。');
await user.click(screen.getByRole('button', { name: '保存' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'update_draft_card',
cardId: 'world-foundation',
sections: [
{
sectionId: 'title',
value: '潮雾列岛',
},
{
sectionId: 'summary',
value: '这是更新后的世界摘要。',
},
],
});
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(getCustomWorldAgentCardDetail).toHaveBeenLastCalledWith(
baseSession.sessionId,
'character-1',
);
});
const [generateCharacterButton] = screen.getAllByRole('button', { name: '新增角色' });
await user.click(generateCharacterButton!);
expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '生成角色' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_characters',
count: 2,
promptText: null,
anchorCardIds: ['character-1'],
});
const [generateLandmarkButton] = screen.getAllByRole('button', { name: '新增场景' });
await user.click(generateLandmarkButton!);
expect(screen.getByRole('button', { name: '生成场景' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '生成场景' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_landmarks',
count: 2,
promptText: null,
anchorCardIds: ['character-1'],
});
const [openRoleAssetsButton] = screen.getAllByRole('button', {
name: '角色资产',
});
await user.click(openRoleAssetsButton!);
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_role_assets',
roleIds: ['character-1'],
});
rerender(
<CustomWorldAgentWorkspace
session={{
...baseSession,
stage: 'visual_refining',
draftCards: [
...baseSession.draftCards,
{
id: 'character-2',
kind: 'character',
title: '顾潮音',
subtitle: '回潮记录员',
summary: '她会把每一次海雾异常都记到连自己都不愿复看的本子里。',
status: 'suggested',
linkedIds: ['thread-1'],
warningCount: 0,
},
],
updatedAt: '2026-04-14T10:05:00.000Z',
}}
activeOperation={{
operationId: 'operation-role-assets',
type: 'generate_role_assets',
status: 'completed',
phaseLabel: '角色资产工坊已就绪',
phaseDetail: '可以开始生成角色主图与动作。',
progress: 100,
error: null,
}}
onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
await waitFor(() => {
expect(screen.getByText('顾潮音')).toBeTruthy();
});
expect(screen.getByText('角色资产工坊:沈砺')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '模拟同步角色资产' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'sync_role_assets',
roleId: 'character-1',
portraitPath: '/generated/character-1.png',
generatedVisualAssetId: 'visual-character-1',
generatedAnimationSetId: 'animation-set-character-1',
animationMap: {
idle: { basePath: '/generated/character-1/idle' },
run: { basePath: '/generated/character-1/run' },
attack: { basePath: '/generated/character-1/attack' },
hurt: { basePath: '/generated/character-1/hurt' },
die: { basePath: '/generated/character-1/die' },
},
});
rerender(
<CustomWorldAgentWorkspace
session={{
...baseSession,
stage: 'visual_refining',
draftCards: [
{
...baseSession.draftCards[0]!,
},
{
...baseSession.draftCards[1]!,
subtitle: '守灯会旧友 / 动作已就绪',
assetStatus: 'complete',
assetStatusLabel: '动作已就绪',
},
],
assetCoverage: {
roleAssets: [
{
roleId: 'character-1',
roleName: '沈砺',
roleKind: 'story',
priorityTier: 'featured',
portraitPath: '/generated/character-1.png',
generatedVisualAssetId: 'visual-character-1',
generatedAnimationSetId: 'animation-set-character-1',
status: 'complete',
missingAnimations: [],
nextPointCost: 0,
},
],
sceneAssets: [],
allRoleAssetsReady: true,
allSceneAssetsReady: false,
},
draftProfile: {
...baseSession.draftProfile,
storyNpcs: [
{
id: 'character-1',
name: '沈砺',
title: '守灯会旧友',
role: '航道向导',
publicMask: '守灯会里最熟悉旧航道的人。',
hiddenHook: '暗地里正在为沉船商盟引路。',
relationToPlayer: '旧友兼宿敌',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
imageSrc: '/generated/character-1.png',
generatedVisualAssetId: 'visual-character-1',
generatedAnimationSetId: 'animation-set-character-1',
animationMap: {
idle: { basePath: '/generated/character-1/idle' },
run: { basePath: '/generated/character-1/run' },
attack: { basePath: '/generated/character-1/attack' },
hurt: { basePath: '/generated/character-1/hurt' },
die: { basePath: '/generated/character-1/die' },
},
},
],
},
}}
activeOperation={{
operationId: 'operation-sync-role-assets',
type: 'sync_role_assets',
status: 'completed',
phaseLabel: '角色资产已同步',
phaseDetail: '角色资产已经写回草稿。',
progress: 100,
error: null,
}}
onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
await waitFor(() => {
expect(screen.getAllByText('动作已就绪').length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,96 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { CustomWorldAgentWorkspace } from './CustomWorldAgentWorkspace';
test('custom world agent workspace renders progress labels, action button and recommended replies', () => {
const html = renderToStaticMarkup(
<CustomWorldAgentWorkspace
session={{
sessionId: 'custom-world-agent-session-1',
stage: 'object_refining',
focusCardId: 'world-foundation',
creatorIntent: {},
creatorIntentReadiness: {
isReady: true,
completedKeys: [
'world_hook',
'player_premise',
'theme_and_tone',
'core_conflict',
'relationship_seed',
'iconic_element',
],
missingKeys: [],
},
anchorPack: {},
lockState: {},
draftProfile: {
name: '潮雾列岛',
},
messages: [
{
id: 'message-1',
role: 'assistant',
kind: 'summary',
text: '欢迎。当前底稿已经可以继续精修。',
createdAt: new Date().toISOString(),
relatedOperationId: null,
},
],
draftCards: [
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与航道争夺',
summary: '世界总卡已经生成。',
status: 'warning',
linkedIds: ['thread-1', 'character-1'],
warningCount: 1,
},
{
id: 'character-1',
kind: 'character',
title: '沈砺',
subtitle: '守灯会旧友',
summary: '他最了解旧航道,也最可能先背叛。',
status: 'suggested',
linkedIds: ['thread-1'],
warningCount: 0,
},
],
pendingClarifications: [],
suggestedActions: [
{
id: 'request-summary',
type: 'request_summary',
label: '总结当前世界底稿',
targetId: null,
},
],
recommendedReplies: ['现在开始生成草稿', '先总结一下当前设定'],
qualityFindings: [],
assetCoverage: {
roleAssets: [],
sceneAssets: [],
allRoleAssetsReady: false,
allSceneAssetsReady: false,
},
updatedAt: new Date().toISOString(),
}}
activeOperation={null}
onBack={() => {}}
onRefresh={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
/>,
);
expect(html).toContain('首轮草稿会先确认这 6 项信息');
expect(html).toContain('世界核心');
expect(html).toContain('玩家开局');
expect(html).toContain('现在开始生成草稿');
expect(html).toContain('开始生成草稿');
expect(html).toContain('欢迎。当前底稿已经可以继续精修。');
});

View File

@@ -0,0 +1,702 @@
import { useEffect, useState } from 'react';
import type {
CustomWorldAgentActionRequest,
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
CustomWorldDraftCardDetail,
SendCustomWorldAgentMessageRequest,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldRoleAssetStudioModal } from '../CustomWorldRoleAssetStudioModal';
import { getCustomWorldAgentCardDetail } from '../../services/aiService';
import { CustomWorldAgentComposer } from './CustomWorldAgentComposer';
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
import { CustomWorldAgentDraftDrawer } from './CustomWorldAgentDraftDrawer';
import { CustomWorldAgentHeader } from './CustomWorldAgentHeader';
import { CustomWorldAgentOperationBanner } from './CustomWorldAgentOperationBanner';
import { CustomWorldAgentQuickActions } from './CustomWorldAgentQuickActions';
import { CustomWorldAgentThread } from './CustomWorldAgentThread';
import { CustomWorldDraftCardDetailModal } from './CustomWorldDraftCardDetailModal';
import { CustomWorldGenerateEntityModal } from './CustomWorldGenerateEntityModal';
type WorkspaceRoleAssetTarget = {
id: string;
name: string;
title: string;
role: string;
description?: string;
backstory?: string;
personality?: string;
motivation?: string;
combatStyle?: string;
tags?: string[];
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, unknown>;
};
type CustomWorldAgentWorkspaceProps = {
session: CustomWorldAgentSessionSnapshot | null;
activeOperation: CustomWorldAgentOperationRecord | null;
onBack: () => void;
onRefresh: () => void;
onSubmitMessage: (payload: SendCustomWorldAgentMessageRequest) => void;
onExecuteAction: (payload: CustomWorldAgentActionRequest) => void;
};
const TOTAL_READINESS_STEPS = 6;
const READINESS_ITEMS = [
{ key: 'world_hook', label: '世界核心' },
{ key: 'player_premise', label: '玩家开局' },
{ key: 'theme_and_tone', label: '主题气质' },
{ key: 'core_conflict', label: '核心冲突' },
{ key: 'relationship_seed', label: '关键关系' },
{ key: 'iconic_element', label: '标志元素' },
] as const;
function createClientMessageId() {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return crypto.randomUUID();
}
return `client-message-${Date.now()}`;
}
function resolveInitialCardId(session: CustomWorldAgentSessionSnapshot | null) {
if (!session || session.draftCards.length === 0) {
return null;
}
return (
session.focusCardId ||
session.draftCards.find((card) => card.kind === 'world')?.id ||
session.draftCards[0]?.id ||
null
);
}
function buildRecommendedReplies(session: CustomWorldAgentSessionSnapshot) {
return session.recommendedReplies;
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === 'object' && !Array.isArray(item),
)
: [];
}
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function resolveRoleAssetTarget(
session: CustomWorldAgentSessionSnapshot | null,
roleId: string | null,
) {
if (!session || !roleId) {
return null;
}
const draftProfile = toRecord(session.draftProfile);
if (!draftProfile) {
return null;
}
const playableRole = toRecordArray(draftProfile.playableNpcs).find(
(item) => toText(item.id) === roleId,
);
const storyRole = toRecordArray(draftProfile.storyNpcs).find(
(item) => toText(item.id) === roleId,
);
const role = playableRole ?? storyRole;
if (!role) {
return null;
}
const assetSummary =
session.assetCoverage.roleAssets.find((entry) => entry.roleId === roleId) ??
null;
return {
role: {
id: roleId,
name: toText(role.name) || '未命名角色',
title: toText(role.title) || toText(role.role) || '关键角色',
role: toText(role.role) || toText(role.title) || '关键角色',
description: toText(role.summary),
backstory: toText(role.hiddenHook) || undefined,
personality: toText(role.publicMask) || undefined,
motivation: toText(role.relationToPlayer) || undefined,
combatStyle: toText(role.role) || undefined,
tags: Array.isArray(role.threadIds)
? role.threadIds
.map((item) => toText(item))
.filter(Boolean)
.slice(0, 4)
: [],
imageSrc: toText(role.imageSrc) || undefined,
generatedVisualAssetId: toText(role.generatedVisualAssetId) || undefined,
generatedAnimationSetId: toText(role.generatedAnimationSetId) || undefined,
animationMap: toRecord(role.animationMap) ?? undefined,
} satisfies WorkspaceRoleAssetTarget,
roleKind: playableRole ? ('playable' as const) : ('story' as const),
assetSummary,
};
}
function CustomWorldAgentReadinessBar(props: {
completedKeys: string[];
isReady: boolean;
busy: boolean;
onStartDraft: () => void;
}) {
const { completedKeys, isReady, busy, onStartDraft } = props;
const completedKeySet = new Set(completedKeys);
const completedCount = READINESS_ITEMS.filter((item) =>
completedKeySet.has(item.key),
).length;
return (
<div className="rounded-[1.35rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-xs font-semibold tracking-[0.12em] text-zinc-300">
稿 6
</div>
</div>
<div className="text-xs text-zinc-400">
{Math.min(completedCount, TOTAL_READINESS_STEPS)}/
{TOTAL_READINESS_STEPS}
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="grid min-w-0 flex-1 grid-cols-3 gap-2 sm:grid-cols-6">
{READINESS_ITEMS.map((item) => (
<div
key={item.key}
className={`rounded-2xl border px-2.5 py-2 text-center text-[11px] ${
completedKeySet.has(item.key)
? 'border-emerald-300/25 bg-emerald-500/10 text-emerald-100'
: 'border-white/10 bg-black/18 text-zinc-500'
}`}
>
{item.label}
</div>
))}
</div>
<div className="flex items-center justify-between gap-3 sm:justify-end">
{isReady ? (
<button
type="button"
onClick={onStartDraft}
disabled={busy}
className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
{busy ? '生成中' : '开始生成草稿'}
</button>
) : null}
</div>
</div>
</div>
);
}
export function CustomWorldAgentWorkspace({
session,
activeOperation,
onBack,
onSubmitMessage,
onExecuteAction,
}: CustomWorldAgentWorkspaceProps) {
const [selectedCardId, setSelectedCardId] = useState<string | null>(() =>
resolveInitialCardId(session),
);
const [detail, setDetail] = useState<CustomWorldDraftCardDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [editMode, setEditMode] = useState(false);
const [autoCompleteConfirmOpen, setAutoCompleteConfirmOpen] = useState(false);
const [generateEntityMode, setGenerateEntityMode] = useState<
'character' | 'landmark' | null
>(null);
const [requestedRoleAssetTargetId, setRequestedRoleAssetTargetId] = useState<
string | null
>(null);
const [activeRoleAssetTargetId, setActiveRoleAssetTargetId] = useState<
string | null
>(null);
const [showRoleAssetStudio, setShowRoleAssetStudio] = useState(false);
const [closeRoleAssetStudioAfterSync, setCloseRoleAssetStudioAfterSync] =
useState(false);
useEffect(() => {
if (!session) {
setSelectedCardId(null);
return;
}
const availableCardIds = new Set(session.draftCards.map((card) => card.id));
if (session.focusCardId && availableCardIds.has(session.focusCardId)) {
setSelectedCardId(session.focusCardId);
return;
}
setSelectedCardId((current) => {
if (current && availableCardIds.has(current)) {
return current;
}
return resolveInitialCardId(session);
});
}, [session]);
useEffect(() => {
setEditMode(false);
}, [detail?.id]);
useEffect(() => {
if (!requestedRoleAssetTargetId || !activeOperation) {
return;
}
if (activeOperation.type !== 'generate_role_assets') {
return;
}
if (activeOperation.status === 'completed') {
setActiveRoleAssetTargetId(requestedRoleAssetTargetId);
setShowRoleAssetStudio(true);
setRequestedRoleAssetTargetId(null);
setDetailModalOpen(false);
return;
}
if (activeOperation.status === 'failed') {
setRequestedRoleAssetTargetId(null);
}
}, [activeOperation, requestedRoleAssetTargetId]);
useEffect(() => {
if (!activeOperation || activeOperation.type !== 'sync_role_assets') {
return;
}
if (activeOperation.status === 'completed') {
if (closeRoleAssetStudioAfterSync) {
setShowRoleAssetStudio(false);
}
setCloseRoleAssetStudioAfterSync(false);
return;
}
if (activeOperation.status === 'failed') {
setCloseRoleAssetStudioAfterSync(false);
}
}, [activeOperation, closeRoleAssetStudioAfterSync]);
useEffect(() => {
if (!session?.sessionId || !selectedCardId) {
setDetail(null);
setDetailLoading(false);
return;
}
let cancelled = false;
setDetailLoading(true);
void getCustomWorldAgentCardDetail(session.sessionId, selectedCardId)
.then((nextDetail) => {
if (cancelled) {
return;
}
setDetail(nextDetail);
})
.catch(() => {
if (cancelled) {
return;
}
setDetail(null);
})
.finally(() => {
if (!cancelled) {
setDetailLoading(false);
}
});
return () => {
cancelled = true;
};
}, [selectedCardId, session?.sessionId, session?.updatedAt]);
if (!session) {
return (
<div className="mx-auto flex h-full w-full max-w-4xl items-center justify-center rounded-[1.75rem] border border-white/10 bg-black/20 px-6 py-8 text-center text-sm text-zinc-400">
</div>
);
}
const isBusy =
activeOperation?.status === 'queued' || activeOperation?.status === 'running';
const canStartDraft =
session.creatorIntentReadiness.isReady &&
session.stage === 'foundation_review';
const showAutoCompleteButton =
!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;
const recommendedReplies = buildRecommendedReplies(session);
const selectedRoleAssetContext = resolveRoleAssetTarget(
session,
activeRoleAssetTargetId,
);
const openRoleAssetStudio = (roleId: string | null) => {
if (!roleId) {
return;
}
setRequestedRoleAssetTargetId(roleId);
onExecuteAction({
action: 'generate_role_assets',
roleIds: [roleId],
});
};
const submitTextMessage = (text: string) => {
onSubmitMessage({
clientMessageId: createClientMessageId(),
text,
focusCardId: selectedCardId,
selectedCardIds: selectedCardId ? [selectedCardId] : [],
});
};
const submitSummaryRequest = () => {
submitTextMessage(
showDraftWorkspace
? '帮我总结当前世界底稿,并指出下一步最值得精修的卡片。'
: '帮我总结当前设定,并指出下一步最值得补的世界锚点。',
);
};
const submitAutoCompleteRequest = () => {
submitTextMessage(
session.creatorIntentReadiness.isReady
? '基于当前设定,帮我自动补强还可以更清晰的细节。'
: '请根据当前信息自动补全还缺的设定,并给我一版默认方案。',
);
setAutoCompleteConfirmOpen(false);
};
const handleRecommendedReply = (reply: string) => {
if (canStartDraft && reply.includes('生成草稿')) {
onExecuteAction({
action: 'draft_foundation',
});
return;
}
submitTextMessage(reply);
};
const openGenerateModal = (mode: 'character' | 'landmark') => {
setGenerateEntityMode(mode);
};
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',
});
}}
/>
<CustomWorldAgentOperationBanner operation={activeOperation} />
{showDraftWorkspace ? (
<div className="grid min-h-0 flex-1 gap-3 xl:grid-cols-[18rem_minmax(0,1fr)_24rem]">
<div className="flex min-h-0 flex-col gap-3 xl:overflow-hidden">
<CustomWorldAgentQuickActions
suggestedActions={session.suggestedActions}
disabled={isBusy}
canDraftFoundation={canStartDraft}
showEntityActions
showRoleAssetAction={selectedCard?.kind === 'character'}
onRequestSummary={submitSummaryRequest}
onDraftFoundation={() => {
onExecuteAction({
action: 'draft_foundation',
});
}}
onGenerateCharacter={() => {
openGenerateModal('character');
}}
onGenerateLandmark={() => {
openGenerateModal('landmark');
}}
onGenerateRoleAssets={() => {
openRoleAssetStudio(selectedCardId);
}}
onFocusSuggestedAction={(action) => {
if (action?.targetId) {
setSelectedCardId(action.targetId);
setDetailModalOpen(true);
return;
}
if (session.draftCards[0]) {
setSelectedCardId(session.draftCards[0].id);
setDetailModalOpen(true);
}
}}
/>
<div className="xl:min-h-0 xl:overflow-y-auto">
<CustomWorldAgentDraftDrawer
draftCards={session.draftCards}
activeCardId={selectedCardId}
onSelectCard={(cardId) => {
setSelectedCardId(cardId);
setDetailModalOpen(true);
}}
/>
</div>
</div>
<div className="hidden min-h-0 xl:block xl:overflow-y-auto">
<CustomWorldAgentDraftDetailPanel
detail={detail}
loading={detailLoading}
busy={isBusy}
editMode={editMode}
onClose={() => {
setSelectedCardId(null);
setDetailModalOpen(false);
setEditMode(false);
}}
onStartEdit={() => {
setEditMode(true);
}}
onCancelEdit={() => {
setEditMode(false);
}}
onSave={(sections) => {
if (!detail) {
return;
}
setEditMode(false);
onExecuteAction({
action: 'update_draft_card',
cardId: detail.id,
sections,
});
}}
onGenerateCharacter={() => {
openGenerateModal('character');
}}
onGenerateLandmark={() => {
openGenerateModal('landmark');
}}
onOpenRoleAssetStudio={() => {
openRoleAssetStudio(detail?.id ?? selectedCardId);
}}
/>
</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">
<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}
/>
</>
)}
{autoCompleteConfirmOpen ? (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
<div className="w-full max-w-md rounded-[1.5rem] border border-white/10 bg-[#111318] px-5 py-5 shadow-[0_20px_60px_rgba(0,0,0,0.45)]">
<div className="text-base font-semibold text-white">
</div>
<div className="mt-3 text-sm leading-7 text-zinc-300">
</div>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
onClick={() => setAutoCompleteConfirmOpen(false)}
className="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-200 transition hover:text-white"
>
</button>
<button
type="button"
onClick={submitAutoCompleteRequest}
className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-4 py-2 text-sm text-emerald-100 transition hover:text-white"
>
</button>
</div>
</div>
</div>
) : null}
<CustomWorldDraftCardDetailModal
open={detailModalOpen}
detail={detail}
loading={detailLoading}
busy={isBusy}
editMode={editMode}
onClose={() => {
setDetailModalOpen(false);
setEditMode(false);
}}
onStartEdit={() => {
setEditMode(true);
}}
onCancelEdit={() => {
setEditMode(false);
}}
onSave={(sections) => {
if (!detail) {
return;
}
setEditMode(false);
setDetailModalOpen(false);
onExecuteAction({
action: 'update_draft_card',
cardId: detail.id,
sections,
});
}}
onGenerateCharacter={() => {
setDetailModalOpen(false);
openGenerateModal('character');
}}
onGenerateLandmark={() => {
setDetailModalOpen(false);
openGenerateModal('landmark');
}}
onOpenRoleAssetStudio={() => {
setDetailModalOpen(false);
openRoleAssetStudio(detail?.id ?? selectedCardId);
}}
/>
<CustomWorldGenerateEntityModal
open={generateEntityMode !== null}
mode={generateEntityMode ?? 'character'}
anchorCardTitle={selectedCard?.title ?? detail?.title ?? null}
disabled={isBusy}
onClose={() => {
setGenerateEntityMode(null);
}}
onSubmit={({ count, promptText }) => {
if (!generateEntityMode) {
return;
}
onExecuteAction({
action:
generateEntityMode === 'character'
? 'generate_characters'
: 'generate_landmarks',
count,
promptText: promptText || null,
anchorCardIds: selectedCardId ? [selectedCardId] : [],
});
setGenerateEntityMode(null);
}}
/>
{showRoleAssetStudio && selectedRoleAssetContext ? (
<CustomWorldRoleAssetStudioModal
role={selectedRoleAssetContext.role}
roleKind={selectedRoleAssetContext.roleKind}
priorityTier={
selectedRoleAssetContext.assetSummary?.priorityTier ??
(selectedRoleAssetContext.roleKind === 'playable'
? 'hero'
: 'featured')
}
visualPointCost={
selectedRoleAssetContext.assetSummary?.status === 'missing'
? selectedRoleAssetContext.assetSummary?.nextPointCost ?? 20
: 20
}
animationPointCost={
selectedRoleAssetContext.assetSummary?.status === 'missing'
? 60
: selectedRoleAssetContext.assetSummary?.nextPointCost ?? 60
}
syncBusy={
activeOperation?.type === 'sync_role_assets' &&
(activeOperation.status === 'queued' ||
activeOperation.status === 'running')
}
onPublishSuccess={(payload, options) => {
setCloseRoleAssetStudioAfterSync(Boolean(options?.closeAfterSync));
onExecuteAction({
action: 'sync_role_assets',
...payload,
});
}}
onClose={() => {
setShowRoleAssetStudio(false);
setCloseRoleAssetStudioAfterSync(false);
}}
/>
) : null}
</div>
);
}

View File

@@ -0,0 +1,67 @@
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
type CustomWorldDraftCardDetailModalProps = {
open: boolean;
detail: CustomWorldDraftCardDetail | null;
loading: boolean;
busy?: boolean;
editMode?: boolean;
onClose: () => void;
onStartEdit?: () => void;
onCancelEdit?: () => void;
onSave?: (
sections: Array<{
sectionId: string;
value: string;
}>,
) => void;
onGenerateCharacter?: () => void;
onGenerateLandmark?: () => void;
onOpenRoleAssetStudio?: () => void;
};
export function CustomWorldDraftCardDetailModal({
open,
detail,
loading,
busy = false,
editMode = false,
onClose,
onStartEdit,
onCancelEdit,
onSave,
onGenerateCharacter,
onGenerateLandmark,
onOpenRoleAssetStudio,
}: CustomWorldDraftCardDetailModalProps) {
if (!open) {
return null;
}
return (
<div className="fixed inset-0 z-[95] flex items-end justify-center bg-black/72 p-3 backdrop-blur-sm xl:hidden">
<button
type="button"
aria-label="关闭卡片详情"
onClick={onClose}
className="absolute inset-0 cursor-default"
/>
<div className="relative z-10 max-h-[85vh] w-full max-w-2xl overflow-y-auto">
<CustomWorldAgentDraftDetailPanel
detail={detail}
loading={loading}
busy={busy}
editMode={editMode}
onClose={onClose}
onStartEdit={onStartEdit}
onCancelEdit={onCancelEdit}
onSave={onSave}
onGenerateCharacter={onGenerateCharacter}
onGenerateLandmark={onGenerateLandmark}
onOpenRoleAssetStudio={onOpenRoleAssetStudio}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel';
test('draft detail panel renders editable form in edit mode', () => {
const html = renderToStaticMarkup(
<CustomWorldAgentDraftDetailPanel
detail={{
id: 'character-1',
kind: 'character',
title: '沈砺',
sections: [
{
id: 'name',
label: '角色名',
value: '沈砺',
},
{
id: 'publicMask',
label: '外显身份',
value: '守灯会里最熟悉旧航道的人。',
},
{
id: 'summary',
label: '角色摘要',
value: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
linkedIds: ['thread-1'],
locked: false,
editable: true,
editableSectionIds: ['name', 'publicMask', 'summary'],
warningMessages: [],
}}
loading={false}
editMode
onClose={() => {}}
onCancelEdit={() => {}}
onSave={() => {}}
/>,
);
expect(html).toContain('保存');
expect(html).toContain('取消');
expect(html).toContain('角色名');
expect(html).toContain('textarea');
});

View File

@@ -0,0 +1,136 @@
import { useEffect, useMemo, useState } from 'react';
import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent';
type CustomWorldDraftEditPanelProps = {
detail: CustomWorldDraftCardDetail;
disabled?: boolean;
onSave: (
sections: Array<{
sectionId: string;
value: string;
}>,
) => void;
onCancel: () => void;
};
function shouldUseTextarea(sectionId: string, value: string) {
return (
value.length > 28 ||
value.includes('\n') ||
sectionId === 'summary' ||
sectionId === 'tone' ||
sectionId === 'coreConflicts' ||
sectionId === 'hiddenHook' ||
sectionId === 'secret' ||
sectionId === 'stakes' ||
sectionId === 'openingEvent' ||
sectionId === 'understandingShift' ||
sectionId === 'description'
);
}
export function CustomWorldDraftEditPanel({
detail,
disabled = false,
onSave,
onCancel,
}: CustomWorldDraftEditPanelProps) {
const editableSections = useMemo(
() =>
detail.sections.filter((section) =>
detail.editableSectionIds.includes(section.id),
),
[detail],
);
const [draftValues, setDraftValues] = useState<Record<string, string>>(() =>
Object.fromEntries(
editableSections.map((section) => [section.id, section.value]),
),
);
useEffect(() => {
setDraftValues(
Object.fromEntries(editableSections.map((section) => [section.id, section.value])),
);
}, [editableSections]);
if (editableSections.length === 0) {
return null;
}
return (
<div className="space-y-3">
{editableSections.map((section) => {
const value = draftValues[section.id] ?? '';
const multiline = shouldUseTextarea(section.id, value);
return (
<label
key={section.id}
className="block rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3"
>
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
{section.label}
</div>
{multiline ? (
<textarea
value={value}
onChange={(event) => {
const nextValue = event.target.value;
setDraftValues((current) => ({
...current,
[section.id]: nextValue,
}));
}}
rows={4}
disabled={disabled}
className="mt-2 w-full resize-none rounded-[0.9rem] border border-white/10 bg-black/26 px-3 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40 disabled:cursor-not-allowed disabled:opacity-60"
/>
) : (
<input
type="text"
value={value}
onChange={(event) => {
const nextValue = event.target.value;
setDraftValues((current) => ({
...current,
[section.id]: nextValue,
}));
}}
disabled={disabled}
className="mt-2 h-11 w-full rounded-[0.9rem] border border-white/10 bg-black/26 px-3 text-sm text-white outline-none transition focus:border-sky-400/40 disabled:cursor-not-allowed disabled:opacity-60"
/>
)}
</label>
);
})}
<div className="flex items-center justify-end gap-3">
<button
type="button"
onClick={onCancel}
disabled={disabled}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
<button
type="button"
onClick={() => {
onSave(
editableSections.map((section) => ({
sectionId: section.id,
value: draftValues[section.id] ?? '',
})),
);
}}
disabled={disabled}
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-4 py-2 text-sm font-medium text-sky-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { useEffect, useState } from 'react';
type CustomWorldGenerateEntityModalProps = {
open: boolean;
mode: 'character' | 'landmark';
anchorCardTitle?: string | null;
disabled?: boolean;
onClose: () => void;
onSubmit: (payload: {
count: number;
promptText: string;
}) => void;
};
export function CustomWorldGenerateEntityModal({
open,
mode,
anchorCardTitle,
disabled = false,
onClose,
onSubmit,
}: CustomWorldGenerateEntityModalProps) {
const [count, setCount] = useState(2);
const [promptText, setPromptText] = useState('');
useEffect(() => {
if (!open) {
return;
}
setCount(2);
setPromptText('');
}, [open, mode]);
if (!open) {
return null;
}
const title = mode === 'character' ? '新增角色' : '新增场景';
const submitLabel = mode === 'character' ? '生成角色' : '生成场景';
return (
<div className="fixed inset-0 z-[96] flex items-end justify-center bg-black/72 p-3 backdrop-blur-sm sm:items-center">
<button
type="button"
aria-label="关闭新增弹窗"
onClick={onClose}
className="absolute inset-0 cursor-default"
/>
<div className="relative z-10 w-full max-w-xl rounded-[1.8rem] border border-white/10 bg-[linear-gradient(180deg,rgba(10,12,18,0.96),rgba(8,10,14,0.96))] px-4 py-4 shadow-[0_18px_60px_rgba(0,0,0,0.35)] sm:px-5 sm:py-5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
AI
</div>
<div className="mt-2 text-lg font-semibold text-white">{title}</div>
</div>
<button
type="button"
onClick={onClose}
disabled={disabled}
className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] text-zinc-300 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
</div>
<div className="mt-4 space-y-4">
{anchorCardTitle ? (
<div className="rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
</div>
<div className="mt-2 text-sm text-zinc-100">{anchorCardTitle}</div>
</div>
) : null}
<div className="rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-400"></div>
<div className="mt-3 flex gap-2">
{[1, 2, 3].map((value) => (
<button
key={value}
type="button"
onClick={() => setCount(value)}
disabled={disabled}
className={`rounded-full border px-4 py-2 text-sm transition ${
count === value
? 'border-sky-300/20 bg-sky-500/10 text-sky-100'
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'
} disabled:cursor-not-allowed disabled:opacity-45`}
>
{value}
</button>
))}
</div>
</div>
<label className="block rounded-[1.1rem] border border-white/8 bg-white/5 px-3 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-400">
</div>
<textarea
value={promptText}
onChange={(event) => setPromptText(event.target.value)}
rows={5}
disabled={disabled}
className="mt-2 w-full resize-none rounded-[0.9rem] border border-white/10 bg-black/26 px-3 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40 disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
</div>
<div className="mt-4 flex items-center justify-end gap-3">
<button
type="button"
onClick={onClose}
disabled={disabled}
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
<button
type="button"
onClick={() => {
onSubmit({
count,
promptText: promptText.trim(),
});
}}
disabled={disabled}
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-4 py-2 text-sm font-medium text-sky-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
{submitLabel}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
const baseDraftItem: CustomWorldWorkSummary = {
workId: 'draft:session-1',
sourceType: 'agent_session',
status: 'draft',
title: '潮雾列岛',
subtitle: '补齐关键锚点',
summary: '玩家是失职返乡的守灯人。',
coverImageSrc: null,
updatedAt: new Date('2026-04-14T10:00:00.000Z').toISOString(),
publishedAt: null,
stage: 'object_refining',
stageLabel: '精修对象',
playableNpcCount: 3,
landmarkCount: 4,
sessionId: 'session-1',
profileId: null,
canResume: true,
canEnterWorld: false,
};
test('creation hub reflects updated draft title summary and counts after rerender', () => {
const { rerender } = render(
<CustomWorldCreationHub
items={[baseDraftItem]}
loading={false}
error={null}
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onResumeDraft={() => {}}
onEnterPublished={() => {}}
/>,
);
expect(screen.getByText('潮雾列岛')).toBeTruthy();
expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy();
expect(screen.getByText('角色 3')).toBeTruthy();
expect(screen.getByText('地点 4')).toBeTruthy();
rerender(
<CustomWorldCreationHub
items={[
{
...baseDraftItem,
title: '潮雾列岛·回潮版',
summary: '世界总卡和角色网已经继续长出了新的支线。',
playableNpcCount: 5,
landmarkCount: 6,
updatedAt: new Date('2026-04-14T10:10:00.000Z').toISOString(),
},
]}
loading={false}
error={null}
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onResumeDraft={() => {}}
onEnterPublished={() => {}}
/>,
);
expect(screen.getByText('潮雾列岛·回潮版')).toBeTruthy();
expect(screen.getByText('世界总卡和角色网已经继续长出了新的支线。')).toBeTruthy();
expect(screen.getByText('角色 5')).toBeTruthy();
expect(screen.getByText('地点 6')).toBeTruthy();
});

View File

@@ -0,0 +1,44 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
test('creation hub draft card renders compiled work summary fields', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[
{
workId: 'draft:session-1',
sourceType: 'agent_session',
status: 'draft',
title: '一个被潮雾切开的列岛世界',
subtitle: '补齐关键锚点',
summary:
'玩家是失职返乡的守灯人 · 核心冲突:守灯会与沉船商盟争夺航道解释权',
coverImageSrc: null,
updatedAt: new Date('2026-04-13T12:00:00.000Z').toISOString(),
publishedAt: null,
stage: 'clarifying',
stageLabel: '补齐关键锚点',
playableNpcCount: 0,
landmarkCount: 0,
sessionId: 'session-1',
profileId: null,
canResume: true,
canEnterWorld: false,
},
]}
loading={false}
error={null}
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onResumeDraft={() => {}}
onEnterPublished={() => {}}
/>,
);
expect(html).toContain('一个被潮雾切开的列岛世界');
expect(html).toContain('玩家是失职返乡的守灯人');
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
});

View File

@@ -0,0 +1,154 @@
import { useMemo, useState } from 'react';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
import {
type CustomWorldWorkFilter,
CustomWorldWorkTabs,
} from './CustomWorldWorkTabs';
type CustomWorldCreationHubProps = {
items: CustomWorldWorkSummary[];
loading: boolean;
error: string | null;
onBack: () => void;
onRetry: () => void;
onCreateNew: () => void;
onResumeDraft: (sessionId: string) => void;
onEnterPublished: (profileId: string) => void;
};
function EmptyState({ title }: { title: string }) {
return (
<div className="flex min-h-[16rem] flex-col items-center justify-center rounded-[1.8rem] border border-white/8 bg-white/5 px-6 py-8 text-center">
<div className="text-lg font-semibold text-white">{title}</div>
</div>
);
}
export function CustomWorldCreationHub({
items,
loading,
error,
onBack,
onRetry,
onCreateNew,
onResumeDraft,
onEnterPublished,
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
useState<CustomWorldWorkFilter>('all');
const draftCount = items.filter((item) => item.status === 'draft').length;
const publishedCount = items.filter(
(item) => item.status === 'published',
).length;
const filteredItems = useMemo(
() =>
items.filter((item) =>
activeFilter === 'all' ? true : item.status === activeFilter,
),
[activeFilter, items],
);
return (
<div
className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<div className="sticky top-0 z-20 -mx-3 bg-[linear-gradient(180deg,rgba(10,12,18,0.96),rgba(10,12,18,0.88),rgba(10,12,18,0))] px-3 pb-4 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-5 sm:pt-0">
<div className="flex items-start justify-between gap-3">
<div>
<button
type="button"
onClick={onBack}
className="rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white"
>
</button>
<div className="mt-4 text-[1.8rem] font-black leading-tight text-white sm:text-[2.3rem]">
</div>
</div>
<div className="hidden shrink-0 gap-2 sm:flex">
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-100">
稿 {draftCount}
</span>
<span className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[11px] text-emerald-100">
{publishedCount}
</span>
</div>
</div>
</div>
<div className="space-y-4">
<CustomWorldCreationStartCard onCreateNew={onCreateNew} />
<CustomWorldWorkTabs
activeFilter={activeFilter}
draftCount={draftCount}
publishedCount={publishedCount}
onChange={setActiveFilter}
/>
{error ? (
<div className="rounded-3xl border border-rose-400/18 bg-rose-500/10 px-4 py-4 text-sm leading-7 text-rose-100">
<div>{error}</div>
<button
type="button"
onClick={onRetry}
className="mt-3 rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-200 transition hover:text-white"
>
</button>
</div>
) : null}
{loading ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={`skeleton-${index}`}
className="min-h-[12rem] rounded-[1.8rem] border border-white/8 bg-white/5 p-5"
>
<div className="h-4 w-20 rounded-full bg-white/10" />
<div className="mt-6 h-8 w-36 rounded-full bg-white/10" />
<div className="mt-4 h-4 w-full rounded-full bg-white/10" />
<div className="mt-2 h-4 w-4/5 rounded-full bg-white/10" />
<div className="mt-8 flex gap-2">
<div className="h-7 w-20 rounded-full bg-white/10" />
<div className="h-7 w-20 rounded-full bg-white/10" />
</div>
</div>
))}
</div>
) : filteredItems.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{filteredItems.map((item) => (
<CustomWorldWorkCard
key={item.workId}
item={item}
onClick={() => {
if (item.status === 'draft' && item.sessionId) {
onResumeDraft(item.sessionId);
return;
}
if (item.status === 'published' && item.profileId) {
onEnterPublished(item.profileId);
}
}}
/>
))}
</div>
) : items.length === 0 ? (
<EmptyState title="还没有作品" />
) : (
<EmptyState title="当前筛选下没有内容" />
)}
</div>
</div>
);
}
export type { CustomWorldWorkFilter };

View File

@@ -0,0 +1,146 @@
import { X } from 'lucide-react';
import type { CustomWorldQuestion } from '../../../packages/shared/src/contracts/runtime';
type CustomWorldCreationLauncherModalProps = {
isOpen: boolean;
mode: 'create' | 'resume';
seedText: string;
seedTextLocked: boolean;
questions: CustomWorldQuestion[];
answers: Record<string, string>;
isBusy: boolean;
error: string | null;
lastError?: string | null;
primaryLabel: string;
onClose: () => void;
onSeedTextChange: (value: string) => void;
onAnswerChange: (questionId: string, value: string) => void;
onPrimaryAction: () => void;
};
export function CustomWorldCreationLauncherModal({
isOpen,
mode,
seedText,
seedTextLocked,
questions,
answers,
isBusy,
error,
lastError = null,
primaryLabel,
onClose,
onSeedTextChange,
onAnswerChange,
onPrimaryAction,
}: CustomWorldCreationLauncherModalProps) {
if (!isOpen) {
return null;
}
const unansweredQuestions = questions.filter((question) => !question.answer?.trim());
return (
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm">
<div className="flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden rounded-[2rem] border border-white/10 bg-[#11161f] shadow-[0_30px_90px_rgba(0,0,0,0.6)]">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div>
<div className="text-base font-semibold text-white">
{mode === 'create' ? '新建作品' : '继续创作'}
</div>
<div className="mt-1 text-xs text-zinc-400">
</div>
</div>
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div className="space-y-4">
<label className="block">
<div className="mb-2 text-sm font-medium text-zinc-200">
</div>
<textarea
value={seedText}
onChange={(event) => onSeedTextChange(event.target.value)}
rows={seedTextLocked ? 4 : 6}
readOnly={seedTextLocked}
placeholder="例:一个被潮雾切碎的列岛世界,灯塔守望者、沉船秘术与旧盟约残片正在重新苏醒。"
className={`w-full rounded-3xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40 ${
seedTextLocked ? 'cursor-not-allowed opacity-75' : ''
}`}
/>
</label>
{unansweredQuestions.length > 0 ? (
<div className="space-y-3">
<div className="rounded-3xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-7 text-zinc-300">
</div>
{unansweredQuestions.map((question) => (
<label key={question.id} className="block">
<div className="mb-2 text-sm font-medium text-zinc-200">
{question.label}
</div>
<div className="mb-2 text-xs leading-6 text-zinc-400">
{question.question}
</div>
<textarea
value={answers[question.id] ?? question.answer ?? ''}
onChange={(event) =>
onAnswerChange(question.id, event.target.value)
}
rows={3}
placeholder="补充一句就可以。"
className="w-full rounded-3xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40"
/>
</label>
))}
</div>
) : null}
{lastError ? (
<div className="rounded-3xl border border-amber-400/25 bg-amber-500/10 px-4 py-3 text-sm leading-6 text-amber-100">
{lastError}
</div>
) : null}
{error ? (
<div className="rounded-3xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{error}
</div>
) : null}
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
<button
type="button"
onClick={onPrimaryAction}
disabled={isBusy}
className="rounded-2xl bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-45"
>
{isBusy ? '处理中...' : primaryLabel}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
type CustomWorldCreationStartCardProps = {
onCreateNew: () => void;
};
export function CustomWorldCreationStartCard({
onCreateNew,
}: CustomWorldCreationStartCardProps) {
return (
<div
className="pixel-nine-slice pixel-panel overflow-hidden"
style={getNineSliceStyle(UI_CHROME.storyPanel, {
paddingX: 18,
paddingY: 16,
})}
>
<div className="relative overflow-hidden rounded-[1.75rem] border border-white/8 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.18),transparent_36%),linear-gradient(180deg,rgba(8,10,14,0.28),rgba(8,10,14,0.82))] px-5 py-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="text-2xl font-black text-white sm:text-3xl">
</div>
</div>
<button
type="button"
onClick={onCreateNew}
className="pixel-nine-slice pixel-pressable w-full text-left sm:w-auto"
style={getNineSliceStyle(UI_CHROME.choiceButton, {
paddingX: 18,
paddingY: 11,
})}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white"></span>
<span className="text-white/60"></span>
</div>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,119 @@
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
function formatUpdatedAt(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '最近更新';
}
return new Intl.DateTimeFormat('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
type CustomWorldWorkCardProps = {
item: CustomWorldWorkSummary;
onClick: () => void;
};
export function CustomWorldWorkCard({
item,
onClick,
}: CustomWorldWorkCardProps) {
const isDraft = item.status === 'draft';
const hasFoundationDraft =
item.playableNpcCount > 0 || item.landmarkCount > 0;
const roleCountLabel = isDraft ? '角色' : '可扮演角色';
return (
<div
className="pixel-nine-slice pixel-panel relative overflow-hidden"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 15,
})}
>
{item.coverImageSrc ? (
<img
src={item.coverImageSrc}
alt={item.title}
className="absolute inset-0 h-full w-full object-cover opacity-20"
style={{ imageRendering: 'pixelated' }}
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.12),rgba(8,10,14,0.82))]" />
<div className="relative z-10 flex h-full min-h-[12rem] flex-col">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap gap-2">
<span
className={`rounded-full border px-3 py-1 text-[10px] tracking-[0.18em] ${
isDraft
? 'border-amber-300/20 bg-amber-500/10 text-amber-100'
: 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100'
}`}
>
{isDraft ? '草稿' : '已发布'}
</span>
{item.stageLabel ? (
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-200">
{item.stageLabel}
</span>
) : null}
</div>
<div className="text-[11px] text-zinc-400">
{formatUpdatedAt(item.updatedAt)}
</div>
</div>
<div className="mt-4">
<div className="text-2xl font-black text-white">
{item.title}
</div>
<div className="mt-1 text-xs tracking-[0.12em] text-zinc-400">
{item.subtitle}
</div>
<div className="mt-3 line-clamp-3 text-sm leading-7 text-zinc-200/90">
{item.summary}
</div>
</div>
<div className="mt-auto flex items-center justify-between gap-3 pt-4">
<div className="flex flex-wrap gap-2">
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-100">
{roleCountLabel} {item.playableNpcCount}
</span>
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-100">
{item.landmarkCount}
</span>
{item.roleVisualReadyCount ? (
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] text-amber-100">
{item.roleVisualReadyCount}
</span>
) : null}
{item.roleAnimationReadyCount ? (
<span className="rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1 text-[10px] text-emerald-100">
{item.roleAnimationReadyCount}
</span>
) : null}
{item.roleAssetSummaryLabel ? (
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[10px] text-zinc-200">
{item.roleAssetSummaryLabel}
</span>
) : null}
</div>
<button
type="button"
onClick={onClick}
className="rounded-full border border-sky-300/20 bg-sky-500/10 px-4 py-2 text-sm font-medium text-sky-100 transition hover:text-white"
>
{isDraft ? (hasFoundationDraft ? '继续精修' : '继续创作') : '进入世界'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
export type CustomWorldWorkFilter = 'all' | 'draft' | 'published';
const FILTER_OPTIONS: Array<{
id: CustomWorldWorkFilter;
label: string;
}> = [
{ id: 'all', label: '全部' },
{ id: 'draft', label: '草稿' },
{ id: 'published', label: '已发布' },
];
type CustomWorldWorkTabsProps = {
activeFilter: CustomWorldWorkFilter;
draftCount: number;
publishedCount: number;
onChange: (filter: CustomWorldWorkFilter) => void;
};
export function CustomWorldWorkTabs({
activeFilter,
draftCount,
publishedCount,
onChange,
}: CustomWorldWorkTabsProps) {
return (
<div className="flex items-center gap-2 overflow-x-auto pb-1">
{FILTER_OPTIONS.map((option) => {
const count =
option.id === 'draft'
? draftCount
: option.id === 'published'
? publishedCount
: draftCount + publishedCount;
return (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
className={`shrink-0 rounded-full border px-4 py-2 text-sm transition ${
activeFilter === option.id
? 'border-sky-300/20 bg-sky-500/10 text-sky-100'
: 'border-white/10 bg-black/18 text-zinc-300 hover:text-white'
}`}
>
{option.label} {count}
</button>
);
})}
</div>
);
}

View File

@@ -111,6 +111,9 @@ export function GameCanvasEntityLayer({
monsterAnchorMeters,
playerX,
}: GameCanvasEntityLayerProps) {
const shouldRenderPeacefulEncounter =
Boolean(encounter) && (!inBattle || sceneCombatants.length === 0);
return (
<>
{companions.map(companion => {
@@ -327,8 +330,12 @@ export function GameCanvasEntityLayer({
);
})}
{encounter &&
{shouldRenderPeacefulEncounter &&
(() => {
if (!encounter) {
return null;
}
const isCampCompanionEncounter =
encounter.specialBehavior === 'initial_companion'
|| encounter.specialBehavior === 'camp_companion';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,157 @@
import { existsSync } from 'node:fs';
import { SERVER_RUNTIME_FUNCTION_IDS } from '../../../packages/shared/src/contracts/story';
import { describe, expect, it } from 'vitest';
import {
ALL_FUNCTION_DOCUMENTATION,
buildCampTravelHomeOption,
buildContinueAdventureOption,
buildNpcGiftModalState,
buildNpcPreviewTalkOption,
buildNpcRecruitModalState,
buildNpcTradeModalState,
CONTINUE_ADVENTURE_FUNCTION,
getFunctionDocumentationById,
isNpcPreviewTalkOption,
NPC_PREVIEW_TALK_FUNCTION,
shouldNpcRecruitOpenModal,
} from './index';
import type { Encounter, GameState, InventoryItem } from '../../types';
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
return {
id: 'npc-trader',
kind: 'npc',
npcName: '梁伯',
npcDescription: '沿路摆摊的商人。',
npcAvatar: '梁',
context: '商贩',
...overrides,
};
}
function createInventoryItem(
id: string,
name: string,
overrides: Partial<InventoryItem> = {},
): InventoryItem {
return {
id,
name,
description: `${name} 的测试描述`,
quantity: 1,
category: 'misc',
rarity: 'common',
tags: [],
value: 1,
...overrides,
};
}
function createModalState(overrides: Partial<GameState> = {}): GameState {
return {
playerInventory: [
createInventoryItem('player-potion', '疗伤药'),
createInventoryItem('player-charm', '护符'),
],
companions: [
{
npcId: 'npc-ally-1',
characterId: 'ally-1',
name: '阿青',
role: '同伴',
joinedAtAffinity: 12,
},
],
...overrides,
} as GameState;
}
describe('functionCatalog', () => {
it('keeps function documentation ids unique and source files resolvable', () => {
const documentationIds = ALL_FUNCTION_DOCUMENTATION.map((entry) => entry.id);
expect(new Set(documentationIds).size).toBe(documentationIds.length);
ALL_FUNCTION_DOCUMENTATION.forEach((entry) => {
expect(existsSync(entry.source), `${entry.id} -> ${entry.source}`).toBe(
true,
);
expect(getFunctionDocumentationById(entry.id)).toEqual(entry);
});
});
it('covers every server runtime function id with documentation metadata', () => {
SERVER_RUNTIME_FUNCTION_IDS.forEach((functionId) => {
expect(getFunctionDocumentationById(functionId)).not.toBeNull();
});
});
it('builds flow helper options with the expected function ids', () => {
const continueOption = buildContinueAdventureOption();
const campTravelOption = buildCampTravelHomeOption('竹林古道');
expect(continueOption.functionId).toBe(CONTINUE_ADVENTURE_FUNCTION.id);
expect(continueOption.priority).toBe(99);
expect(campTravelOption.functionId).toBe('camp_travel_home_scene');
expect(campTravelOption.actionText).toBe('前往 竹林古道');
expect(campTravelOption.detailText).toBe('离开营地,前往 竹林古道。');
});
it('builds npc preview talk options from the current encounter', () => {
const option = buildNpcPreviewTalkOption(createEncounter());
expect(option.functionId).toBe(NPC_PREVIEW_TALK_FUNCTION.id);
expect(option.actionText).toBe('与 梁伯 交谈');
expect(isNpcPreviewTalkOption(option)).toBe(true);
});
it('builds modal helper state for trade, gift and recruit flows', () => {
const state = createModalState();
const encounter = createEncounter();
const tradeModal = buildNpcTradeModalState(
state,
encounter,
'先看看货',
[
createInventoryItem('npc-herb', '止血草'),
createInventoryItem('npc-ore', '陨铁碎片'),
],
);
const giftModal = buildNpcGiftModalState(
state,
encounter,
'送你一样东西',
'player-charm',
);
const recruitModal = buildNpcRecruitModalState(
state,
encounter,
'谈谈同行的事',
);
expect(tradeModal.selectedNpcItemId).toBe('npc-herb');
expect(tradeModal.selectedPlayerItemId).toBe('player-potion');
expect(giftModal.selectedItemId).toBe('player-charm');
expect(recruitModal.selectedReleaseNpcId).toBe('npc-ally-1');
expect(shouldNpcRecruitOpenModal(2, 2)).toBe(true);
expect(shouldNpcRecruitOpenModal(1, 2)).toBe(false);
});
it('prefers the first tradable player item when zero-quantity items exist', () => {
const encounter = createEncounter();
const tradeModal = buildNpcTradeModalState(
createModalState({
playerInventory: [
createInventoryItem('empty-slot', '空槽位', { quantity: 0 }),
createInventoryItem('usable-item', '可售草药', { quantity: 2 }),
],
}),
encounter,
'交易',
[createInventoryItem('npc-herb', '止血草')],
);
expect(tradeModal.selectedPlayerItemId).toBe('usable-item');
});
});

View File

@@ -21,13 +21,18 @@ export function buildNpcTradeModalState(
actionText: string,
npcInventory: InventoryItem[],
): TradeModalState {
const selectedNpcItemId =
npcInventory.find((item) => item.quantity > 0)?.id ?? null;
const selectedPlayerItemId =
state.playerInventory.find((item) => item.quantity > 0)?.id ?? null;
return {
encounter,
actionText,
introText: buildNpcTradeModalIntroText(encounter),
mode: 'buy',
selectedNpcItemId: npcInventory[0]?.id ?? null,
selectedPlayerItemId: state.playerInventory[0]?.id ?? null,
selectedNpcItemId,
selectedPlayerItemId,
selectedQuantity: 1,
};
}

View File

@@ -0,0 +1,204 @@
import { describe, expect, it } from 'vitest';
import {
buildStateFunctionDefinitions,
getExecutableFunctions,
getFunctionById,
resolveFunctionOption,
sortStoryOptionsByPriority,
} from './stateFunctions';
import {
getScenePresetsByWorld,
getTravelScenePreset,
getWorldCampScenePreset,
} from './scenePresets';
import type { FunctionAvailabilityContext } from './stateFunctions';
import type { SceneHostileNpc, StoryOption } from '../types';
import { AnimationState, WorldType } from '../types';
function createMonster(
overrides: Partial<SceneHostileNpc> = {},
): SceneHostileNpc {
return {
id: 'monster-wolf',
name: '山狼',
action: '朝你低吼逼近',
description: '一只逼近的山狼。',
animation: 'idle',
xMeters: 3.2,
yOffset: 0,
facing: 'left',
attackRange: 1.4,
speed: 1,
hp: 18,
maxHp: 18,
...overrides,
};
}
function createContext(
overrides: Partial<FunctionAvailabilityContext> = {},
): FunctionAvailabilityContext {
const defaultScene = getScenePresetsByWorld(WorldType.WUXIA)[0];
if (!defaultScene) {
throw new Error('Expected wuxia scene presets to exist');
}
return {
worldType: WorldType.WUXIA,
playerCharacter: null,
inBattle: false,
currentSceneId: defaultScene.id,
currentSceneName: defaultScene.name,
monsters: [],
playerHp: 80,
playerMaxHp: 100,
playerMana: 50,
playerMaxMana: 100,
...overrides,
};
}
describe('stateFunctions', () => {
it('builds runtime state function definitions without inactive idle_follow_clue', () => {
const definitions = buildStateFunctionDefinitions();
expect(getFunctionById('idle_follow_clue', definitions)).toBeNull();
expect(getFunctionById('idle_explore_forward', definitions)?.text).toBe(
'继续向前探索',
);
});
it('prioritizes battle_recover_breath in high-pressure combat contexts', () => {
const executableFunctions = getExecutableFunctions(
createContext({
inBattle: true,
monsters: [createMonster()],
playerHp: 20,
playerMana: 10,
}),
);
expect(executableFunctions[0]?.id).toBe('battle_recover_breath');
});
it('hides idle_explore_forward in the world camp scene', () => {
const campScene = getWorldCampScenePreset(WorldType.WUXIA);
if (!campScene) {
throw new Error('Expected wuxia camp scene to exist');
}
const executableIds = getExecutableFunctions(
createContext({
currentSceneId: campScene.id,
currentSceneName: campScene.name,
}),
).map((definition) => definition.id);
expect(executableIds).not.toContain('idle_explore_forward');
});
it('forces suggested action text for travel options and keeps custom battle copy', () => {
const idleContext = createContext();
const travelScene = getTravelScenePreset(
idleContext.worldType,
idleContext.currentSceneId,
);
const travelOption = resolveFunctionOption(
'idle_travel_next_scene',
idleContext,
'这段自定义文案会被覆盖',
);
const battleOption = resolveFunctionOption(
'battle_all_in_crush',
createContext({
inBattle: true,
monsters: [createMonster()],
}),
'顶上去狠狠干一轮',
);
expect(travelOption).not.toBeNull();
expect(travelOption?.actionText).toBe(
travelScene ? `前往${travelScene.name}` : '前往其他场景',
);
expect(battleOption?.actionText).toBe('顶上去狠狠干一轮');
});
it('sorts story options after preserving the first two model-locked entries', () => {
const options: StoryOption[] = [
{
functionId: 'npc_chat',
actionText: '继续交谈',
text: '继续交谈',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
{
functionId: 'battle_probe_pressure',
actionText: '稳住节奏',
text: '稳住节奏',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
{
functionId: 'npc_preview_talk',
actionText: '和对方说话',
text: '和对方说话',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
{
functionId: 'camp_travel_home_scene',
actionText: '离开营地',
text: '离开营地',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
];
const sortedOptions = sortStoryOptionsByPriority(options);
expect(sortedOptions.map((option) => option.functionId)).toEqual([
'npc_chat',
'battle_probe_pressure',
'camp_travel_home_scene',
'npc_preview_talk',
]);
});
it('removes battle_recover_breath when combat has no living monsters', () => {
const executableIds = getExecutableFunctions(
createContext({
inBattle: true,
monsters: [createMonster({ hp: 0 })],
}),
).map((definition) => definition.id);
expect(executableIds).toEqual([]);
});
});

View File

@@ -390,7 +390,7 @@ function matchesCategory(
return !context.inBattle;
case 'recovery':
return definition.state === 'battle'
? context.inBattle
? context.inBattle && hasAliveMonsters(context.monsters)
: !context.inBattle;
default:
return false;

View File

@@ -155,6 +155,106 @@ describe('createStoryChoiceActions', () => {
resolveServerRuntimeChoiceMock.mockReset();
});
it('reveals deferred adventure options when story_continue_adventure is selected', async () => {
const state = {
...createBaseState(),
inBattle: false,
sceneHostileNpcs: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
};
const deferredOptions = [
{
functionId: 'idle_explore_forward',
actionText: '继续向前探索',
text: '继续向前探索',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right' as const,
scrollWorld: false,
monsterChanges: [],
},
},
] satisfies StoryOption[];
const continueOption: StoryOption = {
functionId: 'story_continue_adventure',
actionText: '查看后续',
text: '查看后续',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right' as const,
scrollWorld: false,
monsterChanges: [],
},
};
const currentStory: StoryMoment = {
text: '对话已经完成',
options: [continueOption],
deferredOptions,
};
const setCurrentStory = vi.fn();
const generateStoryForState = vi.fn();
const handleNpcInteraction = vi.fn();
const { handleChoice } = createStoryChoiceActions({
gameState: state,
currentStory,
isLoading: false,
setGameState: vi.fn(),
setCurrentStory,
setAiError: vi.fn(),
setIsLoading: vi.fn(),
setBattleReward: vi.fn(),
buildResolvedChoiceState: vi.fn(),
playResolvedChoice: vi.fn(),
buildStoryContextFromState: vi.fn(),
buildStoryFromResponse: vi.fn((_, __, response) => response),
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
generateStoryForState,
getAvailableOptionsForState: vi.fn(() => null),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn(
(inputState: GameState) => inputState.sceneHostileNpcs,
),
buildNpcStory: vi.fn(() => createFallbackStory()),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
startOpeningAdventure: vi.fn(),
enterNpcInteraction: vi.fn(() => false),
handleNpcInteraction,
handleTreasureInteraction: vi.fn(() => false),
commitGeneratedStateWithEncounterEntry: vi.fn(),
finalizeNpcBattleResult: vi.fn(() => null),
isContinueAdventureOption: vi.fn(
(option: StoryOption) =>
option.functionId === 'story_continue_adventure',
),
isCampTravelHomeOption: vi.fn(() => false),
isInitialCompanionEncounter: neverNpcEncounter,
isRegularNpcEncounter: neverNpcEncounter,
isNpcEncounter: neverNpcEncounter,
npcPreviewTalkFunctionId: 'npc_preview_talk',
fallbackCompanionName: '同伴',
turnVisualMs: 820,
});
await handleChoice(continueOption);
expect(setCurrentStory).toHaveBeenCalledWith({
...currentStory,
options: deferredOptions,
deferredOptions: undefined,
});
expect(generateStoryForState).not.toHaveBeenCalled();
expect(handleNpcInteraction).not.toHaveBeenCalled();
expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
});
it('routes task5 story choices through the server runtime action endpoint', async () => {
const state = createBaseState();
const option = createBattleOption('npc_chat');

View File

@@ -33,12 +33,13 @@ vi.mock('../../services/runtimeStoryService', async () => {
};
});
import type { GameState, StoryMoment, StoryOption } from '../../types';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { GameState, StoryMoment, StoryOption } from '../../types';
import { WorldType } from '../../types';
import {
loadServerRuntimeOptionCatalog,
resumeServerRuntimeStory,
resolveServerRuntimeChoice,
resumeServerRuntimeStory,
} from './runtimeStoryCoordinator';
function createStory(text: string): StoryMoment {
@@ -55,6 +56,97 @@ function createGameState(): GameState {
} as GameState;
}
function createRuntimeNpcBattleSnapshot(
overrides: Partial<HydratedSavedGameSnapshot['gameState']> = {},
) {
return {
version: 8,
savedAt: '2026-04-14T00:00:00.000Z',
bottomTab: 'adventure' as const,
currentStory: createStory('战斗中的服务端故事'),
gameState: {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: {
id: 'hero',
},
runtimeActionVersion: 8,
runtimeSessionId: 'runtime-main',
currentScene: 'Story',
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
storyHistory: [],
characterChats: {},
animationState: 'idle',
currentEncounter: {
kind: 'npc',
id: 'npc-bandit',
npcName: '断桥匪首',
npcDescription: '拦路的刀客',
context: '断桥口',
hostile: true,
},
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [
{
id: 'npc-bandit',
name: '断桥匪首',
hp: 21,
maxHp: 32,
description: '拦路的刀客',
},
],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: true,
playerHp: 42,
playerMaxHp: 50,
playerMana: 20,
playerMaxMana: 20,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {
'npc-bandit': {
affinity: -12,
chattedCount: 0,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: 'npc-bandit',
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
...overrides,
} as unknown as GameState,
} as HydratedSavedGameSnapshot;
}
describe('runtimeStoryCoordinator', () => {
beforeEach(() => {
putSaveSnapshotMock.mockReset();
@@ -363,4 +455,181 @@ describe('runtimeStoryCoordinator', () => {
expect(result.hydratedSnapshot).toBe(localHydratedSnapshot);
expect(result.nextStory).toBe(localHydratedSnapshot.currentStory);
});
it('rehydrates npc_fight server snapshots before returning runtime choices', async () => {
const gameState = createGameState();
const currentStory = createStory('当前故事');
const option = {
functionId: 'npc_fight',
actionText: '直接开战',
text: '直接开战',
interaction: {
kind: 'npc',
npcId: 'npc-bandit',
action: 'fight',
},
visuals: {
playerAnimation: 'idle',
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
} as StoryOption;
const rawBattleSnapshot = createRuntimeNpcBattleSnapshot();
resolveRuntimeStoryActionMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 8,
viewModel: {
player: {
hp: 42,
maxHp: 50,
mana: 20,
maxMana: 20,
},
encounter: {
id: 'npc-bandit',
kind: 'npc',
npcName: '断桥匪首',
hostile: true,
affinity: -12,
recruited: false,
interactionActive: false,
battleMode: 'fight',
},
companions: [],
availableOptions: [
{
functionId: 'battle_probe_pressure',
actionText: '稳步试探',
scope: 'combat',
},
],
status: {
inBattle: true,
npcInteractionActive: false,
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '直接开战',
resultText: '当前冲突正式转入战斗结算。',
storyText: '断桥匪首已经摆开架势。',
options: [],
},
patches: [],
snapshot: rawBattleSnapshot,
});
const result = await resolveServerRuntimeChoice({
gameState,
currentStory,
option,
});
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
expect.objectContaining({
id: 'npc-bandit',
hp: 21,
maxHp: 32,
encounter: expect.objectContaining({
kind: 'npc',
id: 'npc-bandit',
npcName: '断桥匪首',
}),
}),
);
expect(result.nextStory.options[0]).toEqual(
expect.objectContaining({
functionId: 'battle_probe_pressure',
}),
);
});
it('rehydrates mid-battle snapshots when resuming a saved runtime story', async () => {
const localHydratedSnapshot = createRuntimeNpcBattleSnapshot({
runtimeActionVersion: 7,
});
const rawServerBattleSnapshot = createRuntimeNpcBattleSnapshot({
runtimeActionVersion: 8,
playerHp: 39,
sceneHostileNpcs: [
{
id: 'npc-bandit',
name: '断桥匪首',
hp: 14,
maxHp: 32,
description: '拦路的刀客',
},
] as unknown as GameState['sceneHostileNpcs'],
});
getRuntimeStoryStateMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 8,
viewModel: {
player: {
hp: 39,
maxHp: 50,
mana: 20,
maxMana: 20,
},
encounter: {
id: 'npc-bandit',
kind: 'npc',
npcName: '断桥匪首',
hostile: true,
affinity: -12,
recruited: false,
interactionActive: false,
battleMode: 'fight',
},
companions: [],
availableOptions: [
{
functionId: 'battle_guard_break',
actionText: '破架重击',
scope: 'combat',
},
],
status: {
inBattle: true,
npcInteractionActive: false,
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '',
resultText: '',
storyText: '断桥匪首还在步步逼近。',
options: [],
},
patches: [],
snapshot: rawServerBattleSnapshot,
});
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
expect.objectContaining({
id: 'npc-bandit',
hp: 14,
maxHp: 32,
encounter: expect.objectContaining({
kind: 'npc',
id: 'npc-bandit',
}),
}),
);
expect(result.nextStory).not.toBeNull();
expect(result.nextStory?.options[0]).toEqual(
expect.objectContaining({
functionId: 'battle_guard_break',
}),
);
});
});

View File

@@ -1,3 +1,4 @@
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
buildStoryMomentFromRuntimeOptions,
@@ -49,7 +50,7 @@ export async function loadServerRuntimeOptionCatalog(params: {
export async function resumeServerRuntimeStory(
snapshot: HydratedSavedGameSnapshot,
) {
const hydratedSnapshot = snapshot;
const hydratedSnapshot = rehydrateSavedSnapshot(snapshot);
const shouldRefreshFromServer =
hydratedSnapshot.gameState.currentScene === 'Story' &&
Boolean(hydratedSnapshot.gameState.worldType) &&
@@ -65,7 +66,7 @@ export async function resumeServerRuntimeStory(
const response = await getRuntimeStoryState(
getRuntimeSessionId(hydratedSnapshot.gameState),
);
const resumedSnapshot = response.snapshot;
const resumedSnapshot = rehydrateSavedSnapshot(response.snapshot);
const runtimeOptions = getRuntimeResponseOptions(response);
const nextStory =
response.presentation.storyText || runtimeOptions.length > 0
@@ -105,7 +106,7 @@ export async function resolveServerRuntimeChoice(params: {
: undefined,
payload: params.payload,
});
const hydratedSnapshot = response.snapshot;
const hydratedSnapshot = rehydrateSavedSnapshot(response.snapshot);
return {
response,

View File

@@ -218,6 +218,26 @@ describe('storyGenerationState', () => {
expect(decision.modal.selectedQuantity).toBe(1);
});
it('skips zero-quantity player items when opening the trade modal', () => {
const decision = resolveNpcInteractionDecision(
{
...createBaseState(),
playerInventory: [
createInventoryItem('empty-slot', 'Empty Slot', { quantity: 0 }),
createInventoryItem('player-herb', 'Herb'),
],
},
createInteractionOption('trade'),
);
expect(decision.kind).toBe('trade_modal');
if (decision.kind !== 'trade_modal') {
throw new Error('Expected trade modal decision');
}
expect(decision.modal.selectedPlayerItemId).toBe('player-herb');
});
it('forces a recruit replacement modal when the active party is full', () => {
const state = {
...createBaseState(),
@@ -306,4 +326,3 @@ describe('storyGenerationState', () => {
expect(resolution.nextState.runtimeStats.scenesTraveled).toBe(1);
});
});

View File

@@ -1,7 +1,9 @@
import { describe, expect, it } from 'vitest';
import type { GameState, StoryMoment } from '../types';
import { WorldType } from '../types';
import {
rehydrateSavedSnapshot,
resolveHydratedSnapshotState,
} from './runtimeSnapshot';
@@ -16,6 +18,97 @@ function createStory(
};
}
function createHydratedBattleSnapshot(
gameStateOverrides: Partial<GameState> = {},
) {
return {
version: 3,
savedAt: '2026-04-14T00:00:00.000Z',
bottomTab: 'adventure' as const,
currentStory: createStory('战斗故事'),
gameState: {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: {
id: 'hero',
},
runtimeActionVersion: 3,
runtimeSessionId: 'runtime-main',
currentScene: 'Story',
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
storyHistory: [],
characterChats: {},
animationState: 'idle',
currentEncounter: {
kind: 'npc',
id: 'npc-fighter',
npcName: '断桥客',
npcDescription: '拦路的刀客',
context: '断桥对峙',
hostile: false,
},
npcInteractionActive: false,
currentScenePreset: null,
sceneHostileNpcs: [
{
id: 'npc-fighter',
name: '断桥客',
hp: 18,
maxHp: 32,
description: '拦路的刀客',
},
],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: true,
playerHp: 40,
playerMaxHp: 40,
playerMana: 18,
playerMaxMana: 18,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {
'npc-fighter': {
affinity: -16,
chattedCount: 0,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: 'npc-fighter',
currentNpcBattleMode: 'fight',
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
...gameStateOverrides,
} as unknown as GameState,
};
}
describe('runtimeSnapshot', () => {
it('keeps server-hydrated snapshots unchanged', () => {
const snapshot = {
@@ -72,4 +165,65 @@ describe('runtimeSnapshot', () => {
expect(hydrated.gameState.playerMaxMana).toBe(12);
expect(hydrated.gameState.playerMana).toBe(12);
});
it('rehydrates minimal npc battle snapshots into renderable combatants', () => {
const snapshot = createHydratedBattleSnapshot();
const hydrated = rehydrateSavedSnapshot(snapshot);
const hostileNpc = hydrated.gameState.sceneHostileNpcs[0];
expect(hydrated).not.toBe(snapshot);
expect(hostileNpc).toEqual(
expect.objectContaining({
id: 'npc-fighter',
name: '断桥客',
description: '拦路的刀客',
hp: 18,
maxHp: 32,
attackRange: expect.any(Number),
speed: expect.any(Number),
animation: 'idle',
renderKind: 'npc',
encounter: expect.objectContaining({
kind: 'npc',
id: 'npc-fighter',
npcName: '断桥客',
xMeters: expect.any(Number),
}),
}),
);
});
it('does not rewrite already renderable npc battle snapshots', () => {
const snapshot = createHydratedBattleSnapshot({
sceneHostileNpcs: [
{
id: 'npc-fighter',
name: '断桥客',
action: '摆开架势,随时准备出手',
description: '拦路的刀客',
animation: 'idle',
xMeters: 3.2,
yOffset: 0,
facing: 'left',
attackRange: 1.8,
speed: 7,
hp: 18,
maxHp: 32,
renderKind: 'npc',
encounter: {
kind: 'npc',
id: 'npc-fighter',
npcName: '断桥客',
npcDescription: '拦路的刀客',
npcAvatar: '',
context: '断桥对峙',
xMeters: 3.2,
},
},
],
});
expect(rehydrateSavedSnapshot(snapshot)).toBe(snapshot);
});
});

View File

@@ -1,4 +1,15 @@
import type { GameState, StoryMoment } from '../types';
import {
buildInitialNpcState,
createNpcBattleMonster,
} from '../data/npcInteractions';
import type {
Encounter,
GameState,
NpcPersistentState,
SceneHostileNpc,
StoryMoment,
} from '../types';
import { WorldType } from '../types';
import type { BottomTab } from '../types/navigation';
import type {
HydratableGameState,
@@ -37,6 +48,199 @@ function createEmptyEquipmentLoadout() {
} satisfies GameState['playerEquipment'];
}
function resolveHydrationWorldType(worldType: GameState['worldType']) {
const normalizedWorldType =
typeof worldType === 'string' ? worldType.toUpperCase() : worldType;
if (normalizedWorldType === WorldType.WUXIA) {
return WorldType.WUXIA;
}
if (normalizedWorldType === WorldType.XIANXIA) {
return WorldType.XIANXIA;
}
if (normalizedWorldType === WorldType.CUSTOM) {
return WorldType.CUSTOM;
}
return null;
}
function hasRenderableRuntimeNpcBattleFields(hostileNpc: SceneHostileNpc) {
const candidate = hostileNpc as Partial<SceneHostileNpc>;
return Boolean(
candidate.encounter &&
typeof candidate.animation === 'string' &&
typeof candidate.xMeters === 'number' &&
typeof candidate.yOffset === 'number' &&
typeof candidate.facing === 'string' &&
typeof candidate.attackRange === 'number' &&
typeof candidate.speed === 'number',
);
}
function normalizeRuntimeBattleEncounter(
encounter: GameState['currentEncounter'],
): Encounter | null {
if (!encounter || encounter.kind !== 'npc') {
return null;
}
const npcName =
typeof encounter.npcName === 'string' ? encounter.npcName.trim() : '';
if (!npcName) {
return null;
}
return {
...encounter,
kind: 'npc',
npcName,
npcDescription:
typeof encounter.npcDescription === 'string'
? encounter.npcDescription
: '',
npcAvatar:
typeof encounter.npcAvatar === 'string' ? encounter.npcAvatar : '',
context: typeof encounter.context === 'string' ? encounter.context : '',
hostile: true,
} satisfies Encounter;
}
function resolveRuntimeNpcBattleState(
gameState: Pick<
GameState,
| 'currentBattleNpcId'
| 'currentEncounter'
| 'customWorldProfile'
| 'npcStates'
| 'sceneHostileNpcs'
| 'worldType'
>,
) {
const encounter = normalizeRuntimeBattleEncounter(gameState.currentEncounter);
if (!encounter || gameState.sceneHostileNpcs.length === 0) {
return null;
}
const npcStateKey =
gameState.currentBattleNpcId ??
encounter.id ??
encounter.npcName;
const npcState =
gameState.npcStates[npcStateKey] ??
buildInitialNpcState(
encounter,
resolveHydrationWorldType(gameState.worldType),
gameState as GameState,
);
return {
encounter,
npcState,
};
}
function hydrateRuntimeNpcBattleMonster(params: {
hostileNpc: SceneHostileNpc;
encounter: Encounter;
npcState: NpcPersistentState;
gameState: Pick<GameState, 'customWorldProfile' | 'worldType'>;
battleMode: NonNullable<GameState['currentNpcBattleMode']>;
}) {
const template = createNpcBattleMonster(
params.encounter,
params.npcState,
params.battleMode,
{
worldType: resolveHydrationWorldType(params.gameState.worldType),
customWorldProfile: params.gameState.customWorldProfile,
},
);
const candidate = params.hostileNpc as Partial<SceneHostileNpc>;
const xMeters =
typeof candidate.xMeters === 'number' ? candidate.xMeters : template.xMeters;
const yOffset =
typeof candidate.yOffset === 'number' ? candidate.yOffset : template.yOffset;
return {
...template,
id:
typeof candidate.id === 'string' && candidate.id.trim()
? candidate.id
: template.id,
name:
typeof candidate.name === 'string' && candidate.name.trim()
? candidate.name
: template.name,
description:
typeof candidate.description === 'string'
? candidate.description
: template.description,
hp: typeof candidate.hp === 'number' ? candidate.hp : template.hp,
maxHp:
typeof candidate.maxHp === 'number' ? candidate.maxHp : template.maxHp,
animation:
typeof candidate.animation === 'string'
? candidate.animation
: template.animation,
xMeters,
yOffset,
facing:
candidate.facing === 'left' || candidate.facing === 'right'
? candidate.facing
: template.facing,
attackRange:
typeof candidate.attackRange === 'number'
? candidate.attackRange
: template.attackRange,
speed:
typeof candidate.speed === 'number' ? candidate.speed : template.speed,
encounter: {
...template.encounter,
xMeters,
},
} satisfies SceneHostileNpc;
}
export function hydrateRuntimeNpcBattleGameState(
gameState: HydratedGameState,
): HydratedGameState {
const battleMode = gameState.currentNpcBattleMode;
if (
gameState.inBattle !== true ||
(battleMode !== 'fight' && battleMode !== 'spar') ||
gameState.currentEncounter?.kind !== 'npc' ||
gameState.sceneHostileNpcs.length === 0 ||
gameState.sceneHostileNpcs.every(hasRenderableRuntimeNpcBattleFields)
) {
return gameState;
}
const resolvedState = resolveRuntimeNpcBattleState(gameState);
if (!resolvedState) {
return gameState;
}
return {
...gameState,
sceneHostileNpcs: gameState.sceneHostileNpcs.map((hostileNpc) =>
hasRenderableRuntimeNpcBattleFields(hostileNpc)
? hostileNpc
: hydrateRuntimeNpcBattleMonster({
hostileNpc,
encounter: resolvedState.encounter,
npcState: resolvedState.npcState,
gameState,
battleMode,
}),
),
};
}
export function normalizeSavedStory(story: StoryMoment | null) {
if (!story) {
return null;
@@ -57,7 +261,7 @@ export function normalizeSavedGameState(gameState: GameState) {
const playerMaxHp = Math.max(1, hydratableState.playerMaxHp);
const playerMaxMana = Math.max(1, hydratableState.playerMaxMana);
return {
return hydrateRuntimeNpcBattleGameState({
...hydratableState,
playerMaxHp,
playerHp: Math.min(hydratableState.playerHp, playerMaxHp),
@@ -72,7 +276,7 @@ export function normalizeSavedGameState(gameState: GameState) {
typeof hydratableState.runtimeSessionId === 'string'
? hydratableState.runtimeSessionId
: null,
} satisfies HydratedGameState;
} satisfies HydratedGameState);
}
export function hydrateSnapshotState(snapshot: {
@@ -105,8 +309,23 @@ export function isHydratedSnapshotState(
);
}
export function rehydrateSavedSnapshot<T extends HydratedSnapshotState>(
snapshot: T,
): T {
const hydratedGameState = hydrateRuntimeNpcBattleGameState(snapshot.gameState);
if (hydratedGameState === snapshot.gameState) {
return snapshot;
}
return {
...snapshot,
gameState: hydratedGameState,
};
}
export function resolveHydratedSnapshotState(snapshot: SnapshotState) {
return isHydratedSnapshotState(snapshot)
? snapshot
? rehydrateSavedSnapshot(snapshot)
: hydrateSnapshotState(snapshot);
}

View File

@@ -1,3 +1,14 @@
import type {
CreateCustomWorldAgentSessionRequest,
CreateCustomWorldAgentSessionResponse,
CustomWorldAgentActionRequest,
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
CustomWorldDraftCardDetail,
GetCustomWorldAgentCardDetailResponse,
ListCustomWorldWorksResponse,
SendCustomWorldAgentMessageRequest,
} from '../../packages/shared/src/contracts/customWorldAgent';
import type {
AnswerCustomWorldSessionQuestionRequest,
CreateCustomWorldSessionRequest,
@@ -365,10 +376,18 @@ export async function generateCustomWorldProfile(
});
}
return streamCustomWorldSessionGeneration(session.sessionId, options);
}
export async function streamCustomWorldSessionGeneration(
sessionId: string,
options: GenerateCustomWorldProfileOptions = {},
): Promise<CustomWorldProfile> {
const response = await fetchWithApiAuth(
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(session.sessionId)}/generate/stream`,
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}/generate/stream`,
{
method: 'GET',
signal: options.signal,
},
);
if (!response.ok) {
@@ -487,6 +506,105 @@ export async function createCustomWorldSession(payload: {
);
}
export async function listCustomWorldWorks() {
const response = await requestJson<ListCustomWorldWorksResponse>(
`${RUNTIME_API_BASE}/custom-world/works`,
{
method: 'GET',
},
'读取创作作品列表失败',
);
return Array.isArray(response?.items) ? response.items : [];
}
export async function createCustomWorldAgentSession(
payload: CreateCustomWorldAgentSessionRequest,
) {
return requestJson<CreateCustomWorldAgentSessionResponse>(
`${RUNTIME_API_BASE}/custom-world/agent/sessions`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'创建世界共创会话失败',
);
}
export async function getCustomWorldAgentSession(sessionId: string) {
return requestJson<CustomWorldAgentSessionSnapshot>(
`${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}`,
{
method: 'GET',
},
'读取世界共创会话失败',
);
}
export async function sendCustomWorldAgentMessage(
sessionId: string,
payload: SendCustomWorldAgentMessageRequest,
) {
return requestJson<{ operation: CustomWorldAgentOperationRecord }>(
`${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/messages`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'发送共创消息失败',
);
}
export async function executeCustomWorldAgentAction(
sessionId: string,
payload: CustomWorldAgentActionRequest,
) {
return requestJson<{ operation: CustomWorldAgentOperationRecord }>(
`${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/actions`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'执行共创操作失败',
);
}
export async function getCustomWorldAgentOperation(
sessionId: string,
operationId: string,
): Promise<CustomWorldAgentOperationRecord> {
const response = await requestJson<{
operation?: CustomWorldAgentOperationRecord;
data?: CustomWorldAgentOperationRecord;
} & Partial<CustomWorldAgentOperationRecord>>(
`${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/operations/${encodeURIComponent(operationId)}`,
{
method: 'GET',
},
'读取共创操作状态失败',
);
return (response.operation ?? response.data ?? response) as CustomWorldAgentOperationRecord;
}
export async function getCustomWorldAgentCardDetail(
sessionId: string,
cardId: string,
) {
const response = await requestJson<GetCustomWorldAgentCardDetailResponse>(
`${RUNTIME_API_BASE}/custom-world/agent/sessions/${encodeURIComponent(sessionId)}/cards/${encodeURIComponent(cardId)}`,
{
method: 'GET',
},
'读取草稿卡详情失败',
);
return response.card as CustomWorldDraftCardDetail;
}
export async function getCustomWorldSession(sessionId: string) {
return requestJson<CustomWorldSessionRecord>(
`${RUNTIME_API_BASE}/custom-world/sessions/${encodeURIComponent(sessionId)}`,

View File

@@ -147,6 +147,30 @@ describe('authService auto auth', () => {
);
});
it('deduplicates concurrent auto auth requests', async () => {
requestJsonMock.mockResolvedValue({
token: 'jwt-auto',
user: {
id: 'user_auto',
username: 'guest_auto',
displayName: 'guest_auto',
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
});
const [firstResult, secondResult] = await Promise.all([
ensureAutoAuthUser(),
ensureAutoAuthUser(),
]);
expect(requestJsonMock).toHaveBeenCalledTimes(1);
expect(firstResult).toEqual(secondResult);
expect(getStoredAutoAuthCredentials()).toEqual(firstResult.credentials);
});
it('sends phone login code through the new auth endpoint', async () => {
requestJsonMock.mockResolvedValue({
ok: true,

View File

@@ -16,6 +16,7 @@ import type {
AuthRiskBlockSummary,
AuthSessionsResponse,
AuthSessionSummary,
AuthUser,
AuthWechatBindPhoneResponse,
AuthWechatStartResponse,
LogoutResponse,
@@ -53,6 +54,11 @@ export type ConsumedAuthCallback = {
error: string | null;
};
let pendingAutoAuthUser: Promise<{
user: AuthUser;
credentials: AutoAuthCredentials;
}> | null = null;
export function normalizePhoneInput(phoneInput: string) {
return phoneInput.replace(/[^\d+]/gu, '').trim();
}
@@ -248,14 +254,22 @@ export async function authEntryWithStoredCredentials(
}
export async function ensureAutoAuthUser() {
const credentials =
getStoredAutoAuthCredentials() ?? createAutoAuthCredentials();
const user = await authEntryWithStoredCredentials(credentials);
pendingAutoAuthUser ??= (async () => {
const credentials =
getStoredAutoAuthCredentials() ?? createAutoAuthCredentials();
const user = await authEntryWithStoredCredentials(credentials);
return {
user,
credentials,
};
return {
user,
credentials,
};
})();
try {
return await pendingAutoAuthUser;
} finally {
pendingAutoAuthUser = null;
}
}
export function consumeAuthCallbackResult(): ConsumedAuthCallback | null {

View File

@@ -0,0 +1,61 @@
import { expect, test } from 'vitest';
import {
clearCustomWorldAgentUiState,
readCustomWorldAgentUiState,
writeCustomWorldAgentUiState,
} from './customWorldAgentUiState';
function createMemoryStorage() {
const store = new Map<string, string>();
return {
getItem(key: string) {
return store.get(key) ?? null;
},
setItem(key: string, value: string) {
store.set(key, value);
},
removeItem(key: string) {
store.delete(key);
},
};
}
test('custom world agent ui state reads from query first and persists to session storage', () => {
const sessionStorage = createMemoryStorage();
let currentUrl = '/play';
const env = {
location: {
pathname: '/play',
get search() {
const [, search = ''] = currentUrl.split('?');
return search ? `?${search}` : '';
},
},
history: {
replaceState: (_data: unknown, _unused: string, nextUrl?: string | URL | null) => {
currentUrl = String(nextUrl ?? '/play');
},
},
sessionStorage,
};
writeCustomWorldAgentUiState(
{
activeSessionId: 'session-1',
activeOperationId: 'operation-1',
},
env,
);
expect(currentUrl).toContain('customWorldSessionId=session-1');
expect(currentUrl).toContain('customWorldOperationId=operation-1');
expect(readCustomWorldAgentUiState(env)).toEqual({
activeSessionId: 'session-1',
activeOperationId: 'operation-1',
});
clearCustomWorldAgentUiState(env);
expect(readCustomWorldAgentUiState(env)).toEqual({});
});

View File

@@ -0,0 +1,139 @@
import type { CustomWorldAgentUiState } from '../types';
export const CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY = 'customWorldSessionId';
export const CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY = 'customWorldOperationId';
export const CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY =
'genarrative.custom-world-agent-ui.v1';
type CustomWorldAgentUiEnvironment = {
location?: {
pathname: string;
search: string;
} | null;
history?: {
replaceState: (
data: unknown,
unused: string,
url?: string | URL | null,
) => void;
} | null;
sessionStorage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | null;
};
function resolveEnvironment(
env?: CustomWorldAgentUiEnvironment,
): Required<CustomWorldAgentUiEnvironment> {
if (env) {
return {
location: env.location ?? null,
history: env.history ?? null,
sessionStorage: env.sessionStorage ?? null,
};
}
if (typeof window === 'undefined') {
return {
location: null,
history: null,
sessionStorage: null,
};
}
return {
location: window.location,
history: window.history,
sessionStorage: window.sessionStorage,
};
}
function normalizeValue(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
export function readCustomWorldAgentUiState(
env?: CustomWorldAgentUiEnvironment,
): CustomWorldAgentUiState {
const resolved = resolveEnvironment(env);
const params = new URLSearchParams(resolved.location?.search ?? '');
const stateFromQuery: CustomWorldAgentUiState = {
activeSessionId: normalizeValue(
params.get(CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY),
),
activeOperationId: normalizeValue(
params.get(CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY),
),
};
if (stateFromQuery.activeSessionId || stateFromQuery.activeOperationId) {
return stateFromQuery;
}
const storedValue = resolved.sessionStorage?.getItem(
CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY,
);
if (!storedValue) {
return {};
}
try {
const parsed = JSON.parse(storedValue) as CustomWorldAgentUiState;
return {
activeSessionId: normalizeValue(parsed.activeSessionId),
activeOperationId: normalizeValue(parsed.activeOperationId),
};
} catch {
resolved.sessionStorage?.removeItem(CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY);
return {};
}
}
export function writeCustomWorldAgentUiState(
state: CustomWorldAgentUiState,
env?: CustomWorldAgentUiEnvironment,
) {
const resolved = resolveEnvironment(env);
const activeSessionId = normalizeValue(state.activeSessionId);
const activeOperationId = normalizeValue(state.activeOperationId);
const nextState: CustomWorldAgentUiState = {
activeSessionId,
activeOperationId,
};
if (resolved.location && resolved.history?.replaceState) {
const params = new URLSearchParams(resolved.location.search);
if (activeSessionId) {
params.set(CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY, activeSessionId);
} else {
params.delete(CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY);
}
if (activeOperationId) {
params.set(CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY, activeOperationId);
} else {
params.delete(CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY);
}
const search = params.toString();
const nextUrl = search
? `${resolved.location.pathname}?${search}`
: resolved.location.pathname;
resolved.history.replaceState(null, '', nextUrl);
}
if (resolved.sessionStorage) {
if (activeSessionId || activeOperationId) {
resolved.sessionStorage.setItem(
CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY,
JSON.stringify(nextState),
);
} else {
resolved.sessionStorage.removeItem(CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY);
}
}
}
export function clearCustomWorldAgentUiState(
env?: CustomWorldAgentUiEnvironment,
) {
writeCustomWorldAgentUiState({}, env);
}

View File

@@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest';
import {
buildCustomWorldAnchorPackFromIntent,
buildPendingClarifications,
buildCustomWorldCreatorIntentDisplayText,
createEmptyCustomWorldCreatorIntent,
evaluateCustomWorldCreatorIntentReadiness,
mergeCustomWorldCreatorIntent,
normalizeCustomWorldCreatorIntent,
} from './customWorldCreatorIntent';
@@ -32,7 +35,9 @@ describe('customWorldCreatorIntent', () => {
const summary = buildCustomWorldCreatorIntentDisplayText(intent);
expect(summary).toContain('世界一句话:一个会被灵潮反复改写地形的边境世界。');
expect(summary).toContain(
'世界一句话:一个会被灵潮反复改写地形的边境世界。',
);
expect(summary).toContain('主题关键词:边境、灵潮');
expect(summary).toContain('关键角色:沈砺 / 灰炬向导');
});
@@ -90,4 +95,72 @@ describe('customWorldCreatorIntent', () => {
expect(intent?.keyCharacters[0]?.name).toBe('梁砺');
expect(intent?.keyCharacters[0]?.id).toBeTruthy();
});
it('merges creator intent patches without dropping unrelated anchors', () => {
const baseIntent = {
...createEmptyCustomWorldCreatorIntent('freeform'),
worldHook: '潮雾会改写地形的列岛世界。',
playerPremise: '玩家是失职返乡的守灯人。',
};
const merged = mergeCustomWorldCreatorIntent(baseIntent, {
coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
toneDirectives: ['冷峻'],
});
if (!merged) {
throw new Error('expected merged creator intent');
}
expect(merged.worldHook).toBe('潮雾会改写地形的列岛世界。');
expect(merged.playerPremise).toBe('玩家是失职返乡的守灯人。');
expect(merged.coreConflicts).toEqual(['守灯会与沉船商盟争夺航道解释权']);
expect(merged.toneDirectives).toEqual(['冷峻']);
});
it('replaces array anchors when a patch marks explicit rewrite fields', () => {
const merged = mergeCustomWorldCreatorIntent(
{
...createEmptyCustomWorldCreatorIntent('freeform'),
themeKeywords: ['海岛', '旧案'],
coreConflicts: ['守灯会与沉船商盟争夺航道解释权'],
},
{
themeKeywords: ['宫廷', '悬疑'],
coreConflicts: ['王庭继承人与旧灯塔盟约对抗'],
replaceFields: ['themeKeywords', 'coreConflicts'],
},
);
if (!merged) {
throw new Error('expected merged creator intent');
}
expect(merged.themeKeywords).toEqual(['宫廷', '悬疑']);
expect(merged.coreConflicts).toEqual(['王庭继承人与旧灯塔盟约对抗']);
});
it('evaluates readiness and limits clarifications to top gaps', () => {
const readiness = evaluateCustomWorldCreatorIntentReadiness({
...createEmptyCustomWorldCreatorIntent('freeform'),
worldHook: '一个被潮雾切开的列岛世界。',
themeKeywords: ['海岛'],
toneDirectives: ['冷峻'],
coreConflicts: ['旧灯塔正在被沉船商盟接管'],
});
const clarifications = buildPendingClarifications(
{
...createEmptyCustomWorldCreatorIntent('freeform'),
worldHook: '一个被潮雾切开的列岛世界。',
themeKeywords: ['海岛'],
toneDirectives: ['冷峻'],
coreConflicts: ['旧灯塔正在被沉船商盟接管'],
},
readiness,
);
expect(readiness.isReady).toBe(false);
expect(readiness.completedKeys).toContain('world_hook');
expect(readiness.missingKeys).toContain('player_premise');
expect(clarifications).toHaveLength(3);
expect(clarifications[0]?.targetKey).toBe('player_premise');
});
});

View File

@@ -1,3 +1,7 @@
import type {
CreatorIntentReadiness,
CustomWorldPendingClarification,
} from '../../packages/shared/src/contracts/customWorldAgent';
import type {
ActorAnchor,
CreatorCharacterSeed,
@@ -10,6 +14,51 @@ import type {
LandmarkAnchor,
} from '../types';
export type CustomWorldCreatorIntentPatch = Partial<
Pick<
CustomWorldCreatorIntent,
| 'rawSettingText'
| 'worldHook'
| 'themeKeywords'
| 'toneDirectives'
| 'playerPremise'
| 'openingSituation'
| 'coreConflicts'
| 'keyFactions'
| 'keyCharacters'
| 'keyLandmarks'
| 'iconicElements'
| 'forbiddenDirectives'
>
>;
export type CustomWorldCreatorIntentReplaceableField =
| 'rawSettingText'
| 'worldHook'
| 'themeKeywords'
| 'toneDirectives'
| 'playerPremise'
| 'openingSituation'
| 'coreConflicts'
| 'keyFactions'
| 'keyCharacters'
| 'keyLandmarks'
| 'iconicElements'
| 'forbiddenDirectives';
export type CustomWorldCreatorIntentPatchInput =
CustomWorldCreatorIntentPatch & {
replaceFields?: CustomWorldCreatorIntentReplaceableField[];
};
type CreatorIntentReadinessKey =
| 'world_hook'
| 'player_premise'
| 'theme_and_tone'
| 'core_conflict'
| 'relationship_seed'
| 'iconic_element';
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
@@ -19,13 +68,10 @@ function toStringArray(value: unknown, maxCount = 8) {
return [];
}
return [
...new Set(
value
.map((item) => toText(item))
.filter(Boolean),
),
].slice(0, maxCount);
return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice(
0,
maxCount,
);
}
function slugify(value: string) {
@@ -72,7 +118,9 @@ function normalizeCreatorFactionSeed(
}
return {
id: toText(item.id) || createSeedId('creator-faction', name || publicGoal, index),
id:
toText(item.id) ||
createSeedId('creator-faction', name || publicGoal, index),
name,
publicGoal,
tension,
@@ -167,6 +215,126 @@ function normalizeAnchorArray<T>(
.slice(0, maxCount);
}
function mergeStringArray(
base: string[],
patch: string[] | undefined,
maxCount: number,
) {
if (!patch || patch.length === 0) {
return [...base];
}
return [
...new Set([...base, ...patch.map((item) => toText(item)).filter(Boolean)]),
].slice(0, maxCount);
}
function mergeNarrativeText(base: string, patch: string | undefined) {
const nextText = toText(patch);
if (!nextText) {
return base;
}
if (!base) {
return nextText;
}
if (base.includes(nextText)) {
return base;
}
return `${base}\n${nextText}`.trim();
}
function mergeSeedArray<T extends { id: string; name?: string }>(
base: T[],
patch: T[] | undefined,
maxCount: number,
mergeEntry: (current: T, next: T) => T,
) {
if (!patch || patch.length === 0) {
return [...base];
}
const nextItems = [...base];
patch.forEach((entry) => {
const normalizedName = toText(entry.name);
const existingIndex = nextItems.findIndex(
(item) =>
item.id === entry.id ||
(normalizedName &&
toText(item.name).toLowerCase() === normalizedName.toLowerCase()),
);
if (existingIndex >= 0) {
const currentItem = nextItems[existingIndex];
if (!currentItem) {
nextItems.push(entry);
return;
}
nextItems[existingIndex] = mergeEntry(currentItem, entry);
return;
}
nextItems.push(entry);
});
return nextItems.slice(0, maxCount);
}
function mergeCharacterSeed(
current: CreatorCharacterSeed,
next: CreatorCharacterSeed,
): CreatorCharacterSeed {
return {
...current,
...next,
id: next.id || current.id,
name: toText(next.name) || current.name,
role: toText(next.role) || current.role,
publicMask: toText(next.publicMask) || current.publicMask,
hiddenHook: toText(next.hiddenHook) || current.hiddenHook,
relationToPlayer: toText(next.relationToPlayer) || current.relationToPlayer,
notes: toText(next.notes) || current.notes,
locked:
typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked),
};
}
function mergeFactionSeed(
current: CreatorFactionSeed,
next: CreatorFactionSeed,
): CreatorFactionSeed {
return {
...current,
...next,
id: next.id || current.id,
name: toText(next.name) || current.name,
publicGoal: toText(next.publicGoal) || current.publicGoal,
tension: toText(next.tension) || current.tension,
notes: toText(next.notes) || current.notes,
locked:
typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked),
};
}
function mergeLandmarkSeed(
current: CreatorLandmarkSeed,
next: CreatorLandmarkSeed,
): CreatorLandmarkSeed {
return {
...current,
...next,
id: next.id || current.id,
name: toText(next.name) || current.name,
purpose: toText(next.purpose) || current.purpose,
mood: toText(next.mood) || current.mood,
secret: toText(next.secret) || current.secret,
locked:
typeof next.locked === 'boolean' ? next.locked : Boolean(current.locked),
};
}
export function createEmptyCustomWorldCreatorIntent(
sourceMode: CustomWorldCreatorInputMode = 'freeform',
): CustomWorldCreatorIntent {
@@ -259,6 +427,221 @@ export function normalizeCustomWorldCreatorIntent(
};
}
export function mergeCustomWorldCreatorIntent(
current: CustomWorldCreatorIntent | null | undefined,
patch: CustomWorldCreatorIntentPatchInput | null | undefined,
fallbackMode: CustomWorldCreatorInputMode = 'freeform',
) {
if (!patch) {
return current
? normalizeCustomWorldCreatorIntent(current, fallbackMode)
: createEmptyCustomWorldCreatorIntent(fallbackMode);
}
const base =
normalizeCustomWorldCreatorIntent(current, fallbackMode) ??
createEmptyCustomWorldCreatorIntent(fallbackMode);
const replaceFields = new Set(patch.replaceFields ?? []);
const patchIntent =
normalizeCustomWorldCreatorIntent(
{
sourceMode: base.sourceMode,
...patch,
},
base.sourceMode,
) ?? createEmptyCustomWorldCreatorIntent(base.sourceMode);
return {
...base,
rawSettingText: replaceFields.has('rawSettingText')
? toText(patchIntent.rawSettingText) || base.rawSettingText
: mergeNarrativeText(base.rawSettingText, patchIntent.rawSettingText),
worldHook: toText(patchIntent.worldHook) || base.worldHook,
themeKeywords: replaceFields.has('themeKeywords')
? [...patchIntent.themeKeywords]
: mergeStringArray(base.themeKeywords, patchIntent.themeKeywords, 8),
toneDirectives: replaceFields.has('toneDirectives')
? [...patchIntent.toneDirectives]
: mergeStringArray(base.toneDirectives, patchIntent.toneDirectives, 8),
playerPremise: toText(patchIntent.playerPremise) || base.playerPremise,
openingSituation:
toText(patchIntent.openingSituation) || base.openingSituation,
coreConflicts: replaceFields.has('coreConflicts')
? [...patchIntent.coreConflicts]
: mergeStringArray(base.coreConflicts, patchIntent.coreConflicts, 6),
keyFactions: replaceFields.has('keyFactions')
? [...patchIntent.keyFactions]
: mergeSeedArray(
base.keyFactions,
patchIntent.keyFactions,
6,
mergeFactionSeed,
),
keyCharacters: replaceFields.has('keyCharacters')
? [...patchIntent.keyCharacters]
: mergeSeedArray(
base.keyCharacters,
patchIntent.keyCharacters,
8,
mergeCharacterSeed,
),
keyLandmarks: replaceFields.has('keyLandmarks')
? [...patchIntent.keyLandmarks]
: mergeSeedArray(
base.keyLandmarks,
patchIntent.keyLandmarks,
8,
mergeLandmarkSeed,
),
iconicElements: replaceFields.has('iconicElements')
? [...patchIntent.iconicElements]
: mergeStringArray(base.iconicElements, patchIntent.iconicElements, 8),
forbiddenDirectives: replaceFields.has('forbiddenDirectives')
? [...patchIntent.forbiddenDirectives]
: mergeStringArray(
base.forbiddenDirectives,
patchIntent.forbiddenDirectives,
8,
),
} satisfies CustomWorldCreatorIntent;
}
export function evaluateCustomWorldCreatorIntentReadiness(
intent: CustomWorldCreatorIntent | null | undefined,
): CreatorIntentReadiness {
const normalized =
normalizeCustomWorldCreatorIntent(intent) ??
createEmptyCustomWorldCreatorIntent('freeform');
const completedKeys: CreatorIntentReadinessKey[] = [];
const missingKeys: CreatorIntentReadinessKey[] = [];
const relationshipReady = normalized.keyCharacters.some(
(entry) =>
Boolean(toText(entry.name)) &&
Boolean(toText(entry.relationToPlayer) || toText(entry.hiddenHook)),
);
const keyChecks: Array<{
key: CreatorIntentReadinessKey;
ready: boolean;
}> = [
{
key: 'world_hook',
ready:
normalized.worldHook.trim().length >= 8 ||
normalized.rawSettingText.trim().length >= 24,
},
{
key: 'player_premise',
ready: Boolean(
normalized.playerPremise.trim() && normalized.openingSituation.trim(),
),
},
{
key: 'theme_and_tone',
ready:
normalized.themeKeywords.length >= 1 &&
normalized.toneDirectives.length >= 1,
},
{
key: 'core_conflict',
ready: normalized.coreConflicts.length >= 1,
},
{
key: 'relationship_seed',
ready: normalized.keyCharacters.length >= 1 && relationshipReady,
},
{
key: 'iconic_element',
ready: normalized.iconicElements.length >= 1,
},
];
keyChecks.forEach((entry) => {
if (entry.ready) {
completedKeys.push(entry.key);
return;
}
missingKeys.push(entry.key);
});
return {
isReady: missingKeys.length === 0,
completedKeys,
missingKeys,
};
}
const CLARIFICATION_DEFINITIONS: Array<{
targetKey: CreatorIntentReadinessKey;
priority: number;
label: string;
question: string;
}> = [
{
targetKey: 'world_hook',
priority: 1,
label: '世界一句话',
question:
'先用一句话说清,这个世界最独特的核心幻想是什么?可以直接给我一句钉住调性的描述。',
},
{
targetKey: 'player_premise',
priority: 2,
label: '玩家身份与开局',
question:
'玩家是谁,故事开场时正卡在什么局面里?你可以直接把身份和开局困境一起告诉我。',
},
{
targetKey: 'core_conflict',
priority: 3,
label: '核心冲突',
question:
'现在这个世界最主要的冲突是什么?最好是能立刻推动剧情的那种对抗或危机。',
},
{
targetKey: 'theme_and_tone',
priority: 4,
label: '主题气质',
question:
'你想要它整体更偏什么主题和气质?比如克制、压迫、浪漫、冷峻,或者明确不要什么。',
},
{
targetKey: 'relationship_seed',
priority: 5,
label: '关键关系钩子',
question:
'给我一个最值得写的关键人物种子就行,他和玩家是什么关系,或者身上藏着什么暗线?',
},
{
targetKey: 'iconic_element',
priority: 6,
label: '标志性要素',
question:
'这个世界有什么一眼就能认出来的标志性元素、意象或硬规则?先给 1 到 2 个就够。',
},
];
export function buildPendingClarifications(
intent: CustomWorldCreatorIntent | null | undefined,
readiness = evaluateCustomWorldCreatorIntentReadiness(intent),
) {
return CLARIFICATION_DEFINITIONS.filter((entry) =>
readiness.missingKeys.includes(entry.targetKey),
)
.sort((left, right) => left.priority - right.priority)
.slice(0, 3)
.map(
(entry): CustomWorldPendingClarification => ({
id: entry.targetKey,
label: entry.label,
question: entry.question,
targetKey: entry.targetKey,
priority: entry.priority,
}),
);
}
export function normalizeCustomWorldLockState(
value: unknown,
): CustomWorldLockState {
@@ -308,8 +691,7 @@ export function hasMeaningfulCustomWorldCreatorIntent(
) {
return Boolean(
intent &&
(
intent.rawSettingText ||
(intent.rawSettingText ||
intent.worldHook ||
intent.themeKeywords.length > 0 ||
intent.toneDirectives.length > 0 ||
@@ -320,8 +702,7 @@ export function hasMeaningfulCustomWorldCreatorIntent(
intent.keyCharacters.length > 0 ||
intent.keyLandmarks.length > 0 ||
intent.iconicElements.length > 0 ||
intent.forbiddenDirectives.length > 0
),
intent.forbiddenDirectives.length > 0),
);
}
@@ -348,7 +729,9 @@ export function buildCustomWorldCreatorIntentDisplayText(
'关键势力',
intent?.keyFactions
.map((entry) =>
[entry.name, entry.publicGoal, entry.tension].filter(Boolean).join(' / '),
[entry.name, entry.publicGoal, entry.tension]
.filter(Boolean)
.join(' / '),
)
.filter(Boolean)
.join('') || '',
@@ -477,7 +860,9 @@ function buildCharacterAnchorSummary(entry: CreatorCharacterSeed): ActorAnchor {
};
}
function buildLandmarkAnchorSummary(entry: CreatorLandmarkSeed): LandmarkAnchor {
function buildLandmarkAnchorSummary(
entry: CreatorLandmarkSeed,
): LandmarkAnchor {
const summary = clampText(
[entry.purpose, entry.mood, entry.secret ? `秘密 ${entry.secret}` : '']
.filter(Boolean)
@@ -500,9 +885,15 @@ export function buildCustomWorldAnchorPackFromIntent(
}
const lockedAnchorIds = [
...(intent?.keyCharacters.filter((entry) => entry.locked).map((entry) => entry.id) ?? []),
...(intent?.keyLandmarks.filter((entry) => entry.locked).map((entry) => entry.id) ?? []),
...(intent?.keyFactions.filter((entry) => entry.locked).map((entry) => entry.id) ?? []),
...(intent?.keyCharacters
.filter((entry) => entry.locked)
.map((entry) => entry.id) ?? []),
...(intent?.keyLandmarks
.filter((entry) => entry.locked)
.map((entry) => entry.id) ?? []),
...(intent?.keyFactions
.filter((entry) => entry.locked)
.map((entry) => entry.id) ?? []),
];
return {
@@ -515,18 +906,24 @@ export function buildCustomWorldAnchorPackFromIntent(
240,
),
lockedAnchorIds,
keyConflictSummaries: intent?.coreConflicts.map((entry) => clampText(entry, 48)) ?? [],
keyConflictSummaries:
intent?.coreConflicts.map((entry) => clampText(entry, 48)) ?? [],
keyFactionSummaries:
intent?.keyFactions.map((entry) =>
clampText(
[entry.name, entry.publicGoal, entry.tension].filter(Boolean).join(''),
[entry.name, entry.publicGoal, entry.tension]
.filter(Boolean)
.join(''),
72,
),
) ?? [],
keyCharacterAnchors:
intent?.keyCharacters.map((entry) => buildCharacterAnchorSummary(entry)) ?? [],
intent?.keyCharacters.map((entry) =>
buildCharacterAnchorSummary(entry),
) ?? [],
keyLandmarkAnchors:
intent?.keyLandmarks.map((entry) => buildLandmarkAnchorSummary(entry)) ?? [],
intent?.keyLandmarks.map((entry) => buildLandmarkAnchorSummary(entry)) ??
[],
motifDirectives: [
...(intent?.themeKeywords ?? []),
...(intent?.toneDirectives ?? []),

View File

@@ -9,13 +9,14 @@ import {
SERVER_RUNTIME_FUNCTION_IDS,
TASK5_RUNTIME_FUNCTION_IDS,
} from '../../packages/shared/src/contracts/story';
import { rehydrateSavedSnapshot } from '../persistence/runtimeSnapshot';
import type {
HydratedGameState,
HydratedSavedGameSnapshot,
} from '../persistence/runtimeSnapshotTypes';
import type { GameState, StoryMoment, StoryOption } from '../types';
import { AnimationState } from '../types';
import { requestJson, type ApiRetryOptions } from './apiClient';
import { type ApiRetryOptions,requestJson } from './apiClient';
const RUNTIME_STORY_API_BASE = '/api/runtime/story';
const DEFAULT_SESSION_ID = 'runtime-main';
@@ -171,12 +172,17 @@ export async function getRuntimeStoryState(
sessionId: string,
options: RuntimeStoryServiceOptions = {},
) {
return requestRuntimeStoryJson<RuntimeStoryResponse>(
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
`/state/${encodeURIComponent(sessionId || DEFAULT_SESSION_ID)}`,
{ method: 'GET' },
'读取运行时故事状态失败',
options,
);
return {
...response,
snapshot: rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot),
} satisfies RuntimeStoryResponse;
}
export async function resolveRuntimeStoryAction(
@@ -189,7 +195,7 @@ export async function resolveRuntimeStoryAction(
},
options: RuntimeStoryServiceOptions = {},
) {
return requestRuntimeStoryJson<RuntimeStoryResponse>(
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
'/actions/resolve',
{
method: 'POST',
@@ -211,8 +217,15 @@ export async function resolveRuntimeStoryAction(
'执行运行时动作失败',
options,
);
return {
...response,
snapshot: rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot),
} satisfies RuntimeStoryResponse;
}
export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {
return response.snapshot as HydratedSavedGameSnapshot;
return rehydrateSavedSnapshot(
response.snapshot as HydratedSavedGameSnapshot,
);
}

View File

@@ -1,3 +1,6 @@
import type {
ListCustomWorldWorksResponse,
} from '../../packages/shared/src/contracts/customWorldAgent';
import type {
BasicOkResult,
CustomWorldLibraryResponse,
@@ -6,6 +9,7 @@ import type {
import type {
SavedGameSnapshotInput,
} from '../persistence/gameSaveStorage';
import { rehydrateSavedSnapshot } from '../persistence/runtimeSnapshot';
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
import type { CustomWorldProfile } from '../types';
import { type ApiRetryOptions,requestJson } from './apiClient';
@@ -51,19 +55,21 @@ function requestRuntimeJson<T>(
}
export async function getSaveSnapshot(options: RuntimeRequestOptions = {}) {
return requestRuntimeJson<HydratedSavedGameSnapshot | null>(
const snapshot = await requestRuntimeJson<HydratedSavedGameSnapshot | null>(
'/save/snapshot',
{ method: 'GET' },
'读取存档失败',
options,
);
return snapshot ? rehydrateSavedSnapshot(snapshot) : null;
}
export async function putSaveSnapshot(
snapshot: SavedGameSnapshotInput,
options: RuntimeRequestOptions = {},
) {
return requestRuntimeJson<HydratedSavedGameSnapshot>(
const savedSnapshot = await requestRuntimeJson<HydratedSavedGameSnapshot>(
'/save/snapshot',
{
method: 'PUT',
@@ -73,6 +79,8 @@ export async function putSaveSnapshot(
'保存存档失败',
options,
);
return rehydrateSavedSnapshot(savedSnapshot);
}
export async function deleteSaveSnapshot(options: RuntimeRequestOptions = {}) {
@@ -120,6 +128,17 @@ export async function listCustomWorldLibrary(options: RuntimeRequestOptions = {}
return Array.isArray(response?.profiles) ? response.profiles : [];
}
export async function listCustomWorldWorks(options: RuntimeRequestOptions = {}) {
const response = await requestRuntimeJson<ListCustomWorldWorksResponse>(
'/custom-world/works',
{ method: 'GET' },
'读取创作作品列表失败',
options,
);
return Array.isArray(response?.items) ? response.items : [];
}
export async function upsertCustomWorldProfile(
profile: CustomWorldProfile,
options: RuntimeRequestOptions = {},
@@ -161,6 +180,7 @@ export const runtimeStorageClient = {
getSettings,
putSettings,
listCustomWorldLibrary,
listCustomWorldWorks,
upsertCustomWorldProfile,
deleteCustomWorldProfile,
};

View File

@@ -24,6 +24,10 @@ import type {
export type CustomWorldCreatorInputMode = 'freeform' | 'card';
export type CustomWorldGenerationMode = 'fast' | 'full';
export type CustomWorldGenerationStatus = 'key_only' | 'complete';
export type CustomWorldAgentUiState = {
activeSessionId?: string | null;
activeOperationId?: string | null;
};
export interface CreatorFactionSeed {
id: string;