1
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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('提示词');
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../../prompts/customWorldRolePromptDefaults';
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -4,6 +4,5 @@
|
||||
*/
|
||||
export {
|
||||
buildCreationHubFallbackItems,
|
||||
normalizeAgentBackedProfile,
|
||||
resolveRpgCreationErrorMessage,
|
||||
} from '../rpg-entry/rpgEntryShared';
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
|
||||
40
src/components/rpg-entry/rpgEntryShared.test.ts
Normal file
40
src/components/rpg-entry/rpgEntryShared.test.ts
Normal 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: '结果页用户正在编辑的草稿文案',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user