This commit is contained in:
2026-04-28 19:36:39 +08:00
parent a9febe7678
commit f0471a4f8d
206 changed files with 18456 additions and 10133 deletions

View File

@@ -1,7 +1,11 @@
import { useMemo, useState } from 'react';
import type {
RuntimeStoryEquipmentSlotView,
RuntimeStoryForgeRecipeView,
RuntimeStoryInventoryItemView,
} from '../../packages/shared/src/contracts/rpgRuntimeStoryState';
import { formatCurrency } from '../data/economy';
import { type ForgeRecipeView } from '../data/forgeSystem';
import { buildInitialPlayerInventory } from '../data/npcInteractions';
import {
Character,
@@ -25,9 +29,12 @@ interface InventoryPanelProps {
playerMana: number;
playerMaxMana: number;
inBattle: boolean;
currencyText?: string | null;
backpackItems?: RuntimeStoryInventoryItemView[];
equipmentSlots?: RuntimeStoryEquipmentSlotView[];
onUseItem: (itemId: string) => Promise<boolean>;
onEquipItem: (itemId: string) => Promise<boolean>;
forgeRecipes: ForgeRecipeView[];
forgeRecipes: RuntimeStoryForgeRecipeView[];
onCraftRecipe: (recipeId: string) => Promise<boolean>;
onDismantleItem: (itemId: string) => Promise<boolean>;
onReforgeItem: (itemId: string) => Promise<boolean>;
@@ -42,8 +49,15 @@ export function InventoryPanel({
playerInventory,
playerCurrency,
inBattle,
currencyText = null,
backpackItems = [],
equipmentSlots: _equipmentSlots = [],
onUseItem: _onUseItem,
onEquipItem: _onEquipItem,
forgeRecipes,
onCraftRecipe,
onDismantleItem: _onDismantleItem,
onReforgeItem: _onReforgeItem,
continueGameDigest = null,
narrativeCodex = [],
narrativeQaReport = null,
@@ -51,12 +65,24 @@ export function InventoryPanel({
const [selectedItem, setSelectedItem] = useState<InventoryItem | null>(null);
const [forgeActionKey, setForgeActionKey] = useState<string | null>(null);
const serverInventoryItems = useMemo(
() =>
backpackItems
.map((view) => view.item as unknown as InventoryItem)
.filter(
(item) =>
typeof item.id === 'string' && typeof item.name === 'string',
),
[backpackItems],
);
const inventoryItems = useMemo(
() =>
playerInventory.length > 0
? playerInventory
: buildInitialPlayerInventory(playerCharacter, worldType),
[playerCharacter, playerInventory, worldType],
serverInventoryItems.length > 0
? serverInventoryItems
: playerInventory.length > 0
? playerInventory
: buildInitialPlayerInventory(playerCharacter, worldType),
[playerCharacter, playerInventory, serverInventoryItems, worldType],
);
const documentItems = useMemo(
() => inventoryItems.filter((item) => item.category === '文书' || item.tags.includes('document')),
@@ -141,7 +167,7 @@ export function InventoryPanel({
<div className="mb-2 flex items-center justify-between gap-3 text-xs uppercase tracking-[0.2em] text-zinc-500">
<span></span>
<span className="text-emerald-200/80">
{formatCurrency(playerCurrency, worldType)}
{currencyText ?? formatCurrency(playerCurrency, worldType)}
</span>
</div>
<div className="space-y-3">
@@ -169,6 +195,7 @@ export function InventoryPanel({
type="button"
disabled={
!recipe.canCraft ||
!recipe.action.enabled ||
inBattle ||
forgeActionKey === recipe.id
}
@@ -181,7 +208,7 @@ export function InventoryPanel({
}
}}
className={`rounded-lg border px-3 py-1.5 text-xs transition ${
recipe.canCraft && !inBattle
recipe.canCraft && recipe.action.enabled && !inBattle
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100 hover:bg-emerald-500/20'
: 'border-white/8 bg-black/20 text-zinc-500'
}`}
@@ -208,6 +235,12 @@ export function InventoryPanel({
</span>
))}
</div>
{(!recipe.canCraft || !recipe.action.enabled) &&
(recipe.disabledReason || recipe.action.reason) && (
<div className="mt-2 text-[11px] text-zinc-500">
{recipe.disabledReason ?? recipe.action.reason}
</div>
)}
</div>
))}
</div>

View File

@@ -4,10 +4,7 @@ import { useState } from 'react';
import { getCharacterById } from '../data/characterPresets';
import {
formatCurrency,
getCurrencyName,
getInventoryItemValue,
getNpcBuybackPrice,
getNpcPurchasePrice,
} from '../data/economy';
import {
getEquipmentSlotFromItem,
@@ -19,12 +16,15 @@ import {
getInventoryTagLabels,
} from '../data/itemPresentation';
import {
buildInitialNpcState,
getGiftCandidates,
getRarityLabel,
} from '../data/npcInteractions';
import { StoryGenerationNpcUi } from '../hooks/rpg-runtime-story';
import { GameState, InventoryItem } from '../types';
import {
GameState,
InventoryItem,
RuntimeNpcGiftItemView,
RuntimeNpcTradeItemView,
} from '../types';
import { CHROME_ICONS, getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { PixelIcon } from './PixelIcon';
@@ -38,10 +38,6 @@ type TradeDetailState = {
source: 'buy' | 'sell';
} | null;
function getNpcEncounterKey(encounter: NonNullable<GameState['currentEncounter']>) {
return encounter.id ?? encounter.npcName;
}
function getItemVisualSrc(item: InventoryItem) {
return getInventoryItemVisualSrc(item);
}
@@ -88,7 +84,7 @@ function TradeItemRow({
<PixelIcon src={getItemVisualSrc(item)} className="h-7 w-7" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-white">{item.name}</div>
<div className="truncate text-sm font-medium text-white">{item.name ?? item.id}</div>
<div className="mt-1 text-[10px] text-zinc-500">
{item.category} / {getRarityLabel(item.rarity)} / {unitPrice} {currencyName}
</div>
@@ -150,71 +146,70 @@ function TradeQuantityStepper({
export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
const [tradeDetail, setTradeDetail] = useState<TradeDetailState>(null);
const currencyName = getCurrencyName(
gameState.worldType,
gameState.customWorldProfile,
);
const npcInteraction = gameState.runtimeNpcInteraction ?? null;
const currencyName = npcInteraction?.currencyName ?? '钱币';
const tradeModal = npcUi.tradeModal;
const tradeNpcState = tradeModal
? gameState.npcStates[getNpcEncounterKey(tradeModal.encounter)]
?? buildInitialNpcState(tradeModal.encounter, gameState.worldType, gameState)
: null;
const selectedTradeNpcItem = tradeNpcState?.inventory.find(item => item.id === tradeModal?.selectedNpcItemId) ?? null;
const selectedTradePlayerItem = tradeModal?.selectedPlayerItemId
? gameState.playerInventory.find(item => item.id === tradeModal?.selectedPlayerItemId) ?? null
: null;
const tradeMode = tradeModal?.mode ?? 'buy';
const activeTradeItem = tradeMode === 'buy' ? selectedTradeNpcItem : selectedTradePlayerItem;
const activeTradeUnitPrice = tradeModal && activeTradeItem && tradeNpcState
? tradeMode === 'buy'
? getNpcPurchasePrice(activeTradeItem, tradeNpcState.affinity)
: getNpcBuybackPrice(activeTradeItem, tradeNpcState.affinity)
: 0;
const activeTradeMaxQuantity = activeTradeItem?.quantity ?? 0;
const tradeItemViews: RuntimeNpcTradeItemView[] = tradeMode === 'buy'
? npcInteraction?.trade.buyItems ?? []
: npcInteraction?.trade.sellItems ?? [];
const activeTradeView = tradeModal
? tradeItemViews.find(view =>
view.itemId === (tradeMode === 'buy'
? tradeModal.selectedNpcItemId
: tradeModal.selectedPlayerItemId),
) ?? null
: null;
const activeTradeItem = activeTradeView?.item ?? null;
const activeTradeUnitPrice = activeTradeView?.unitPrice ?? 0;
const activeTradeMaxQuantity = activeTradeView?.maxQuantity ?? 0;
const activeTradeQuantity = tradeModal
? Math.max(1, Math.min(tradeModal.selectedQuantity, Math.max(1, activeTradeMaxQuantity)))
: 1;
const activeTradeTotalPrice = activeTradeUnitPrice * activeTradeQuantity;
const canConfirmTrade = Boolean(
activeTradeItem &&
activeTradeMaxQuantity > 0 &&
activeTradeQuantity >= 1 &&
activeTradeQuantity <= activeTradeMaxQuantity &&
(tradeMode === 'sell' || gameState.playerCurrency >= activeTradeTotalPrice)
activeTradeView &&
activeTradeView.canSubmit &&
activeTradeQuantity >= 1,
);
const tradeItemList = tradeMode === 'buy'
? (tradeNpcState?.inventory ?? [])
: gameState.playerInventory;
const tradeItemList = tradeItemViews;
const tradeDetailItem = tradeDetail
? (tradeDetail.source === 'buy' ? tradeNpcState?.inventory ?? [] : gameState.playerInventory)
.find(item => item.id === tradeDetail.itemId) ?? null
? (tradeDetail.source === 'buy'
? npcInteraction?.trade.buyItems ?? []
: npcInteraction?.trade.sellItems ?? [])
.find(view => view.itemId === tradeDetail.itemId)?.item ?? null
: null;
const tradeDetailView = tradeDetail
? (tradeDetail.source === 'buy'
? npcInteraction?.trade.buyItems ?? []
: npcInteraction?.trade.sellItems ?? [])
.find(view => view.itemId === tradeDetail.itemId) ?? null
: null;
const tradeDetailUseEffect = tradeDetailItem && gameState.playerCharacter
? resolveInventoryItemUseEffect(tradeDetailItem, gameState.playerCharacter)
: null;
const tradeDetailEquipSlot = tradeDetailItem ? getEquipmentSlotFromItem(tradeDetailItem) : null;
const tradeDetailEffectText = buildTradeUseEffectText(tradeDetailUseEffect);
const giftCandidates = npcUi.giftModal
? getGiftCandidates(gameState.playerInventory, npcUi.giftModal.encounter, {
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
})
const giftCandidates: RuntimeNpcGiftItemView[] = npcUi.giftModal
? npcInteraction?.gift.items ?? []
: [];
const activeGiftView =
giftCandidates.find(item => item.itemId === npcUi.giftModal?.selectedItemId) ?? null;
const handleTradeItemClick = (item: InventoryItem) => {
const handleTradeItemClick = (view: RuntimeNpcTradeItemView) => {
if (tradeMode === 'buy') {
npcUi.selectTradeNpcItem(item.id);
setTradeDetail({ itemId: item.id, source: 'buy' });
npcUi.selectTradeNpcItem(view.itemId);
setTradeDetail({ itemId: view.itemId, source: 'buy' });
return;
}
npcUi.selectTradePlayerItem(item.id);
setTradeDetail({ itemId: item.id, source: 'sell' });
npcUi.selectTradePlayerItem(view.itemId);
setTradeDetail({ itemId: view.itemId, source: 'sell' });
};
return (
<AnimatePresence>
{tradeModal && tradeNpcState && (
{tradeModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -234,7 +229,7 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
<div className="min-w-0">
<div className="text-sm font-semibold text-white"></div>
<div className="mt-1 text-xs text-zinc-500">
{tradeModal.encounter.npcName} / {currencyName}{gameState.playerCurrency}
{npcInteraction?.npcName ?? tradeModal.encounter.npcName} / {currencyName}{npcInteraction?.playerCurrency ?? gameState.playerCurrency}
</div>
</div>
<button
@@ -285,19 +280,22 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
</div>
<div className="max-h-[42vh] space-y-2 overflow-y-auto pr-1 scrollbar-hide">
{tradeItemList.length > 0 ? tradeItemList.map(item => (
<div key={item.id}>
{tradeItemList.length > 0 ? tradeItemList.map(view => (
<div key={view.itemId}>
<TradeItemRow
item={item}
item={view.item}
selected={tradeMode === 'buy'
? tradeModal.selectedNpcItemId === item.id
: tradeModal.selectedPlayerItemId === item.id}
unitPrice={tradeMode === 'buy'
? getNpcPurchasePrice(item, tradeNpcState.affinity)
: getNpcBuybackPrice(item, tradeNpcState.affinity)}
? tradeModal.selectedNpcItemId === view.itemId
: tradeModal.selectedPlayerItemId === view.itemId}
unitPrice={view.unitPrice}
currencyName={currencyName}
onClick={() => handleTradeItemClick(item)}
onClick={() => handleTradeItemClick(view)}
/>
{!view.canSubmit && view.reason && (
<div className="mt-1 px-1 text-[10px] text-rose-300">
{view.reason}
</div>
)}
</div>
)) : (
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-500">
@@ -324,9 +322,9 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
{formatCurrency(activeTradeTotalPrice, gameState.worldType)}
</span>
</div>
{tradeMode === 'buy' && gameState.playerCurrency < activeTradeTotalPrice && (
{!activeTradeView?.canSubmit && activeTradeView?.reason && (
<div className="mt-2 text-xs text-rose-300">
{formatCurrency(activeTradeTotalPrice - gameState.playerCurrency, gameState.worldType)}
{activeTradeView.reason}
</div>
)}
</div>
@@ -411,8 +409,8 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
<div>: {getInventoryItemValue(tradeDetailItem)}</div>
<div>
{tradeDetail.source === 'buy'
? `购买价格: ${formatCurrency(getNpcPurchasePrice(tradeDetailItem, tradeNpcState?.affinity ?? 0), gameState.worldType)}`
: `回收价格: ${formatCurrency(getNpcBuybackPrice(tradeDetailItem, tradeNpcState?.affinity ?? 0), gameState.worldType)}`}
? `购买价格: ${formatCurrency(tradeDetailView?.unitPrice ?? 0, gameState.worldType)}`
: `回收价格: ${formatCurrency(tradeDetailView?.unitPrice ?? 0, gameState.worldType)}`}
</div>
</div>
</div>
@@ -489,10 +487,10 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
)}
{giftCandidates.length > 0 ? giftCandidates.map(candidate => (
<button
key={candidate.item.id}
key={candidate.itemId}
type="button"
onClick={() => npcUi.selectGiftItem(candidate.item.id)}
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${npcUi.giftModal?.selectedItemId === candidate.item.id ? 'border-rose-400/60 bg-rose-500/10' : 'border-white/5 bg-black/20 hover:border-white/15'}`}
onClick={() => npcUi.selectGiftItem(candidate.itemId)}
className={`w-full rounded-lg border px-3 py-2 text-left transition-colors ${npcUi.giftModal?.selectedItemId === candidate.itemId ? 'border-rose-400/60 bg-rose-500/10' : 'border-white/5 bg-black/20 hover:border-white/15'}`}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
@@ -500,9 +498,9 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
<div>
<div className="text-sm text-white">{candidate.item.name}</div>
<div className="mt-1 text-[10px] text-zinc-500">{candidate.item.category} / {getRarityLabel(candidate.item.rarity)}</div>
{candidate.attributeInsight?.reasonText && (
{!candidate.canSubmit && candidate.reason && (
<div className="mt-1 text-[10px] text-rose-200/80">
{candidate.attributeInsight.reasonText}
{candidate.reason}
</div>
)}
</div>
@@ -523,7 +521,7 @@ export function NpcModals({ gameState, npcUi }: NpcModalsProps) {
<button type="button" onClick={npcUi.closeGiftModal} className="pixel-nine-slice pixel-pressable px-4 py-2 text-xs text-zinc-200" style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}>
</button>
<button type="button" disabled={!npcUi.giftModal.selectedItemId} onClick={npcUi.confirmGift} className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${npcUi.giftModal.selectedItemId ? 'text-white' : 'text-zinc-600'}`} style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}>
<button type="button" disabled={!activeGiftView?.canSubmit} onClick={npcUi.confirmGift} className={`pixel-nine-slice pixel-pressable px-4 py-2 text-xs ${activeGiftView?.canSubmit ? 'text-white' : 'text-zinc-600'}`} style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 14, paddingY: 8 })}>
</button>
</div>

View File

@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
putCharacterRoleAssetWorkflow,
resolveCharacterRoleAssetWorkflow,
} from './characterAssetWorkflowPersistence';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('角色资产工坊 workflow client', () => {
it('通过后端 workflow 接口解析默认 prompt 和缓存合并结果', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
text: async () =>
JSON.stringify({
ok: true,
cache: null,
workflow: {
defaultPromptBundle: {
visualPromptText: '默认视觉',
animationPromptText: '默认动作',
scenePromptText: '默认场景',
},
visualPromptText: '默认视觉',
animationPromptText: '默认动作',
animationPromptTextByKey: { run: '默认动作' },
visualDrafts: [],
selectedVisualDraftId: '',
selectedAnimation: 'run',
},
}),
});
vi.stubGlobal('fetch', fetchMock);
const result = await resolveCharacterRoleAssetWorkflow({
characterId: 'role 01',
cacheScopeId: 'world-01',
role: {
id: 'role 01',
name: '沈砺',
title: '灰炬向导',
role: '边路同行者',
},
});
expect(result.workflow.visualPromptText).toBe('默认视觉');
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/custom-world/asset-studio/role/role%2001/workflow',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
cacheScopeId: 'world-01',
role: {
id: 'role 01',
name: '沈砺',
title: '灰炬向导',
role: '边路同行者',
},
}),
}),
);
});
it('使用 PUT 保存用户当前工坊草稿缓存', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
text: async () =>
JSON.stringify({
ok: true,
cache: { characterId: 'role-01' },
saveMessage: '已保存',
}),
});
vi.stubGlobal('fetch', fetchMock);
await putCharacterRoleAssetWorkflow({
characterId: 'role-01',
cacheScopeId: 'world-01',
visualPromptText: '视觉草稿',
animationPromptText: '动作草稿',
animationPromptTextByKey: { run: '奔跑草稿' },
visualDrafts: [],
selectedVisualDraftId: '',
selectedAnimation: 'run',
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/custom-world/asset-studio/role/role-01/workflow',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({
characterId: 'role-01',
cacheScopeId: 'world-01',
visualPromptText: '视觉草稿',
animationPromptText: '动作草稿',
animationPromptTextByKey: { run: '奔跑草稿' },
visualDrafts: [],
selectedVisualDraftId: '',
selectedAnimation: 'run',
}),
}),
);
});
});

View File

@@ -2,7 +2,10 @@ import {
ASSET_API_PATHS,
postApiJson,
} from '../../editor/shared/editorApiClient';
import { fetchJson } from '../../editor/shared/jsonClient';
import {
fetchJson,
parseApiErrorMessage,
} from '../../editor/shared/jsonClient';
export const CHARACTER_VISUAL_GENERATE_API_PATH =
ASSET_API_PATHS.characterVisualGenerate;
@@ -21,6 +24,8 @@ export const CHARACTER_ANIMATION_IMPORT_VIDEO_API_PATH =
ASSET_API_PATHS.characterAnimationImportVideo;
export const CHARACTER_ANIMATION_TEMPLATES_API_PATH =
ASSET_API_PATHS.characterAnimationTemplates;
export const ROLE_ASSET_WORKFLOW_API_PATH =
'/api/runtime/custom-world/asset-studio/role';
export type CharacterVisualSourceMode =
| 'text-to-image'
@@ -61,6 +66,48 @@ export type CharacterAssetWorkflowCache = {
updatedAt?: string;
};
export type CharacterAssetRolePromptInput = {
id: string;
name?: string;
title?: string;
role?: string;
visualDescription?: string;
actionDescription?: string;
sceneVisualDescription?: string;
description?: string;
backstory?: string;
personality?: string;
motivation?: string;
combatStyle?: string;
tags?: string[];
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, unknown> | null;
};
export type CharacterRolePromptBundle = {
visualPromptText: string;
animationPromptText: string;
scenePromptText: string;
};
export type CharacterRoleAssetWorkflow = {
role: CharacterAssetRolePromptInput;
defaultPromptBundle: CharacterRolePromptBundle;
visualPromptText: string;
animationPromptText: string;
animationPromptTextByKey: Record<string, string>;
visualDrafts: CharacterVisualDraft[];
selectedVisualDraftId: string;
selectedAnimation: string;
imageSrc?: string;
generatedVisualAssetId?: string;
generatedAnimationSetId?: string;
animationMap?: Record<string, unknown> | null;
updatedAt?: string;
};
export type CharacterVisualGenerationPayload = {
characterId: string;
sourceMode: Exclude<CharacterVisualSourceMode, 'upload'>;
@@ -185,6 +232,60 @@ export async function saveCharacterWorkflowCache(
);
}
export async function resolveCharacterRoleAssetWorkflow(payload: {
characterId: string;
cacheScopeId?: string;
role: CharacterAssetRolePromptInput;
}) {
const { characterId, cacheScopeId, role } = payload;
return postApiJson<{
ok: true;
cache: CharacterAssetWorkflowCache | null;
workflow: CharacterRoleAssetWorkflow;
}>(
`${ROLE_ASSET_WORKFLOW_API_PATH}/${encodeURIComponent(characterId)}/workflow`,
{
cacheScopeId,
role,
},
'读取角色资产工坊工作流失败',
);
}
export async function putCharacterRoleAssetWorkflow(
payload: CharacterAssetWorkflowCache,
) {
const url = `${ROLE_ASSET_WORKFLOW_API_PATH}/${encodeURIComponent(payload.characterId)}/workflow`;
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const responseText = await response.text();
if (!response.ok) {
throw new Error(
parseApiErrorMessage(responseText, '保存角色资产工坊缓存失败'),
);
}
return responseText
? (JSON.parse(responseText) as {
ok: true;
cache: CharacterAssetWorkflowCache;
saveMessage: string;
})
: ({
ok: true,
cache: payload,
saveMessage: '',
} as {
ok: true;
cache: CharacterAssetWorkflowCache;
saveMessage: string;
});
}
export async function fetchCharacterVisualJobStatus(taskId: string) {
return fetchJson<CharacterAssetJobStatus>(
`${CHARACTER_VISUAL_JOB_API_PATH}/${encodeURIComponent(taskId)}`,

View File

@@ -1,51 +0,0 @@
import { describe, expect, it } from 'vitest';
import { buildDefaultRolePromptBundle } from './customWorldRolePromptDefaults';
describe('buildDefaultRolePromptBundle', () => {
it('uses model-generated role descriptions directly', () => {
const result = buildDefaultRolePromptBundle({
name: '沈砺',
title: '灰炬向导',
role: '边路同行者',
visualDescription:
'灰黑短斗篷压着风痕,肩侧挂着旧路标与短弓,整个人像常年在裂潮边路里行走的人。',
actionDescription:
'起手先观察风向和站位,再用短弓牵制后迅速贴近补刀,动作克制但很准。',
sceneVisualDescription:
'他常出现的边路哨点铺着潮湿石板,旧灯火和风旗一直在晃,空气里带着将散未散的盐雾。',
description: '熟悉裂潮边路的灰炬向导。',
});
expect(result.visualPromptText).toBe(
'灰黑短斗篷压着风痕,肩侧挂着旧路标与短弓,整个人像常年在裂潮边路里行走的人。',
);
expect(result.animationPromptText).toBe(
'起手先观察风向和站位,再用短弓牵制后迅速贴近补刀,动作克制但很准。',
);
expect(result.scenePromptText).toBe(
'他常出现的边路哨点铺着潮湿石板,旧灯火和风旗一直在晃,空气里带着将散未散的盐雾。',
);
});
it('falls back to existing entity descriptions without assembling new rules', () => {
const result = buildDefaultRolePromptBundle({
name: '顾潮音',
title: '港口守望者',
role: '场景角色',
description: '总在潮雾港高处盯着来往船影的守望者。',
personality: '寡言、敏锐、先看人再开口。',
combatStyle: '长枪封线后借高差压制。',
motivation: '想在港口旧秩序彻底崩掉前找出新的站位。',
backstory: '他把许多没说出口的旧案痕迹留在港口高处。',
tags: ['潮雾港', '守望', '旧案'],
});
expect(result.visualPromptText).toBe('总在潮雾港高处盯着来往船影的守望者。');
expect(result.animationPromptText).toBe('长枪封线后借高差压制。');
expect(result.scenePromptText).toBe('他把许多没说出口的旧案痕迹留在港口高处。');
expect(result.visualPromptText).not.toContain('经典横版像素动作角色');
expect(result.visualPromptText).not.toContain('深色粗轮廓配合清晰大色块');
expect(result.visualPromptText).not.toContain('提示词');
});
});

View File

@@ -1 +0,0 @@
export * from '../../prompts/customWorldRolePromptDefaults';

View File

@@ -60,9 +60,17 @@ function createSession(): BigFishSessionSnapshotResponse {
level: 1,
name: '荧潮幼体',
oneLineFantasy: '在深海荧光裂谷中寻找第一个同伴。',
textDescription:
'荧潮幼体是深海谜境里的初始个体,体型最小,会先谨慎试探并寻找可吞噬目标。',
silhouetteDirection: '圆润鱼苗',
sizeRatio: 1,
visualDescription:
'带有浅青色荧光纹路的小型鱼苗,轮廓圆润,呈现弱小但灵动的开局形象。',
visualPromptSeed: '深海荧光幼体',
idleMotionDescription:
'待机时轻微漂浮,尾鳍做小幅摆动,像是在观察周围海流。',
moveMotionDescription:
'移动时身体前探,尾鳍清晰摆尾推进,呈现连续游动感。',
motionPromptSeed: '轻微摆尾',
mergeSourceLevel: null,
preyWindow: [1],
@@ -147,6 +155,34 @@ describe('BigFishResultView', () => {
expect(screen.getByAltText('深海谜境 场地背景')).toBeTruthy();
});
test('uses level descriptions as default prompt content in asset studio', () => {
render(
<BigFishResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={() => {}}
onStartTestRun={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '主图' }));
expect(
screen.getByText('带有浅青色荧光纹路的小型鱼苗,轮廓圆润,呈现弱小但灵动的开局形象。'),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
fireEvent.click(screen.getByRole('button', { name: '待机' }));
expect(
screen.getByText('待机时轻微漂浮,尾鳍做小幅摆动,像是在观察周围海流。'),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
fireEvent.click(screen.getByRole('button', { name: '移动' }));
expect(
screen.getByText('移动时身体前探,尾鳍清晰摆尾推进,呈现连续游动感。'),
).toBeTruthy();
});
test('shows publish failures in a dismissible modal', () => {
const onDismissError = vi.fn();

View File

@@ -128,8 +128,10 @@ function BigFishAssetStudioModal({
target.kind === 'stage_background'
? draft.background.backgroundPromptSeed
: target.kind === 'level_main_image'
? target.level.visualPromptSeed
: `${target.level.motionPromptSeed} / ${target.motionKey}`;
? target.level.visualDescription || target.level.visualPromptSeed
: target.motionKey === 'move_swim'
? target.level.moveMotionDescription || target.level.motionPromptSeed
: target.level.idleMotionDescription || target.level.motionPromptSeed;
const execute = () => {
if (target.kind === 'stage_background') {
@@ -162,7 +164,7 @@ function BigFishAssetStudioModal({
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
{target.kind === 'stage_background'
? draft.background.theme
: target.level.oneLineFantasy}
: target.level.textDescription || target.level.oneLineFantasy}
</div>
</div>
<div className="space-y-4 px-4 py-4">

View File

@@ -79,6 +79,12 @@ export function PlatformEntryCreationTypeModal({
return null;
}
// 平台入口只渲染当前允许展示的创作类型;
// 被隐藏的玩法仍保留既有实现与路由,不在这里删除能力本体。
const visibleCreationTypes = PLATFORM_CREATION_TYPES.filter(
(item) => !item.hidden,
);
return (
<UnifiedModal
open={isOpen}
@@ -89,7 +95,7 @@ export function PlatformEntryCreationTypeModal({
size="lg"
>
<div className="grid gap-3 sm:grid-cols-5">
{PLATFORM_CREATION_TYPES.map((item) => (
{visibleCreationTypes.map((item) => (
<CreationTypeCard
key={item.id}
item={item}

View File

@@ -95,7 +95,9 @@ import {
} from '../../services/puzzle-runtime';
import {
dragLocalPuzzlePiece,
isLocalPuzzleRun,
startLocalPuzzleRun,
submitLocalPuzzleLeaderboard,
swapLocalPuzzlePieces,
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works';
@@ -126,7 +128,6 @@ import {
} from './PlatformEntryHomeView';
import {
buildCreationHubFallbackItems,
normalizeAgentBackedProfile,
resolveRpgCreationErrorMessage,
} from './platformEntryShared';
import type { PlatformEntryFlowShellProps } from './platformEntryTypes';
@@ -590,7 +591,6 @@ export function PlatformEntryFlowShellImpl({
const autosaveCoordinator = useRpgCreationResultAutosave({
selectionStage,
activeAgentSessionId: sessionController.activeAgentSessionId,
agentSession: sessionController.agentSession,
generatedCustomWorldProfile: sessionController.generatedCustomWorldProfile,
isAgentDraftResultView: sessionController.isAgentDraftResultView,
userId: authUi?.user?.id,
@@ -602,8 +602,9 @@ export function PlatformEntryFlowShellImpl({
refreshCustomWorldWorks: platformBootstrap.refreshCustomWorldWorks,
persistAgentUiState: sessionController.persistAgentUiState,
syncAgentSessionSnapshot: sessionController.syncAgentSessionSnapshot,
buildDraftResultProfile: (session) =>
rpgCreationPreviewAdapter.buildPreviewFromSession(session),
syncAgentCreationResultView: sessionController.syncAgentCreationResultView,
buildDraftResultProfile: (view) =>
rpgCreationPreviewAdapter.buildPreviewFromResultView(view),
});
const detailNavigation = usePlatformEntryLibraryDetail({
@@ -630,9 +631,9 @@ export function PlatformEntryFlowShellImpl({
refreshCustomWorldWorks: platformBootstrap.refreshCustomWorldWorks,
refreshPublishedGallery: platformBootstrap.refreshPublishedGallery,
persistAgentUiState: sessionController.persistAgentUiState,
syncAgentSessionSnapshot: sessionController.syncAgentSessionSnapshot,
buildDraftResultProfile: (session) =>
rpgCreationPreviewAdapter.buildPreviewFromSession(session),
syncAgentCreationResultView: sessionController.syncAgentCreationResultView,
buildDraftResultProfile: (view) =>
rpgCreationPreviewAdapter.buildPreviewFromResultView(view),
suppressAgentDraftResultAutoOpen:
sessionController.suppressAgentDraftResultAutoOpen,
releaseAgentDraftResultAutoOpenSuppression:
@@ -646,9 +647,9 @@ export function PlatformEntryFlowShellImpl({
isAgentDraftResultView: sessionController.isAgentDraftResultView,
activeAgentSessionId: sessionController.activeAgentSessionId,
generatedCustomWorldProfile: sessionController.generatedCustomWorldProfile,
agentSessionProfile: sessionController.agentDraftResultProfile,
agentSession: sessionController.agentSession,
handleCustomWorldSelect,
syncAgentDraftResultProfile:
autosaveCoordinator.syncAgentDraftResultProfile,
executePublishWorld: async () => {
const latestSession = await autosaveCoordinator.executeAgentActionAndWait(
{
@@ -664,6 +665,7 @@ export function PlatformEntryFlowShellImpl({
]);
return latestSession;
},
syncAgentCreationResultView: sessionController.syncAgentCreationResultView,
setGeneratedCustomWorldProfile:
sessionController.setGeneratedCustomWorldProfile,
});
@@ -1252,7 +1254,9 @@ export function PlatformEntryFlowShellImpl({
}
setBigFishRun((currentRun) =>
currentRun ? advanceLocalBigFishRuntimeRun(currentRun, payload) : currentRun,
currentRun
? advanceLocalBigFishRuntimeRun(currentRun, payload)
: currentRun,
);
},
[bigFishRun],
@@ -1308,13 +1312,21 @@ export function PlatformEntryFlowShellImpl({
nickname: authUi?.user?.displayName?.trim() || '玩家',
};
if (isLocalPuzzleRun(puzzleRun)) {
setPuzzleRun(submitLocalPuzzleLeaderboard(puzzleRun, payload.nickname));
setIsPuzzleLeaderboardBusy(false);
return;
}
void submitPuzzleLeaderboard(puzzleRun.runId, payload)
.then(({ run }) => {
setPuzzleRun(run);
})
.catch((error) => {
submittedPuzzleLeaderboardKeysRef.current.delete(submitKey);
setPuzzleError(resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。'));
setPuzzleError(
resolvePuzzleErrorMessage(error, '提交拼图排行榜失败。'),
);
})
.finally(() => {
setIsPuzzleLeaderboardBusy(false);
@@ -1684,25 +1696,25 @@ export function PlatformEntryFlowShellImpl({
const startBigFishRunFromWork = useCallback(
(item: BigFishWorkSummary) => {
const sessionId = item.sourceSessionId?.trim();
if (!sessionId) {
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
return;
}
const sessionId = item.sourceSessionId?.trim();
if (!sessionId) {
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
return;
}
const publicWorkCode = buildBigFishPublicWorkCode(item.sourceSessionId);
setBigFishError(null);
bigFishFlow.setSession(null);
setBigFishRuntimeShare({
title: item.title,
publicWorkCode,
});
setBigFishRun(startLocalBigFishRuntimeRun({ work: item }));
setSelectionStage('big-fish-runtime');
pushAppHistoryPath(
buildPublicWorkStagePath('big-fish-runtime', publicWorkCode),
);
},
const publicWorkCode = buildBigFishPublicWorkCode(item.sourceSessionId);
setBigFishError(null);
bigFishFlow.setSession(null);
setBigFishRuntimeShare({
title: item.title,
publicWorkCode,
});
setBigFishRun(startLocalBigFishRuntimeRun({ work: item }));
setSelectionStage('big-fish-runtime');
pushAppHistoryPath(
buildPublicWorkStagePath('big-fish-runtime', publicWorkCode),
);
},
[bigFishFlow, setSelectionStage],
);
@@ -2532,17 +2544,17 @@ export function PlatformEntryFlowShellImpl({
<Suspense
fallback={<LazyPanelFallback label="正在加载拼图玩法..." />}
>
<PuzzleRuntimeShell
run={puzzleRun}
isBusy={
isPuzzleBusy ||
isPuzzleNextLevelGenerating ||
isPuzzleLeaderboardBusy
}
error={puzzleError}
onBack={() => {
setSelectionStage(puzzleRuntimeReturnStage);
}}
<PuzzleRuntimeShell
run={puzzleRun}
isBusy={
isPuzzleBusy ||
isPuzzleNextLevelGenerating ||
isPuzzleLeaderboardBusy
}
error={puzzleError}
onBack={() => {
setSelectionStage(puzzleRuntimeReturnStage);
}}
onSwapPieces={(payload) => {
void swapPuzzlePiecesInRun(payload);
}}
@@ -2627,9 +2639,7 @@ export function PlatformEntryFlowShellImpl({
progressLabel=""
error={resultViewError}
onProfileChange={(profile) => {
sessionController.setGeneratedCustomWorldProfile(
normalizeAgentBackedProfile(profile),
);
sessionController.setGeneratedCustomWorldProfile(profile);
}}
onBack={
sessionController.isAgentDraftResultView
@@ -2699,23 +2709,25 @@ export function PlatformEntryFlowShellImpl({
kind === 'landmark'
? 'generate_landmarks'
: 'generate_characters';
const latestSession =
await autosaveCoordinator.executeAgentActionAndWait(
{
action,
count: 1,
...(kind === 'playable'
? { roleType: 'playable' as const }
: kind === 'story'
? { roleType: 'story' as const }
: {}),
},
await autosaveCoordinator.executeAgentActionAndWait({
action,
count: 1,
...(kind === 'playable'
? { roleType: 'playable' as const }
: kind === 'story'
? { roleType: 'story' as const }
: {}),
});
const latestView =
sessionController.activeAgentSessionId
? await sessionController.syncAgentCreationResultView(
sessionController.activeAgentSessionId,
)
: null;
const latestProfile =
rpgCreationPreviewAdapter.buildPreviewFromResultView(
latestView,
);
const latestProfile = latestSession
? rpgCreationPreviewAdapter.buildPreviewFromSession(
latestSession,
)
: null;
if (latestProfile) {
sessionController.setGeneratedCustomWorldProfile(
latestProfile,
@@ -2729,17 +2741,21 @@ export function PlatformEntryFlowShellImpl({
sessionController.isAgentDraftResultView
? async (kind, ids) => {
if (ids.length === 0) return;
const latestSession =
await autosaveCoordinator.executeAgentActionAndWait(
kind === 'story'
? { action: 'delete_characters', roleIds: ids }
: { action: 'delete_landmarks', sceneIds: ids },
await autosaveCoordinator.executeAgentActionAndWait(
kind === 'story'
? { action: 'delete_characters', roleIds: ids }
: { action: 'delete_landmarks', sceneIds: ids },
);
const latestView =
sessionController.activeAgentSessionId
? await sessionController.syncAgentCreationResultView(
sessionController.activeAgentSessionId,
)
: null;
const latestProfile =
rpgCreationPreviewAdapter.buildPreviewFromResultView(
latestView,
);
const latestProfile = latestSession
? rpgCreationPreviewAdapter.buildPreviewFromSession(
latestSession,
)
: null;
if (latestProfile) {
sessionController.setGeneratedCustomWorldProfile(
latestProfile,

View File

@@ -11,10 +11,12 @@ export type PlatformCreationTypeCard = {
subtitle: string;
badge: string;
locked: boolean;
hidden?: boolean;
};
/**
* 创作页与类型弹层共用同一份模板元数据,避免多入口文案和可用状态漂移。
* `hidden` 只控制平台入口是否展示,不影响既有玩法链路和路由能力。
*/
export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [
{
@@ -30,6 +32,7 @@ export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [
subtitle: '实时成长玩法',
badge: '可创建',
locked: false,
hidden: true,
},
{
id: 'puzzle',

View File

@@ -4,6 +4,5 @@
*/
export {
buildCreationHubFallbackItems,
normalizeAgentBackedProfile,
resolveRpgCreationErrorMessage,
} from '../rpg-entry/rpgEntryShared';

View File

@@ -1,6 +1,7 @@
import type {
CustomWorldAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { CustomWorldProfile } from '../../types';
@@ -37,6 +38,7 @@ export type CustomWorldAutoSaveState = 'idle' | 'saving' | 'saved' | 'error';
export type SyncedAgentDraftResult = {
session: CustomWorldAgentSessionSnapshot | null;
profile: CustomWorldProfile | null;
view?: RpgCreationResultView | null;
};
export type PlatformEntryFlowShellProps = {

View File

@@ -18,10 +18,9 @@ import { readFileAsDataUrl } from '../asset-studio/characterAssetWorkflowModel';
import {
type CharacterAssetWorkflowCache,
type CharacterVisualDraft,
fetchCharacterWorkflowCache,
saveCharacterWorkflowCache,
putCharacterRoleAssetWorkflow,
resolveCharacterRoleAssetWorkflow,
} from '../asset-studio/characterAssetWorkflowPersistence';
import { buildDefaultRolePromptBundle } from '../asset-studio/customWorldRolePromptDefaults';
import { buildProjectPixelStyleReferenceBoard } from '../asset-studio/projectPixelStyleReference';
import { useAuthUi } from '../auth/AuthUiContext';
import { CharacterAnimator } from '../CharacterAnimator';
@@ -51,42 +50,6 @@ function clampAnimationPlaybackRate(value: number) {
);
}
function buildDefaultAnimationPromptTextByKey(defaultText: string) {
return CORE_ACTIONS.reduce<Partial<Record<AnimationState, string>>>(
(result, action) => ({
...result,
[action.animation]: defaultText,
}),
{},
);
}
function pickCachedAnimationPromptTextByKey(
cache: CharacterAssetWorkflowCache,
fallbackText: string,
preferFreshRoleText: boolean,
) {
const fromCache = cache.animationPromptTextByKey ?? {};
return CORE_ACTIONS.reduce<Partial<Record<AnimationState, string>>>(
(result, action) => {
const cachedText = fromCache[action.animation]?.trim();
const legacyText = cache.animationPromptText?.trim();
return {
...result,
[action.animation]: preferFreshRoleText
? fallbackText
: cachedText && !isLegacyGeneratedActionDescription(cachedText)
? cachedText
: legacyText && !isLegacyGeneratedActionDescription(legacyText)
? legacyText
: fallbackText,
};
},
{},
);
}
function roundAnimationFps(value: number) {
return Math.round(value * 100) / 100;
}
@@ -321,37 +284,6 @@ function buildRoleCharacterBrief(
.join('\n');
}
function isLegacyGeneratedVisualDescription(value: string) {
const normalized = value.trim();
if (!normalized) {
return false;
}
return [
'2D 横版 RPG',
'纯绿色绿幕',
'2 到 2.5 头身',
'深色粗轮廓',
'身体整体朝右',
'脚底完整可见',
].some((marker) => normalized.includes(marker));
}
function isLegacyGeneratedActionDescription(value: string) {
const normalized = value.trim();
if (!normalized) {
return false;
}
return [
'动作气质参考:',
'发力起手明确',
'收招利落',
'动作表现偏向',
'起手克制',
].some((marker) => normalized.includes(marker));
}
function mergeRole<T extends EditableCustomWorldRole>(
role: T,
patch: Partial<T>,
@@ -561,13 +493,9 @@ export function RpgCreationRoleAssetStudioModal({
role.visualDescription,
],
);
const initialPromptBundle = useMemo(
() => buildDefaultRolePromptBundle(baseRole),
[baseRole],
);
const [visualPromptText, setVisualPromptText] = useState(
initialPromptBundle.visualPromptText,
);
const [defaultAnimationPromptText, setDefaultAnimationPromptText] =
useState('');
const [visualPromptText, setVisualPromptText] = useState('');
const [referenceImageDataUrls, setReferenceImageDataUrls] = useState<
string[]
>([]);
@@ -586,11 +514,7 @@ export function RpgCreationRoleAssetStudioModal({
);
const [animationPromptTextByKey, setAnimationPromptTextByKey] = useState<
Partial<Record<AnimationState, string>>
>(() =>
buildDefaultAnimationPromptTextByKey(
initialPromptBundle.animationPromptText,
),
);
>({});
const [animationStatusByKey, setAnimationStatusByKey] = useState<
Partial<Record<AnimationState, string | null>>
>({});
@@ -655,7 +579,7 @@ export function RpgCreationRoleAssetStudioModal({
CORE_ACTIONS[0]!;
const animationPromptText =
animationPromptTextByKey[selectedAnimation] ??
initialPromptBundle.animationPromptText;
defaultAnimationPromptText;
const previewCharacter = useMemo(
() =>
buildAnimationPreviewCharacter({
@@ -727,12 +651,9 @@ export function RpgCreationRoleAssetStudioModal({
useEffect(() => {
let cancelled = false;
setWorkingRole(baseRole);
setVisualPromptText(initialPromptBundle.visualPromptText);
setAnimationPromptTextByKey(
buildDefaultAnimationPromptTextByKey(
initialPromptBundle.animationPromptText,
),
);
setDefaultAnimationPromptText('');
setVisualPromptText('');
setAnimationPromptTextByKey({});
setReferenceImageDataUrls([]);
setVisualDrafts([]);
setSelectedVisualDraftId('');
@@ -744,50 +665,52 @@ export function RpgCreationRoleAssetStudioModal({
setSaveStatus(null);
setIsHydratingCache(true);
void fetchCharacterWorkflowCache(baseRole.id, cacheScopeId)
void resolveCharacterRoleAssetWorkflow({
characterId: baseRole.id,
cacheScopeId,
role: {
...baseRole,
animationMap:
(baseRole.animationMap as Record<string, unknown> | undefined) ??
null,
},
})
.then((result) => {
if (cancelled || !result.cache) {
if (cancelled) {
return;
}
const cache = result.cache;
if (cacheScopeId && cache.cacheScopeId !== cacheScopeId) {
return;
}
const { workflow } = result;
const nextRole = mergeRole(baseRole, {
imageSrc: cache.imageSrc ?? baseRole.imageSrc,
imageSrc: workflow.imageSrc ?? baseRole.imageSrc,
generatedVisualAssetId:
cache.generatedVisualAssetId ?? baseRole.generatedVisualAssetId,
workflow.generatedVisualAssetId ?? baseRole.generatedVisualAssetId,
generatedAnimationSetId:
cache.generatedAnimationSetId ?? baseRole.generatedAnimationSetId,
workflow.generatedAnimationSetId ??
baseRole.generatedAnimationSetId,
animationMap:
(cache.animationMap as EditableCustomWorldRole['animationMap']) ??
(workflow.animationMap as EditableCustomWorldRole['animationMap']) ??
baseRole.animationMap,
});
setWorkingRole(nextRole);
setVisualPromptText(
!baseRole.visualDescription?.trim() &&
cache.visualPromptText &&
!isLegacyGeneratedVisualDescription(cache.visualPromptText)
? cache.visualPromptText
: initialPromptBundle.visualPromptText,
setDefaultAnimationPromptText(
workflow.defaultPromptBundle.animationPromptText,
);
setVisualPromptText(workflow.visualPromptText);
setAnimationPromptTextByKey(
pickCachedAnimationPromptTextByKey(
cache,
initialPromptBundle.animationPromptText,
Boolean(baseRole.actionDescription?.trim()),
),
workflow.animationPromptTextByKey as Partial<
Record<AnimationState, string>
>,
);
setVisualDrafts(cache.visualDrafts ?? []);
setVisualDrafts(workflow.visualDrafts ?? []);
setSelectedVisualDraftId(
cache.selectedVisualDraftId || cache.visualDrafts?.[0]?.id || '',
workflow.selectedVisualDraftId || workflow.visualDrafts?.[0]?.id || '',
);
setSelectedAnimation(
CORE_ACTIONS.some(
(item) => item.animation === cache.selectedAnimation,
(item) => item.animation === workflow.selectedAnimation,
)
? (cache.selectedAnimation as AnimationState)
? (workflow.selectedAnimation as AnimationState)
: (CORE_ACTIONS[0]?.animation ?? AnimationState.IDLE),
);
})
@@ -801,7 +724,7 @@ export function RpgCreationRoleAssetStudioModal({
return () => {
cancelled = true;
};
}, [baseRole, cacheScopeId, initialPromptBundle, roleSnapshotKey]);
}, [baseRole, cacheScopeId, roleSnapshotKey]);
useEffect(() => {
if (isHydratingCache) {
@@ -826,7 +749,7 @@ export function RpgCreationRoleAssetStudioModal({
unknown
> | null,
};
void saveCharacterWorkflowCache(payload).catch(() => undefined);
void putCharacterRoleAssetWorkflow(payload).catch(() => undefined);
}, 350);
return () => {

View File

@@ -8,6 +8,7 @@ import { beforeEach, expect, test, vi } from 'vitest';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { ApiClientError } from '../../services/apiClient';
import type { AuthUser } from '../../services/authService';
@@ -32,6 +33,7 @@ import {
createRpgCreationSession,
executeRpgCreationAction,
getRpgCreationOperation,
getRpgCreationResultView,
getRpgCreationSession,
listRpgCreationWorks,
streamRpgCreationMessage,
@@ -101,6 +103,7 @@ vi.mock('../../services/rpg-creation', () => ({
createRpgCreationSession: vi.fn(),
executeRpgCreationAction: vi.fn(),
getRpgCreationOperation: vi.fn(),
getRpgCreationResultView: vi.fn(),
getRpgCreationSession: vi.fn(),
listRpgCreationWorks: vi.fn(),
streamRpgCreationMessage: vi.fn(),
@@ -523,6 +526,48 @@ const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = {
},
};
function buildResultViewForSession(
session: CustomWorldAgentSessionSnapshot,
): RpgCreationResultView {
const profile = session.resultPreview?.preview ?? null;
const isResultStage =
session.stage === 'object_refining' ||
session.stage === 'visual_refining' ||
session.stage === 'long_tail_review' ||
session.stage === 'ready_to_publish' ||
session.stage === 'published';
return {
session,
profile,
profileSource: profile ? 'result_preview' : 'none',
targetStage: profile && isResultStage
? 'custom-world-result'
: session.stage === 'error'
? 'custom-world-generating'
: 'agent-workspace',
generationViewSource: session.stage === 'error'
? 'agent-draft-foundation'
: null,
resultViewSource: profile && isResultStage ? 'agent-draft' : null,
canAutosaveLibrary: Boolean(profile && isResultStage),
canSyncResultProfile:
session.stage === 'object_refining' ||
session.stage === 'visual_refining' ||
session.stage === 'long_tail_review' ||
session.stage === 'ready_to_publish',
publishReady: Boolean(session.resultPreview?.publishReady),
canEnterWorld: Boolean(session.resultPreview?.canEnterWorld),
blockerCount: session.resultPreview?.blockers?.length ?? 0,
recoveryAction: profile && isResultStage
? 'open_result'
: session.stage === 'error'
? 'resume_generation'
: 'continue_agent',
recoveryReason: null,
};
}
type TestAuthValue = {
user: AuthUser | null;
canAccessProtectedData: boolean;
@@ -573,8 +618,11 @@ function TestWrapper({
onContinueGame?: (snapshot?: HydratedSavedGameSnapshot | null) => void;
onSelectWorld?: RpgEntryFlowShellProps['handleCustomWorldSelect'];
} = {}) {
const [selectionStage, setSelectionStage] =
useState<SelectionStage>('platform');
const [selectionStage, setSelectionStage] = useState<SelectionStage>(() =>
window.location.pathname === '/creation/rpg/agent'
? 'agent-workspace'
: 'platform',
);
const content = (
<RpgEntryFlowShell
@@ -600,7 +648,7 @@ function TestWrapper({
}
beforeEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
window.history.replaceState(null, '', '/');
window.sessionStorage.clear();
window.localStorage.clear();
@@ -667,6 +715,9 @@ beforeEach(() => {
vi.mocked(createRpgCreationSession).mockResolvedValue({
session: mockSession,
});
vi.mocked(getRpgCreationResultView).mockImplementation(async () =>
buildResultViewForSession(mockSession),
);
vi.mocked(createBigFishCreationSession).mockResolvedValue({
session: {
sessionId: 'big-fish-session-1',
@@ -757,9 +808,17 @@ beforeEach(() => {
level: 1,
name: '微光孢子',
oneLineFantasy: '像发光尘埃一样在深海漂浮。',
textDescription:
'微光孢子是机械深海生态中的起始个体,体型最小,会先漂浮试探并寻找可吞并目标。',
silhouetteDirection: '圆润微型机械球',
sizeRatio: 1,
visualDescription:
'带有浅色发光核心的微型机械鱼苗或孢子体,轮廓圆润,表现出弱小但灵动的初始形象。',
visualPromptSeed: 'deep sea glowing mechanical spore',
idleMotionDescription:
'待机时轻轻漂浮,身体和尾部做小幅摆动,像在适应深海水流。',
moveMotionDescription:
'移动时核心前探,尾部快速摆动推进,带出轻盈的游动轨迹。',
motionPromptSeed: 'soft floating mechanical spore',
mergeSourceLevel: null,
preyWindow: [1],
@@ -1168,6 +1227,9 @@ test('create tab opens compiled agent draft in result refinement page', async ()
},
]);
vi.mocked(getRpgCreationSession).mockResolvedValue(compiledAgentDraftSession);
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession(compiledAgentDraftSession),
);
render(<TestWrapper withAuth />);
@@ -1271,6 +1333,13 @@ test('create tab resumes agent workspace when session has no draft profile even
stage: 'clarifying',
draftProfile: null,
});
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession({
...mockSession,
stage: 'clarifying',
draftProfile: null,
}),
);
render(<TestWrapper withAuth />);
@@ -1316,13 +1385,13 @@ test('opening a compiled draft with a missing agent session falls back to create
])
.mockResolvedValueOnce([]);
vi.mocked(getRpgCreationSession).mockRejectedValueOnce(
new ApiClientError({
message: 'custom world agent session not found',
status: 404,
code: 'NOT_FOUND',
}),
);
const missingSessionError = new ApiClientError({
message: 'custom world agent session not found',
status: 404,
code: 'NOT_FOUND',
});
vi.mocked(getRpgCreationSession).mockRejectedValueOnce(missingSessionError);
vi.mocked(getRpgCreationResultView).mockRejectedValueOnce(missingSessionError);
render(<TestWrapper withAuth />);
@@ -1699,6 +1768,22 @@ test('restoring an agent workspace ignores a stored session owned by another use
expect(window.location.search).toBe('');
});
test('restoring an agent workspace ignores explicit session pointer without local owner after login', async () => {
window.history.replaceState(
null,
'',
'/?customWorldSessionId=custom-world-agent-session-legacy',
);
render(<TestWrapper withAuth />);
await waitFor(() => {
expect(window.location.search).toBe('');
});
expect(getRpgCreationSession).not.toHaveBeenCalled();
});
test('refreshing platform home ignores stored agent workspace pointer without explicit restore path', async () => {
window.sessionStorage.setItem(
'genarrative.custom-world-agent-ui.v1',
@@ -2243,6 +2328,15 @@ test('failed draft work continues on generation progress view instead of agent w
},
]);
vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession);
vi.mocked(getRpgCreationResultView).mockResolvedValue({
...buildResultViewForSession({
...mockSession,
stage: 'error',
}),
targetStage: 'custom-world-generating',
generationViewSource: 'agent-draft-foundation',
recoveryAction: 'resume_generation',
});
render(<TestWrapper withAuth />);
@@ -2267,6 +2361,9 @@ test('existing draft sessions open result page refinement instead of agent dialo
error: null,
});
vi.mocked(getRpgCreationSession).mockResolvedValue(compiledAgentDraftSession);
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession(compiledAgentDraftSession),
);
render(<TestWrapper withAuth />);
@@ -2306,7 +2403,7 @@ test('agent result view shows publish blocker dialog before publish action when
progress: 100,
error: null,
});
vi.mocked(getRpgCreationSession).mockResolvedValue({
const blockedSession = {
...compiledAgentDraftSession,
resultPreview: {
...compiledAgentDraftSession.resultPreview!,
@@ -2319,7 +2416,11 @@ test('agent result view shows publish blocker dialog before publish action when
},
],
},
});
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getRpgCreationSession).mockResolvedValue(blockedSession);
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession(blockedSession),
);
render(<TestWrapper withAuth />);
@@ -2424,6 +2525,11 @@ test('agent draft result publishes to gallery from publish panel', async () => {
vi.mocked(getRpgCreationSession).mockImplementation(async () =>
hasPublishedWorld ? publishedSession : publishReadyDraftSession,
);
vi.mocked(getRpgCreationResultView).mockImplementation(async () =>
buildResultViewForSession(
hasPublishedWorld ? publishedSession : publishReadyDraftSession,
),
);
function PublishFlowWrapper() {
const [selectionStage, setSelectionStage] =
@@ -2482,7 +2588,7 @@ test('agent draft result test button enters current draft without publish gate',
progress: 100,
error: null,
});
vi.mocked(getRpgCreationSession).mockResolvedValue({
const testDraftSession = {
...compiledAgentDraftSession,
stage: 'ready_to_publish',
resultPreview: {
@@ -2497,7 +2603,11 @@ test('agent draft result test button enters current draft without publish gate',
},
],
},
});
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getRpgCreationSession).mockResolvedValue(testDraftSession);
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession(testDraftSession),
);
function TestDraftWrapper() {
const [selectionStage, setSelectionStage] =
@@ -2582,7 +2692,7 @@ test('agent result view does not keep legacy publish blockers when preview uses
progress: 100,
error: null,
});
vi.mocked(getRpgCreationSession).mockResolvedValue({
const publishGateSession = {
...compiledAgentDraftSession,
stage: 'ready_to_publish',
resultPreview: {
@@ -2650,7 +2760,11 @@ test('agent result view does not keep legacy publish blockers when preview uses
],
},
},
});
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getRpgCreationSession).mockResolvedValue(publishGateSession);
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession(publishGateSession),
);
render(<TestWrapper withAuth />);
@@ -2809,6 +2923,9 @@ test('agent draft result back button returns to creation hub without syncing res
},
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getRpgCreationSession).mockResolvedValue(resultSession);
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession(resultSession),
);
render(<TestWrapper withAuth />);
@@ -2840,7 +2957,7 @@ test('agent draft result back button returns to creation hub without syncing res
expect(screen.queryByText('世界档案')).toBeNull();
});
test('agent draft result auto-save persists the latest profile from session draft without result sync action', async () => {
test('agent draft result auto-save syncs result profile before persisting backend result view', async () => {
const user = userEvent.setup();
const syncedSession = {
@@ -2940,6 +3057,35 @@ test('agent draft result auto-save persists the latest profile from session draf
},
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getRpgCreationSession).mockResolvedValue(syncedSession);
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession(syncedSession),
);
vi.mocked(executeRpgCreationAction).mockImplementation(async (_, payload) => ({
operation: {
operationId:
payload.action === 'sync_result_profile'
? 'operation-sync-result-profile-1'
: 'operation-draft-foundation-1',
type: payload.action,
status: 'queued',
phaseLabel: '已接收请求',
phaseDetail:
payload.action === 'sync_result_profile'
? '正在同步结果页档案。'
: '正在准备生成世界底稿。',
progress: 10,
error: null,
},
}));
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-sync-result-profile-1',
type: 'sync_result_profile',
status: 'completed',
phaseLabel: '结果页档案已同步',
phaseDetail: '服务端已根据最新结果页档案刷新会话预览。',
progress: 100,
error: null,
});
render(<TestWrapper withAuth />);
@@ -2978,7 +3124,7 @@ test('agent draft result auto-save persists the latest profile from session draf
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile',
),
).toBe(false);
).toBe(true);
});
test('agent draft result can open from server result preview without embedded legacyResultProfile', async () => {
@@ -3021,6 +3167,9 @@ test('agent draft result can open from server result preview without embedded le
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getRpgCreationSession).mockResolvedValue(previewOnlySession);
vi.mocked(getRpgCreationResultView).mockResolvedValue(
buildResultViewForSession(previewOnlySession),
);
render(<TestWrapper withAuth />);

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import type { CustomWorldProfile } from '../../types';
import {
normalizeRpgEntryAgentBackedProfile,
stringifyRpgEntryAgentBackedProfile,
} from './rpgEntryShared';
describe('rpgEntryShared profile save boundary', () => {
it('does not rewrite settingText from creatorIntent on the frontend', () => {
const profile = {
id: 'cwprof_test',
settingText: '结果页用户正在编辑的草稿文案',
creatorIntent: {
worldHook: '海图会在午夜改写群岛航路',
playerPremise: '玩家是失忆领航员',
openingSituation: '正在禁航区醒来',
themeKeywords: ['海雾'],
toneDirectives: ['悬疑'],
coreConflicts: ['议会隐瞒沉船真相'],
keyCharacters: [
{
name: '顾潮音',
role: '守灯人',
relationToPlayer: '旧识',
hiddenHook: '掌握伪造海图',
},
],
iconicElements: ['会说谎的罗盘'],
},
} as CustomWorldProfile;
expect(normalizeRpgEntryAgentBackedProfile(profile)).toBe(profile);
expect(
JSON.parse(stringifyRpgEntryAgentBackedProfile(profile)),
).toMatchObject({
settingText: '结果页用户正在编辑的草稿文案',
});
});
});

View File

@@ -5,13 +5,9 @@ import type {
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { ApiClientError, isTimeoutError } from '../../services/apiClient';
import { buildCustomWorldCreatorIntentFoundationText } from '../../services/customWorldCreatorIntent';
import type { CustomWorldProfile } from '../../types';
export function resolveRpgEntryErrorMessage(
error: unknown,
fallback: string,
) {
export function resolveRpgEntryErrorMessage(error: unknown, fallback: string) {
if (isTimeoutError(error)) {
if (//u.test(fallback)) {
return '开启拼图创作工作台超时,请确认运行时后端已启动后重试。';
@@ -68,24 +64,15 @@ export function buildOptimisticRpgEntryAgentMessage(
export function normalizeRpgEntryAgentBackedProfile(
profile: CustomWorldProfile,
) {
const foundationText = buildCustomWorldCreatorIntentFoundationText(
profile.creatorIntent,
).trim();
if (!foundationText || foundationText === profile.settingText.trim()) {
return profile;
}
return {
...profile,
settingText: foundationText,
} satisfies CustomWorldProfile;
// 中文注释:保存前 canonicalize 已迁到 server-rs
// 这里保留透传函数只为了兼容旧导入,不再改写正式 profile 字段。
return profile;
}
export function stringifyRpgEntryAgentBackedProfile(
profile: CustomWorldProfile,
) {
return JSON.stringify(normalizeRpgEntryAgentBackedProfile(profile));
return JSON.stringify(profile);
}
export function buildRpgEntryCreationHubFallbackItems(
@@ -123,13 +110,9 @@ export function buildRpgEntryCreationHubFallbackItems(
* 兼容创作链工作包已经接入的旧 helper 命名,避免本轮迁移波及其他并行改动。
*/
export const resolveRpgCreationErrorMessage = resolveRpgEntryErrorMessage;
export const createFailedAgentOperation =
createFailedRpgEntryAgentOperation;
export const buildOptimisticAgentMessage =
buildOptimisticRpgEntryAgentMessage;
export const normalizeAgentBackedProfile =
normalizeRpgEntryAgentBackedProfile;
export const stringifyAgentBackedProfile =
stringifyRpgEntryAgentBackedProfile;
export const createFailedAgentOperation = createFailedRpgEntryAgentOperation;
export const buildOptimisticAgentMessage = buildOptimisticRpgEntryAgentMessage;
export const normalizeAgentBackedProfile = normalizeRpgEntryAgentBackedProfile;
export const stringifyAgentBackedProfile = stringifyRpgEntryAgentBackedProfile;
export const buildCreationHubFallbackItems =
buildRpgEntryCreationHubFallbackItems;

View File

@@ -115,11 +115,6 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
describe('useRpgCreationEnterWorld', () => {
it('Agent 草稿测试进入游戏时优先使用结果页当前 profile而不是回退到会话快照', async () => {
const staleResultProfile = buildProfile({
id: 'session-profile',
name: '会话旧快照',
imageSrc: '/template/old-role.png',
});
const resultProfile = buildProfile({
id: 'draft-profile',
name: '结果页真相源',
@@ -128,16 +123,21 @@ describe('useRpgCreationEnterWorld', () => {
const handleCustomWorldSelect = vi.fn();
const setGeneratedCustomWorldProfile = vi.fn();
const executePublishWorld = vi.fn(async () => buildSession());
const syncAgentCreationResultView = vi.fn();
const syncAgentDraftResultProfile = vi.fn(async () => ({
profile: resultProfile,
view: null,
}));
function Harness() {
const { enterWorldForTestFromCurrentResult } = useRpgCreationEnterWorld({
isAgentDraftResultView: true,
activeAgentSessionId: 'session-1',
generatedCustomWorldProfile: resultProfile,
agentSessionProfile: staleResultProfile,
agentSession: buildSession(),
handleCustomWorldSelect,
syncAgentDraftResultProfile,
executePublishWorld,
syncAgentCreationResultView,
setGeneratedCustomWorldProfile,
});

View File

@@ -1,6 +1,6 @@
import { useCallback } from 'react';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import type { CustomWorldProfile } from '../../types';
@@ -9,13 +9,17 @@ type UseRpgCreationEnterWorldParams = {
isAgentDraftResultView: boolean;
activeAgentSessionId: string | null;
generatedCustomWorldProfile: CustomWorldProfile | null;
agentSessionProfile: CustomWorldProfile | null;
agentSession: CustomWorldAgentSessionSnapshot | null;
handleCustomWorldSelect: (
customWorldProfile: CustomWorldProfile,
options?: CustomWorldRuntimeLaunchOptions,
) => void;
executePublishWorld: () => Promise<CustomWorldAgentSessionSnapshot | null>;
syncAgentDraftResultProfile: (
profile: CustomWorldProfile,
) => Promise<{ profile: CustomWorldProfile | null; view?: RpgCreationResultView | null }>;
executePublishWorld: () => Promise<unknown>;
syncAgentCreationResultView: (
sessionId: string,
) => Promise<RpgCreationResultView | null>;
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
};
@@ -30,10 +34,10 @@ export function useRpgCreationEnterWorld(
isAgentDraftResultView,
activeAgentSessionId,
generatedCustomWorldProfile,
agentSessionProfile,
agentSession,
handleCustomWorldSelect,
syncAgentDraftResultProfile,
executePublishWorld,
syncAgentCreationResultView,
setGeneratedCustomWorldProfile,
} = params;
@@ -44,7 +48,7 @@ export function useRpgCreationEnterWorld(
// 中文注释:作品测试必须复用“结果页当前真相源”。
// 用户在结果页看到并可能继续编辑的是 generatedCustomWorldProfile
// 如果这里又回退成会话里的 agentSessionProfile,就会出现
// 如果这里又回退成 session 里的旧 preview,就会出现
// “结果页看起来已经是新版,但作品测试实际进入的是旧版快照”的错位。
if (isAgentDraftResultView && activeAgentSessionId) {
setGeneratedCustomWorldProfile(generatedCustomWorldProfile);
@@ -73,36 +77,47 @@ export function useRpgCreationEnterWorld(
return generatedCustomWorldProfile;
}
if (!agentSessionProfile) {
const syncedResult = await syncAgentDraftResultProfile(
generatedCustomWorldProfile,
);
const latestProfile =
syncedResult.profile ??
rpgCreationPreviewAdapter.buildPreviewFromResultView(syncedResult.view);
if (!latestProfile) {
return null;
}
setGeneratedCustomWorldProfile(agentSessionProfile);
setGeneratedCustomWorldProfile(latestProfile);
const latestSession = agentSession;
const canEnterPublishedWorld =
latestSession?.stage === 'published' &&
latestSession.resultPreview?.canEnterWorld;
syncedResult.view?.session.stage === 'published' &&
syncedResult.view.canEnterWorld;
if (canEnterPublishedWorld) {
return agentSessionProfile;
const latestView = await syncAgentCreationResultView(activeAgentSessionId);
return (
rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView) ??
latestProfile
);
}
const publishedSession = await executePublishWorld();
await executePublishWorld();
const latestView = await syncAgentCreationResultView(activeAgentSessionId);
const publishedProfile =
rpgCreationPreviewAdapter.buildPreviewFromSession(publishedSession) ??
agentSessionProfile;
rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView) ??
latestProfile;
setGeneratedCustomWorldProfile(publishedProfile);
return publishedProfile;
}, [
activeAgentSessionId,
agentSession,
agentSessionProfile,
executePublishWorld,
generatedCustomWorldProfile,
isAgentDraftResultView,
setGeneratedCustomWorldProfile,
syncAgentDraftResultProfile,
syncAgentCreationResultView,
]);
const enterWorldFromCurrentResult = useCallback(async () => {

View File

@@ -4,9 +4,9 @@ import type {
CustomWorldAgentOperationRecord,
CustomWorldAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type {
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
import {
executeRpgCreationAction,
getRpgCreationOperation,
@@ -14,7 +14,6 @@ import {
} from '../../services/rpg-creation';
import type { CustomWorldProfile } from '../../types';
import {
normalizeAgentBackedProfile,
resolveRpgCreationErrorMessage,
stringifyAgentBackedProfile,
} from './rpgEntryShared';
@@ -27,7 +26,6 @@ import type {
type UseRpgCreationResultAutosaveParams = {
selectionStage: SelectionStage;
activeAgentSessionId: string | null;
agentSession: CustomWorldAgentSessionSnapshot | null;
generatedCustomWorldProfile: CustomWorldProfile | null;
isAgentDraftResultView: boolean;
userId: string | null | undefined;
@@ -55,8 +53,11 @@ type UseRpgCreationResultAutosaveParams = {
syncAgentSessionSnapshot: (
sessionId: string,
) => Promise<CustomWorldAgentSessionSnapshot | null>;
syncAgentCreationResultView: (
sessionId: string,
) => Promise<RpgCreationResultView | null>;
buildDraftResultProfile: (
session: CustomWorldAgentSessionSnapshot | null,
view: RpgCreationResultView | null,
) => CustomWorldProfile | null;
};
@@ -70,7 +71,6 @@ export function useRpgCreationResultAutosave(
const {
selectionStage,
activeAgentSessionId,
agentSession,
generatedCustomWorldProfile,
isAgentDraftResultView,
userId,
@@ -81,6 +81,7 @@ export function useRpgCreationResultAutosave(
refreshCustomWorldWorks,
persistAgentUiState,
syncAgentSessionSnapshot,
syncAgentCreationResultView,
buildDraftResultProfile,
} = params;
@@ -118,29 +119,33 @@ export function useRpgCreationResultAutosave(
return null;
}
const normalizedProfile = normalizeAgentBackedProfile(profile);
const profileSignature = stringifyAgentBackedProfile(normalizedProfile);
const requestId = latestAutoSaveRequestIdRef.current + 1;
latestAutoSaveRequestIdRef.current = requestId;
setCustomWorldAutoSaveState('saving');
setCustomWorldAutoSaveError(null);
try {
const mutation =
await upsertRpgWorldProfile(
normalizedProfile,
{
sourceAgentSessionId:
isAgentDraftResultView && activeAgentSessionId
? activeAgentSessionId
: null,
},
);
const mutation = await upsertRpgWorldProfile(profile, {
sourceAgentSessionId:
isAgentDraftResultView && activeAgentSessionId
? activeAgentSessionId
: null,
});
if (latestAutoSaveRequestIdRef.current !== requestId) {
return mutation;
}
lastAutoSavedProfileSignatureRef.current = profileSignature;
const canonicalProfile =
normalizeCustomWorldProfileRecord(mutation.entry.profile) ??
mutation.entry.profile;
// Agent 结果页的界面真相来自 result-view作品库响应只用于列表与签名回写
// 避免旧兼容响应缺字段时覆盖当前完整编辑态。
lastAutoSavedProfileSignatureRef.current = stringifyAgentBackedProfile(
isAgentDraftResultView ? profile : canonicalProfile,
);
if (!isAgentDraftResultView) {
setGeneratedCustomWorldProfile(canonicalProfile);
}
setSavedCustomWorldEntries(mutation.entries);
if (userId) {
void refreshCustomWorldWorks().catch(() => {});
@@ -174,73 +179,8 @@ export function useRpgCreationResultAutosave(
refreshCustomWorldWorks,
setSavedCustomWorldEntries,
setSelectedDetailEntry,
userId,
],
);
const syncAgentDraftResultProfile = useCallback(
async (profile: CustomWorldProfile) => {
if (!activeAgentSessionId) {
return {
session: null,
profile: null,
} satisfies SyncedAgentDraftResult;
}
const normalizedProfile = normalizeAgentBackedProfile(profile);
const profileSignature = stringifyAgentBackedProfile(normalizedProfile);
const latestSessionProfile = buildDraftResultProfile(agentSession);
const latestSessionProfileSignature = latestSessionProfile
? stringifyAgentBackedProfile(latestSessionProfile)
: '';
const shouldRefreshPublishGate = Boolean(
agentSession?.resultPreview && !agentSession.resultPreview.publishReady,
);
if (
latestSessionProfileSignature === profileSignature &&
!shouldRefreshPublishGate
) {
latestAgentResultSyncSignatureRef.current = profileSignature;
return {
session: agentSession,
profile: normalizeAgentBackedProfile(latestSessionProfile ?? profile),
} satisfies SyncedAgentDraftResult;
}
if (
latestAgentResultSyncSignatureRef.current === profileSignature &&
!shouldRefreshPublishGate
) {
return {
session: agentSession,
profile: normalizeAgentBackedProfile(latestSessionProfile ?? profile),
} satisfies SyncedAgentDraftResult;
}
// Agent 结果页不再把前端 profile 回写到 session。
// 这里只刷新后端结果页快照,避免在采集/生成早期误触 sync_result_profile。
const latestSession = await syncAgentSessionSnapshot(activeAgentSessionId);
const latestProfile = normalizeAgentBackedProfile(
buildDraftResultProfile(latestSession) ?? profile,
);
if (latestProfile) {
setGeneratedCustomWorldProfile(latestProfile);
}
latestAgentResultSyncSignatureRef.current =
stringifyAgentBackedProfile(latestProfile);
return {
session: latestSession,
profile: latestProfile,
} satisfies SyncedAgentDraftResult;
},
[
activeAgentSessionId,
agentSession,
buildDraftResultProfile,
setGeneratedCustomWorldProfile,
syncAgentSessionSnapshot,
userId,
],
);
@@ -290,6 +230,65 @@ export function useRpgCreationResultAutosave(
],
);
const syncAgentDraftResultProfile = useCallback(
async (profile: CustomWorldProfile) => {
if (!activeAgentSessionId) {
return {
session: null,
profile: null,
} satisfies SyncedAgentDraftResult;
}
const profileSignature = stringifyAgentBackedProfile(profile);
const currentView =
await syncAgentCreationResultView(activeAgentSessionId);
if (!currentView?.canSyncResultProfile) {
const latestProfile = buildDraftResultProfile(currentView) ?? profile;
if (latestProfile) {
setGeneratedCustomWorldProfile(latestProfile);
}
latestAgentResultSyncSignatureRef.current =
stringifyAgentBackedProfile(latestProfile);
return {
session: currentView?.session ?? null,
profile: latestProfile,
view: currentView,
} satisfies SyncedAgentDraftResult;
}
if (latestAgentResultSyncSignatureRef.current !== profileSignature) {
await executeAgentActionAndWait({
action: 'sync_result_profile',
profile: profile as unknown as Record<string, unknown>,
});
latestAgentResultSyncSignatureRef.current = profileSignature;
}
const latestView =
await syncAgentCreationResultView(activeAgentSessionId);
const latestProfile = buildDraftResultProfile(latestView) ?? profile;
if (latestProfile) {
setGeneratedCustomWorldProfile(latestProfile);
}
latestAgentResultSyncSignatureRef.current =
stringifyAgentBackedProfile(latestProfile);
return {
session: latestView?.session ?? null,
profile: latestProfile,
view: latestView,
} satisfies SyncedAgentDraftResult;
},
[
activeAgentSessionId,
buildDraftResultProfile,
executeAgentActionAndWait,
setGeneratedCustomWorldProfile,
syncAgentCreationResultView,
],
);
useEffect(
() => () => {
if (customWorldAutoSaveTimeoutRef.current !== null) {
@@ -313,7 +312,9 @@ export function useRpgCreationResultAutosave(
return;
}
const nextSignature = stringifyAgentBackedProfile(generatedCustomWorldProfile);
const nextSignature = stringifyAgentBackedProfile(
generatedCustomWorldProfile,
);
if (nextSignature === lastAutoSavedProfileSignatureRef.current) {
return;
}
@@ -328,14 +329,16 @@ export function useRpgCreationResultAutosave(
void (async () => {
isCustomWorldAutoSaveBusyRef.current = true;
try {
let latestProfileToSave = normalizeAgentBackedProfile(profileToSave);
let latestProfileToSave = profileToSave;
if (isAgentDraftResultView) {
const syncedResult =
await syncAgentDraftResultProfile(profileToSave);
if (syncedResult.view && !syncedResult.view.canAutosaveLibrary) {
setCustomWorldAutoSaveState('idle');
return;
}
// 作品库自动保存优先落同步后 session 重编译出的结果,避免继续保存旧的前端内存态。
latestProfileToSave = normalizeAgentBackedProfile(
syncedResult.profile ?? profileToSave,
);
latestProfileToSave = syncedResult.profile ?? profileToSave;
}
await saveGeneratedCustomWorld(latestProfileToSave);
} catch (error) {

View File

@@ -21,6 +21,7 @@ import {
import {
createRpgCreationSession,
executeRpgCreationAction,
getRpgCreationResultView,
getRpgCreationSession,
streamRpgCreationMessage,
} from '../../services/rpg-creation';
@@ -29,7 +30,6 @@ import type { CustomWorldProfile } from '../../types';
import {
buildOptimisticAgentMessage,
createFailedAgentOperation,
normalizeAgentBackedProfile,
resolveRpgCreationErrorMessage,
} from './rpgEntryShared';
import type {
@@ -40,7 +40,9 @@ import type {
type UseRpgCreationSessionControllerParams = {
userId: string | null | undefined;
openLoginModal?: ((postLoginAction?: (() => void) | null) => void) | undefined;
openLoginModal?:
| ((postLoginAction?: (() => void) | null) => void)
| undefined;
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
enterCreateTab?: (() => void) | undefined;
@@ -70,12 +72,23 @@ export function useRpgCreationSessionController(
const shouldRestoreInitialAgentUiStateRef = useRef(
shouldRestoreCustomWorldAgentUiState(),
);
const initialAgentSessionId = initialAgentUiStateRef.current.activeSessionId;
const isInitialAgentGenerationRestore =
Boolean(initialAgentUiStateRef.current.activeOperationId) &&
initialAgentUiStateRef.current.customWorldGenerationSource ===
'agent-draft-foundation';
const canResolveInitialAgentSessionOwner =
!initialAgentSessionId ||
!userId ||
Boolean(initialAgentUiStateRef.current.ownerUserId) ||
isInitialAgentGenerationRestore;
const isInitialAgentUiStateOwnedByCurrentUser =
!initialAgentUiStateRef.current.ownerUserId ||
initialAgentUiStateRef.current.ownerUserId === userId;
canResolveInitialAgentSessionOwner &&
(!initialAgentUiStateRef.current.ownerUserId ||
initialAgentUiStateRef.current.ownerUserId === userId);
const isHydratingInitialAgentWorkspaceRef = useRef(
Boolean(
initialAgentUiStateRef.current.activeSessionId &&
initialAgentSessionId &&
shouldRestoreInitialAgentUiStateRef.current &&
isInitialAgentUiStateOwnedByCurrentUser,
),
@@ -115,9 +128,12 @@ export function useRpgCreationSessionController(
const [pendingAgentUserMessage, setPendingAgentUserMessage] =
useState<PendingAgentUserMessage | null>(null);
const [isLoadingAgentSession, setIsLoadingAgentSession] = useState(false);
const [creationTypeError, setCreationTypeError] = useState<string | null>(null);
const [agentWorkspaceRestoreError, setAgentWorkspaceRestoreError] =
useState<string | null>(null);
const [creationTypeError, setCreationTypeError] = useState<string | null>(
null,
);
const [agentWorkspaceRestoreError, setAgentWorkspaceRestoreError] = useState<
string | null
>(null);
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
useState<CustomWorldProfile | null>(null);
const [customWorldError, setCustomWorldError] = useState<string | null>(null);
@@ -127,7 +143,10 @@ export function useRpgCreationSessionController(
useState<CustomWorldResultViewSource>(null);
const [agentDraftGenerationStartedAt, setAgentDraftGenerationStartedAt] =
useState<number | null>(null);
const pendingAgentUserMessageRef = useRef<PendingAgentUserMessage | null>(null);
const pendingAgentUserMessageRef = useRef<PendingAgentUserMessage | null>(
null,
);
const latestAgentResultViewOpenRequestIdRef = useRef(0);
useEffect(() => {
currentAgentSessionIdRef.current = agentSession?.sessionId ?? null;
@@ -191,35 +210,54 @@ export function useRpgCreationSessionController(
[userId],
);
const syncAgentSessionSnapshot = useCallback(async (sessionId: string) => {
const requestId = latestAgentSessionSyncRequestIdRef.current + 1;
latestAgentSessionSyncRequestIdRef.current = requestId;
const nextSession = await getRpgCreationSession(sessionId);
const mergedSession = mergePendingAgentUserMessageIntoSession(nextSession);
const syncAgentSessionSnapshot = useCallback(
async (sessionId: string) => {
const requestId = latestAgentSessionSyncRequestIdRef.current + 1;
latestAgentSessionSyncRequestIdRef.current = requestId;
const nextSession = await getRpgCreationSession(sessionId);
const mergedSession =
mergePendingAgentUserMessageIntoSession(nextSession);
if (latestAgentSessionSyncRequestIdRef.current === requestId) {
setAgentSession(mergedSession);
const currentPendingAgentUserMessage = pendingAgentUserMessageRef.current;
const hasServerEchoedPendingMessage =
currentPendingAgentUserMessage?.sessionId === nextSession.sessionId &&
nextSession.messages.some(
(message) => message.id === currentPendingAgentUserMessage.message.id,
);
if (hasServerEchoedPendingMessage) {
setPendingAgentUserMessage(null);
if (latestAgentSessionSyncRequestIdRef.current === requestId) {
setAgentSession(mergedSession);
const currentPendingAgentUserMessage =
pendingAgentUserMessageRef.current;
const hasServerEchoedPendingMessage =
currentPendingAgentUserMessage?.sessionId === nextSession.sessionId &&
nextSession.messages.some(
(message) =>
message.id === currentPendingAgentUserMessage.message.id,
);
if (hasServerEchoedPendingMessage) {
setPendingAgentUserMessage(null);
}
}
}
return mergedSession;
}, [mergePendingAgentUserMessageIntoSession]);
return mergedSession;
},
[mergePendingAgentUserMessageIntoSession],
);
const syncAgentCreationResultView = useCallback(
async (sessionId: string) => {
const resultView = await getRpgCreationResultView(sessionId);
const mergedSession = mergePendingAgentUserMessageIntoSession(
resultView.session,
);
setAgentSession(mergedSession);
return {
...resultView,
session: mergedSession ?? resultView.session,
};
},
[mergePendingAgentUserMessageIntoSession],
);
useEffect(() => {
const initialAgentSessionId = initialAgentUiStateRef.current.activeSessionId;
const initialAgentSessionId =
initialAgentUiStateRef.current.activeSessionId;
if (
!initialAgentSessionId ||
hasAppliedInitialAgentWorkspaceRef.current
) {
if (!initialAgentSessionId || hasAppliedInitialAgentWorkspaceRef.current) {
return;
}
@@ -260,6 +298,20 @@ export function useRpgCreationSessionController(
return;
}
if (
!initialAgentUiStateRef.current.ownerUserId &&
!(
initialAgentUiStateRef.current.activeOperationId &&
initialAgentUiStateRef.current.customWorldGenerationSource ===
'agent-draft-foundation'
)
) {
hasAppliedInitialAgentWorkspaceRef.current = true;
isHydratingInitialAgentWorkspaceRef.current = false;
persistAgentUiState(null, null);
return;
}
if (
initialAgentUiStateRef.current.ownerUserId &&
initialAgentUiStateRef.current.ownerUserId !== userId
@@ -283,7 +335,13 @@ export function useRpgCreationSessionController(
}
setSelectionStage('agent-workspace');
}, [enterCreateTab, openLoginModal, persistAgentUiState, setSelectionStage, userId]);
}, [
enterCreateTab,
openLoginModal,
persistAgentUiState,
setSelectionStage,
userId,
]);
useEffect(() => {
if (
@@ -365,7 +423,10 @@ export function useRpgCreationSessionController(
setAgentWorkspaceRestoreError(null);
} else {
setAgentWorkspaceRestoreError(
resolveRpgCreationErrorMessage(error, '读取 Agent 共创工作区失败。'),
resolveRpgCreationErrorMessage(
error,
'读取 Agent 共创工作区失败。',
),
);
}
setAgentSession(null);
@@ -426,37 +487,32 @@ export function useRpgCreationSessionController(
attempt += 1
) {
await new Promise((resolve) => {
window.setTimeout(
resolve,
AGENT_DRAFT_RESULT_AUTO_OPEN_RETRY_MS,
);
window.setTimeout(resolve, AGENT_DRAFT_RESULT_AUTO_OPEN_RETRY_MS);
});
if (cancelled) {
return;
}
const latestSession = activeAgentSessionId
? await syncAgentSessionSnapshot(activeAgentSessionId).catch(
const latestResultView = activeAgentSessionId
? await syncAgentCreationResultView(activeAgentSessionId).catch(
() => null,
)
: agentSession;
: null;
if (cancelled) {
return;
}
const draftResultProfile =
rpgCreationPreviewAdapter.buildPreviewFromSession(
latestSession ?? agentSession,
rpgCreationPreviewAdapter.buildPreviewFromResultView(
latestResultView,
);
if (!draftResultProfile) {
continue;
}
setGeneratedCustomWorldProfile(
normalizeAgentBackedProfile(draftResultProfile),
);
setGeneratedCustomWorldProfile(draftResultProfile);
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource('agent-draft');
@@ -479,7 +535,7 @@ export function useRpgCreationSessionController(
customWorldGenerationViewSource,
selectionStage,
setSelectionStage,
syncAgentSessionSnapshot,
syncAgentCreationResultView,
]);
const agentDraftSettingPreview = useMemo(
@@ -490,25 +546,6 @@ export function useRpgCreationSessionController(
() => buildAgentDraftFoundationAnchorEntries(agentSession),
[agentSession],
);
const agentDraftResultProfile = useMemo(
() => rpgCreationPreviewAdapter.buildPreviewFromSession(agentSession),
[agentSession],
);
const shouldAutoOpenAgentDraftResult = useMemo(
() =>
Boolean(
agentDraftResultProfile &&
agentSession &&
(agentSession.stage === 'object_refining' ||
agentSession.stage === 'visual_refining' ||
agentSession.stage === 'long_tail_review' ||
agentSession.stage === 'ready_to_publish' ||
agentSession.stage === 'published') &&
agentSession.draftCards.length > 0,
),
[agentDraftResultProfile, agentSession],
);
const agentDraftGenerationProgress = useMemo(
() =>
buildAgentDraftFoundationGenerationProgress(
@@ -530,7 +567,11 @@ export function useRpgCreationSessionController(
: null;
useEffect(() => {
if (!shouldAutoOpenAgentDraftResult || !agentDraftResultProfile) {
if (
!agentSession ||
!activeAgentSessionId ||
agentSession.draftCards.length === 0
) {
return;
}
@@ -538,28 +579,63 @@ export function useRpgCreationSessionController(
return;
}
if (selectionStage === 'agent-workspace') {
setGeneratedCustomWorldProfile(agentDraftResultProfile);
setCustomWorldResultViewSource('agent-draft');
isAgentDraftResultAutoOpenSuppressedRef.current = false;
setSelectionStage('custom-world-result');
if (
selectionStage !== 'agent-workspace' &&
selectionStage !== 'custom-world-result'
) {
return;
}
if (
selectionStage === 'custom-world-result' &&
!generatedCustomWorldProfile
generatedCustomWorldProfile
) {
setGeneratedCustomWorldProfile(agentDraftResultProfile);
setCustomWorldResultViewSource('agent-draft');
isAgentDraftResultAutoOpenSuppressedRef.current = false;
return;
}
const requestId = latestAgentResultViewOpenRequestIdRef.current + 1;
latestAgentResultViewOpenRequestIdRef.current = requestId;
let cancelled = false;
void syncAgentCreationResultView(activeAgentSessionId)
.then((resultView) => {
if (
cancelled ||
latestAgentResultViewOpenRequestIdRef.current !== requestId ||
isAgentDraftResultAutoOpenSuppressedRef.current ||
resultView.targetStage !== 'custom-world-result'
) {
return;
}
const resultProfile =
rpgCreationPreviewAdapter.buildPreviewFromResultView(resultView);
if (!resultProfile) {
return;
}
setGeneratedCustomWorldProfile(resultProfile);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(
resultView.resultViewSource ?? 'agent-draft',
);
isAgentDraftResultAutoOpenSuppressedRef.current = false;
if (selectionStage === 'agent-workspace') {
setSelectionStage('custom-world-result');
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [
agentDraftResultProfile,
activeAgentSessionId,
agentSession,
generatedCustomWorldProfile,
selectionStage,
setSelectionStage,
shouldAutoOpenAgentDraftResult,
syncAgentCreationResultView,
]);
const openRpgAgentWorkspace = useCallback(
@@ -689,26 +765,26 @@ export function useRpgCreationSessionController(
kind: 'warning',
text: errorMessage,
});
setAgentSession((current) =>
{
const mergedCurrentSession = mergePendingAgentUserMessageIntoSession(
current,
pendingMessagePayload,
);
return mergedCurrentSession
? {
...mergedCurrentSession,
messages: [...mergedCurrentSession.messages, warningMessage],
updatedAt: warningMessage.createdAt,
}
: current;
},
);
setAgentSession((current) => {
const mergedCurrentSession = mergePendingAgentUserMessageIntoSession(
current,
pendingMessagePayload,
);
return mergedCurrentSession
? {
...mergedCurrentSession,
messages: [...mergedCurrentSession.messages, warningMessage],
updatedAt: warningMessage.createdAt,
}
: current;
});
setPendingAgentUserMessage(null);
setStreamingAgentReplyText('');
persistAgentUiState(activeAgentSessionId, null);
} finally {
if (activeAgentReplyAbortControllerRef.current === replyAbortController) {
if (
activeAgentReplyAbortControllerRef.current === replyAbortController
) {
activeAgentReplyAbortControllerRef.current = null;
}
if (!replyAbortController.signal.aborted) {
@@ -780,9 +856,7 @@ export function useRpgCreationSessionController(
const setNormalizedGeneratedCustomWorldProfile = useCallback(
(profile: CustomWorldProfile | null) => {
setGeneratedCustomWorldProfile(
profile ? normalizeAgentBackedProfile(profile) : null,
);
setGeneratedCustomWorldProfile(profile);
},
[],
);
@@ -807,7 +881,8 @@ export function useRpgCreationSessionController(
return {
initialAgentSessionId:
shouldRestoreInitialAgentUiStateRef.current
shouldRestoreInitialAgentUiStateRef.current &&
isInitialAgentUiStateOwnedByCurrentUser
? (initialAgentUiStateRef.current.activeSessionId ?? null)
: null,
isCreatingAgentSession,
@@ -837,7 +912,10 @@ export function useRpgCreationSessionController(
setAgentDraftGenerationStartedAt,
agentDraftSettingPreview,
agentDraftAnchorPreviewEntries,
agentDraftResultProfile,
agentDraftResultProfile:
rpgCreationPreviewAdapter.buildPreviewFromResultPreview(
agentSession?.resultPreview,
),
agentDraftGenerationProgress,
isAgentDraftGenerationView,
isAgentDraftResultView,
@@ -848,6 +926,7 @@ export function useRpgCreationSessionController(
releaseAgentDraftResultAutoOpenSuppression,
persistAgentUiState,
syncAgentSessionSnapshot,
syncAgentCreationResultView,
openRpgAgentWorkspace,
submitAgentMessage,
executeAgentAction,

View File

@@ -5,9 +5,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
import { WorldType, type CustomWorldProfile } from '../../types';
import {
executeRpgCreationAction,
getRpgCreationOperation,
upsertRpgWorldProfile,
} from '../../services/rpg-creation';
import { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave';
@@ -109,16 +111,42 @@ function buildSession(
};
}
function buildResultView(
overrides: Partial<RpgCreationResultView> = {},
): RpgCreationResultView {
const session = overrides.session ?? buildSession();
return {
session,
profile: null,
profileSource: 'none',
targetStage: 'agent-workspace',
generationViewSource: null,
resultViewSource: null,
canAutosaveLibrary: false,
canSyncResultProfile: false,
publishReady: false,
canEnterWorld: false,
blockerCount: 0,
recoveryAction: 'continue_agent',
recoveryReason: null,
...overrides,
};
}
describe('RPG Agent 草稿恢复', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('作品摘要已有对象数量但 session 没有 draftProfile 时恢复 Agent 页面', async () => {
const syncAgentSessionSnapshot = vi.fn(async () =>
buildSession({
stage: 'clarifying',
draftProfile: null,
const syncAgentCreationResultView = vi.fn(async () =>
buildResultView({
session: buildSession({
stage: 'clarifying',
draftProfile: null,
}),
targetStage: 'agent-workspace',
recoveryAction: 'continue_agent',
}),
);
const setSelectionStage = vi.fn();
@@ -150,9 +178,9 @@ describe('RPG Agent 草稿恢复', () => {
refreshCustomWorldWorks: vi.fn(async () => []),
refreshPublishedGallery: vi.fn(async () => []),
persistAgentUiState,
syncAgentSessionSnapshot,
buildDraftResultProfile: (session) =>
(session?.draftProfile as CustomWorldProfile | null) ?? null,
syncAgentCreationResultView,
buildDraftResultProfile: (view) =>
(view?.profile as CustomWorldProfile | null) ?? null,
suppressAgentDraftResultAutoOpen,
releaseAgentDraftResultAutoOpenSuppression: vi.fn(),
resetAutoSaveTrackingToIdle: vi.fn(),
@@ -183,7 +211,7 @@ describe('RPG Agent 草稿恢复', () => {
});
});
expect(syncAgentSessionSnapshot).toHaveBeenCalledWith('agent-session-1');
expect(syncAgentCreationResultView).toHaveBeenCalledWith('agent-session-1');
expect(suppressAgentDraftResultAutoOpen).toHaveBeenCalled();
expect(persistAgentUiState).toHaveBeenCalledWith('agent-session-1', null);
expect(setGeneratedCustomWorldProfile).toHaveBeenLastCalledWith(null);
@@ -192,7 +220,7 @@ describe('RPG Agent 草稿恢复', () => {
expect(setSelectionStage).not.toHaveBeenCalledWith('custom-world-result');
});
it('Agent 结果页自动保存只刷新 session draftProfile不触发 sync_result_profile', async () => {
it('Agent 结果页自动保存先回写 session,再保存后端 result-view profile', async () => {
const oldProfile = buildProfile('旧前端快照');
const latestProfile = {
...buildProfile('服务端草稿快照'),
@@ -203,6 +231,36 @@ describe('RPG Agent 草稿恢复', () => {
draftProfile: latestProfile as unknown as Record<string, unknown>,
});
const syncAgentSessionSnapshot = vi.fn(async () => latestSession);
const syncAgentCreationResultView = vi.fn(async () =>
buildResultView({
session: latestSession,
profile: latestProfile,
profileSource: 'result_preview',
targetStage: 'custom-world-result',
resultViewSource: 'agent-draft',
canAutosaveLibrary: true,
canSyncResultProfile: true,
recoveryAction: 'open_result',
}),
);
vi.mocked(executeRpgCreationAction).mockResolvedValue({
operation: {
operationId: 'operation-sync-result',
type: 'sync_result_profile',
status: 'running',
phaseLabel: '结果页同步中',
phaseDetail: '正在同步结果页。',
progress: 50,
},
});
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-sync-result',
type: 'sync_result_profile',
status: 'completed',
phaseLabel: '结果页已同步',
phaseDetail: '结果页已同步。',
progress: 100,
});
vi.mocked(upsertRpgWorldProfile).mockResolvedValue({
entry: {
@@ -230,16 +288,6 @@ describe('RPG Agent 草稿恢复', () => {
useRpgCreationResultAutosave({
selectionStage: 'custom-world-result',
activeAgentSessionId: 'agent-session-1',
agentSession: buildSession({
stage: 'object_refining',
draftProfile: oldProfile as unknown as Record<string, unknown>,
resultPreview: {
publishReady: false,
blockers: [],
qualityFindings: [],
sourceLabel: '旧预览',
} as never,
}),
generatedCustomWorldProfile: oldProfile,
isAgentDraftResultView: true,
userId: 'user-1',
@@ -250,8 +298,9 @@ describe('RPG Agent 草稿恢复', () => {
refreshCustomWorldWorks: vi.fn(async () => []),
persistAgentUiState: vi.fn(),
syncAgentSessionSnapshot,
buildDraftResultProfile: (session) =>
(session?.draftProfile as CustomWorldProfile | null) ?? null,
syncAgentCreationResultView,
buildDraftResultProfile: (view) =>
(view?.profile as CustomWorldProfile | null) ?? null,
});
return null;
@@ -266,13 +315,23 @@ describe('RPG Agent 草稿恢复', () => {
vi.useRealTimers();
expect(syncAgentSessionSnapshot).toHaveBeenCalledWith('agent-session-1');
expect(upsertRpgWorldProfile).toHaveBeenCalledWith(latestProfile, {
sourceAgentSessionId: 'agent-session-1',
expect(syncAgentCreationResultView).toHaveBeenCalledWith('agent-session-1');
expect(executeRpgCreationAction).toHaveBeenCalledWith('agent-session-1', {
action: 'sync_result_profile',
profile: expect.objectContaining({
id: oldProfile.id,
name: oldProfile.name,
}),
});
expect(
vi.mocked(executeRpgCreationAction).mock.calls.some(
([, payload]) => payload?.action === 'sync_result_profile',
),
).toBe(false);
expect(upsertRpgWorldProfile).toHaveBeenCalledWith(
expect.objectContaining({
id: latestProfile.id,
name: latestProfile.name,
summary: latestProfile.summary,
}),
{
sourceAgentSessionId: 'agent-session-1',
},
);
});
});

View File

@@ -1,9 +1,7 @@
import { useCallback, useEffect, useState } from 'react';
import type {
CustomWorldAgentSessionSnapshot,
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
@@ -22,10 +20,7 @@ import {
unpublishRpgEntryWorldProfile,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
import type { CustomWorldProfile } from '../../types';
import {
normalizeRpgEntryAgentBackedProfile,
resolveRpgEntryErrorMessage,
} from './rpgEntryShared';
import { resolveRpgEntryErrorMessage } from './rpgEntryShared';
import type {
CustomWorldAutoSaveState,
CustomWorldGenerationViewSource,
@@ -48,18 +43,14 @@ type UseRpgEntryLibraryDetailParams = {
setSavedCustomWorldEntries: (
entries: CustomWorldLibraryEntry<CustomWorldProfile>[],
) => void;
setGeneratedCustomWorldProfile: (
profile: CustomWorldProfile | null,
) => void;
setGeneratedCustomWorldProfile: (profile: CustomWorldProfile | null) => void;
setCustomWorldError: (error: string | null) => void;
setCustomWorldAutoSaveError: (error: string | null) => void;
setCustomWorldAutoSaveState: (state: CustomWorldAutoSaveState) => void;
setCustomWorldGenerationViewSource: (
source: CustomWorldGenerationViewSource,
) => void;
setCustomWorldResultViewSource: (
source: CustomWorldResultViewSource,
) => void;
setCustomWorldResultViewSource: (source: CustomWorldResultViewSource) => void;
setSelectionStage: (stage: SelectionStage) => void;
setPlatformTabToCreate: () => void;
setPlatformError: (error: string | null) => void;
@@ -73,11 +64,11 @@ type UseRpgEntryLibraryDetailParams = {
operationId: string | null,
generationSource?: 'agent-draft-foundation' | null,
) => void;
syncAgentSessionSnapshot: (
syncAgentCreationResultView: (
sessionId: string,
) => Promise<CustomWorldAgentSessionSnapshot | null>;
) => Promise<RpgCreationResultView | null>;
buildDraftResultProfile: (
session: CustomWorldAgentSessionSnapshot | null,
view: RpgCreationResultView | null,
) => CustomWorldProfile | null;
suppressAgentDraftResultAutoOpen: () => void;
releaseAgentDraftResultAutoOpenSuppression: () => void;
@@ -85,14 +76,6 @@ type UseRpgEntryLibraryDetailParams = {
markAutoSavedProfile: (profile: CustomWorldProfile) => void;
};
const AGENT_RESULT_STAGES = new Set([
'object_refining',
'visual_refining',
'long_tail_review',
'ready_to_publish',
'published',
]);
function isMissingRpgEntryAgentSessionError(error: unknown) {
return (
error instanceof ApiClientError &&
@@ -127,7 +110,7 @@ export function useRpgEntryLibraryDetail(
refreshCustomWorldWorks,
refreshPublishedGallery,
persistAgentUiState,
syncAgentSessionSnapshot,
syncAgentCreationResultView,
buildDraftResultProfile,
suppressAgentDraftResultAutoOpen,
releaseAgentDraftResultAutoOpenSuppression,
@@ -225,11 +208,8 @@ export function useRpgEntryLibraryDetail(
setCustomWorldResultViewSource(null);
setCustomWorldError(null);
setGeneratedCustomWorldProfile(null);
const normalizedProfile = normalizeRpgEntryAgentBackedProfile(
entry.profile,
);
setGeneratedCustomWorldProfile(normalizedProfile);
markAutoSavedProfile(normalizedProfile);
setGeneratedCustomWorldProfile(entry.profile);
markAutoSavedProfile(entry.profile);
setCustomWorldAutoSaveState('saved');
setCustomWorldAutoSaveError(null);
setCustomWorldError(null);
@@ -262,34 +242,28 @@ export function useRpgEntryLibraryDetail(
resetAutoSaveTrackingToIdle();
try {
const latestSession = await syncAgentSessionSnapshot(work.sessionId);
const nextProfile = buildDraftResultProfile(latestSession);
const shouldOpenAgentWorkspace =
!latestSession?.draftProfile ||
!latestSession.stage ||
!AGENT_RESULT_STAGES.has(latestSession.stage);
const resultView = await syncAgentCreationResultView(work.sessionId);
const nextProfile = buildDraftResultProfile(resultView);
const shouldResumeFailedGenerationView =
!nextProfile &&
//u.test(`${work.stageLabel ?? ''}${work.summary ?? ''}`);
if (shouldResumeFailedGenerationView) {
if (resultView?.targetStage === 'custom-world-generating') {
// 生成过程中失败的草稿要回到生成过程页承接错误处理,避免误回 Agent 对话。
suppressAgentDraftResultAutoOpen();
persistAgentUiState(
work.sessionId,
null,
'agent-draft-foundation',
resultView.generationViewSource ?? 'agent-draft-foundation',
);
setGeneratedCustomWorldProfile(null);
setCustomWorldGenerationViewSource('agent-draft-foundation');
setCustomWorldGenerationViewSource(
resultView.generationViewSource ?? 'agent-draft-foundation',
);
setCustomWorldResultViewSource(null);
setPlatformTabToCreate();
setSelectionStage('custom-world-generating');
return;
}
if (shouldOpenAgentWorkspace) {
if (resultView?.targetStage === 'agent-workspace') {
// 还没有服务端草稿真相源时只能恢复 Agent对象数量等摘要字段不能决定结果页入口。
suppressAgentDraftResultAutoOpen();
persistAgentUiState(work.sessionId, null);
@@ -302,12 +276,10 @@ export function useRpgEntryLibraryDetail(
releaseAgentDraftResultAutoOpenSuppression();
if (!nextProfile) {
persistAgentUiState(
work.sessionId,
null,
'agent-draft-foundation',
persistAgentUiState(work.sessionId, null, 'agent-draft-foundation');
setPlatformError(
'当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。',
);
setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。');
setPlatformTabToCreate();
setCustomWorldGenerationViewSource('agent-draft-foundation');
setSelectionStage('custom-world-generating');
@@ -315,12 +287,12 @@ export function useRpgEntryLibraryDetail(
}
persistAgentUiState(work.sessionId, null);
setGeneratedCustomWorldProfile(
normalizeRpgEntryAgentBackedProfile(nextProfile),
setGeneratedCustomWorldProfile(nextProfile);
setCustomWorldResultViewSource(
resultView?.resultViewSource ?? 'agent-draft',
);
setCustomWorldResultViewSource('agent-draft');
setPlatformTabToCreate();
setSelectionStage('custom-world-result');
setSelectionStage(resultView?.targetStage ?? 'custom-world-result');
return;
} catch (error) {
if (isMissingRpgEntryAgentSessionError(error)) {
@@ -391,7 +363,7 @@ export function useRpgEntryLibraryDetail(
setSavedCustomWorldEntries,
setSelectionStage,
suppressAgentDraftResultAutoOpen,
syncAgentSessionSnapshot,
syncAgentCreationResultView,
userId,
],
);

View File

@@ -283,6 +283,9 @@ export function RpgRuntimePanelRouter({
playerMana={visibleGameState.playerMana}
playerMaxMana={visibleGameState.playerMaxMana}
inBattle={visibleGameState.inBattle}
currencyText={inventoryUi.currencyText}
backpackItems={inventoryUi.backpackItems}
equipmentSlots={inventoryUi.equipmentSlots}
onUseItem={inventoryUi.useInventoryItem}
onEquipItem={inventoryUi.equipInventoryItem}
forgeRecipes={inventoryUi.forgeRecipes}

View File

@@ -225,6 +225,9 @@ export function RpgRuntimeOverlayHost({
playerMana={gameState.playerMana}
playerMaxMana={gameState.playerMaxMana}
inBattle={gameState.inBattle}
currencyText={inventoryUi.currencyText}
backpackItems={inventoryUi.backpackItems}
equipmentSlots={inventoryUi.equipmentSlots}
onUseItem={inventoryUi.useInventoryItem}
onEquipItem={inventoryUi.equipInventoryItem}
forgeRecipes={inventoryUi.forgeRecipes}