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}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
NPC_PREVIEW_TALK_FUNCTION,
|
||||
shouldNpcRecruitOpenModal,
|
||||
} from './index';
|
||||
import { RPG_FUNCTION_RUNTIME_OVERVIEW } from './runtimeIndex';
|
||||
import type { Encounter, GameState, InventoryItem } from '../../types';
|
||||
|
||||
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
||||
@@ -87,6 +88,12 @@ describe('functionCatalog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps runtime overview aligned with the main function documentation list', () => {
|
||||
expect(RPG_FUNCTION_RUNTIME_OVERVIEW.allDocumentation).toEqual(
|
||||
ALL_FUNCTION_DOCUMENTATION,
|
||||
);
|
||||
});
|
||||
|
||||
it('builds flow helper options with the expected function ids', () => {
|
||||
const continueOption = buildContinueAdventureOption();
|
||||
const campTravelOption = buildCampTravelHomeOption('竹林古道');
|
||||
@@ -110,16 +117,12 @@ describe('functionCatalog', () => {
|
||||
const state = createModalState();
|
||||
const encounter = createEncounter();
|
||||
const tradeModal = buildNpcTradeModalState(
|
||||
state,
|
||||
encounter,
|
||||
'先看看货',
|
||||
[
|
||||
createInventoryItem('npc-herb', '止血草'),
|
||||
createInventoryItem('npc-ore', '陨铁碎片'),
|
||||
],
|
||||
'npc-herb',
|
||||
'player-potion',
|
||||
);
|
||||
const giftModal = buildNpcGiftModalState(
|
||||
state,
|
||||
encounter,
|
||||
'送你一样东西',
|
||||
'player-charm',
|
||||
@@ -138,18 +141,13 @@ describe('functionCatalog', () => {
|
||||
expect(shouldNpcRecruitOpenModal(1, 2)).toBe(false);
|
||||
});
|
||||
|
||||
it('prefers the first tradable player item when zero-quantity items exist', () => {
|
||||
it('keeps server-selected trade item ids when opening the trade modal', () => {
|
||||
const encounter = createEncounter();
|
||||
const tradeModal = buildNpcTradeModalState(
|
||||
createModalState({
|
||||
playerInventory: [
|
||||
createInventoryItem('empty-slot', '空槽位', { quantity: 0 }),
|
||||
createInventoryItem('usable-item', '可售草药', { quantity: 2 }),
|
||||
],
|
||||
}),
|
||||
encounter,
|
||||
'交易',
|
||||
[createInventoryItem('npc-herb', '止血草')],
|
||||
'npc-herb',
|
||||
'usable-item',
|
||||
);
|
||||
|
||||
expect(tradeModal.selectedPlayerItemId).toBe('usable-item');
|
||||
|
||||
@@ -31,6 +31,7 @@ export * from './panel/forgeCraft';
|
||||
export * from './panel/forgeDismantle';
|
||||
export * from './panel/forgeReforge';
|
||||
export * from './panel/inventoryUse';
|
||||
export * from './runtimeIndex';
|
||||
export * from './state';
|
||||
export * from './treasure/treasureInspect';
|
||||
export * from './treasure/treasureLeave';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { GiftModalState } from '../../../hooks/rpg-runtime-story/uiTypes';
|
||||
import type { Encounter, GameState } from '../../../types';
|
||||
import type { Encounter } from '../../../types';
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
@@ -16,10 +16,9 @@ export function buildNpcGiftModalIntroText(encounter: Encounter) {
|
||||
}
|
||||
|
||||
export function buildNpcGiftModalState(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
actionText: string,
|
||||
selectedItemId: string | null = state.playerInventory[0]?.id ?? null,
|
||||
selectedItemId: string | null,
|
||||
): GiftModalState {
|
||||
return {
|
||||
encounter,
|
||||
@@ -34,13 +33,13 @@ export const NPC_GIFT_FUNCTION: FunctionDocumentationEntry = {
|
||||
domain: 'npc',
|
||||
title: '向该角色送礼',
|
||||
source: 'src/data/functionCatalog/npc/npcGift.ts',
|
||||
summary: '打开送礼面板并根据礼物质量结算 affinity 变化。',
|
||||
summary: '打开送礼面板并由后端结算 affinity 变化。',
|
||||
detailedDescription:
|
||||
'它会把当前互动引到礼物选择 modal,通过本地规则估算礼物对该 NPC 的吸引力和好感增益,避免送礼结果漂移。',
|
||||
trigger: '玩家背包里存在可送出的物品时出现在 NPC 交互菜单里。',
|
||||
'它会把当前互动引到礼物选择 modal,礼物列表、好感增益和不可选原因都读取后端 runtimeNpcInteraction view。',
|
||||
trigger: '后端判断当前 NPC 可接收礼物时出现在 NPC 交互菜单里。',
|
||||
execution:
|
||||
'首次点击只打开 gift modal,确认礼物后再调用 commitGeneratedState 把送礼结果写回主流程。',
|
||||
result: '玩家可立即看到好感变化与送礼反馈,并影响后续交易、聊天和招募阈值。',
|
||||
'首次点击只打开 gift modal,确认礼物后只提交 itemId 给后端结算。',
|
||||
result: '玩家可立即看到后端结算后的好感变化与送礼反馈,并影响后续交易、聊天和招募阈值。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'modal_then_generate',
|
||||
@@ -50,7 +49,7 @@ export const NPC_GIFT_FUNCTION: FunctionDocumentationEntry = {
|
||||
animationNote: '第一次点击不驱动额外演出,重点是切到礼物面板。',
|
||||
storyNote:
|
||||
'真正的剧情推进发生在 confirmGift 之后,届时才会写入好感变化与结果文本。',
|
||||
uiNote: '会先打开 gift modal,并默认选中当前最适合作为礼物的物品。',
|
||||
uiNote: '会先打开 gift modal,并默认选中后端 view 中第一件可提交的礼物。',
|
||||
compactDetailText: '送礼提升好感',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TradeModalState } from '../../../hooks/rpg-runtime-story/uiTypes';
|
||||
import type { Encounter, GameState, InventoryItem } from '../../../types';
|
||||
import type { Encounter } from '../../../types';
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
@@ -16,21 +16,17 @@ export function buildNpcTradeModalIntroText(encounter: Encounter) {
|
||||
}
|
||||
|
||||
export function buildNpcTradeModalState(
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
actionText: string,
|
||||
npcInventory: InventoryItem[],
|
||||
selectedNpcItemId: string | null,
|
||||
selectedPlayerItemId: string | null,
|
||||
mode: 'buy' | 'sell' = selectedNpcItemId ? 'buy' : 'sell',
|
||||
): TradeModalState {
|
||||
const selectedNpcItemId =
|
||||
npcInventory.find((item) => item.quantity > 0)?.id ?? null;
|
||||
const selectedPlayerItemId =
|
||||
state.playerInventory.find((item) => item.quantity > 0)?.id ?? null;
|
||||
|
||||
return {
|
||||
encounter,
|
||||
actionText,
|
||||
introText: buildNpcTradeModalIntroText(encounter),
|
||||
mode: 'buy',
|
||||
mode,
|
||||
selectedNpcItemId,
|
||||
selectedPlayerItemId,
|
||||
selectedQuantity: 1,
|
||||
@@ -44,11 +40,11 @@ export const NPC_TRADE_FUNCTION: FunctionDocumentationEntry = {
|
||||
source: 'src/data/functionCatalog/npc/npcTrade.ts',
|
||||
summary: '打开 NPC 交易流程并结算买卖或交换。',
|
||||
detailedDescription:
|
||||
'它负责把当前交互引到交易面板,展示 NPC 库存、折扣和可交换物。第一次点击通常只打开 modal,真正确认后才继续推进剧情。',
|
||||
'它负责把当前交互引到交易面板,库存、价格、折扣和不可选原因都读取后端 runtimeNpcInteraction view。第一次点击通常只打开 modal,真正确认后才继续推进剧情。',
|
||||
trigger: '当 NPC 允许交易且自身库存非空时出现在 NPC 交互菜单里。',
|
||||
execution:
|
||||
'首次点击进入 trade modal,确认后再通过 commitGeneratedState 把结果写回主流程。',
|
||||
result: '玩家可以买入、以物易物,或在失败时得到明确的价值差提示。',
|
||||
'首次点击进入 trade modal,确认后只提交 mode、itemId、quantity 给后端结算。',
|
||||
result: '玩家可以买入、出售物品,或在后端拒绝时得到明确的失败原因。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'modal_then_generate',
|
||||
@@ -58,7 +54,7 @@ export const NPC_TRADE_FUNCTION: FunctionDocumentationEntry = {
|
||||
animationNote: '第一次点击不播额外战斗或位移动画,重点是切到交易窗口。',
|
||||
storyNote:
|
||||
'真正的剧情推进发生在 confirmTrade 之后,而不是打开 modal 的瞬间。',
|
||||
uiNote: '会先打开交易 modal,并预选 NPC 第一件商品与玩家第一件可卖物品。',
|
||||
uiNote: '会先打开交易 modal,并预选后端 view 中第一件可提交的买入 / 卖出物品。',
|
||||
compactDetailText: '查看库存与价格',
|
||||
},
|
||||
};
|
||||
|
||||
35
src/data/functionCatalog/runtimeIndex.ts
Normal file
35
src/data/functionCatalog/runtimeIndex.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { FLOW_FUNCTION_DOCUMENTATION } from './flow';
|
||||
import { NPC_FUNCTION_DOCUMENTATION } from './npc';
|
||||
import { PANEL_FUNCTION_DOCUMENTATION } from './panel';
|
||||
import {
|
||||
STATE_FUNCTION_DOCUMENTATION,
|
||||
STATE_FUNCTION_RUNTIME_SOURCES,
|
||||
} from './state';
|
||||
import { TREASURE_FUNCTION_DOCUMENTATION } from './treasure';
|
||||
import type { FunctionDocumentationEntry } from './types';
|
||||
|
||||
export const RPG_FUNCTION_RUNTIME_ALL_DOCUMENTATION: FunctionDocumentationEntry[] =
|
||||
[
|
||||
...STATE_FUNCTION_DOCUMENTATION,
|
||||
...NPC_FUNCTION_DOCUMENTATION,
|
||||
...TREASURE_FUNCTION_DOCUMENTATION,
|
||||
...FLOW_FUNCTION_DOCUMENTATION,
|
||||
...PANEL_FUNCTION_DOCUMENTATION,
|
||||
];
|
||||
|
||||
/**
|
||||
* RPG function 运行时总览入口。
|
||||
*
|
||||
* 目的:
|
||||
* 1. 在同一个脚本里集中看到当前所有 function 的注册入口。
|
||||
* 2. 先看总表,再跳到各自独立文件维护实现,避免重新回到巨型 switch。
|
||||
*/
|
||||
export const RPG_FUNCTION_RUNTIME_OVERVIEW = {
|
||||
allDocumentation: RPG_FUNCTION_RUNTIME_ALL_DOCUMENTATION,
|
||||
stateDocumentation: STATE_FUNCTION_DOCUMENTATION,
|
||||
npcDocumentation: NPC_FUNCTION_DOCUMENTATION,
|
||||
treasureDocumentation: TREASURE_FUNCTION_DOCUMENTATION,
|
||||
flowDocumentation: FLOW_FUNCTION_DOCUMENTATION,
|
||||
panelDocumentation: PANEL_FUNCTION_DOCUMENTATION,
|
||||
stateRuntimeSources: STATE_FUNCTION_RUNTIME_SOURCES,
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
import type { StateFunctionRuntimeSource } from '../types';
|
||||
|
||||
/**
|
||||
* battle_all_in_crush
|
||||
@@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types';
|
||||
* 战斗中的正面爆发动作。它要求主角不绕、不拖,直接把当前回合的叙事、
|
||||
* 技能权重和视觉表现都推向“强压正面敌人”的方向。
|
||||
*/
|
||||
export const BATTLE_ALL_IN_CRUSH_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
export const BATTLE_ALL_IN_CRUSH_FUNCTION_SOURCE: StateFunctionRuntimeSource = {
|
||||
definition: {
|
||||
id: 'battle_all_in_crush',
|
||||
state: 'battle',
|
||||
@@ -56,5 +56,23 @@ export const BATTLE_ALL_IN_CRUSH_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
category: 'battle',
|
||||
active: true,
|
||||
},
|
||||
runtime: {
|
||||
buildSuggestedActionText({ metrics, environment }) {
|
||||
if (metrics.monsterHpRatio <= 0.25) {
|
||||
return `压上去收掉${environment.monsterName}最后一口气`;
|
||||
}
|
||||
if (metrics.playerHpRatio <= 0.35) {
|
||||
return `顶着伤势强压${environment.monsterName}赌一波强杀`;
|
||||
}
|
||||
return `正面强压${environment.monsterName}不给喘息`;
|
||||
},
|
||||
getPriority({ metrics }) {
|
||||
return metrics.monsterHpRatio <= 0.25
|
||||
? 8
|
||||
: metrics.playerHpRatio <= 0.35
|
||||
? 2
|
||||
: 4;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
import type { StateFunctionRuntimeSource } from '../types';
|
||||
|
||||
/**
|
||||
* battle_escape_breakout
|
||||
@@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types';
|
||||
* 战斗中的脱离动作。它不是继续换血,而是明确让主角放弃当前缠斗,
|
||||
* 把叙事重心切到“拉开距离、甩开追击、离开战场”。
|
||||
*/
|
||||
export const BATTLE_ESCAPE_BREAKOUT_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
export const BATTLE_ESCAPE_BREAKOUT_FUNCTION_SOURCE: StateFunctionRuntimeSource = {
|
||||
definition: {
|
||||
id: 'battle_escape_breakout',
|
||||
state: 'battle',
|
||||
@@ -51,5 +51,20 @@ export const BATTLE_ESCAPE_BREAKOUT_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
category: 'escape',
|
||||
active: true,
|
||||
},
|
||||
runtime: {
|
||||
buildSuggestedActionText({ metrics, environment }) {
|
||||
if (metrics.playerHpRatio <= 0.35) {
|
||||
return `撑着伤势先脱离${environment.monsterName}的追杀`;
|
||||
}
|
||||
return `转身拉开距离,甩开${environment.monsterName}`;
|
||||
},
|
||||
getPriority({ metrics }) {
|
||||
return metrics.playerHpRatio <= 0.2
|
||||
? 9
|
||||
: metrics.playerHpRatio <= 0.35
|
||||
? 5
|
||||
: 1;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
import type { StateFunctionRuntimeSource } from '../types';
|
||||
|
||||
/**
|
||||
* battle_feint_step
|
||||
@@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types';
|
||||
* 战斗中的机动切入动作。它把重点放在虚晃、变线与抢身位,
|
||||
* 让战斗叙事更偏向灵活切入而不是硬扛伤害。
|
||||
*/
|
||||
export const BATTLE_FEINT_STEP_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
export const BATTLE_FEINT_STEP_FUNCTION_SOURCE: StateFunctionRuntimeSource = {
|
||||
definition: {
|
||||
id: 'battle_feint_step',
|
||||
state: 'battle',
|
||||
@@ -56,5 +56,16 @@ export const BATTLE_FEINT_STEP_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
category: 'battle',
|
||||
active: true,
|
||||
},
|
||||
runtime: {
|
||||
buildSuggestedActionText({ metrics, environment }) {
|
||||
if (metrics.monsterHpRatio <= 0.35) {
|
||||
return `虚晃切进去收掉${environment.monsterName}`;
|
||||
}
|
||||
return `借假动作切进${environment.monsterName}身前`;
|
||||
},
|
||||
getPriority({ metrics }) {
|
||||
return metrics.monsterHpRatio <= 0.5 ? 5 : 3;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
import type { StateFunctionRuntimeSource } from '../types';
|
||||
|
||||
/**
|
||||
* battle_finisher_window
|
||||
@@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types';
|
||||
* 战斗中的终结窗口动作。它要求系统把这一回合理解为“敌人已经露出空档”,
|
||||
* 因而优先演出收割、补刀和终结技。
|
||||
*/
|
||||
export const BATTLE_FINISHER_WINDOW_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
export const BATTLE_FINISHER_WINDOW_FUNCTION_SOURCE: StateFunctionRuntimeSource = {
|
||||
definition: {
|
||||
id: 'battle_finisher_window',
|
||||
state: 'battle',
|
||||
@@ -55,5 +55,23 @@ export const BATTLE_FINISHER_WINDOW_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
category: 'battle',
|
||||
active: true,
|
||||
},
|
||||
runtime: {
|
||||
buildSuggestedActionText({ metrics, environment }) {
|
||||
if (metrics.monsterHpRatio <= 0.25) {
|
||||
return `完成对${environment.monsterName}的残血收割`;
|
||||
}
|
||||
if (metrics.monsterHpRatio <= 0.45) {
|
||||
return `抓住${environment.monsterName}露出的破绽补上重击`;
|
||||
}
|
||||
return `盯住${environment.monsterName}的空当准备终结一击`;
|
||||
},
|
||||
getPriority({ metrics }) {
|
||||
return metrics.monsterHpRatio <= 0.25
|
||||
? 10
|
||||
: metrics.monsterHpRatio <= 0.45
|
||||
? 6
|
||||
: 1;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
import type { StateFunctionRuntimeSource } from '../types';
|
||||
|
||||
/**
|
||||
* battle_guard_break
|
||||
@@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types';
|
||||
* 战斗中的破架重击动作。它强调“针对敌人当前动作强拆架势”,
|
||||
* 比纯换血更讲究把敌人的节奏打断。
|
||||
*/
|
||||
export const BATTLE_GUARD_BREAK_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
export const BATTLE_GUARD_BREAK_FUNCTION_SOURCE: StateFunctionRuntimeSource = {
|
||||
definition: {
|
||||
id: 'battle_guard_break',
|
||||
state: 'battle',
|
||||
@@ -54,5 +54,16 @@ export const BATTLE_GUARD_BREAK_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
category: 'battle',
|
||||
active: true,
|
||||
},
|
||||
runtime: {
|
||||
buildSuggestedActionText({ metrics, environment }) {
|
||||
if (metrics.monsterHpRatio <= 0.35) {
|
||||
return `砸开${environment.monsterName}的架势直接斩落`;
|
||||
}
|
||||
return `重击破开${environment.monsterName}的招架`;
|
||||
},
|
||||
getPriority({ metrics }) {
|
||||
return metrics.monsterHpRatio <= 0.4 ? 6 : 3;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
import type { StateFunctionRuntimeSource } from '../types';
|
||||
|
||||
/**
|
||||
* battle_probe_pressure
|
||||
@@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types';
|
||||
* 战斗中的稳扎试探动作。适合在局势未明、资源需要保留时,
|
||||
* 先用安全且持续的压制把信息和节奏摸出来。
|
||||
*/
|
||||
export const BATTLE_PROBE_PRESSURE_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
export const BATTLE_PROBE_PRESSURE_FUNCTION_SOURCE: StateFunctionRuntimeSource = {
|
||||
definition: {
|
||||
id: 'battle_probe_pressure',
|
||||
state: 'battle',
|
||||
@@ -54,5 +54,19 @@ export const BATTLE_PROBE_PRESSURE_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
category: 'battle',
|
||||
active: true,
|
||||
},
|
||||
runtime: {
|
||||
buildSuggestedActionText({ metrics, environment }) {
|
||||
if (metrics.playerManaRatio <= 0.3) {
|
||||
return `稳住节奏试探${environment.monsterName},先省下灵力`;
|
||||
}
|
||||
if (metrics.monsterHpRatio <= 0.3) {
|
||||
return `稳步逼近,补掉${environment.monsterName}残余血量`;
|
||||
}
|
||||
return `稳扎稳打继续试探${environment.monsterName}`;
|
||||
},
|
||||
getPriority({ metrics }) {
|
||||
return metrics.playerManaRatio <= 0.3 ? 8 : 4;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
import type { StateFunctionRuntimeSource } from '../types';
|
||||
|
||||
/**
|
||||
* battle_recover_breath
|
||||
@@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types';
|
||||
* 战斗中的恢复动作。它会把当前回合塑造成“先稳住伤势与灵力”,
|
||||
* 让数值、冷却和叙事都朝回气与整顿节奏的方向靠拢。
|
||||
*/
|
||||
export const BATTLE_RECOVER_BREATH_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
export const BATTLE_RECOVER_BREATH_FUNCTION_SOURCE: StateFunctionRuntimeSource = {
|
||||
definition: {
|
||||
id: 'battle_recover_breath',
|
||||
state: 'battle',
|
||||
@@ -57,5 +57,22 @@ export const BATTLE_RECOVER_BREATH_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
category: 'recovery',
|
||||
active: true,
|
||||
},
|
||||
runtime: {
|
||||
buildSuggestedActionText({ metrics }) {
|
||||
if (metrics.playerHpRatio <= 0.35) {
|
||||
return '原地打坐恢复血量';
|
||||
}
|
||||
if (metrics.playerManaRatio <= 0.3) {
|
||||
return '收势调息回一口灵力';
|
||||
}
|
||||
return '边守边调息稳住节奏';
|
||||
},
|
||||
getPriority({ metrics }) {
|
||||
return (
|
||||
(metrics.playerHpRatio <= 0.35 ? 10 : 0) +
|
||||
(metrics.playerManaRatio <= 0.3 ? 6 : 0)
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
import type { StateFunctionRuntimeSource } from '../types';
|
||||
|
||||
/**
|
||||
* idle_call_out
|
||||
@@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types';
|
||||
* 空闲状态下的主动喊话动作。它会把探索从“静悄悄地摸过去”
|
||||
* 转成“先出声试探,看谁先回应”的节奏。
|
||||
*/
|
||||
export const IDLE_CALL_OUT_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
export const IDLE_CALL_OUT_FUNCTION_SOURCE: StateFunctionRuntimeSource = {
|
||||
definition: {
|
||||
id: 'idle_call_out',
|
||||
state: 'idle',
|
||||
@@ -44,5 +44,24 @@ export const IDLE_CALL_OUT_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
category: 'idle',
|
||||
active: true,
|
||||
},
|
||||
runtime: {
|
||||
applyDefinitionAdjustments(definition) {
|
||||
return {
|
||||
...definition,
|
||||
text: '主动出声试探',
|
||||
description:
|
||||
'主动朝前方喊话试探,可能把附近潜着的角色或怪物直接从远处引出来。',
|
||||
};
|
||||
},
|
||||
buildSuggestedActionText({ environment }) {
|
||||
return `冲着${environment.sceneName}前方扬声试探,看是谁先被逼出来`;
|
||||
},
|
||||
buildDetailText() {
|
||||
return '主动打破寂静,把附近潜着的角色或怪物从屏幕外直接引到眼前。';
|
||||
},
|
||||
getPriority() {
|
||||
return 5;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
import type { StateFunctionRuntimeSource } from '../types';
|
||||
|
||||
/**
|
||||
* idle_explore_forward
|
||||
@@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types';
|
||||
* 空闲状态下最核心的推进动作。它负责把“继续往前探”从一句泛化文案,
|
||||
* 落成真正会引出下一幕遭遇的运行时 function。
|
||||
*/
|
||||
export const IDLE_EXPLORE_FORWARD_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
export const IDLE_EXPLORE_FORWARD_FUNCTION_SOURCE: StateFunctionRuntimeSource = {
|
||||
definition: {
|
||||
id: 'idle_explore_forward',
|
||||
state: 'idle',
|
||||
@@ -44,5 +44,32 @@ export const IDLE_EXPLORE_FORWARD_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
category: 'idle',
|
||||
active: true,
|
||||
},
|
||||
runtime: {
|
||||
applyDefinitionAdjustments(definition) {
|
||||
return {
|
||||
...definition,
|
||||
text: '继续向前探索',
|
||||
description:
|
||||
'沿着当前场景继续深入,把前路真正探出来,下一刻就可能撞上新的危险或际遇。',
|
||||
};
|
||||
},
|
||||
buildSuggestedActionText({ metrics, environment }) {
|
||||
if (metrics.playerHpRatio <= 0.35) {
|
||||
return `按着伤口,沿着${environment.sceneName}继续往深处摸去`;
|
||||
}
|
||||
if (environment.hasForwardScene) {
|
||||
return `顺着${environment.sceneName}的路势,继续朝前方深处探去`;
|
||||
}
|
||||
return `拨开${environment.sceneName}前的遮挡,继续朝更深处探去`;
|
||||
},
|
||||
buildDetailText({ environment }) {
|
||||
return environment.hasForwardScene
|
||||
? `沿着${environment.sceneName}继续往前压过去,真正把前方会遇到的人影、怪物或宝藏探出来。`
|
||||
: `继续深入${environment.sceneName}前方未探明的地带,下一刻就可能撞见新的动静。`;
|
||||
},
|
||||
getPriority({ metrics }) {
|
||||
return metrics.playerHpRatio > 0.45 ? 6 : 2;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
import type { StateFunctionRuntimeSource } from '../types';
|
||||
|
||||
/**
|
||||
* idle_follow_clue
|
||||
@@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types';
|
||||
* 空闲状态下的循线推进动作。它在源码定义层仍然存在,
|
||||
* 但当前运行时会在聚合阶段被过滤,因此属于保留中的停用 function。
|
||||
*/
|
||||
export const IDLE_FOLLOW_CLUE_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
export const IDLE_FOLLOW_CLUE_FUNCTION_SOURCE: StateFunctionRuntimeSource = {
|
||||
definition: {
|
||||
id: 'idle_follow_clue',
|
||||
state: 'idle',
|
||||
@@ -44,5 +44,16 @@ export const IDLE_FOLLOW_CLUE_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
category: 'idle',
|
||||
active: false,
|
||||
},
|
||||
runtime: {
|
||||
buildSuggestedActionText() {
|
||||
return '顺着可疑痕迹继续靠近';
|
||||
},
|
||||
buildDetailText() {
|
||||
return '沿着声音、脚印或灵气痕迹继续摸过去,可能更快接近前方目标。';
|
||||
},
|
||||
getPriority() {
|
||||
return 5;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
import type { StateFunctionRuntimeSource } from '../types';
|
||||
|
||||
/**
|
||||
* idle_observe_signs
|
||||
@@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types';
|
||||
* 空闲状态下的侦察动作。它把当前回合定义成“停下来观察”,
|
||||
* 重点不是立刻推进,而是为后续选择生成可引用的观察结果。
|
||||
*/
|
||||
export const IDLE_OBSERVE_SIGNS_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
export const IDLE_OBSERVE_SIGNS_FUNCTION_SOURCE: StateFunctionRuntimeSource = {
|
||||
definition: {
|
||||
id: 'idle_observe_signs',
|
||||
state: 'idle',
|
||||
@@ -44,5 +44,16 @@ export const IDLE_OBSERVE_SIGNS_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
category: 'idle',
|
||||
active: true,
|
||||
},
|
||||
runtime: {
|
||||
buildSuggestedActionText() {
|
||||
return '停步观察附近的风吹草动';
|
||||
},
|
||||
buildDetailText() {
|
||||
return '先确认附近是否潜伏着人影、怪物或其他值得靠近的东西。';
|
||||
},
|
||||
getPriority() {
|
||||
return 4;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
import type { StateFunctionRuntimeSource } from '../types';
|
||||
|
||||
/**
|
||||
* idle_rest_focus
|
||||
@@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types';
|
||||
* 空闲状态下的原地恢复动作。它不会推进遭遇,而是给玩家一个
|
||||
* 在非战斗场景里回收少量血蓝的缓冲回合。
|
||||
*/
|
||||
export const IDLE_REST_FOCUS_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
export const IDLE_REST_FOCUS_FUNCTION_SOURCE: StateFunctionRuntimeSource = {
|
||||
definition: {
|
||||
id: 'idle_rest_focus',
|
||||
state: 'idle',
|
||||
@@ -46,5 +46,21 @@ export const IDLE_REST_FOCUS_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
category: 'recovery',
|
||||
active: true,
|
||||
},
|
||||
runtime: {
|
||||
buildSuggestedActionText({ metrics }) {
|
||||
if (metrics.playerHpRatio <= 0.35) {
|
||||
return '原地打坐恢复气血';
|
||||
}
|
||||
if (metrics.playerManaRatio <= 0.35) {
|
||||
return '盘坐调息恢复灵力';
|
||||
}
|
||||
return '原地调息整理状态';
|
||||
},
|
||||
getPriority({ metrics }) {
|
||||
return metrics.playerHpRatio <= 0.35 || metrics.playerManaRatio <= 0.35
|
||||
? 8
|
||||
: 2;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AnimationState } from '../../../types';
|
||||
import type { StateFunctionSource } from '../types';
|
||||
import type { StateFunctionRuntimeSource } from '../types';
|
||||
|
||||
/**
|
||||
* idle_travel_next_scene
|
||||
@@ -7,7 +7,7 @@ import type { StateFunctionSource } from '../types';
|
||||
* 空闲状态下的切场景动作。它代表玩家主动离开当前地点,
|
||||
* 进入相邻场景重新开启新的遭遇周期。
|
||||
*/
|
||||
export const IDLE_TRAVEL_NEXT_SCENE_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
export const IDLE_TRAVEL_NEXT_SCENE_FUNCTION_SOURCE: StateFunctionRuntimeSource = {
|
||||
definition: {
|
||||
id: 'idle_travel_next_scene',
|
||||
state: 'idle',
|
||||
@@ -44,5 +44,21 @@ export const IDLE_TRAVEL_NEXT_SCENE_FUNCTION_SOURCE: StateFunctionSource = {
|
||||
category: 'idle',
|
||||
active: true,
|
||||
},
|
||||
runtime: {
|
||||
buildSuggestedActionText({ environment }) {
|
||||
return environment.travelSceneName
|
||||
? `前往${environment.travelSceneName}`
|
||||
: '前往其他场景';
|
||||
},
|
||||
buildDetailText({ environment }) {
|
||||
return (
|
||||
environment.travelSceneDescription ??
|
||||
'离开当前区域,前往相邻场景继续冒险。'
|
||||
);
|
||||
},
|
||||
getPriority({ metrics }) {
|
||||
return metrics.playerHpRatio > 0.45 ? 5 : 3;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { StateFunctionSource } from '../types';
|
||||
import type { StateFunctionRuntimeSource } from '../types';
|
||||
import { BATTLE_ALL_IN_CRUSH_FUNCTION_SOURCE } from './battleAllInCrush';
|
||||
import { BATTLE_ATTACK_BASIC_FUNCTION } from './battleAttackBasic';
|
||||
import { BATTLE_ESCAPE_BREAKOUT_FUNCTION_SOURCE } from './battleEscapeBreakout';
|
||||
@@ -15,7 +15,7 @@ import { IDLE_OBSERVE_SIGNS_FUNCTION_SOURCE } from './idleObserveSigns';
|
||||
import { IDLE_REST_FOCUS_FUNCTION_SOURCE } from './idleRestFocus';
|
||||
import { IDLE_TRAVEL_NEXT_SCENE_FUNCTION_SOURCE } from './idleTravelNextScene';
|
||||
|
||||
export const STATE_FUNCTION_SOURCES: StateFunctionSource[] = [
|
||||
export const STATE_FUNCTION_SOURCES: StateFunctionRuntimeSource[] = [
|
||||
BATTLE_ALL_IN_CRUSH_FUNCTION_SOURCE,
|
||||
BATTLE_GUARD_BREAK_FUNCTION_SOURCE,
|
||||
BATTLE_PROBE_PRESSURE_FUNCTION_SOURCE,
|
||||
@@ -31,6 +31,10 @@ export const STATE_FUNCTION_SOURCES: StateFunctionSource[] = [
|
||||
IDLE_CALL_OUT_FUNCTION_SOURCE,
|
||||
];
|
||||
|
||||
export const STATE_FUNCTION_RUNTIME_SOURCES = STATE_FUNCTION_SOURCES.filter(
|
||||
(source) => source.runtime,
|
||||
);
|
||||
|
||||
export const STATE_FUNCTION_DEFINITIONS = STATE_FUNCTION_SOURCES.map(
|
||||
(source) => source.definition,
|
||||
);
|
||||
@@ -47,4 +51,3 @@ export const STATE_FUNCTION_DOCUMENTATION = [
|
||||
BATTLE_USE_SKILL_FUNCTION,
|
||||
...STATE_FUNCTION_SOURCES.map((source) => source.documentation),
|
||||
];
|
||||
|
||||
|
||||
@@ -56,3 +56,40 @@ export interface StateFunctionSource {
|
||||
documentation: FunctionDocumentationEntry;
|
||||
promptDescription: string;
|
||||
}
|
||||
|
||||
export interface StateFunctionRuntimeMetrics {
|
||||
playerHpRatio: number;
|
||||
playerManaRatio: number;
|
||||
monsterHpRatio: number;
|
||||
}
|
||||
|
||||
export interface StateFunctionRuntimeEnvironment {
|
||||
sceneName: string;
|
||||
monsterName: string;
|
||||
hasForwardScene: boolean;
|
||||
travelSceneName?: string | null;
|
||||
travelSceneDescription?: string | null;
|
||||
}
|
||||
|
||||
export interface StateFunctionRuntimeHandler {
|
||||
applyDefinitionAdjustments?: (
|
||||
definition: StateFunctionDefinition,
|
||||
) => StateFunctionDefinition;
|
||||
buildSuggestedActionText?: (params: {
|
||||
definition: StateFunctionDefinition;
|
||||
metrics: StateFunctionRuntimeMetrics;
|
||||
environment: StateFunctionRuntimeEnvironment;
|
||||
}) => string;
|
||||
buildDetailText?: (params: {
|
||||
definition: StateFunctionDefinition;
|
||||
environment: StateFunctionRuntimeEnvironment;
|
||||
}) => string | undefined;
|
||||
getPriority?: (params: {
|
||||
definition: StateFunctionDefinition;
|
||||
metrics: StateFunctionRuntimeMetrics;
|
||||
}) => number;
|
||||
}
|
||||
|
||||
export interface StateFunctionRuntimeSource extends StateFunctionSource {
|
||||
runtime?: StateFunctionRuntimeHandler;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
NPC_RECRUIT_FUNCTION,
|
||||
STATE_FUNCTION_DEFINITIONS as SPLIT_STATE_FUNCTION_DEFINITIONS,
|
||||
STATE_FUNCTION_PROMPT_DESCRIPTIONS as SPLIT_STATE_FUNCTION_PROMPT_DESCRIPTIONS,
|
||||
STATE_FUNCTION_RUNTIME_SOURCES,
|
||||
} from './functionCatalog';
|
||||
import {
|
||||
getForwardScenePreset,
|
||||
@@ -103,6 +104,12 @@ export function getFunctionPromptDescription(
|
||||
const STATE_FUNCTION_OVERRIDES =
|
||||
stateFunctionOverridesJson as StateFunctionOverrideMap;
|
||||
const BASE_FUNCTIONS = [...SPLIT_STATE_FUNCTION_DEFINITIONS];
|
||||
const STATE_FUNCTION_RUNTIME_SOURCE_MAP = new Map(
|
||||
STATE_FUNCTION_RUNTIME_SOURCES.map((source) => [
|
||||
source.definition.id,
|
||||
source,
|
||||
]),
|
||||
);
|
||||
|
||||
function mergeStateFunctionDefinition(
|
||||
definition: StateFunctionDefinition,
|
||||
@@ -151,25 +158,9 @@ function applyRuntimeFunctionAdjustments(
|
||||
return definitions
|
||||
.filter((definition) => definition.id !== 'idle_follow_clue')
|
||||
.map((definition) => {
|
||||
if (definition.id === 'idle_explore_forward') {
|
||||
return {
|
||||
...definition,
|
||||
text: '继续向前探索',
|
||||
description:
|
||||
'沿着当前场景继续深入,把前路真正探出来,下一刻就可能撞上新的危险或际遇。',
|
||||
};
|
||||
}
|
||||
|
||||
if (definition.id === 'idle_call_out') {
|
||||
return {
|
||||
...definition,
|
||||
text: '主动出声试探',
|
||||
description:
|
||||
'主动朝前方喊话试探,可能把附近潜着的角色或怪物直接从远处引出来。',
|
||||
};
|
||||
}
|
||||
|
||||
return definition;
|
||||
const runtime =
|
||||
STATE_FUNCTION_RUNTIME_SOURCE_MAP.get(definition.id)?.runtime;
|
||||
return runtime?.applyDefinitionAdjustments?.(definition) ?? definition;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -211,15 +202,16 @@ function getMonsterHpRatio(context: FunctionAvailabilityContext) {
|
||||
return monster.hp / Math.max(monster.maxHp, 1);
|
||||
}
|
||||
|
||||
function buildSuggestedActionText(
|
||||
definition: StateFunctionDefinition,
|
||||
context: FunctionAvailabilityContext,
|
||||
) {
|
||||
function buildRuntimeMetrics(context: FunctionAvailabilityContext) {
|
||||
return {
|
||||
playerHpRatio: getPlayerHpRatio(context),
|
||||
playerManaRatio: getPlayerManaRatio(context),
|
||||
monsterHpRatio: getMonsterHpRatio(context),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimeEnvironment(context: FunctionAvailabilityContext) {
|
||||
const monster = getPrimaryMonster(context);
|
||||
const monsterName = monster?.name ?? '前方怪物';
|
||||
const playerHpRatio = getPlayerHpRatio(context);
|
||||
const playerManaRatio = getPlayerManaRatio(context);
|
||||
const monsterHpRatio = getMonsterHpRatio(context);
|
||||
const forwardScene = getForwardScenePreset(
|
||||
context.worldType,
|
||||
context.currentSceneId,
|
||||
@@ -229,153 +221,51 @@ function buildSuggestedActionText(
|
||||
context.currentSceneId,
|
||||
);
|
||||
|
||||
const sceneName = context.currentSceneName ?? '前路';
|
||||
return {
|
||||
sceneName: context.currentSceneName ?? '前路',
|
||||
monsterName: monster?.name ?? '前方怪物',
|
||||
hasForwardScene: Boolean(forwardScene),
|
||||
travelSceneName: travelScene?.name ?? null,
|
||||
travelSceneDescription: travelScene?.description ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (definition.id === 'idle_explore_forward') {
|
||||
if (playerHpRatio <= 0.35)
|
||||
return `按着伤口,沿着${sceneName}继续往深处摸去`;
|
||||
if (forwardScene) return `顺着${sceneName}的路势,继续朝前方深处探去`;
|
||||
return `拨开${sceneName}前的遮挡,继续朝更深处探去`;
|
||||
}
|
||||
|
||||
if (definition.id === 'idle_call_out') {
|
||||
return `冲着${sceneName}前方扬声试探,看是谁先被逼出来`;
|
||||
}
|
||||
|
||||
switch (definition.id) {
|
||||
case 'battle_finisher_window':
|
||||
if (monsterHpRatio <= 0.25) return `完成对${monsterName}的残血收割`;
|
||||
if (monsterHpRatio <= 0.45) return `抓住${monsterName}露出的破绽补上重击`;
|
||||
return `盯住${monsterName}的空当准备终结一击`;
|
||||
case 'battle_all_in_crush':
|
||||
if (monsterHpRatio <= 0.25) return `压上去收掉${monsterName}最后一口气`;
|
||||
if (playerHpRatio <= 0.35) return `顶着伤势强压${monsterName}赌一波强杀`;
|
||||
return `正面强压${monsterName}不给喘息`;
|
||||
case 'battle_guard_break':
|
||||
if (monsterHpRatio <= 0.35) return `砸开${monsterName}的架势直接斩落`;
|
||||
return `重击破开${monsterName}的招架`;
|
||||
case 'battle_probe_pressure':
|
||||
if (playerManaRatio <= 0.3)
|
||||
return `稳住节奏试探${monsterName},先省下灵力`;
|
||||
if (monsterHpRatio <= 0.3) return `稳步逼近,补掉${monsterName}残余血量`;
|
||||
return `稳扎稳打继续试探${monsterName}`;
|
||||
case 'battle_feint_step':
|
||||
if (monsterHpRatio <= 0.35) return `虚晃切进去收掉${monsterName}`;
|
||||
return `借假动作切进${monsterName}身前`;
|
||||
case 'battle_recover_breath':
|
||||
if (playerHpRatio <= 0.35) return '原地打坐恢复血量';
|
||||
if (playerManaRatio <= 0.3) return '收势调息回一口灵力';
|
||||
return '边守边调息稳住节奏';
|
||||
case 'battle_escape_breakout':
|
||||
if (playerHpRatio <= 0.35) return `撑着伤势先脱离${monsterName}的追杀`;
|
||||
return `转身拉开距离,甩开${monsterName}`;
|
||||
case 'idle_explore_forward':
|
||||
if (forwardScene) return `继续向前探路`;
|
||||
if (playerHpRatio <= 0.35) return '拖着伤势继续向前摸索';
|
||||
return '继续向前探索前路';
|
||||
case 'idle_travel_next_scene':
|
||||
return travelScene ? `前往${travelScene.name}` : '前往其他场景';
|
||||
case 'idle_rest_focus':
|
||||
if (playerHpRatio <= 0.35) return '原地打坐恢复气血';
|
||||
if (playerManaRatio <= 0.35) return '盘坐调息恢复灵力';
|
||||
return '原地调息整理状态';
|
||||
case 'idle_observe_signs':
|
||||
return '停步观察附近的风吹草动';
|
||||
case 'idle_follow_clue':
|
||||
return '顺着可疑痕迹继续靠近';
|
||||
case 'idle_call_out':
|
||||
return '朝前方主动出声试探';
|
||||
default:
|
||||
return definition.text;
|
||||
}
|
||||
function buildSuggestedActionText(
|
||||
definition: StateFunctionDefinition,
|
||||
context: FunctionAvailabilityContext,
|
||||
) {
|
||||
const runtime = STATE_FUNCTION_RUNTIME_SOURCE_MAP.get(definition.id)?.runtime;
|
||||
return (
|
||||
runtime?.buildSuggestedActionText?.({
|
||||
definition,
|
||||
metrics: buildRuntimeMetrics(context),
|
||||
environment: buildRuntimeEnvironment(context),
|
||||
}) ?? definition.text
|
||||
);
|
||||
}
|
||||
|
||||
function buildOptionDetailText(
|
||||
definition: StateFunctionDefinition,
|
||||
context: FunctionAvailabilityContext,
|
||||
) {
|
||||
const forwardScene = getForwardScenePreset(
|
||||
context.worldType,
|
||||
context.currentSceneId,
|
||||
);
|
||||
const travelScene = getTravelScenePreset(
|
||||
context.worldType,
|
||||
context.currentSceneId,
|
||||
);
|
||||
const sceneName = context.currentSceneName ?? '当前区域';
|
||||
|
||||
if (definition.id === 'idle_explore_forward') {
|
||||
return forwardScene
|
||||
? `沿着${sceneName}继续往前压过去,真正把前方会遇到的人影、怪物或宝藏探出来。`
|
||||
: `继续深入${sceneName}前方未探明的地带,下一刻就可能撞见新的动静。`;
|
||||
}
|
||||
|
||||
if (definition.id === 'idle_call_out') {
|
||||
return '主动打破寂静,把附近潜着的角色或怪物从屏幕外直接引到眼前。';
|
||||
}
|
||||
|
||||
switch (definition.id) {
|
||||
case 'idle_explore_forward':
|
||||
return forwardScene
|
||||
? `沿当前路径继续深入,可能会遇到角色、怪物、宝藏……`
|
||||
: '继续向前试探这片区域,可能会遇到角色、怪物、宝藏……';
|
||||
case 'idle_travel_next_scene':
|
||||
return travelScene?.description ?? '离开当前区域,前往相邻场景继续冒险。';
|
||||
case 'idle_observe_signs':
|
||||
return '先确认附近是否潜伏着人影、怪物或其他值得靠近的东西。';
|
||||
case 'idle_follow_clue':
|
||||
return '沿着声音、脚印或灵气痕迹继续摸过去,可能更快接近前方目标。';
|
||||
case 'idle_call_out':
|
||||
return '主动打破寂静,看看附近是谁或什么东西先有反应。';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
const runtime = STATE_FUNCTION_RUNTIME_SOURCE_MAP.get(definition.id)?.runtime;
|
||||
return runtime?.buildDetailText?.({
|
||||
definition,
|
||||
environment: buildRuntimeEnvironment(context),
|
||||
});
|
||||
}
|
||||
|
||||
function getFunctionPriority(
|
||||
definition: StateFunctionDefinition,
|
||||
context: FunctionAvailabilityContext,
|
||||
) {
|
||||
const playerHpRatio = getPlayerHpRatio(context);
|
||||
const playerManaRatio = getPlayerManaRatio(context);
|
||||
const monsterHpRatio = getMonsterHpRatio(context);
|
||||
|
||||
if (definition.id === 'idle_call_out') {
|
||||
return 5;
|
||||
}
|
||||
|
||||
switch (definition.id) {
|
||||
case 'battle_recover_breath':
|
||||
return (
|
||||
(playerHpRatio <= 0.35 ? 10 : 0) + (playerManaRatio <= 0.3 ? 6 : 0)
|
||||
);
|
||||
case 'battle_finisher_window':
|
||||
return monsterHpRatio <= 0.25 ? 10 : monsterHpRatio <= 0.45 ? 6 : 1;
|
||||
case 'battle_all_in_crush':
|
||||
return monsterHpRatio <= 0.25 ? 8 : playerHpRatio <= 0.35 ? 2 : 4;
|
||||
case 'battle_guard_break':
|
||||
return monsterHpRatio <= 0.4 ? 6 : 3;
|
||||
case 'battle_probe_pressure':
|
||||
return playerManaRatio <= 0.3 ? 8 : 4;
|
||||
case 'battle_feint_step':
|
||||
return monsterHpRatio <= 0.5 ? 5 : 3;
|
||||
case 'battle_escape_breakout':
|
||||
return playerHpRatio <= 0.2 ? 9 : playerHpRatio <= 0.35 ? 5 : 1;
|
||||
case 'idle_rest_focus':
|
||||
return playerHpRatio <= 0.35 || playerManaRatio <= 0.35 ? 8 : 2;
|
||||
case 'idle_explore_forward':
|
||||
return playerHpRatio > 0.45 ? 6 : 2;
|
||||
case 'idle_travel_next_scene':
|
||||
return playerHpRatio > 0.45 ? 5 : 3;
|
||||
case 'idle_observe_signs':
|
||||
return 4;
|
||||
case 'idle_follow_clue':
|
||||
return 5;
|
||||
case 'idle_call_out':
|
||||
return 3;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
const runtime = STATE_FUNCTION_RUNTIME_SOURCE_MAP.get(definition.id)?.runtime;
|
||||
return (
|
||||
runtime?.getPriority?.({
|
||||
definition,
|
||||
metrics: buildRuntimeMetrics(context),
|
||||
}) ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
function matchesCategory(
|
||||
|
||||
@@ -6,19 +6,36 @@ vi.mock('../../services/aiService', () => ({
|
||||
|
||||
const {
|
||||
isRpgRuntimeServerFunctionIdMock,
|
||||
runServerRuntimeChoiceActionMock,
|
||||
} = vi.hoisted(() => ({
|
||||
isRpgRuntimeServerFunctionIdMock: vi.fn(() => false),
|
||||
runServerRuntimeChoiceActionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/rpg-runtime', () => ({
|
||||
isRpgRuntimeServerFunctionId: isRpgRuntimeServerFunctionIdMock,
|
||||
}));
|
||||
|
||||
import { generateNextStep } from '../../services/aiService';
|
||||
import { getScenePresetsByWorld } from '../../data/scenePresets';
|
||||
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
|
||||
import { createStoryChoiceActions } from './choiceActions';
|
||||
|
||||
vi.mock('./storyChoiceRuntime', async () => {
|
||||
return {
|
||||
runCampTravelHomeChoice: vi.fn(),
|
||||
runServerRuntimeChoiceAction: runServerRuntimeChoiceActionMock,
|
||||
shouldOpenLocalRuntimeNpcModal: (option: StoryOption) =>
|
||||
(
|
||||
option.interaction?.kind === 'npc' ||
|
||||
!option.interaction
|
||||
) &&
|
||||
(
|
||||
option.functionId === 'npc_chat' ||
|
||||
option.functionId === 'npc_trade' ||
|
||||
option.functionId === 'npc_gift'
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
function createTestCharacter(): Character {
|
||||
return {
|
||||
id: 'test-hero',
|
||||
@@ -150,6 +167,7 @@ describe('createStoryChoiceActions', () => {
|
||||
beforeEach(() => {
|
||||
isRpgRuntimeServerFunctionIdMock.mockReset();
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(false);
|
||||
runServerRuntimeChoiceActionMock.mockReset();
|
||||
});
|
||||
|
||||
it('reveals deferred adventure options when story_continue_adventure is selected', async () => {
|
||||
@@ -290,19 +308,13 @@ describe('createStoryChoiceActions', () => {
|
||||
options: [continueOption],
|
||||
deferredOptions,
|
||||
deferredRuntimeState: {
|
||||
storyEngineMemory: {
|
||||
discoveredFactIds: [],
|
||||
activeThreadIds: [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
currentSceneActState: {
|
||||
sceneId: 'scene-bridge',
|
||||
chapterId: 'scene-bridge-chapter',
|
||||
currentActId: 'scene-bridge-act-2',
|
||||
currentActIndex: 1,
|
||||
completedActIds: ['scene-bridge-act-1'],
|
||||
visitedActIds: ['scene-bridge-act-1', 'scene-bridge-act-2'],
|
||||
},
|
||||
currentScenePreset: {
|
||||
id: 'scene-bridge',
|
||||
name: '断桥',
|
||||
description: '桥上雾气很重。',
|
||||
imageSrc: '/scene-bridge.png',
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -355,13 +367,14 @@ describe('createStoryChoiceActions', () => {
|
||||
|
||||
expect(setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
storyEngineMemory: expect.objectContaining({
|
||||
currentSceneActState: expect.objectContaining({
|
||||
currentActId: 'scene-bridge-act-2',
|
||||
}),
|
||||
currentScenePreset: expect.objectContaining({
|
||||
id: 'scene-bridge',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(setGameState.mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
'storyEngineMemory',
|
||||
);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith({
|
||||
...currentStory,
|
||||
options: deferredOptions,
|
||||
@@ -527,360 +540,7 @@ describe('createStoryChoiceActions', () => {
|
||||
expect(handleNpcInteraction).toHaveBeenCalledWith(option);
|
||||
});
|
||||
|
||||
it('uses deterministic continue option after local npc victory', async () => {
|
||||
const encounter: Encounter = {
|
||||
id: 'npc-opponent',
|
||||
kind: 'npc',
|
||||
npcName: '山道客',
|
||||
npcDescription: '拦路旧敌',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道旧案',
|
||||
};
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
currentEncounter: encounter,
|
||||
npcInteractionActive: true,
|
||||
};
|
||||
const option = createBattleOption();
|
||||
const afterSequence = {
|
||||
...state,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
currentNpcBattleOutcome: 'fight_victory' as const,
|
||||
};
|
||||
const generateStoryForState = vi.fn().mockResolvedValue(createFallbackStory('战后续写'));
|
||||
const setCurrentStory = vi.fn();
|
||||
const setGameState = vi.fn();
|
||||
const handleNpcBattleConversationContinuation = vi.fn(() => true);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory: createFallbackStory(),
|
||||
isLoading: false,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(() => ({
|
||||
optionKind: 'battle' as const,
|
||||
battlePlan: null,
|
||||
afterSequence,
|
||||
})),
|
||||
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
|
||||
buildStoryContextFromState: vi.fn(() => ({
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
})),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState,
|
||||
getAvailableOptionsForState: vi.fn(() => null),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation,
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(() => false),
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => ({
|
||||
nextState: {
|
||||
...afterSequence,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
inBattle: false,
|
||||
},
|
||||
resultText: '山道客已经败下阵来。胜利奖励:无战利品。',
|
||||
})),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(handleNpcBattleConversationContinuation).not.toHaveBeenCalled();
|
||||
expect(setGameState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
inBattle: false,
|
||||
}),
|
||||
);
|
||||
expect(generateStoryForState).not.toHaveBeenCalled();
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '山道客已经败下阵来。胜利奖励:无战利品。',
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
functionId: 'story_continue_adventure',
|
||||
actionText: '继续前进',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps local npc defeat on the death revive chain and resets to the first scene act', async () => {
|
||||
vi.useFakeTimers();
|
||||
const firstScene = getScenePresetsByWorld(WorldType.WUXIA)[0]!;
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
currentScenePreset: firstScene,
|
||||
storyEngineMemory: {
|
||||
discoveredFactIds: [],
|
||||
activeThreadIds: [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
currentSceneActState: {
|
||||
sceneId: firstScene.id,
|
||||
chapterId: `${firstScene.id}-chapter`,
|
||||
currentActId: `${firstScene.id}-act-2`,
|
||||
currentActIndex: 1,
|
||||
completedActIds: [`${firstScene.id}-act-1`],
|
||||
visitedActIds: [`${firstScene.id}-act-1`, `${firstScene.id}-act-2`],
|
||||
},
|
||||
},
|
||||
currentEncounter: {
|
||||
id: 'npc-opponent',
|
||||
kind: 'npc' as const,
|
||||
npcName: '山道客',
|
||||
npcDescription: '拦路旧敌',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '山道旧案',
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
};
|
||||
const option = createBattleOption();
|
||||
const afterSequence = {
|
||||
...state,
|
||||
playerHp: 0,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
currentNpcBattleOutcome: 'fight_defeat' as const,
|
||||
};
|
||||
const finalizeNpcBattleResult = vi.fn(() => ({
|
||||
nextState: afterSequence,
|
||||
resultText: '不应该进入胜利结算',
|
||||
}));
|
||||
const setCurrentStory = vi.fn();
|
||||
const setGameState = vi.fn();
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory: createFallbackStory(),
|
||||
isLoading: false,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(() => ({
|
||||
optionKind: 'battle' as const,
|
||||
battlePlan: null,
|
||||
afterSequence,
|
||||
})),
|
||||
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
|
||||
buildStoryContextFromState: vi.fn(() => ({
|
||||
playerHp: 0,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
})),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory('fallback')),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(() => null),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(() => false),
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult,
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
const choicePromise = handleChoice(option);
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
await choicePromise;
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(finalizeNpcBattleResult).not.toHaveBeenCalled();
|
||||
expect(setGameState).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
playerHp: 0,
|
||||
inBattle: false,
|
||||
currentNpcBattleOutcome: 'fight_defeat',
|
||||
animationState: AnimationState.DIE,
|
||||
}),
|
||||
);
|
||||
expect(setGameState).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
currentScenePreset: expect.objectContaining({
|
||||
id: firstScene.id,
|
||||
}),
|
||||
playerHp: 100,
|
||||
playerMana: 20,
|
||||
inBattle: false,
|
||||
currentNpcBattleOutcome: null,
|
||||
}),
|
||||
);
|
||||
const revivedState = setGameState.mock.calls[1]?.[0] as GameState;
|
||||
expect(revivedState.currentBattleNpcId).toBeNull();
|
||||
expect(revivedState.currentNpcBattleMode).toBeNull();
|
||||
expect(revivedState.currentNpcBattleOutcome).toBeNull();
|
||||
expect(
|
||||
revivedState.currentEncounter !== null || revivedState.sceneHostileNpcs.length > 0,
|
||||
).toBe(true);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('重新醒来'),
|
||||
}),
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('settles escape locally without ai continuation', async () => {
|
||||
const mockedGenerateNextStep = vi.mocked(generateNextStep);
|
||||
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'wolf-1',
|
||||
name: '山狼',
|
||||
action: '低伏逼近',
|
||||
description: '一头山狼',
|
||||
animation: 'idle' as const,
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
facing: 'left' as const,
|
||||
attackRange: 1.4,
|
||||
speed: 7,
|
||||
hp: 10,
|
||||
maxHp: 10,
|
||||
renderKind: 'npc' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
const option = createBattleOption('battle_escape_breakout');
|
||||
const afterSequence = {
|
||||
...state,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: -1.2,
|
||||
};
|
||||
const setBattleReward = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
const incrementRuntimeStats = vi.fn((inputState: GameState) => inputState);
|
||||
const buildStoryContextFromState = vi.fn(() => ({
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: -1.2,
|
||||
playerFacing: 'right' as const,
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
}));
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory: createFallbackStory(),
|
||||
isLoading: false,
|
||||
setGameState: vi.fn(),
|
||||
setCurrentStory,
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward,
|
||||
buildResolvedChoiceState: vi.fn(() => ({
|
||||
optionKind: 'escape' as const,
|
||||
battlePlan: null,
|
||||
afterSequence,
|
||||
})),
|
||||
playResolvedChoice: vi.fn().mockResolvedValue(afterSequence),
|
||||
buildStoryContextFromState,
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(() => null),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats,
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(() => false),
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(mockedGenerateNextStep).not.toHaveBeenCalled();
|
||||
expect(buildStoryContextFromState).not.toHaveBeenCalled();
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '你已成功逃脱,与山狼的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。',
|
||||
}),
|
||||
);
|
||||
expect(setBattleReward).toHaveBeenCalledTimes(1);
|
||||
expect(setBattleReward).toHaveBeenCalledWith(null);
|
||||
expect(incrementRuntimeStats).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ hostileNpcsDefeated: 0 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps battle attack and skill choices on the local combat path even if runtime server supports them', async () => {
|
||||
it('routes battle attack and skill choices to the backend resolver even while in battle', async () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
sceneHostileNpcs: [
|
||||
@@ -969,17 +629,20 @@ describe('createStoryChoiceActions', () => {
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(buildResolvedChoiceState).toHaveBeenCalledWith(
|
||||
state,
|
||||
option,
|
||||
state.playerCharacter!,
|
||||
expect(runServerRuntimeChoiceActionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gameState: state,
|
||||
option,
|
||||
character: state.playerCharacter,
|
||||
}),
|
||||
);
|
||||
expect(playResolvedChoice).toHaveBeenCalled();
|
||||
expect(setGameState).toHaveBeenCalled();
|
||||
expect(setCurrentStory).toHaveBeenCalled();
|
||||
expect(buildResolvedChoiceState).not.toHaveBeenCalled();
|
||||
expect(playResolvedChoice).not.toHaveBeenCalled();
|
||||
expect(setGameState).not.toHaveBeenCalled();
|
||||
expect(setCurrentStory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps stale battle panel choices on the local combat path when combat presentation is still visible', async () => {
|
||||
it('routes stale battle panel choices to the backend resolver when combat presentation is still visible', async () => {
|
||||
const battleOption = createBattleOption('battle_attack_basic');
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
@@ -1072,11 +735,80 @@ describe('createStoryChoiceActions', () => {
|
||||
|
||||
await handleChoice(battleOption);
|
||||
|
||||
expect(buildResolvedChoiceState).toHaveBeenCalledWith(
|
||||
state,
|
||||
battleOption,
|
||||
state.playerCharacter!,
|
||||
expect(runServerRuntimeChoiceActionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gameState: state,
|
||||
currentStory,
|
||||
option: battleOption,
|
||||
character: state.playerCharacter,
|
||||
}),
|
||||
);
|
||||
expect(playResolvedChoice).toHaveBeenCalled();
|
||||
expect(buildResolvedChoiceState).not.toHaveBeenCalled();
|
||||
expect(playResolvedChoice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes inventory_use combat choices to the backend resolver', async () => {
|
||||
const state = createBaseState();
|
||||
const option: StoryOption = {
|
||||
...createBattleOption('inventory_use'),
|
||||
runtimePayload: {
|
||||
itemId: 'focus-tonic',
|
||||
},
|
||||
};
|
||||
const buildResolvedChoiceState = vi.fn();
|
||||
const playResolvedChoice = vi.fn();
|
||||
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory: createFallbackStory(),
|
||||
isLoading: false,
|
||||
setGameState: vi.fn(),
|
||||
setCurrentStory: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(() => [option]),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => state.sceneHostileNpcs),
|
||||
getResolvedSceneHostileNpcs: vi.fn(
|
||||
(inputState: GameState) => inputState.sceneHostileNpcs,
|
||||
),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => null),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(() => false),
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(option);
|
||||
|
||||
expect(runServerRuntimeChoiceActionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gameState: state,
|
||||
option,
|
||||
character: state.playerCharacter,
|
||||
}),
|
||||
);
|
||||
expect(buildResolvedChoiceState).not.toHaveBeenCalled();
|
||||
expect(playResolvedChoice).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,39 +77,6 @@ type IncrementRuntimeStats = (
|
||||
increments: RuntimeStatsIncrements,
|
||||
) => GameState;
|
||||
|
||||
function isImmediateCombatChoice(option: StoryOption) {
|
||||
return (
|
||||
option.functionId.startsWith('battle_') ||
|
||||
option.functionId === 'inventory_use'
|
||||
);
|
||||
}
|
||||
|
||||
function shouldResolveCombatChoiceLocally(
|
||||
gameState: GameState,
|
||||
currentStory: StoryMoment | null,
|
||||
option: StoryOption,
|
||||
) {
|
||||
if (!isImmediateCombatChoice(option)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (gameState.inBattle) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasBattleMarkers =
|
||||
Boolean(gameState.currentBattleNpcId || gameState.currentNpcBattleMode) ||
|
||||
gameState.sceneHostileNpcs.some((hostileNpc) => hostileNpc.hp > 0);
|
||||
const storyStillShowsBattleChoices = Boolean(
|
||||
currentStory?.options.some(isImmediateCombatChoice),
|
||||
);
|
||||
|
||||
// 中文注释:真实运行态里可能短暂出现“可见层仍在战斗,但逻辑态 inBattle
|
||||
// 已经被提前切回 false”的窗口。如果这时玩家点击了还在面板上的 battle_* /
|
||||
// inventory_use 选项,必须继续走本地逐帧战斗链,不能误分流到服务端直结算。
|
||||
return hasBattleMarkers || storyStillShowsBattleChoices;
|
||||
}
|
||||
|
||||
export function createStoryChoiceActions({
|
||||
gameState,
|
||||
currentStory,
|
||||
@@ -213,9 +180,6 @@ export function createStoryChoiceActions({
|
||||
currentScenePreset:
|
||||
currentStory.deferredRuntimeState.currentScenePreset ??
|
||||
gameState.currentScenePreset,
|
||||
storyEngineMemory:
|
||||
currentStory.deferredRuntimeState.storyEngineMemory ??
|
||||
gameState.storyEngineMemory,
|
||||
});
|
||||
}
|
||||
setCurrentStory({
|
||||
@@ -252,10 +216,7 @@ export function createStoryChoiceActions({
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isRpgRuntimeServerFunctionId(option.functionId) &&
|
||||
!shouldResolveCombatChoiceLocally(gameState, currentStory, option)
|
||||
) {
|
||||
if (isRpgRuntimeServerFunctionId(option.functionId)) {
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory,
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { useMemo, type Dispatch, type SetStateAction } from 'react';
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||
|
||||
import type { RuntimeStoryInventoryActionView } from '../../../packages/shared/src/contracts/rpgRuntimeStoryState';
|
||||
import {
|
||||
EQUIPMENT_EQUIP_FUNCTION,
|
||||
EQUIPMENT_UNEQUIP_FUNCTION,
|
||||
FORGE_CRAFT_FUNCTION,
|
||||
FORGE_DISMANTLE_FUNCTION,
|
||||
FORGE_REFORGE_FUNCTION,
|
||||
INVENTORY_USE_FUNCTION,
|
||||
} from '../../data/functionCatalog';
|
||||
import { getForgeRecipeViews } from '../../data/forgeSystem';
|
||||
loadRpgRuntimeInventoryView,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryInventoryView,
|
||||
} from '../../services/rpg-runtime';
|
||||
import type { Character, GameState, StoryMoment } from '../../types';
|
||||
import { resolveRpgRuntimeChoice } from '.';
|
||||
import type { InventoryFlowUi } from './uiTypes';
|
||||
@@ -41,20 +38,71 @@ export function useStoryInventoryActions({
|
||||
setIsLoading,
|
||||
buildFallbackStoryForState,
|
||||
} = runtime;
|
||||
const forgeRecipes = useMemo(
|
||||
() =>
|
||||
getForgeRecipeViews(
|
||||
gameState.playerInventory,
|
||||
gameState.playerCurrency,
|
||||
gameState.worldType,
|
||||
),
|
||||
[gameState.playerCurrency, gameState.playerInventory, gameState.worldType],
|
||||
);
|
||||
const [serverInventoryView, setServerInventoryView] =
|
||||
useState<RuntimeStoryInventoryView | null>(null);
|
||||
const runtimeSessionId = gameState.runtimeSessionId;
|
||||
const runtimeActionVersion = gameState.runtimeActionVersion;
|
||||
const currentScene = gameState.currentScene;
|
||||
const hasPlayerCharacter = Boolean(gameState.playerCharacter);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasPlayerCharacter || currentScene !== 'Story') {
|
||||
setServerInventoryView(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
void loadRpgRuntimeInventoryView(
|
||||
{
|
||||
gameState: {
|
||||
runtimeSessionId,
|
||||
runtimeActionVersion,
|
||||
},
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((view) => {
|
||||
setServerInventoryView(view);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
console.error('Failed to load inventory runtime view:', error);
|
||||
setAiError(error instanceof Error ? error.message : '背包视图同步失败');
|
||||
});
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [
|
||||
currentScene,
|
||||
hasPlayerCharacter,
|
||||
runtimeActionVersion,
|
||||
runtimeSessionId,
|
||||
setAiError,
|
||||
]);
|
||||
|
||||
const rejectInventoryAction = (message: string) => {
|
||||
setAiError(message);
|
||||
return false;
|
||||
};
|
||||
|
||||
const findBackpackItemView = (itemId: string) =>
|
||||
serverInventoryView?.backpackItems.find(
|
||||
(candidate) => candidate.item.id === itemId,
|
||||
) ?? null;
|
||||
|
||||
const findEquipmentSlotView = (slot: 'weapon' | 'armor' | 'relic') =>
|
||||
serverInventoryView?.equipmentSlots.find(
|
||||
(candidate) => candidate.slotId === slot,
|
||||
) ?? null;
|
||||
|
||||
const resolveServerInventoryAction = async (params: {
|
||||
functionId: string;
|
||||
actionText: string;
|
||||
payload: Record<string, unknown>;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
}) => {
|
||||
const character = gameState.playerCharacter;
|
||||
if (
|
||||
@@ -69,7 +117,7 @@ export function useStoryInventoryActions({
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
||||
const { response, hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option: {
|
||||
@@ -81,6 +129,7 @@ export function useStoryInventoryActions({
|
||||
|
||||
setGameState(hydratedSnapshot.gameState);
|
||||
setCurrentStory(nextStory);
|
||||
setServerInventoryView(response.viewModel.inventory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve inventory runtime action on the server:', error);
|
||||
@@ -94,100 +143,80 @@ export function useStoryInventoryActions({
|
||||
}
|
||||
};
|
||||
|
||||
const useInventoryItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
const submitInventoryAction = async (
|
||||
action: RuntimeStoryInventoryActionView | undefined,
|
||||
fallbackReason: string,
|
||||
) => {
|
||||
if (!action) {
|
||||
return rejectInventoryAction(fallbackReason);
|
||||
}
|
||||
if (!action.enabled) {
|
||||
return rejectInventoryAction(action.reason ?? fallbackReason);
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: action.functionId,
|
||||
actionText: action.actionText,
|
||||
payload: action.payload as RuntimeStoryChoicePayload | undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const useInventoryItem = async (itemId: string) =>
|
||||
submitInventoryAction(
|
||||
findBackpackItemView(itemId)?.actions.use,
|
||||
'后端背包视图尚未提供该物品的使用动作。',
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: INVENTORY_USE_FUNCTION.id,
|
||||
actionText: `使用${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
const equipInventoryItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
const equipInventoryItem = async (itemId: string) =>
|
||||
submitInventoryAction(
|
||||
findBackpackItemView(itemId)?.actions.equip,
|
||||
'后端背包视图尚未提供该物品的装备动作。',
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: EQUIPMENT_EQUIP_FUNCTION.id,
|
||||
actionText: `装备${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
const unequipItem = async (slot: 'weapon' | 'armor' | 'relic') => {
|
||||
const equippedItem = gameState.playerEquipment[slot];
|
||||
if (!equippedItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: EQUIPMENT_UNEQUIP_FUNCTION.id,
|
||||
actionText: `卸下${equippedItem.name}`,
|
||||
payload: { slotId: slot },
|
||||
});
|
||||
};
|
||||
const unequipItem = async (slot: 'weapon' | 'armor' | 'relic') =>
|
||||
submitInventoryAction(
|
||||
findEquipmentSlotView(slot)?.unequip,
|
||||
'后端装备视图尚未提供该槽位的卸装动作。',
|
||||
);
|
||||
|
||||
const craftRecipe = async (recipeId: string) => {
|
||||
const recipe = forgeRecipes.find(
|
||||
const recipe = serverInventoryView?.forgeRecipes.find(
|
||||
(candidate) => candidate.id === recipeId,
|
||||
);
|
||||
if (!recipe) {
|
||||
return false;
|
||||
return rejectInventoryAction('后端锻造视图尚未提供该配方。');
|
||||
}
|
||||
if (!recipe.canCraft) {
|
||||
return rejectInventoryAction(
|
||||
recipe.disabledReason ?? recipe.action.reason ?? '当前配方不可制作。',
|
||||
);
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: FORGE_CRAFT_FUNCTION.id,
|
||||
actionText: `制作${recipe.resultLabel}`,
|
||||
payload: { recipeId },
|
||||
});
|
||||
return submitInventoryAction(recipe.action, '当前配方不可制作。');
|
||||
};
|
||||
|
||||
const dismantleItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
const dismantleItem = async (itemId: string) =>
|
||||
submitInventoryAction(
|
||||
findBackpackItemView(itemId)?.actions.dismantle,
|
||||
'后端背包视图尚未提供该物品的拆解动作。',
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: FORGE_DISMANTLE_FUNCTION.id,
|
||||
actionText: `拆解${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
const reforgeItem = async (itemId: string) => {
|
||||
const item = gameState.playerInventory.find(
|
||||
(candidate) => candidate.id === itemId,
|
||||
const reforgeItem = async (itemId: string) =>
|
||||
submitInventoryAction(
|
||||
findBackpackItemView(itemId)?.actions.reforge,
|
||||
'后端背包视图尚未提供该物品的重铸动作。',
|
||||
);
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resolveServerInventoryAction({
|
||||
functionId: FORGE_REFORGE_FUNCTION.id,
|
||||
actionText: `重铸${item.name}`,
|
||||
payload: { itemId },
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
inventoryUi: {
|
||||
useInventoryItem,
|
||||
equipInventoryItem,
|
||||
unequipItem,
|
||||
forgeRecipes,
|
||||
playerCurrency: serverInventoryView?.playerCurrency ?? null,
|
||||
currencyText: serverInventoryView?.currencyText ?? null,
|
||||
backpackItems: serverInventoryView?.backpackItems ?? [],
|
||||
equipmentSlots: serverInventoryView?.equipmentSlots ?? [],
|
||||
forgeRecipes: serverInventoryView?.forgeRecipes ?? [],
|
||||
craftRecipe,
|
||||
dismantleItem,
|
||||
reforgeItem,
|
||||
|
||||
323
src/hooks/rpg-runtime-story/npcInteraction.test.tsx
Normal file
323
src/hooks/rpg-runtime-story/npcInteraction.test.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { resolveRpgRuntimeChoiceMock } = vi.hoisted(() => ({
|
||||
resolveRpgRuntimeChoiceMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('.', () => ({
|
||||
resolveRpgRuntimeChoice: resolveRpgRuntimeChoiceMock,
|
||||
}));
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type { Character, Encounter, GameState, StoryMoment } from '../../types';
|
||||
import { AnimationState, WorldType } from '../../types';
|
||||
import { useStoryNpcInteractionFlow } from './npcInteraction';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '沈行',
|
||||
title: '试剑客',
|
||||
description: '测试角色',
|
||||
personality: '谨慎',
|
||||
skills: [],
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createEncounter(): Encounter {
|
||||
return {
|
||||
id: 'npc-merchant',
|
||||
kind: 'npc',
|
||||
npcName: '梁伯',
|
||||
npcDescription: '守着小摊的老人',
|
||||
npcAvatar: '',
|
||||
context: '行商',
|
||||
};
|
||||
}
|
||||
|
||||
function createGameState(overrides: Partial<GameState> = {}): GameState {
|
||||
const encounter = createEncounter();
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 0,
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: encounter,
|
||||
npcInteractionActive: true,
|
||||
currentScenePreset: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 40,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 16,
|
||||
playerMaxMana: 16,
|
||||
playerSkillCooldowns: {},
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
runtimeNpcInteraction: {
|
||||
npcId: 'npc-merchant',
|
||||
npcName: '梁伯',
|
||||
playerCurrency: 0,
|
||||
currencyName: '铜钱',
|
||||
trade: {
|
||||
buyItems: [
|
||||
{
|
||||
itemId: 'merchant-tonic',
|
||||
item: {
|
||||
id: 'merchant-tonic',
|
||||
category: '消耗品',
|
||||
name: '回气散',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
tags: ['mana'],
|
||||
},
|
||||
mode: 'buy',
|
||||
unitPrice: 29,
|
||||
maxQuantity: 2,
|
||||
canSubmit: false,
|
||||
reason: '当前钱币不足。',
|
||||
},
|
||||
],
|
||||
sellItems: [
|
||||
{
|
||||
itemId: 'player-ingot',
|
||||
item: {
|
||||
id: 'player-ingot',
|
||||
category: '材料',
|
||||
name: '精炼锭材',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
tags: ['material'],
|
||||
},
|
||||
mode: 'sell',
|
||||
unitPrice: 23,
|
||||
maxQuantity: 1,
|
||||
canSubmit: true,
|
||||
reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
gift: {
|
||||
items: [
|
||||
{
|
||||
itemId: 'gift-herb',
|
||||
item: {
|
||||
id: 'gift-herb',
|
||||
category: '材料',
|
||||
name: '暖息草',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
tags: ['material', 'mana'],
|
||||
},
|
||||
affinityGain: 16,
|
||||
canSubmit: true,
|
||||
reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
npcStates: {
|
||||
'npc-merchant': {
|
||||
affinity: 0,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createRuntime(gameState: GameState) {
|
||||
return {
|
||||
currentStory: null,
|
||||
setCurrentStory: vi.fn(),
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
buildStoryContextFromState: vi.fn(
|
||||
() => ({}) as unknown as StoryGenerationContext,
|
||||
),
|
||||
buildFallbackStoryForState: vi.fn(
|
||||
() =>
|
||||
({
|
||||
text: 'fallback',
|
||||
options: [],
|
||||
}) satisfies StoryMoment,
|
||||
),
|
||||
buildDialogueStoryMoment: vi.fn(
|
||||
(npcName: string, text: string) =>
|
||||
({
|
||||
text,
|
||||
options: [],
|
||||
displayMode: 'dialogue',
|
||||
dialogue: text
|
||||
? [{ speaker: 'npc' as const, speakerName: npcName, text }]
|
||||
: [],
|
||||
}) satisfies StoryMoment,
|
||||
),
|
||||
generateStoryForState: vi.fn(
|
||||
async () =>
|
||||
({
|
||||
text: 'next',
|
||||
options: [],
|
||||
}) satisfies StoryMoment,
|
||||
),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => gameState.sceneHostileNpcs),
|
||||
getTypewriterDelay: vi.fn(() => 0),
|
||||
};
|
||||
}
|
||||
|
||||
function Harness({
|
||||
action,
|
||||
initialState,
|
||||
}: {
|
||||
action: 'buy' | 'gift';
|
||||
initialState: GameState;
|
||||
}) {
|
||||
const [gameState, setGameState] = useState(initialState);
|
||||
const openedRef = useRef(false);
|
||||
const confirmedRef = useRef(false);
|
||||
const runtime = createRuntime(gameState);
|
||||
const flow = useStoryNpcInteractionFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
getNpcEncounterKey: encounter => encounter.id ?? encounter.npcName,
|
||||
getResolvedNpcState: (state, encounter) =>
|
||||
state.npcStates[encounter.id ?? encounter.npcName]!,
|
||||
updateNpcState: (state) => state,
|
||||
cloneInventoryItemForOwner: (item) => item,
|
||||
runtime,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (openedRef.current) {
|
||||
return;
|
||||
}
|
||||
openedRef.current = true;
|
||||
const encounter = initialState.currentEncounter as Encounter;
|
||||
if (action === 'buy') {
|
||||
flow.openTradeModal(encounter, '交易');
|
||||
return;
|
||||
}
|
||||
flow.openGiftModal(encounter, '赠送礼物');
|
||||
}, [action, flow, initialState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (confirmedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (action === 'buy' && flow.npcUi.tradeModal) {
|
||||
confirmedRef.current = true;
|
||||
flow.npcUi.confirmTrade();
|
||||
return;
|
||||
}
|
||||
if (action === 'gift' && flow.npcUi.giftModal) {
|
||||
confirmedRef.current = true;
|
||||
flow.npcUi.confirmGift();
|
||||
}
|
||||
}, [action, flow.npcUi]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('useStoryNpcInteractionFlow', () => {
|
||||
beforeEach(() => {
|
||||
resolveRpgRuntimeChoiceMock.mockReset();
|
||||
resolveRpgRuntimeChoiceMock.mockResolvedValue({
|
||||
hydratedSnapshot: {
|
||||
gameState: createGameState({
|
||||
playerCurrency: 12,
|
||||
}),
|
||||
},
|
||||
nextStory: {
|
||||
text: 'server resolved',
|
||||
options: [],
|
||||
} satisfies StoryMoment,
|
||||
});
|
||||
});
|
||||
|
||||
it('submits npc trade to the server even when the server view marks local currency insufficient', async () => {
|
||||
render(<Harness action="buy" initialState={createGameState()} />);
|
||||
|
||||
await waitFor(() => expect(resolveRpgRuntimeChoiceMock).toHaveBeenCalled());
|
||||
expect(resolveRpgRuntimeChoiceMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
option: expect.objectContaining({
|
||||
functionId: 'npc_trade',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-merchant',
|
||||
action: 'trade',
|
||||
},
|
||||
}),
|
||||
payload: {
|
||||
mode: 'buy',
|
||||
itemId: 'merchant-tonic',
|
||||
quantity: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('submits npc gift from the server gift view without checking local inventory first', async () => {
|
||||
render(<Harness action="gift" initialState={createGameState()} />);
|
||||
|
||||
await waitFor(() => expect(resolveRpgRuntimeChoiceMock).toHaveBeenCalled());
|
||||
expect(resolveRpgRuntimeChoiceMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
option: expect.objectContaining({
|
||||
functionId: 'npc_gift',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-merchant',
|
||||
action: 'gift',
|
||||
},
|
||||
}),
|
||||
payload: {
|
||||
itemId: 'gift-herb',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,34 +7,22 @@ import { useState } from 'react';
|
||||
import {
|
||||
getCharacterById,
|
||||
} from '../../data/characterPresets';
|
||||
import {
|
||||
getNpcBuybackPrice,
|
||||
getNpcPurchasePrice,
|
||||
} from '../../data/economy';
|
||||
import {
|
||||
buildNpcGiftModalState,
|
||||
buildNpcRecruitModalState,
|
||||
buildNpcTradeModalState,
|
||||
buildNpcTradeModalIntroText,
|
||||
} from '../../data/functionCatalog';
|
||||
import {
|
||||
addInventoryItems,
|
||||
buildNpcGiftCommitActionText,
|
||||
buildNpcGiftResultText,
|
||||
buildNpcTradeTransactionActionText,
|
||||
buildNpcTradeTransactionResultText,
|
||||
getGiftCandidates,
|
||||
getPreferredGiftItemId,
|
||||
removeInventoryItem,
|
||||
syncNpcTradeInventory,
|
||||
} from '../../data/npcInteractions';
|
||||
import { streamNpcChatDialogue, streamNpcRecruitDialogue } from '../../services/aiService';
|
||||
import { streamNpcRecruitDialogue } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
InventoryItem,
|
||||
RuntimeNpcTradeItemView,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
@@ -154,13 +142,17 @@ function normalizeRecruitDialogue(
|
||||
return compactLines.slice(0, 6).join('\n');
|
||||
}
|
||||
|
||||
function normalizeTradeQuantity(quantity: number) {
|
||||
return Math.max(1, Math.floor(Number.isFinite(quantity) ? quantity : 1));
|
||||
}
|
||||
|
||||
export function useStoryNpcInteractionFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
getNpcEncounterKey,
|
||||
getResolvedNpcState,
|
||||
updateNpcState,
|
||||
cloneInventoryItemForOwner,
|
||||
getResolvedNpcState: _getResolvedNpcState,
|
||||
updateNpcState: _updateNpcState,
|
||||
cloneInventoryItemForOwner: _cloneInventoryItemForOwner,
|
||||
runtime,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
@@ -183,184 +175,6 @@ export function useStoryNpcInteractionFlow({
|
||||
const [giftModal, setGiftModal] = useState<GiftModalState | null>(null);
|
||||
const [recruitModal, setRecruitModal] = useState<RecruitModalState | null>(null);
|
||||
|
||||
const getTradeNpcItem = (state: GameState, modal: TradeModalState) => {
|
||||
const npcState = getResolvedNpcState(state, modal.encounter);
|
||||
return npcState.inventory.find(item => item.id === modal.selectedNpcItemId) ?? null;
|
||||
};
|
||||
|
||||
const getTradePlayerItem = (state: GameState, modal: TradeModalState) =>
|
||||
state.playerInventory.find(item => item.id === modal.selectedPlayerItemId) ?? null;
|
||||
|
||||
const getTradeUnitPrice = (state: GameState, modal: TradeModalState) => {
|
||||
if (modal.mode === 'buy') {
|
||||
const npcItem = getTradeNpcItem(state, modal);
|
||||
const npcState = getResolvedNpcState(state, modal.encounter);
|
||||
return npcItem ? getNpcPurchasePrice(npcItem, npcState.affinity) : 0;
|
||||
}
|
||||
|
||||
const playerItem = getTradePlayerItem(state, modal);
|
||||
const npcState = getResolvedNpcState(state, modal.encounter);
|
||||
return playerItem ? getNpcBuybackPrice(playerItem, npcState.affinity) : 0;
|
||||
};
|
||||
|
||||
const getTradeMaxQuantity = (state: GameState, modal: TradeModalState) => {
|
||||
if (modal.mode === 'buy') {
|
||||
return getTradeNpcItem(state, modal)?.quantity ?? 0;
|
||||
}
|
||||
|
||||
return getTradePlayerItem(state, modal)?.quantity ?? 0;
|
||||
};
|
||||
|
||||
const clampTradeQuantity = (state: GameState, modal: TradeModalState, quantity: number) => {
|
||||
const maxQuantity = getTradeMaxQuantity(state, modal);
|
||||
if (maxQuantity <= 0) return 1;
|
||||
return Math.max(1, Math.min(maxQuantity, Math.floor(quantity)));
|
||||
};
|
||||
|
||||
const commitNpcReactionAndGenerate = async ({
|
||||
nextState,
|
||||
encounter,
|
||||
actionText,
|
||||
resultText,
|
||||
lastFunctionId,
|
||||
contextNpcStateOverride,
|
||||
}: {
|
||||
nextState: GameState;
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
lastFunctionId: string;
|
||||
contextNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
}) => {
|
||||
if (!gameState.playerCharacter || !gameState.worldType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const provisionalHistory = [
|
||||
...gameState.storyHistory,
|
||||
createHistoryMoment(actionText, 'action'),
|
||||
createHistoryMoment(resultText, 'result'),
|
||||
];
|
||||
const provisionalState = {
|
||||
...nextState,
|
||||
storyHistory: provisionalHistory,
|
||||
};
|
||||
|
||||
setGameState(provisionalState);
|
||||
runtime.setAiError(null);
|
||||
runtime.setIsLoading(true);
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true),
|
||||
);
|
||||
|
||||
let dialogueText = '';
|
||||
let streamedTargetText = '';
|
||||
let displayedText = '';
|
||||
let streamCompleted = false;
|
||||
|
||||
const typewriterPromise = (async () => {
|
||||
while (!streamCompleted || displayedText.length < streamedTargetText.length) {
|
||||
if (displayedText.length >= streamedTargetText.length) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 40));
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextChar = streamedTargetText[displayedText.length];
|
||||
if (!nextChar) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 40));
|
||||
continue;
|
||||
}
|
||||
|
||||
displayedText += nextChar;
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildDialogueStoryMoment(
|
||||
encounter.npcName,
|
||||
displayedText,
|
||||
[],
|
||||
true,
|
||||
),
|
||||
);
|
||||
await new Promise(resolve =>
|
||||
window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar)),
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
dialogueText = await streamNpcChatDialogue(
|
||||
gameState.worldType,
|
||||
gameState.playerCharacter,
|
||||
encounter,
|
||||
runtime.getStoryGenerationHostileNpcs(provisionalState),
|
||||
provisionalHistory,
|
||||
runtime.buildStoryContextFromState(provisionalState, {
|
||||
lastFunctionId,
|
||||
encounterNpcStateOverride: contextNpcStateOverride,
|
||||
}),
|
||||
actionText,
|
||||
resultText,
|
||||
{
|
||||
onUpdate: text => {
|
||||
streamedTargetText = text;
|
||||
},
|
||||
},
|
||||
);
|
||||
streamedTargetText = dialogueText;
|
||||
streamCompleted = true;
|
||||
await typewriterPromise;
|
||||
|
||||
const finalDialogueText = dialogueText.trim() || displayedText.trim();
|
||||
const finalHistory = finalDialogueText
|
||||
? [...provisionalHistory, createHistoryMoment(finalDialogueText, 'result')]
|
||||
: provisionalHistory;
|
||||
const finalState = {
|
||||
...nextState,
|
||||
storyHistory: finalHistory,
|
||||
};
|
||||
|
||||
setGameState(finalState);
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildDialogueStoryMoment(
|
||||
encounter.npcName,
|
||||
finalDialogueText || resultText,
|
||||
[],
|
||||
false,
|
||||
),
|
||||
);
|
||||
await new Promise(resolve => window.setTimeout(resolve, 260));
|
||||
|
||||
const nextStory = await runtime.generateStoryForState({
|
||||
state: finalState,
|
||||
character: gameState.playerCharacter,
|
||||
history: finalHistory,
|
||||
choice: actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
runtime.setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
streamCompleted = true;
|
||||
await typewriterPromise;
|
||||
console.error('Failed to continue npc interaction reaction:', error);
|
||||
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
|
||||
const fallbackHistory = provisionalHistory;
|
||||
const fallbackState = {
|
||||
...nextState,
|
||||
storyHistory: fallbackHistory,
|
||||
};
|
||||
setGameState(fallbackState);
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildFallbackStoryForState(
|
||||
fallbackState,
|
||||
gameState.playerCharacter,
|
||||
resultText,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
runtime.setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveRecruitmentOnServer = async (params: {
|
||||
encounter: Encounter;
|
||||
actionText: string;
|
||||
@@ -516,45 +330,68 @@ export function useStoryNpcInteractionFlow({
|
||||
});
|
||||
};
|
||||
|
||||
const openTradeModal = (encounter: Encounter, actionText: string) => {
|
||||
const currentNpcState = getResolvedNpcState(gameState, encounter);
|
||||
const npcState = syncNpcTradeInventory(
|
||||
gameState,
|
||||
encounter,
|
||||
currentNpcState,
|
||||
const getRuntimeTradeItems = (
|
||||
mode: 'buy' | 'sell',
|
||||
): RuntimeNpcTradeItemView[] =>
|
||||
mode === 'buy'
|
||||
? gameState.runtimeNpcInteraction?.trade.buyItems ?? []
|
||||
: gameState.runtimeNpcInteraction?.trade.sellItems ?? [];
|
||||
|
||||
const findRuntimeTradeItem = (modal: TradeModalState) => {
|
||||
const itemId =
|
||||
modal.mode === 'buy'
|
||||
? modal.selectedNpcItemId
|
||||
: modal.selectedPlayerItemId;
|
||||
if (!itemId) return null;
|
||||
|
||||
return (
|
||||
getRuntimeTradeItems(modal.mode).find((item) => item.itemId === itemId) ??
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
if (
|
||||
gameState.npcStates[getNpcEncounterKey(encounter)] !== npcState
|
||||
|| npcState !== currentNpcState
|
||||
) {
|
||||
setGameState(updateNpcState(gameState, encounter, () => npcState));
|
||||
}
|
||||
const findRuntimeGiftItem = (itemId: string | null) => {
|
||||
if (!itemId) return null;
|
||||
return (
|
||||
gameState.runtimeNpcInteraction?.gift.items.find(
|
||||
(item) => item.itemId === itemId,
|
||||
) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
const openTradeModal = (encounter: Encounter, actionText: string) => {
|
||||
setTradeModal(
|
||||
buildNpcTradeModalState(
|
||||
gameState,
|
||||
{
|
||||
encounter,
|
||||
actionText,
|
||||
npcState.inventory,
|
||||
),
|
||||
introText: buildNpcTradeModalIntroText(encounter),
|
||||
mode: 'buy',
|
||||
selectedNpcItemId:
|
||||
gameState.runtimeNpcInteraction?.trade.buyItems.find(
|
||||
(item) => item.canSubmit,
|
||||
)?.itemId ??
|
||||
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
|
||||
null,
|
||||
selectedPlayerItemId:
|
||||
gameState.runtimeNpcInteraction?.trade.sellItems.find(
|
||||
(item) => item.canSubmit,
|
||||
)?.itemId ??
|
||||
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
|
||||
null,
|
||||
selectedQuantity: 1,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const openGiftModal = (encounter: Encounter, actionText: string) => {
|
||||
const selectedItemId = getPreferredGiftItemId(
|
||||
gameState.playerInventory,
|
||||
encounter,
|
||||
{
|
||||
worldType: gameState.worldType,
|
||||
customWorldProfile: gameState.customWorldProfile,
|
||||
},
|
||||
);
|
||||
if (!selectedItemId) return;
|
||||
const selectedItemId =
|
||||
gameState.runtimeNpcInteraction?.gift.items.find((item) => item.canSubmit)
|
||||
?.itemId ??
|
||||
gameState.runtimeNpcInteraction?.gift.items[0]?.itemId ??
|
||||
null;
|
||||
|
||||
setGiftModal(
|
||||
buildNpcGiftModalState(
|
||||
gameState,
|
||||
encounter,
|
||||
actionText,
|
||||
selectedItemId,
|
||||
@@ -630,53 +467,24 @@ export function useStoryNpcInteractionFlow({
|
||||
if (!tradeModal || !gameState.playerCharacter) return;
|
||||
|
||||
const encounter = tradeModal.encounter;
|
||||
const quantity = clampTradeQuantity(gameState, tradeModal, tradeModal.selectedQuantity);
|
||||
const unitPrice = getTradeUnitPrice(gameState, tradeModal);
|
||||
const totalPrice = unitPrice * quantity;
|
||||
|
||||
if (tradeModal.mode === 'buy') {
|
||||
const npcItem = getTradeNpcItem(gameState, tradeModal);
|
||||
if (!npcItem || quantity <= 0) return;
|
||||
if (npcItem.quantity < quantity || gameState.playerCurrency < totalPrice) return;
|
||||
|
||||
setTradeModal(null);
|
||||
void resolveServerNpcAction({
|
||||
encounter,
|
||||
actionText: buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
mode: 'buy',
|
||||
item: npcItem,
|
||||
quantity,
|
||||
}),
|
||||
functionId: 'npc_trade',
|
||||
action: 'trade',
|
||||
payload: {
|
||||
mode: 'buy',
|
||||
itemId: npcItem.id,
|
||||
quantity,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const playerItem = getTradePlayerItem(gameState, tradeModal);
|
||||
if (!playerItem || quantity <= 0) return;
|
||||
if (playerItem.quantity < quantity) return;
|
||||
const quantity = normalizeTradeQuantity(tradeModal.selectedQuantity);
|
||||
const tradeItem = findRuntimeTradeItem(tradeModal);
|
||||
if (!tradeItem) return;
|
||||
|
||||
setTradeModal(null);
|
||||
void resolveServerNpcAction({
|
||||
encounter,
|
||||
actionText: buildNpcTradeTransactionActionText({
|
||||
encounter,
|
||||
mode: 'sell',
|
||||
item: playerItem,
|
||||
mode: tradeModal.mode,
|
||||
item: tradeItem.item,
|
||||
quantity,
|
||||
}),
|
||||
functionId: 'npc_trade',
|
||||
action: 'trade',
|
||||
payload: {
|
||||
mode: 'sell',
|
||||
itemId: playerItem.id,
|
||||
mode: tradeModal.mode,
|
||||
itemId: tradeItem.itemId,
|
||||
quantity,
|
||||
},
|
||||
});
|
||||
@@ -686,17 +494,17 @@ export function useStoryNpcInteractionFlow({
|
||||
if (!giftModal || !gameState.playerCharacter) return;
|
||||
|
||||
const encounter = giftModal.encounter;
|
||||
const giftItem = gameState.playerInventory.find(item => item.id === giftModal.selectedItemId);
|
||||
const giftItem = findRuntimeGiftItem(giftModal.selectedItemId);
|
||||
if (!giftItem) return;
|
||||
|
||||
setGiftModal(null);
|
||||
void resolveServerNpcAction({
|
||||
encounter,
|
||||
actionText: buildNpcGiftCommitActionText(encounter, giftItem),
|
||||
actionText: `把${giftItem.item.name}赠给${encounter.npcName}`,
|
||||
functionId: 'npc_gift',
|
||||
action: 'gift',
|
||||
payload: {
|
||||
itemId: giftItem.id,
|
||||
itemId: giftItem.itemId,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -708,44 +516,40 @@ export function useStoryNpcInteractionFlow({
|
||||
recruitModal,
|
||||
setTradeMode: (mode: 'buy' | 'sell') => setTradeModal(current => {
|
||||
if (!current) return current;
|
||||
const nextModal = {
|
||||
return {
|
||||
...current,
|
||||
mode,
|
||||
selectedNpcItemId: current.selectedNpcItemId ?? getResolvedNpcState(gameState, current.encounter).inventory[0]?.id ?? null,
|
||||
selectedPlayerItemId: current.selectedPlayerItemId ?? gameState.playerInventory[0]?.id ?? null,
|
||||
selectedNpcItemId:
|
||||
current.selectedNpcItemId ??
|
||||
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
|
||||
null,
|
||||
selectedPlayerItemId:
|
||||
current.selectedPlayerItemId ??
|
||||
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
|
||||
null,
|
||||
selectedQuantity: 1,
|
||||
};
|
||||
return {
|
||||
...nextModal,
|
||||
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
|
||||
};
|
||||
}),
|
||||
selectTradeNpcItem: (itemId: string) => setTradeModal(current => {
|
||||
if (!current) return current;
|
||||
const nextModal = {
|
||||
return {
|
||||
...current,
|
||||
selectedNpcItemId: itemId,
|
||||
};
|
||||
return {
|
||||
...nextModal,
|
||||
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
|
||||
selectedQuantity: 1,
|
||||
};
|
||||
}),
|
||||
selectTradePlayerItem: (itemId: string) => setTradeModal(current => {
|
||||
if (!current) return current;
|
||||
const nextModal = {
|
||||
return {
|
||||
...current,
|
||||
selectedPlayerItemId: itemId,
|
||||
};
|
||||
return {
|
||||
...nextModal,
|
||||
selectedQuantity: clampTradeQuantity(gameState, nextModal, 1),
|
||||
selectedQuantity: 1,
|
||||
};
|
||||
}),
|
||||
setTradeQuantity: (quantity: number) => setTradeModal(current => current
|
||||
? {
|
||||
...current,
|
||||
selectedQuantity: clampTradeQuantity(gameState, current, quantity),
|
||||
selectedQuantity: normalizeTradeQuantity(quantity),
|
||||
}
|
||||
: current),
|
||||
closeTradeModal: () => setTradeModal(null),
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { ensureSceneEncounterPreviewMock } = vi.hoisted(() => ({
|
||||
ensureSceneEncounterPreviewMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../data/sceneEncounterPreviews', () => ({
|
||||
ensureSceneEncounterPreview: ensureSceneEncounterPreviewMock,
|
||||
}));
|
||||
|
||||
import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime';
|
||||
import { getScenePresetsByWorld } from '../../data/scenePresets';
|
||||
import { AnimationState, type GameState, WorldType } from '../../types';
|
||||
import { buildRevivedFirstSceneState } from './postBattleFlow';
|
||||
|
||||
function createBackstoryReveal(label: string) {
|
||||
return {
|
||||
publicSummary: `${label}的公开背景`,
|
||||
chapters: [
|
||||
{
|
||||
id: `${label}-surface`,
|
||||
title: '表层来意',
|
||||
affinityRequired: 15,
|
||||
teaser: `${label}先收着话。`,
|
||||
content: `${label}把真正目的藏在后面。`,
|
||||
contextSnippet: `${label}表面上仍在试探。`,
|
||||
},
|
||||
{
|
||||
id: `${label}-scar`,
|
||||
title: '旧事裂痕',
|
||||
affinityRequired: 30,
|
||||
teaser: `${label}提到旧事会迟疑。`,
|
||||
content: `${label}背后压着旧伤。`,
|
||||
contextSnippet: `${label}仍被旧事牵制。`,
|
||||
},
|
||||
{
|
||||
id: `${label}-hidden`,
|
||||
title: '隐藏执念',
|
||||
affinityRequired: 60,
|
||||
teaser: `${label}真正执念并不在表面。`,
|
||||
content: `${label}真正想守住的是另一条暗线。`,
|
||||
contextSnippet: `${label}另有没说出口的理由。`,
|
||||
},
|
||||
{
|
||||
id: `${label}-final`,
|
||||
title: '最终底牌',
|
||||
affinityRequired: 90,
|
||||
teaser: `${label}手里还扣着底牌。`,
|
||||
content: `${label}掌握能改写局势的最后证据。`,
|
||||
contextSnippet: `${label}最后底牌还没翻出。`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createStoryRole(id: string, name: string, hostile = false) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
title: `${name}的头衔`,
|
||||
role: hostile ? '敌对角色' : '同幕角色',
|
||||
description: `${name}的测试描述`,
|
||||
backstory: `${name}的测试背景`,
|
||||
personality: '冷静克制',
|
||||
motivation: hostile ? '阻拦玩家继续向前' : '观察局势变化',
|
||||
combatStyle: hostile ? '正面压制' : '后排支援',
|
||||
initialAffinity: hostile ? -20 : 12,
|
||||
relationshipHooks: [],
|
||||
tags: [],
|
||||
backstoryReveal: createBackstoryReveal(name),
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createReviveState(): GameState {
|
||||
const customWorldProfile = {
|
||||
id: 'custom-revive-test',
|
||||
name: '复活回场测试世界',
|
||||
subtitle: '首幕站位恢复',
|
||||
summary: '用于验证复活后第一幕 NPC 会按既有 encounter preview 恢复。',
|
||||
settingText: '围绕开局营地与第一幕对峙角色展开的自定义世界。',
|
||||
tone: '紧张、克制',
|
||||
playerGoal: '复活后重新回到第一幕并面对主交互角色。',
|
||||
templateWorldType: WorldType.WUXIA,
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
id: 'schema:test',
|
||||
worldId: 'CUSTOM',
|
||||
schemaVersion: 1,
|
||||
schemaName: '测试属性',
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '复活回场测试世界',
|
||||
settingSummary: '首幕站位恢复',
|
||||
tone: '紧张、克制',
|
||||
conflictCore: '复活后重新面对主交互角色',
|
||||
},
|
||||
slots: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [
|
||||
createStoryRole('npc-front', '正面对手', true),
|
||||
createStoryRole('npc-back-1', '后排甲'),
|
||||
createStoryRole('npc-back-2', '后排乙'),
|
||||
],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
camp: {
|
||||
id: 'custom-scene-camp',
|
||||
name: '开局营地',
|
||||
description: '用于复活回场测试。',
|
||||
visualDescription: '营地火光映着即将重开的第一幕。',
|
||||
imageSrc: '/camp.png',
|
||||
sceneNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
connections: [],
|
||||
narrativeResidues: null,
|
||||
},
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'custom-scene-camp-chapter',
|
||||
sceneId: 'custom-scene-camp',
|
||||
title: '开局章节',
|
||||
summary: '复活后应回到这里的第一幕。',
|
||||
sceneTaskDescription: '',
|
||||
linkedThreadIds: [],
|
||||
linkedLandmarkIds: [],
|
||||
acts: [
|
||||
{
|
||||
id: 'custom-scene-camp-act-1',
|
||||
sceneId: 'custom-scene-camp',
|
||||
title: '第一幕',
|
||||
summary: '主交互角色与后排角色一同出现。',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc: '/act-1.png',
|
||||
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
primaryNpcId: 'npc-front',
|
||||
oppositeNpcId: 'npc-front',
|
||||
eventDescription: '第一幕事件',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '重新进入首幕',
|
||||
transitionHook: '首幕回场',
|
||||
},
|
||||
{
|
||||
id: 'custom-scene-camp-act-2',
|
||||
sceneId: 'custom-scene-camp',
|
||||
title: '第二幕',
|
||||
summary: '这是死亡前已经推进到的幕。',
|
||||
stageCoverage: ['expansion'],
|
||||
backgroundImageSrc: '/act-2.png',
|
||||
encounterNpcIds: ['npc-front', 'npc-back-1', 'npc-back-2'],
|
||||
primaryNpcId: 'npc-front',
|
||||
oppositeNpcId: 'npc-front',
|
||||
eventDescription: '第二幕事件',
|
||||
linkedThreadIds: [],
|
||||
advanceRule: 'after_primary_contact',
|
||||
actGoal: '推进第二幕',
|
||||
transitionHook: '第二幕推进',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as NonNullable<GameState['customWorldProfile']>;
|
||||
|
||||
setRuntimeCustomWorldProfile(customWorldProfile);
|
||||
const firstScene = getScenePresetsByWorld(WorldType.CUSTOM)[0]!;
|
||||
|
||||
return {
|
||||
worldType: WorldType.CUSTOM,
|
||||
customWorldProfile,
|
||||
playerCharacter: {
|
||||
id: 'hero',
|
||||
name: '测试主角',
|
||||
title: '旅人',
|
||||
description: '测试角色',
|
||||
backstory: '测试背景',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 10,
|
||||
spirit: 10,
|
||||
},
|
||||
personality: 'calm',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
},
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.DIE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: firstScene,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 0,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 0,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {
|
||||
'npc-front': {
|
||||
affinity: -20,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
'npc-back-1': {
|
||||
affinity: 8,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
'npc-back-2': {
|
||||
affinity: 6,
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: 'npc-front',
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: 'fight_defeat',
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
storyEngineMemory: {
|
||||
discoveredFactIds: [],
|
||||
activeThreadIds: [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
currentSceneActState: {
|
||||
sceneId: 'custom-scene-camp',
|
||||
chapterId: 'custom-scene-camp-chapter',
|
||||
currentActId: 'custom-scene-camp-act-2',
|
||||
currentActIndex: 1,
|
||||
completedActIds: ['custom-scene-camp-act-1'],
|
||||
visitedActIds: ['custom-scene-camp-act-1', 'custom-scene-camp-act-2'],
|
||||
},
|
||||
},
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('postBattleFlow', () => {
|
||||
afterEach(() => {
|
||||
ensureSceneEncounterPreviewMock.mockReset();
|
||||
setRuntimeCustomWorldProfile(null);
|
||||
});
|
||||
|
||||
it('rebuilds revived first-scene state through encounter preview restoration', () => {
|
||||
const reviveState = createReviveState();
|
||||
const previewRestoredState = {
|
||||
...reviveState,
|
||||
currentEncounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc' as const,
|
||||
characterId: 'npc-front',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手的测试描述',
|
||||
npcAvatar: '正',
|
||||
context: '敌对角色',
|
||||
xMeters: 12,
|
||||
},
|
||||
};
|
||||
ensureSceneEncounterPreviewMock.mockReturnValue(previewRestoredState);
|
||||
|
||||
const revived = buildRevivedFirstSceneState(reviveState);
|
||||
|
||||
expect(ensureSceneEncounterPreviewMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
currentScenePreset: expect.objectContaining({
|
||||
id: 'custom-scene-camp',
|
||||
}),
|
||||
currentEncounter: null,
|
||||
sceneHostileNpcs: [],
|
||||
playerHp: 100,
|
||||
playerMana: 20,
|
||||
inBattle: false,
|
||||
currentNpcBattleOutcome: null,
|
||||
storyEngineMemory: expect.objectContaining({
|
||||
currentSceneActState: expect.objectContaining({
|
||||
currentActId: 'custom-scene-camp-act-1',
|
||||
currentActIndex: 0,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(revived).toBe(previewRestoredState);
|
||||
});
|
||||
});
|
||||
@@ -1,229 +0,0 @@
|
||||
import { getScenePresetById, getScenePresetsByWorld } from '../../data/scenePresets';
|
||||
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
|
||||
import {
|
||||
advanceSceneActRuntimeState,
|
||||
buildInitialSceneActRuntimeState,
|
||||
getSceneConnectionDirectionText,
|
||||
resolveSceneActProgression,
|
||||
} from '../../services/customWorldSceneActRuntime';
|
||||
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||
import {
|
||||
AnimationState,
|
||||
type GameState,
|
||||
type ScenePresetInfo,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
} from '../../types';
|
||||
|
||||
const CONTINUE_ADVENTURE_FUNCTION_ID = 'story_continue_adventure';
|
||||
const TRAVEL_NEXT_SCENE_FUNCTION_ID = 'idle_travel_next_scene';
|
||||
|
||||
function buildBaseFlowVisuals(): StoryOption['visuals'] {
|
||||
return {
|
||||
playerAnimation: AnimationState.RUN,
|
||||
playerMoveMeters: 0.9,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildContinueOption(): StoryOption {
|
||||
return {
|
||||
functionId: CONTINUE_ADVENTURE_FUNCTION_ID,
|
||||
actionText: '继续前进',
|
||||
text: '继续前进',
|
||||
priority: 1,
|
||||
visuals: buildBaseFlowVisuals(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildTravelOption(scene: ScenePresetInfo, actionText: string): StoryOption {
|
||||
return {
|
||||
functionId: TRAVEL_NEXT_SCENE_FUNCTION_ID,
|
||||
actionText,
|
||||
text: actionText,
|
||||
priority: 2,
|
||||
visuals: buildBaseFlowVisuals(),
|
||||
runtimePayload: {
|
||||
targetSceneId: scene.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSceneTravelOptions(state: GameState): StoryOption[] {
|
||||
if (!state.worldType) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const currentSceneId = state.currentScenePreset?.id ?? null;
|
||||
const currentScene = currentSceneId
|
||||
? getScenePresetById(state.worldType, currentSceneId)
|
||||
: null;
|
||||
const connectionOptions =
|
||||
currentScene?.connections
|
||||
?.map((connection) => {
|
||||
const scene = getScenePresetById(state.worldType!, connection.sceneId);
|
||||
if (!scene || scene.id === currentSceneId) {
|
||||
return null;
|
||||
}
|
||||
const directionText = getSceneConnectionDirectionText(connection.relativePosition);
|
||||
return buildTravelOption(scene, `${directionText},前往${scene.name}`);
|
||||
})
|
||||
.filter((option): option is StoryOption => Boolean(option)) ?? [];
|
||||
|
||||
if (connectionOptions.length > 0) {
|
||||
return connectionOptions;
|
||||
}
|
||||
|
||||
return getScenePresetsByWorld(state.worldType)
|
||||
.filter((scene) => scene.id !== currentSceneId)
|
||||
.slice(0, 4)
|
||||
.map((scene) => buildTravelOption(scene, `前往${scene.name}`));
|
||||
}
|
||||
|
||||
export function buildPostBattleVictoryState(state: GameState) {
|
||||
return {
|
||||
...state,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
inBattle: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
} satisfies GameState;
|
||||
}
|
||||
|
||||
export function buildPostBattleVictoryStory(
|
||||
state: GameState,
|
||||
resultText: string,
|
||||
fallbackOptions: StoryOption[] = [],
|
||||
): { state: GameState; story: StoryMoment } {
|
||||
const progress = resolveSceneActProgression({
|
||||
profile: state.customWorldProfile,
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
storyEngineMemory: state.storyEngineMemory,
|
||||
});
|
||||
const nextActState = progress
|
||||
? advanceSceneActRuntimeState({ progress })
|
||||
: null;
|
||||
const nextState = nextActState
|
||||
? {
|
||||
...state,
|
||||
storyEngineMemory: {
|
||||
...(state.storyEngineMemory ?? createEmptyStoryEngineMemoryState()),
|
||||
currentSceneActState: nextActState,
|
||||
},
|
||||
}
|
||||
: state;
|
||||
if (progress?.isLastAct) {
|
||||
return {
|
||||
state: nextState,
|
||||
story: {
|
||||
text: resultText,
|
||||
options: buildSceneTravelOptions(nextState),
|
||||
streaming: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const deferredOptions =
|
||||
fallbackOptions.length > 0
|
||||
? fallbackOptions
|
||||
: buildSceneTravelOptions(nextState);
|
||||
|
||||
return {
|
||||
state: nextState,
|
||||
story: {
|
||||
text: resultText,
|
||||
options: [buildContinueOption()],
|
||||
deferredOptions,
|
||||
deferredRuntimeState: nextActState
|
||||
? {
|
||||
storyEngineMemory: nextState.storyEngineMemory,
|
||||
}
|
||||
: undefined,
|
||||
streaming: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRevivedFirstSceneState(state: GameState): GameState {
|
||||
const firstScene = state.worldType
|
||||
? getScenePresetsByWorld(state.worldType)[0] ?? state.currentScenePreset
|
||||
: state.currentScenePreset;
|
||||
const storyEngineMemory =
|
||||
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
const firstActState = buildInitialSceneActRuntimeState({
|
||||
profile: state.customWorldProfile,
|
||||
sceneId: firstScene?.id ?? null,
|
||||
storyEngineMemory: undefined,
|
||||
});
|
||||
|
||||
const revivedBaseState = {
|
||||
...state,
|
||||
currentScenePreset: firstScene,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerHp: state.playerMaxHp,
|
||||
playerMana: state.playerMaxMana,
|
||||
inBattle: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle',
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
currentSceneActState: firstActState,
|
||||
},
|
||||
} satisfies GameState;
|
||||
|
||||
// 中文注释:角色复活后要回到“开局进入世界”同一套首幕 encounter preview
|
||||
// 构建链,而不是只清空战斗态。这样第一幕主交互 NPC 与同幕陪衬 NPC
|
||||
// 会按既有槽位一起恢复,避免退化成所有人站成一排。
|
||||
return ensureSceneEncounterPreview(revivedBaseState);
|
||||
}
|
||||
|
||||
export function buildDeathStory(
|
||||
state: GameState,
|
||||
deferredOptions?: StoryOption[],
|
||||
): StoryMoment {
|
||||
const firstSceneName =
|
||||
state.worldType
|
||||
? getScenePresetsByWorld(state.worldType)[0]?.name
|
||||
: state.currentScenePreset?.name;
|
||||
|
||||
return {
|
||||
text: firstSceneName
|
||||
? `你在战斗中倒下,随后在${firstSceneName}重新醒来。`
|
||||
: '你在战斗中倒下,随后重新醒来。',
|
||||
options: [buildContinueOption()],
|
||||
// 中文注释:复活后的“继续前进”只负责揭示已经准备好的首场景入口,
|
||||
// 不能再次触发普通剧情推演,否则容易直接被推进到主 NPC 执行态。
|
||||
deferredOptions:
|
||||
deferredOptions && deferredOptions.length > 0
|
||||
? deferredOptions
|
||||
: undefined,
|
||||
streaming: false,
|
||||
};
|
||||
}
|
||||
@@ -1,86 +1,9 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
acceptQuest,
|
||||
buildChapterQuestForScene,
|
||||
getChapterQuestForScene,
|
||||
} from '../../data/questFlow';
|
||||
import { resolveSceneChapterBlueprint } from '../../services/customWorldSceneActRuntime';
|
||||
import {
|
||||
hasEncounterEntity,
|
||||
interpolateEncounterTransitionState,
|
||||
} from '../../data/encounterTransition';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
} from '../../services/storyEngine/actorNarrativeProfile';
|
||||
import { resolveCurrentActState } from '../../services/storyEngine/actPlanner';
|
||||
import { buildAuthorialConstraintPack } from '../../services/storyEngine/authorialConstraintPack';
|
||||
import { evaluateBranchBudget } from '../../services/storyEngine/branchBudgetPlanner';
|
||||
import {
|
||||
advanceCampaignState,
|
||||
resolveCampaignState,
|
||||
} from '../../services/storyEngine/campaignDirector';
|
||||
import { compileCampaignFromWorldProfile } from '../../services/storyEngine/campaignPackCompiler';
|
||||
import {
|
||||
buildCampEvent,
|
||||
evaluateCampEventOpportunity,
|
||||
} from '../../services/storyEngine/campEventDirector';
|
||||
import {
|
||||
advanceChapterState,
|
||||
resolveCurrentChapterState,
|
||||
} from '../../services/storyEngine/chapterDirector';
|
||||
import {
|
||||
advanceCompanionArc,
|
||||
buildCompanionArcStates,
|
||||
} from '../../services/storyEngine/companionArcDirector';
|
||||
import {
|
||||
applyCompanionReactionToStance,
|
||||
buildCompanionReactionBatch,
|
||||
} from '../../services/storyEngine/companionReactionDirector';
|
||||
import { resolveAllCompanionResolutions } from '../../services/storyEngine/companionResolutionDirector';
|
||||
import { appendConsequenceRecord } from '../../services/storyEngine/consequenceLedger';
|
||||
import { buildContentDiffReport } from '../../services/storyEngine/contentDiffReport';
|
||||
import { resolveEndingState } from '../../services/storyEngine/endingResolver';
|
||||
import { buildEpilogueSummary } from '../../services/storyEngine/epilogueComposer';
|
||||
import { buildFactionTensionState } from '../../services/storyEngine/factionTensionState';
|
||||
import { resolveCurrentJourneyBeat } from '../../services/storyEngine/journeyBeatPlanner';
|
||||
import { buildNarrativeCodex } from '../../services/storyEngine/narrativeCodex';
|
||||
import { runNarrativeConsistencyChecks } from '../../services/storyEngine/narrativeConsistencyChecks';
|
||||
import { buildNarrativeQaReport } from '../../services/storyEngine/narrativeQaReport';
|
||||
import {
|
||||
recordReplaySeed,
|
||||
replayNarrativeRun,
|
||||
} from '../../services/storyEngine/narrativeRegressionReplay';
|
||||
import { captureNarrativeTelemetry } from '../../services/storyEngine/narrativeTelemetry';
|
||||
import { updatePlayerStyleProfileFromAction } from '../../services/storyEngine/playerStyleProfiler';
|
||||
import { runPlaythroughMatrix } from '../../services/storyEngine/playthroughMatrixLab';
|
||||
import { buildContinueGameDigest } from '../../services/storyEngine/recapDigest';
|
||||
import { buildReleaseGateReport } from '../../services/storyEngine/releaseGateReport';
|
||||
import { buildSaveMigrationManifest } from '../../services/storyEngine/saveMigrationManifest';
|
||||
import { resolveScenarioPack } from '../../services/storyEngine/scenarioPackRegistry';
|
||||
import {
|
||||
buildSetpieceDirective,
|
||||
evaluateSetpieceOpportunity,
|
||||
} from '../../services/storyEngine/setpieceDirector';
|
||||
import { appendChronicleEntries } from '../../services/storyEngine/storyChronicle';
|
||||
import { buildThemePackFromWorldProfile } from '../../services/storyEngine/themePack';
|
||||
import { buildThreadContractsFromProfile } from '../../services/storyEngine/threadContract';
|
||||
import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime';
|
||||
import {
|
||||
collectStorySignals,
|
||||
resolveSignalsToThreadUpdates,
|
||||
} from '../../services/storyEngine/threadSignalRouter';
|
||||
import {
|
||||
buildEncounterVisibilitySlice,
|
||||
createEmptyStoryEngineMemoryState,
|
||||
} from '../../services/storyEngine/visibilityEngine';
|
||||
import {
|
||||
applyWorldMutationsToGameState,
|
||||
resolveWorldMutations,
|
||||
} from '../../services/storyEngine/worldMutationRouter';
|
||||
import { buildFallbackWorldStoryGraph } from '../../services/storyEngine/worldStoryGraph';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
Character,
|
||||
@@ -93,516 +16,6 @@ import type { CommitGeneratedState } from '../generatedState';
|
||||
const ENCOUNTER_ENTRY_DURATION_MS = 1800;
|
||||
const ENCOUNTER_ENTRY_TICK_MS = 180;
|
||||
|
||||
function dedupeStrings(values: Array<string | null | undefined>, limit = 10) {
|
||||
return [
|
||||
...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean)),
|
||||
].slice(0, limit);
|
||||
}
|
||||
|
||||
function hydrateStoryEngineMemory(state: GameState): GameState {
|
||||
const storyEngineMemory =
|
||||
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
if (!state.customWorldProfile || state.currentEncounter?.kind !== 'npc') {
|
||||
return {
|
||||
...state,
|
||||
storyEngineMemory,
|
||||
};
|
||||
}
|
||||
|
||||
const role =
|
||||
state.customWorldProfile.storyNpcs.find(
|
||||
(npc) =>
|
||||
npc.id === state.currentEncounter?.id ||
|
||||
npc.name === state.currentEncounter?.npcName,
|
||||
) ??
|
||||
state.customWorldProfile.playableNpcs.find(
|
||||
(npc) =>
|
||||
npc.id === state.currentEncounter?.id ||
|
||||
npc.name === state.currentEncounter?.npcName,
|
||||
);
|
||||
if (!role) {
|
||||
return {
|
||||
...state,
|
||||
storyEngineMemory,
|
||||
};
|
||||
}
|
||||
|
||||
const themePack =
|
||||
state.customWorldProfile.themePack ??
|
||||
buildThemePackFromWorldProfile(state.customWorldProfile);
|
||||
const storyGraph =
|
||||
state.customWorldProfile.storyGraph ??
|
||||
buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
|
||||
const narrativeProfile = normalizeActorNarrativeProfile(
|
||||
role.narrativeProfile,
|
||||
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
|
||||
);
|
||||
const npcState =
|
||||
state.npcStates[
|
||||
state.currentEncounter.id ?? state.currentEncounter.npcName
|
||||
];
|
||||
const activeThreadIds =
|
||||
storyEngineMemory.activeThreadIds.length > 0
|
||||
? storyEngineMemory.activeThreadIds
|
||||
: narrativeProfile.relatedThreadIds.slice(0, 4);
|
||||
const visibilitySlice = buildEncounterVisibilitySlice({
|
||||
narrativeProfile,
|
||||
backstoryReveal: state.currentEncounter.backstoryReveal ?? null,
|
||||
disclosureStage:
|
||||
npcState?.affinity != null
|
||||
? npcState.affinity < 15
|
||||
? 'guarded'
|
||||
: npcState.affinity < 45
|
||||
? 'partial'
|
||||
: npcState.affinity < 75
|
||||
? 'honest'
|
||||
: 'deep'
|
||||
: 'guarded',
|
||||
isFirstMeaningfulContact: npcState?.firstMeaningfulContactResolved !== true,
|
||||
seenBackstoryChapterIds: npcState?.seenBackstoryChapterIds ?? [],
|
||||
storyEngineMemory,
|
||||
activeThreadIds,
|
||||
});
|
||||
|
||||
return {
|
||||
...state,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
discoveredFactIds: dedupeStrings(
|
||||
[
|
||||
...storyEngineMemory.discoveredFactIds,
|
||||
...visibilitySlice.sayableFactIds,
|
||||
],
|
||||
16,
|
||||
),
|
||||
activeThreadIds: dedupeStrings(
|
||||
[...storyEngineMemory.activeThreadIds, ...activeThreadIds],
|
||||
6,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function findNewInventoryItems(previousState: GameState, nextState: GameState) {
|
||||
const previousIds = new Set(
|
||||
previousState.playerInventory.map((item) => item.id),
|
||||
);
|
||||
return nextState.playerInventory.filter((item) => !previousIds.has(item.id));
|
||||
}
|
||||
|
||||
function ensureSceneChapterQuestState(params: {
|
||||
previousState: GameState;
|
||||
nextState: GameState;
|
||||
}) {
|
||||
const storyEngineMemory =
|
||||
params.nextState.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
const scene = params.nextState.currentScenePreset;
|
||||
if (
|
||||
params.nextState.currentScene !== 'Story' ||
|
||||
!params.nextState.worldType ||
|
||||
!scene?.id
|
||||
) {
|
||||
return {
|
||||
...params.nextState,
|
||||
storyEngineMemory,
|
||||
};
|
||||
}
|
||||
|
||||
const openedSceneChapterIds = dedupeStrings(
|
||||
[...(storyEngineMemory.openedSceneChapterIds ?? [])],
|
||||
64,
|
||||
);
|
||||
if (openedSceneChapterIds.includes(scene.id)) {
|
||||
return {
|
||||
...params.nextState,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
openedSceneChapterIds,
|
||||
currentSceneActState:
|
||||
buildInitialSceneActRuntimeState({
|
||||
profile: params.nextState.customWorldProfile,
|
||||
sceneId: scene.id,
|
||||
storyEngineMemory,
|
||||
}) ?? storyEngineMemory.currentSceneActState ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const nextMemory = {
|
||||
...storyEngineMemory,
|
||||
openedSceneChapterIds: [...openedSceneChapterIds, scene.id],
|
||||
currentSceneActState:
|
||||
buildInitialSceneActRuntimeState({
|
||||
profile: params.nextState.customWorldProfile,
|
||||
sceneId: scene.id,
|
||||
storyEngineMemory,
|
||||
}) ?? storyEngineMemory.currentSceneActState ?? null,
|
||||
};
|
||||
const existingChapterQuest = getChapterQuestForScene(
|
||||
params.nextState.quests,
|
||||
scene.id,
|
||||
);
|
||||
if (existingChapterQuest) {
|
||||
return {
|
||||
...params.nextState,
|
||||
storyEngineMemory: nextMemory,
|
||||
};
|
||||
}
|
||||
|
||||
const sceneChapter = resolveSceneChapterBlueprint(
|
||||
params.nextState.customWorldProfile,
|
||||
scene.id,
|
||||
);
|
||||
const sceneChapterContext = sceneChapter
|
||||
? {
|
||||
sceneTaskDescription: sceneChapter.sceneTaskDescription,
|
||||
actEventDescriptions: sceneChapter.acts
|
||||
.map((act) => act.eventDescription)
|
||||
.filter(Boolean),
|
||||
primaryNpcName:
|
||||
params.nextState.customWorldProfile?.storyNpcs.find(
|
||||
(npc) => npc.id === sceneChapter.acts[0]?.primaryNpcId,
|
||||
)?.name ?? sceneChapter.acts[0]?.primaryNpcId ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
const chapterQuest = buildChapterQuestForScene({
|
||||
scene,
|
||||
worldType: params.nextState.worldType,
|
||||
sceneChapterContext,
|
||||
context: {
|
||||
worldType: params.nextState.worldType,
|
||||
actState: params.nextState.storyEngineMemory?.actState ?? null,
|
||||
recentStoryMoments: params.nextState.storyHistory.slice(-6),
|
||||
playerCharacter: params.nextState.playerCharacter,
|
||||
playerProgression: params.nextState.playerProgression ?? null,
|
||||
},
|
||||
});
|
||||
if (!chapterQuest) {
|
||||
return {
|
||||
...params.nextState,
|
||||
storyEngineMemory: nextMemory,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...params.nextState,
|
||||
storyEngineMemory: nextMemory,
|
||||
quests: acceptQuest(params.nextState.quests, chapterQuest),
|
||||
};
|
||||
}
|
||||
|
||||
function applyStoryEngineEchoes(params: {
|
||||
previousState: GameState;
|
||||
nextState: GameState;
|
||||
actionText: string;
|
||||
lastFunctionId?: string | null;
|
||||
}) {
|
||||
const hydratedState = hydrateStoryEngineMemory(params.nextState);
|
||||
const contracts = hydratedState.customWorldProfile
|
||||
? (hydratedState.customWorldProfile.threadContracts ??
|
||||
buildThreadContractsFromProfile(hydratedState.customWorldProfile))
|
||||
: [];
|
||||
const newItems = findNewInventoryItems(params.previousState, hydratedState);
|
||||
const signals = collectStorySignals({
|
||||
prevState: params.previousState,
|
||||
nextState: hydratedState,
|
||||
actionText: params.actionText,
|
||||
lastFunctionId: params.lastFunctionId,
|
||||
rewardItems: newItems,
|
||||
});
|
||||
const stateWithSignals = resolveSignalsToThreadUpdates({
|
||||
state: hydratedState,
|
||||
signals,
|
||||
contracts,
|
||||
});
|
||||
const stateWithSceneChapter = ensureSceneChapterQuestState({
|
||||
previousState: params.previousState,
|
||||
nextState: stateWithSignals,
|
||||
});
|
||||
const reactions = buildCompanionReactionBatch({
|
||||
state: stateWithSceneChapter,
|
||||
signals,
|
||||
actionText: params.actionText,
|
||||
});
|
||||
const stateWithReactions = applyCompanionReactionToStance({
|
||||
state: stateWithSceneChapter,
|
||||
reactions,
|
||||
});
|
||||
const storyEngineMemory =
|
||||
stateWithReactions.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
const chapterState = advanceChapterState({
|
||||
previousChapter:
|
||||
stateWithReactions.chapterState ??
|
||||
storyEngineMemory.currentChapter ??
|
||||
null,
|
||||
nextChapter: resolveCurrentChapterState({
|
||||
state: stateWithReactions,
|
||||
}),
|
||||
});
|
||||
const journeyBeat = resolveCurrentJourneyBeat({
|
||||
state: {
|
||||
...stateWithReactions,
|
||||
chapterState,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
currentChapter: chapterState,
|
||||
},
|
||||
},
|
||||
chapterState,
|
||||
});
|
||||
const companionArcStates = advanceCompanionArc({
|
||||
previous: storyEngineMemory.companionArcStates,
|
||||
next: buildCompanionArcStates({
|
||||
state: stateWithReactions,
|
||||
reactions,
|
||||
}),
|
||||
});
|
||||
const campEvent = evaluateCampEventOpportunity({
|
||||
state: stateWithReactions,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
companionArcStates,
|
||||
})
|
||||
? buildCampEvent({
|
||||
state: stateWithReactions,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
companionArcStates,
|
||||
})
|
||||
: null;
|
||||
const worldMutations = resolveWorldMutations({
|
||||
state: stateWithReactions,
|
||||
signals,
|
||||
chapterState,
|
||||
});
|
||||
const stateWithMutations = applyWorldMutationsToGameState({
|
||||
state: stateWithReactions,
|
||||
mutations: worldMutations,
|
||||
});
|
||||
const setpieceDirective = evaluateSetpieceOpportunity({
|
||||
state: stateWithMutations,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
})
|
||||
? buildSetpieceDirective({
|
||||
state: stateWithMutations,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
})
|
||||
: null;
|
||||
const chronicle = appendChronicleEntries({
|
||||
state: stateWithMutations,
|
||||
chapterState,
|
||||
worldMutations,
|
||||
reactions,
|
||||
signals,
|
||||
campEvent,
|
||||
setpieceDirective,
|
||||
});
|
||||
const factionTensionStates = buildFactionTensionState(
|
||||
stateWithMutations.customWorldProfile,
|
||||
storyEngineMemory,
|
||||
);
|
||||
const actState = resolveCurrentActState({
|
||||
state: stateWithMutations,
|
||||
chapterState,
|
||||
});
|
||||
const campaignState = advanceCampaignState({
|
||||
previous:
|
||||
storyEngineMemory.campaignState ??
|
||||
stateWithMutations.campaignState ??
|
||||
null,
|
||||
next: resolveCampaignState({
|
||||
state: stateWithMutations,
|
||||
actState,
|
||||
}),
|
||||
});
|
||||
const consequenceLedger = appendConsequenceRecord({
|
||||
existing: storyEngineMemory.consequenceLedger,
|
||||
signals,
|
||||
reactions,
|
||||
worldMutations,
|
||||
campEvent,
|
||||
});
|
||||
const authorialConstraintPack = buildAuthorialConstraintPack({
|
||||
profile: stateWithMutations.customWorldProfile,
|
||||
});
|
||||
const compiledPacks = stateWithMutations.customWorldProfile
|
||||
? compileCampaignFromWorldProfile({
|
||||
profile: stateWithMutations.customWorldProfile,
|
||||
})
|
||||
: null;
|
||||
const activeScenarioPack =
|
||||
resolveScenarioPack(stateWithMutations.activeScenarioPackId) ??
|
||||
compiledPacks?.scenarioPack ??
|
||||
null;
|
||||
const activeCampaignPack = compiledPacks?.campaignPack ?? null;
|
||||
const playerStyleProfile = updatePlayerStyleProfileFromAction({
|
||||
current: storyEngineMemory.playerStyleProfile,
|
||||
actionText: params.actionText,
|
||||
});
|
||||
const companionResolutions = resolveAllCompanionResolutions({
|
||||
state: stateWithMutations,
|
||||
arcStates: companionArcStates,
|
||||
ledger: consequenceLedger,
|
||||
reactions,
|
||||
});
|
||||
const endingState =
|
||||
actState?.status === 'finale' || actState?.status === 'resolved'
|
||||
? resolveEndingState({
|
||||
state: stateWithMutations,
|
||||
companionResolutions,
|
||||
factionTensionStates,
|
||||
})
|
||||
: (storyEngineMemory.endingState ?? null);
|
||||
const epilogueSummary = endingState
|
||||
? buildEpilogueSummary({
|
||||
endingState,
|
||||
companionResolutions,
|
||||
})
|
||||
: null;
|
||||
const currentJourneyBeatId =
|
||||
journeyBeat?.id ?? storyEngineMemory.currentJourneyBeatId ?? null;
|
||||
const branchBudgetStatus = evaluateBranchBudget({
|
||||
consequenceLedger,
|
||||
authorialConstraintPack,
|
||||
endingFamilyCount: endingState ? 1 : 0,
|
||||
});
|
||||
const baseMemoryForQa = {
|
||||
...storyEngineMemory,
|
||||
currentChapter: chapterState,
|
||||
currentJourneyBeatId,
|
||||
currentJourneyBeat: journeyBeat,
|
||||
companionArcStates,
|
||||
worldMutations,
|
||||
chronicle,
|
||||
factionTensionStates,
|
||||
currentCampEvent: campEvent,
|
||||
currentSetpieceDirective: setpieceDirective,
|
||||
campaignState,
|
||||
actState,
|
||||
consequenceLedger,
|
||||
companionResolutions,
|
||||
endingState,
|
||||
authorialConstraintPack,
|
||||
branchBudgetStatus,
|
||||
playerStyleProfile,
|
||||
};
|
||||
const consistencyIssues = runNarrativeConsistencyChecks({
|
||||
memory: baseMemoryForQa,
|
||||
threadContracts: contracts,
|
||||
branchBudgetStatus,
|
||||
});
|
||||
const narrativeQaReport = buildNarrativeQaReport({
|
||||
issues: consistencyIssues,
|
||||
});
|
||||
const simulationRunResults =
|
||||
activeScenarioPack && activeCampaignPack
|
||||
? runPlaythroughMatrix({
|
||||
scenarioPackId: activeScenarioPack.id,
|
||||
campaignPack: activeCampaignPack,
|
||||
memory: {
|
||||
...baseMemoryForQa,
|
||||
narrativeQaReport,
|
||||
},
|
||||
seeds: ['baseline', 'companion', 'explore'],
|
||||
})
|
||||
: [];
|
||||
const replaySummary = simulationRunResults[0]
|
||||
? replayNarrativeRun({
|
||||
recordedSeed: recordReplaySeed({
|
||||
seed: simulationRunResults[0].seed,
|
||||
label: `${activeCampaignPack?.title ?? 'campaign'} 基线回放`,
|
||||
}),
|
||||
result: simulationRunResults[0],
|
||||
}).summary
|
||||
: null;
|
||||
const releaseGateReport = buildReleaseGateReport({
|
||||
qaReport: narrativeQaReport,
|
||||
simulationResults: simulationRunResults,
|
||||
unresolvedThreadCount:
|
||||
stateWithMutations.storyEngineMemory?.activeThreadIds.length ?? 0,
|
||||
});
|
||||
const saveMigrationManifest = buildSaveMigrationManifest({
|
||||
version: 'story-engine-v5',
|
||||
});
|
||||
const telemetrySnapshot = captureNarrativeTelemetry({
|
||||
memory: {
|
||||
...baseMemoryForQa,
|
||||
narrativeQaReport,
|
||||
},
|
||||
qaReport: narrativeQaReport,
|
||||
});
|
||||
const contentDiffReport = buildContentDiffReport({
|
||||
previousProfile: params.previousState.customWorldProfile,
|
||||
nextProfile: stateWithMutations.customWorldProfile,
|
||||
previousCampaignPack: null,
|
||||
nextCampaignPack: activeCampaignPack,
|
||||
});
|
||||
const narrativeCodex = buildNarrativeCodex({
|
||||
...stateWithMutations,
|
||||
chapterState,
|
||||
campaignState,
|
||||
storyEngineMemory: {
|
||||
...baseMemoryForQa,
|
||||
narrativeQaReport,
|
||||
releaseGateReport,
|
||||
simulationRunResults,
|
||||
},
|
||||
});
|
||||
const continueDigest =
|
||||
buildContinueGameDigest({
|
||||
state: {
|
||||
...stateWithMutations,
|
||||
chapterState,
|
||||
campaignState,
|
||||
storyEngineMemory: {
|
||||
...baseMemoryForQa,
|
||||
currentJourneyBeatId,
|
||||
narrativeQaReport,
|
||||
releaseGateReport,
|
||||
simulationRunResults,
|
||||
narrativeCodex,
|
||||
saveMigrationManifest,
|
||||
},
|
||||
},
|
||||
}) +
|
||||
[
|
||||
epilogueSummary,
|
||||
replaySummary,
|
||||
telemetrySnapshot.summary,
|
||||
contentDiffReport.summary,
|
||||
`发布门禁:${releaseGateReport.status} / ${releaseGateReport.summary}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
...stateWithMutations,
|
||||
chapterState,
|
||||
campaignState,
|
||||
activeScenarioPackId:
|
||||
activeScenarioPack?.id ?? stateWithMutations.activeScenarioPackId ?? null,
|
||||
activeCampaignPackId:
|
||||
activeCampaignPack?.id ?? stateWithMutations.activeCampaignPackId ?? null,
|
||||
storyEngineMemory: {
|
||||
...baseMemoryForQa,
|
||||
currentJourneyBeatId,
|
||||
continueGameDigest: continueDigest,
|
||||
narrativeQaReport,
|
||||
narrativeCodex,
|
||||
releaseGateReport,
|
||||
simulationRunResults,
|
||||
saveMigrationManifest,
|
||||
recentCompanionReactions: [
|
||||
...(storyEngineMemory.recentCompanionReactions ?? []),
|
||||
...reactions,
|
||||
].slice(-6),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type GenerateStoryForState = (params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
@@ -664,15 +77,10 @@ export function createStoryProgressionActions({
|
||||
lastFunctionId,
|
||||
) => {
|
||||
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
|
||||
const stateWithHistory = applyStoryEngineEchoes({
|
||||
previousState: gameState,
|
||||
nextState: {
|
||||
...nextState,
|
||||
storyHistory: nextHistory,
|
||||
} as GameState,
|
||||
actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
const stateWithHistory = {
|
||||
...nextState,
|
||||
storyHistory: nextHistory,
|
||||
} as GameState;
|
||||
|
||||
setGameState(stateWithHistory);
|
||||
setAiError(null);
|
||||
@@ -686,13 +94,7 @@ export function createStoryProgressionActions({
|
||||
choice: actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
const recoveredState = applyStoryEngineEchoes({
|
||||
previousState: gameState,
|
||||
nextState: applyStoryReasoningRecovery(stateWithHistory),
|
||||
actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
setGameState(recoveredState);
|
||||
setGameState(stateWithHistory);
|
||||
setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to continue scripted story:', error);
|
||||
@@ -744,15 +146,10 @@ export function createStoryProgressionActions({
|
||||
}
|
||||
|
||||
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
|
||||
const stateWithHistory = applyStoryEngineEchoes({
|
||||
previousState: gameState,
|
||||
nextState: {
|
||||
...resolvedState,
|
||||
storyHistory: nextHistory,
|
||||
} as GameState,
|
||||
actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
const stateWithHistory = {
|
||||
...resolvedState,
|
||||
storyHistory: nextHistory,
|
||||
} as GameState;
|
||||
|
||||
setGameState(stateWithHistory);
|
||||
|
||||
@@ -764,13 +161,7 @@ export function createStoryProgressionActions({
|
||||
choice: actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
const recoveredState = applyStoryEngineEchoes({
|
||||
previousState: gameState,
|
||||
nextState: applyStoryReasoningRecovery(stateWithHistory),
|
||||
actionText,
|
||||
lastFunctionId,
|
||||
});
|
||||
setGameState(recoveredState);
|
||||
setGameState(stateWithHistory);
|
||||
setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
console.error('Failed to continue encounter-entry story:', error);
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import { createNpcBattleMonster } from '../../data/npcInteractions';
|
||||
import {
|
||||
buildNpcBattleFormationFromEncounter,
|
||||
RESOLVED_ENTITY_X_METERS,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { getForwardScenePreset } from '../../data/scenePresets';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
@@ -14,94 +8,8 @@ import {
|
||||
resolveRpgRuntimeStoryMoment,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryResponse,
|
||||
type RuntimeStorySnapshotRequest,
|
||||
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
|
||||
import type { GameState, SceneHostileNpc, StoryMoment, StoryOption } from '../../types';
|
||||
import { buildMapTravelResolution } from './storyGenerationState';
|
||||
|
||||
function isNpcBattleAlignmentDebugEnabled() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
window.localStorage.getItem('rpg:npc-battle-alignment-debug') === '1' ||
|
||||
window.location.search.includes('npcBattleAlignmentDebug=1')
|
||||
);
|
||||
}
|
||||
|
||||
function logNpcBattleAlignment(label: string, monsters: GameState['sceneHostileNpcs']) {
|
||||
if (!isNpcBattleAlignmentDebugEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.info(
|
||||
`[npc-battle-alignment] ${label}`,
|
||||
monsters.map((monster) => ({
|
||||
id: monster.id,
|
||||
encounterId: monster.encounter?.id ?? null,
|
||||
encounterName: monster.encounter?.npcName ?? null,
|
||||
xMeters: monster.xMeters,
|
||||
yOffset: monster.yOffset,
|
||||
facing: monster.facing,
|
||||
animation: monster.animation,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function cloneBattleFormation(monsters: GameState['sceneHostileNpcs']) {
|
||||
return monsters.map(
|
||||
(monster) =>
|
||||
({
|
||||
...monster,
|
||||
encounter: monster.encounter
|
||||
? {
|
||||
...monster.encounter,
|
||||
}
|
||||
: monster.encounter,
|
||||
}) satisfies SceneHostileNpc,
|
||||
);
|
||||
}
|
||||
|
||||
function alignBattleFormationToVisibleFormation(params: {
|
||||
visibleFormation: GameState['sceneHostileNpcs'];
|
||||
battleFormation: GameState['sceneHostileNpcs'];
|
||||
}) {
|
||||
const { visibleFormation, battleFormation } = params;
|
||||
if (visibleFormation.length === 0 || battleFormation.length === 0) {
|
||||
return battleFormation;
|
||||
}
|
||||
|
||||
const visibleFormationByEncounterId = new Map(
|
||||
visibleFormation.map((monster) => [
|
||||
monster.encounter?.id ?? monster.encounter?.npcName ?? monster.id,
|
||||
monster,
|
||||
]),
|
||||
);
|
||||
|
||||
return battleFormation.map((monster) => {
|
||||
const encounterKey =
|
||||
monster.encounter?.id ?? monster.encounter?.npcName ?? monster.id;
|
||||
const visibleMonster = visibleFormationByEncounterId.get(encounterKey);
|
||||
if (!visibleMonster) {
|
||||
return monster;
|
||||
}
|
||||
|
||||
return {
|
||||
...monster,
|
||||
xMeters: visibleMonster.xMeters,
|
||||
yOffset: visibleMonster.yOffset,
|
||||
facing: visibleMonster.facing,
|
||||
encounter: monster.encounter
|
||||
? {
|
||||
...monster.encounter,
|
||||
xMeters:
|
||||
visibleMonster.encounter?.xMeters ?? visibleMonster.xMeters,
|
||||
}
|
||||
: monster.encounter,
|
||||
} satisfies SceneHostileNpc;
|
||||
});
|
||||
}
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
|
||||
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||
return response.viewModel.availableOptions.length > 0
|
||||
@@ -109,209 +17,6 @@ function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||
: response.presentation.options;
|
||||
}
|
||||
|
||||
function buildRuntimeSnapshotRequest(
|
||||
gameState: GameState,
|
||||
currentStory: StoryMoment | null,
|
||||
): RuntimeStorySnapshotRequest {
|
||||
return {
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveServerTravelTargetSceneId(params: {
|
||||
previousState: GameState;
|
||||
snapshotState: GameState;
|
||||
}) {
|
||||
const { previousState, snapshotState } = params;
|
||||
const snapshotSceneId = snapshotState.currentScenePreset?.id ?? null;
|
||||
if (
|
||||
snapshotSceneId &&
|
||||
snapshotSceneId !== previousState.currentScenePreset?.id
|
||||
) {
|
||||
return snapshotSceneId;
|
||||
}
|
||||
|
||||
if (!previousState.worldType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
getForwardScenePreset(
|
||||
previousState.worldType,
|
||||
previousState.currentScenePreset?.id,
|
||||
)?.id ??
|
||||
previousState.currentScenePreset?.forwardSceneId ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function bridgeServerSceneTravelSnapshot(params: {
|
||||
previousState: GameState;
|
||||
hydratedSnapshot: HydratedSavedGameSnapshot;
|
||||
functionId: string;
|
||||
}) {
|
||||
const { previousState, hydratedSnapshot, functionId } = params;
|
||||
if (functionId !== 'idle_travel_next_scene' || !previousState.worldType) {
|
||||
return hydratedSnapshot;
|
||||
}
|
||||
|
||||
const targetSceneId = resolveServerTravelTargetSceneId({
|
||||
previousState,
|
||||
snapshotState: hydratedSnapshot.gameState,
|
||||
});
|
||||
if (!targetSceneId) {
|
||||
return hydratedSnapshot;
|
||||
}
|
||||
|
||||
const travelResolution = buildMapTravelResolution(previousState, targetSceneId);
|
||||
if (!travelResolution) {
|
||||
return hydratedSnapshot;
|
||||
}
|
||||
|
||||
return {
|
||||
...hydratedSnapshot,
|
||||
gameState: {
|
||||
...hydratedSnapshot.gameState,
|
||||
// 中文注释:服务端 compat 当前只保证“本轮旅行动作已经结算完成”,
|
||||
// 前端这里复用既有地图旅行真相,补齐下一幕场景 preset、遭遇预览和任务推进结果。
|
||||
currentScenePreset: travelResolution.nextState.currentScenePreset,
|
||||
currentEncounter: travelResolution.nextState.currentEncounter,
|
||||
npcInteractionActive: travelResolution.nextState.npcInteractionActive,
|
||||
sceneHostileNpcs: travelResolution.nextState.sceneHostileNpcs,
|
||||
playerX: travelResolution.nextState.playerX,
|
||||
playerFacing: travelResolution.nextState.playerFacing,
|
||||
animationState: travelResolution.nextState.animationState,
|
||||
playerActionMode: travelResolution.nextState.playerActionMode,
|
||||
activeCombatEffects: travelResolution.nextState.activeCombatEffects,
|
||||
scrollWorld: travelResolution.nextState.scrollWorld,
|
||||
inBattle: travelResolution.nextState.inBattle,
|
||||
lastObserveSignsSceneId: travelResolution.nextState.lastObserveSignsSceneId,
|
||||
lastObserveSignsReport: travelResolution.nextState.lastObserveSignsReport,
|
||||
currentBattleNpcId: travelResolution.nextState.currentBattleNpcId,
|
||||
currentNpcBattleMode: travelResolution.nextState.currentNpcBattleMode,
|
||||
currentNpcBattleOutcome: travelResolution.nextState.currentNpcBattleOutcome,
|
||||
sparReturnEncounter: travelResolution.nextState.sparReturnEncounter,
|
||||
sparPlayerHpBefore: travelResolution.nextState.sparPlayerHpBefore,
|
||||
sparPlayerMaxHpBefore: travelResolution.nextState.sparPlayerMaxHpBefore,
|
||||
sparStoryHistoryBefore: travelResolution.nextState.sparStoryHistoryBefore,
|
||||
runtimeStats: {
|
||||
...hydratedSnapshot.gameState.runtimeStats,
|
||||
scenesTraveled:
|
||||
travelResolution.nextState.runtimeStats.scenesTraveled,
|
||||
},
|
||||
quests:
|
||||
hydratedSnapshot.gameState.quests.length > 0
|
||||
? hydratedSnapshot.gameState.quests
|
||||
: travelResolution.nextState.quests,
|
||||
},
|
||||
} satisfies HydratedSavedGameSnapshot;
|
||||
}
|
||||
|
||||
function bridgeServerNpcBattleSnapshot(params: {
|
||||
previousState: GameState;
|
||||
hydratedSnapshot: HydratedSavedGameSnapshot;
|
||||
functionId: string;
|
||||
}) {
|
||||
const { previousState, hydratedSnapshot, functionId } = params;
|
||||
if (functionId !== 'npc_fight' && functionId !== 'npc_spar') {
|
||||
return hydratedSnapshot;
|
||||
}
|
||||
|
||||
const snapshotState = hydratedSnapshot.gameState;
|
||||
const isNpcBattleActive =
|
||||
snapshotState.inBattle &&
|
||||
Boolean(snapshotState.currentBattleNpcId) &&
|
||||
Boolean(snapshotState.currentNpcBattleMode);
|
||||
const hasResolvedBattleMonster = snapshotState.sceneHostileNpcs.length > 0;
|
||||
const sourceEncounter =
|
||||
previousState.currentEncounter?.kind === 'npc'
|
||||
? previousState.currentEncounter
|
||||
: null;
|
||||
|
||||
// 中文注释:作品测试/幕预览里最容易出现的错位,是服务端已经把
|
||||
// currentBattleNpcId / currentNpcBattleMode 切进战斗,但快照里没有把
|
||||
// sceneHostileNpcs 一起带回。这样前端本地 battlePlan 会直接判定
|
||||
// “场上没有敌人”,点击 battle_* 后立刻把整场战斗收掉。
|
||||
// 这里统一在网关层补齐 NPC 战场快照,保证后续本地逐轮回合一定有敌方单位可结算。
|
||||
if (!isNpcBattleActive || !sourceEncounter) {
|
||||
return hydratedSnapshot;
|
||||
}
|
||||
|
||||
const fallbackNpcState =
|
||||
snapshotState.npcStates[
|
||||
snapshotState.currentBattleNpcId ?? sourceEncounter.id ?? sourceEncounter.npcName
|
||||
] ??
|
||||
previousState.npcStates[
|
||||
previousState.currentBattleNpcId ?? sourceEncounter.id ?? sourceEncounter.npcName
|
||||
] ?? {
|
||||
affinity: sourceEncounter.initialAffinity ?? (sourceEncounter.hostile ? -10 : 0),
|
||||
helpUsed: false,
|
||||
chattedCount: 0,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
};
|
||||
|
||||
const battleMode =
|
||||
snapshotState.currentNpcBattleMode === 'spar' ? 'spar' : 'fight';
|
||||
const fallbackFormationFromSceneAct = buildNpcBattleFormationFromEncounter({
|
||||
state: previousState,
|
||||
encounter: {
|
||||
...sourceEncounter,
|
||||
xMeters: sourceEncounter.xMeters ?? RESOLVED_ENTITY_X_METERS,
|
||||
},
|
||||
mode: battleMode,
|
||||
});
|
||||
const fallbackFormation =
|
||||
previousState.sceneHostileNpcs.length > 0
|
||||
? cloneBattleFormation(previousState.sceneHostileNpcs)
|
||||
: fallbackFormationFromSceneAct.length > 0
|
||||
? fallbackFormationFromSceneAct
|
||||
: [
|
||||
createNpcBattleMonster(
|
||||
sourceEncounter,
|
||||
fallbackNpcState,
|
||||
battleMode,
|
||||
{
|
||||
worldType: snapshotState.worldType,
|
||||
customWorldProfile: snapshotState.customWorldProfile,
|
||||
},
|
||||
),
|
||||
];
|
||||
const resolvedBattleFormation = hasResolvedBattleMonster
|
||||
? alignBattleFormationToVisibleFormation({
|
||||
visibleFormation: previousState.sceneHostileNpcs,
|
||||
battleFormation: snapshotState.sceneHostileNpcs,
|
||||
})
|
||||
: fallbackFormation;
|
||||
|
||||
logNpcBattleAlignment('previous-visible-formation', previousState.sceneHostileNpcs);
|
||||
logNpcBattleAlignment('server-battle-formation', snapshotState.sceneHostileNpcs);
|
||||
logNpcBattleAlignment('resolved-battle-formation', resolvedBattleFormation);
|
||||
|
||||
return {
|
||||
...hydratedSnapshot,
|
||||
gameState: {
|
||||
...snapshotState,
|
||||
// 中文注释:优先沿用进入战斗前已经可见的阵容与站位;
|
||||
// 若上一帧还没有 battle combatants,则从幕预览/当前遭遇恢复完整 NPC 编队,
|
||||
// 避免只补出一个前排角色,造成后排消失和敌方位置突变。
|
||||
sceneHostileNpcs: resolvedBattleFormation,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
// 中文注释:服务端兼容链路若未带回战前遭遇,则沿用进入战斗前的原始 encounter,
|
||||
// 让后续 fight_victory / spar_complete 都能恢复到正确站位,而不是战斗中的临时坐标。
|
||||
sparReturnEncounter:
|
||||
snapshotState.sparReturnEncounter ??
|
||||
(previousState.currentEncounter?.kind === 'npc'
|
||||
? previousState.currentEncounter
|
||||
: null),
|
||||
},
|
||||
} satisfies HydratedSavedGameSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端访问服务端 runtime story 的统一网关。
|
||||
* 统一处理 option catalog 拉取、继续游戏恢复与正式动作结算。
|
||||
@@ -320,10 +25,11 @@ export async function loadServerRuntimeOptionCatalog(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
}) {
|
||||
// 中文注释:状态目录只从服务端持久化 session 读取,
|
||||
// 前端不再上传本地 GameState 快照参与动作合法性解析。
|
||||
const response = await getRpgRuntimeStoryState({
|
||||
sessionId: getRpgRuntimeSessionId(params.gameState),
|
||||
clientVersion: getRpgRuntimeClientVersion(params.gameState),
|
||||
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
|
||||
});
|
||||
const options = resolveRpgRuntimeStoryMoment({
|
||||
response,
|
||||
@@ -351,6 +57,8 @@ export async function resumeServerRuntimeStory(
|
||||
};
|
||||
}
|
||||
|
||||
// 中文注释:继续游戏后向服务端刷新一次状态,
|
||||
// 让长期离线的本地快照重新对齐服务端当前 runtime view model。
|
||||
const response = await getRpgRuntimeStoryState({
|
||||
sessionId: getRpgRuntimeSessionId(hydratedSnapshot.gameState),
|
||||
});
|
||||
@@ -383,6 +91,8 @@ export async function resolveServerRuntimeChoice(params: {
|
||||
Partial<Pick<StoryOption, 'interaction'>>;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
}) {
|
||||
// 中文注释:正式动作结算统一先走服务端;
|
||||
// 前端这里只提交 action/payload,并消费后端已经补齐的快照与表现数据。
|
||||
const response = await resolveRpgRuntimeStoryAction({
|
||||
sessionId: getRpgRuntimeSessionId(params.gameState),
|
||||
clientVersion: getRpgRuntimeClientVersion(params.gameState),
|
||||
@@ -392,17 +102,8 @@ export async function resolveServerRuntimeChoice(params: {
|
||||
? params.option.interaction.npcId
|
||||
: undefined,
|
||||
payload: params.payload,
|
||||
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
|
||||
});
|
||||
const hydratedSnapshot = bridgeServerSceneTravelSnapshot({
|
||||
previousState: params.gameState,
|
||||
hydratedSnapshot: bridgeServerNpcBattleSnapshot({
|
||||
previousState: params.gameState,
|
||||
hydratedSnapshot: rehydrateSavedSnapshot(response.snapshot),
|
||||
functionId: params.option.functionId,
|
||||
}),
|
||||
functionId: params.option.functionId,
|
||||
});
|
||||
const hydratedSnapshot = rehydrateSavedSnapshot(response.snapshot);
|
||||
|
||||
return {
|
||||
response,
|
||||
|
||||
@@ -257,7 +257,7 @@ describe('runtimeStoryCoordinator', () => {
|
||||
getRuntimeClientVersionMock.mockReturnValue(7);
|
||||
});
|
||||
|
||||
it('loads runtime option catalogs through the persisted server snapshot flow', async () => {
|
||||
it('loads runtime option catalogs through the persisted server state flow', async () => {
|
||||
const gameState = createGameState();
|
||||
const currentStory = createStory('当前故事');
|
||||
|
||||
@@ -311,11 +311,6 @@ describe('runtimeStoryCoordinator', () => {
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 7,
|
||||
snapshot: {
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
},
|
||||
});
|
||||
expect(options).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -416,11 +411,6 @@ describe('runtimeStoryCoordinator', () => {
|
||||
payload: {
|
||||
note: 'server-runtime-test',
|
||||
},
|
||||
snapshot: {
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
},
|
||||
});
|
||||
expect(result.hydratedSnapshot).toBe(hydratedSnapshot);
|
||||
expect(result.nextStory).toEqual(
|
||||
@@ -653,7 +643,7 @@ describe('runtimeStoryCoordinator', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('backfills npc battle monsters when npc_fight snapshot marks battle active but omits sceneHostileNpcs', async () => {
|
||||
it('does not patch incomplete npc_fight snapshots in the frontend gateway', async () => {
|
||||
const gameState = {
|
||||
...createTravelGameState(),
|
||||
currentEncounter: {
|
||||
@@ -753,419 +743,17 @@ describe('runtimeStoryCoordinator', () => {
|
||||
option,
|
||||
});
|
||||
|
||||
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs).toHaveLength(1);
|
||||
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
|
||||
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs).toEqual([]);
|
||||
expect(result.hydratedSnapshot.gameState.currentEncounter).toEqual(
|
||||
expect.objectContaining({
|
||||
encounter: expect.objectContaining({
|
||||
id: 'npc-bandit',
|
||||
npcName: '断桥匪首',
|
||||
}),
|
||||
renderKind: 'npc',
|
||||
id: 'npc-bandit',
|
||||
npcName: '断桥匪首',
|
||||
}),
|
||||
);
|
||||
expect(result.hydratedSnapshot.gameState.currentEncounter).toBeNull();
|
||||
expect(result.hydratedSnapshot.gameState.npcInteractionActive).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves previous hostile formation when npc_fight snapshot omits battle members', async () => {
|
||||
const gameState = {
|
||||
...createTravelGameState(),
|
||||
currentEncounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
npcAvatar: '/npc-front.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
initialAffinity: -20,
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc-opponent-npc-front',
|
||||
name: '正面对手',
|
||||
action: '摆开架势,随时准备出手',
|
||||
description: '正面对手',
|
||||
animation: 'idle',
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1.8,
|
||||
speed: 8,
|
||||
hp: 88,
|
||||
maxHp: 88,
|
||||
renderKind: 'npc',
|
||||
encounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
npcAvatar: '/npc-front.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
xMeters: 3.2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'npc-opponent-npc-back-1',
|
||||
name: '后排甲',
|
||||
action: '摆开架势,随时准备出手',
|
||||
description: '后排甲',
|
||||
animation: 'idle',
|
||||
xMeters: 4.28,
|
||||
yOffset: 62,
|
||||
facing: 'left',
|
||||
attackRange: 1.8,
|
||||
speed: 7,
|
||||
hp: 76,
|
||||
maxHp: 76,
|
||||
renderKind: 'npc',
|
||||
encounter: {
|
||||
id: 'npc-back-1',
|
||||
kind: 'npc',
|
||||
npcName: '后排甲',
|
||||
npcDescription: '后排甲',
|
||||
npcAvatar: '/npc-back-1.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
xMeters: 4.28,
|
||||
},
|
||||
},
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
} as GameState;
|
||||
const currentStory = createStory('当前故事');
|
||||
const option = {
|
||||
functionId: 'npc_fight',
|
||||
actionText: '直接开战',
|
||||
text: '直接开战',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-front',
|
||||
action: 'fight',
|
||||
},
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 42,
|
||||
maxHp: 50,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
hostile: true,
|
||||
affinity: -20,
|
||||
recruited: false,
|
||||
interactionActive: false,
|
||||
battleMode: 'fight',
|
||||
},
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'battle_attack_basic',
|
||||
actionText: '普通攻击',
|
||||
scope: 'combat',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: true,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '直接开战',
|
||||
resultText: '当前冲突正式转入战斗结算。',
|
||||
storyText: '正面对手带着同伴压了上来。',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: createRuntimeNpcBattleSnapshot({
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc-front',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
} as GameState['currentEncounter'],
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
inBattle: true,
|
||||
currentBattleNpcId: 'npc-front',
|
||||
currentNpcBattleMode: 'fight',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
});
|
||||
|
||||
expect(
|
||||
result.hydratedSnapshot.gameState.sceneHostileNpcs.map((monster) => ({
|
||||
encounterId: monster.encounter?.id,
|
||||
xMeters: monster.xMeters,
|
||||
yOffset: monster.yOffset,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
encounterId: 'npc-front',
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
},
|
||||
{
|
||||
encounterId: 'npc-back-1',
|
||||
xMeters: 4.28,
|
||||
yOffset: 62,
|
||||
},
|
||||
]);
|
||||
expect(result.hydratedSnapshot.gameState.sparReturnEncounter).toEqual(
|
||||
gameState.currentEncounter,
|
||||
);
|
||||
});
|
||||
|
||||
it('realigns non-empty npc_fight battle snapshots back to the visible pre-battle formation', async () => {
|
||||
const gameState = {
|
||||
...createTravelGameState(),
|
||||
currentEncounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
npcAvatar: '/npc-front.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
initialAffinity: -20,
|
||||
},
|
||||
npcInteractionActive: true,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc-opponent-npc-front',
|
||||
name: '正面对手',
|
||||
action: '摆开架势,随时准备出手',
|
||||
description: '正面对手',
|
||||
animation: 'idle',
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1.8,
|
||||
speed: 8,
|
||||
hp: 88,
|
||||
maxHp: 88,
|
||||
renderKind: 'npc',
|
||||
encounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
npcAvatar: '/npc-front.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
xMeters: 3.2,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'npc-opponent-npc-back-1',
|
||||
name: '后排甲',
|
||||
action: '摆开架势,随时准备出手',
|
||||
description: '后排甲',
|
||||
animation: 'idle',
|
||||
xMeters: 4.28,
|
||||
yOffset: 62,
|
||||
facing: 'left',
|
||||
attackRange: 1.8,
|
||||
speed: 7,
|
||||
hp: 76,
|
||||
maxHp: 76,
|
||||
renderKind: 'npc',
|
||||
encounter: {
|
||||
id: 'npc-back-1',
|
||||
kind: 'npc',
|
||||
npcName: '后排甲',
|
||||
npcDescription: '后排甲',
|
||||
npcAvatar: '/npc-back-1.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
xMeters: 4.28,
|
||||
},
|
||||
},
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
} as GameState;
|
||||
const currentStory = createStory('当前故事');
|
||||
const option = {
|
||||
functionId: 'npc_fight',
|
||||
actionText: '直接开战',
|
||||
text: '直接开战',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-front',
|
||||
action: 'fight',
|
||||
},
|
||||
visuals: {
|
||||
playerAnimation: 'idle',
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
} as StoryOption;
|
||||
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 42,
|
||||
maxHp: 50,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
hostile: true,
|
||||
affinity: -20,
|
||||
recruited: false,
|
||||
interactionActive: false,
|
||||
battleMode: 'fight',
|
||||
},
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'battle_attack_basic',
|
||||
actionText: '普通攻击',
|
||||
scope: 'combat',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: true,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '直接开战',
|
||||
resultText: '当前冲突正式转入战斗结算。',
|
||||
storyText: '正面对手带着同伴压了上来。',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: createRuntimeNpcBattleSnapshot({
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc-front',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
} as GameState['currentEncounter'],
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [
|
||||
{
|
||||
id: 'npc-opponent-npc-front',
|
||||
name: '正面对手',
|
||||
action: '摆开架势,随时准备出手',
|
||||
description: '正面对手',
|
||||
animation: 'idle',
|
||||
xMeters: 1.4,
|
||||
yOffset: 0,
|
||||
facing: 'left',
|
||||
attackRange: 1.8,
|
||||
speed: 8,
|
||||
hp: 88,
|
||||
maxHp: 88,
|
||||
renderKind: 'npc',
|
||||
encounter: {
|
||||
id: 'npc-front',
|
||||
kind: 'npc',
|
||||
npcName: '正面对手',
|
||||
npcDescription: '正面对手',
|
||||
npcAvatar: '/npc-front.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
xMeters: 1.4,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'npc-opponent-npc-back-1',
|
||||
name: '后排甲',
|
||||
action: '摆开架势,随时准备出手',
|
||||
description: '后排甲',
|
||||
animation: 'idle',
|
||||
xMeters: 2.1,
|
||||
yOffset: 16,
|
||||
facing: 'left',
|
||||
attackRange: 1.8,
|
||||
speed: 7,
|
||||
hp: 76,
|
||||
maxHp: 76,
|
||||
renderKind: 'npc',
|
||||
encounter: {
|
||||
id: 'npc-back-1',
|
||||
kind: 'npc',
|
||||
npcName: '后排甲',
|
||||
npcDescription: '后排甲',
|
||||
npcAvatar: '/npc-back-1.png',
|
||||
context: '桥口',
|
||||
hostile: true,
|
||||
xMeters: 2.1,
|
||||
},
|
||||
},
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
inBattle: true,
|
||||
currentBattleNpcId: 'npc-front',
|
||||
currentNpcBattleMode: 'fight',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
currentStory,
|
||||
option,
|
||||
});
|
||||
|
||||
expect(
|
||||
result.hydratedSnapshot.gameState.sceneHostileNpcs.map((monster) => ({
|
||||
encounterId: monster.encounter?.id,
|
||||
xMeters: monster.xMeters,
|
||||
yOffset: monster.yOffset,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
encounterId: 'npc-front',
|
||||
xMeters: 3.2,
|
||||
yOffset: 0,
|
||||
},
|
||||
{
|
||||
encounterId: 'npc-back-1',
|
||||
xMeters: 4.28,
|
||||
yOffset: 62,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('bridges idle_travel_next_scene server snapshots into the next scene runtime state', async () => {
|
||||
it('uses idle_travel_next_scene snapshots as returned by the backend resolver', async () => {
|
||||
const gameState = createTravelGameState();
|
||||
const currentStory = createStory('桥口这一段已经收束。');
|
||||
const option = {
|
||||
@@ -1247,13 +835,13 @@ describe('runtimeStoryCoordinator', () => {
|
||||
);
|
||||
expect(
|
||||
result.hydratedSnapshot.gameState.runtimeStats.scenesTraveled,
|
||||
).toBe(1);
|
||||
).toBe(0);
|
||||
expect(
|
||||
Boolean(
|
||||
result.hydratedSnapshot.gameState.currentEncounter ||
|
||||
result.hydratedSnapshot.gameState.sceneHostileNpcs.length > 0,
|
||||
),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rehydrates mid-battle snapshots when resuming a saved runtime story', async () => {
|
||||
|
||||
@@ -176,7 +176,7 @@ describe('sessionActions', () => {
|
||||
expect(rewardClaim).toHaveProperty('handoff');
|
||||
});
|
||||
|
||||
it('refreshes chapter state after a chapter quest is turned in', () => {
|
||||
it('does not rewrite backend-owned chapter state after a chapter quest is turned in', () => {
|
||||
const baseState = {
|
||||
...createBaseState(),
|
||||
currentScenePreset: {
|
||||
@@ -243,7 +243,7 @@ describe('sessionActions', () => {
|
||||
throw new Error('Expected reward claim result');
|
||||
}
|
||||
|
||||
expect(rewardClaim.nextState.chapterState?.stage).toBe('aftermath');
|
||||
expect(rewardClaim.nextState.storyEngineMemory?.currentChapter?.stage).toBe('aftermath');
|
||||
expect(rewardClaim.nextState.chapterState?.stage).toBe('climax');
|
||||
expect(rewardClaim.nextState.storyEngineMemory?.currentChapter?.stage).toBe('climax');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,13 +9,7 @@ import {
|
||||
markQuestCompletionNotified,
|
||||
markQuestTurnedIn,
|
||||
} from '../../data/questFlow';
|
||||
import {
|
||||
advanceChapterState,
|
||||
resolveCurrentChapterState,
|
||||
} from '../../services/storyEngine/chapterDirector';
|
||||
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
|
||||
import { buildGoalHandoffFromState } from '../../services/storyEngine/goalDirector';
|
||||
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||
import type {
|
||||
GameState,
|
||||
StoryMoment,
|
||||
@@ -53,7 +47,7 @@ export function applyQuestRewardClaim(
|
||||
|
||||
const issuerNpcState = state.npcStates[quest.issuerNpcId];
|
||||
|
||||
const nextState = appendStoryEngineCarrierMemory({
|
||||
const nextState: GameState = {
|
||||
...state,
|
||||
quests: markQuestTurnedIn(state.quests, questId),
|
||||
playerCurrency: state.playerCurrency + quest.reward.currency,
|
||||
@@ -67,30 +61,11 @@ export function applyQuestRewardClaim(
|
||||
},
|
||||
}
|
||||
: state.npcStates,
|
||||
}, quest.reward.items);
|
||||
const chapterState = advanceChapterState({
|
||||
previousChapter:
|
||||
nextState.chapterState
|
||||
?? nextState.storyEngineMemory?.currentChapter
|
||||
?? null,
|
||||
nextChapter: resolveCurrentChapterState({
|
||||
state: nextState,
|
||||
}),
|
||||
});
|
||||
const storyEngineMemory =
|
||||
nextState.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
const synchronizedNextState: GameState = {
|
||||
...nextState,
|
||||
chapterState,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
currentChapter: chapterState,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
nextState: synchronizedNextState,
|
||||
handoff: buildGoalHandoffFromState(synchronizedNextState),
|
||||
nextState,
|
||||
handoff: buildGoalHandoffFromState(nextState),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { addInventoryItems } from '../../data/npcInteractions';
|
||||
import { applyQuestProgressFromHostileNpcDefeat } from '../../data/questFlow';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import { generateNextStep } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import { AnimationState } from '../../types';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
@@ -13,19 +9,7 @@ import type {
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import type { EscapePlaybackSync } from '../combat/escapeFlow';
|
||||
import type { BattlePlan } from '../combat/battlePlan';
|
||||
import type { ResolvedChoiceState } from '../combat/resolvedChoice';
|
||||
import {
|
||||
buildDeathStory,
|
||||
buildPostBattleVictoryState,
|
||||
buildPostBattleVictoryStory,
|
||||
buildRevivedFirstSceneState,
|
||||
} from './postBattleFlow';
|
||||
import {
|
||||
buildCombatResolutionContextText,
|
||||
buildHostileNpcBattleReward,
|
||||
} from './storyChoiceRuntime';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
|
||||
type RuntimeStatsIncrements = Partial<
|
||||
@@ -84,78 +68,10 @@ type IncrementRuntimeStats = (
|
||||
increments: RuntimeStatsIncrements,
|
||||
) => GameState;
|
||||
|
||||
const PLAYER_REVIVE_DELAY_MS = 3000;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function buildLocalCombatResultText(params: {
|
||||
option: StoryOption;
|
||||
battlePlan: BattlePlan | null;
|
||||
afterSequence: GameState;
|
||||
combatResolutionContextText: string | null;
|
||||
}) {
|
||||
if (params.combatResolutionContextText) {
|
||||
return params.combatResolutionContextText;
|
||||
}
|
||||
|
||||
const turns = params.battlePlan?.turns ?? [];
|
||||
const dealtDamage = turns
|
||||
.filter((turn) => turn.actor === 'player' || turn.actor === 'companion')
|
||||
.reduce((sum, turn) => sum + turn.damage, 0);
|
||||
const takenDamage = turns
|
||||
.filter((turn) => turn.actor === 'monster' && turn.target === 'player')
|
||||
.reduce((sum, turn) => sum + turn.damage, 0);
|
||||
|
||||
if (params.afterSequence.playerHp <= 0) {
|
||||
return takenDamage > 0
|
||||
? `你承受了${takenDamage}点伤害,气血归零。`
|
||||
: '你在战斗中倒下,气血归零。';
|
||||
}
|
||||
|
||||
const details = [
|
||||
dealtDamage > 0 ? `造成${dealtDamage}点伤害` : null,
|
||||
takenDamage > 0 ? `承受${takenDamage}点伤害` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return details.length > 0
|
||||
? `${params.option.actionText}完成,${details.join(',')}。`
|
||||
: `${params.option.actionText}完成,双方仍在对峙。`;
|
||||
}
|
||||
|
||||
function buildDeterministicStoryForState(params: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
resultText: string;
|
||||
availableOptions: StoryOption[] | null;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
}) {
|
||||
if (params.availableOptions?.length) {
|
||||
return {
|
||||
text: params.resultText,
|
||||
options: params.availableOptions,
|
||||
streaming: false,
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
const fallbackStory = params.buildFallbackStoryForState(
|
||||
params.state,
|
||||
params.character,
|
||||
params.resultText,
|
||||
);
|
||||
return {
|
||||
...fallbackStory,
|
||||
text: params.resultText,
|
||||
streaming: false,
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
function isLocalNpcBattleVictoryOutcome(
|
||||
battleOutcome: GameState['currentNpcBattleOutcome'],
|
||||
) {
|
||||
function isBackendOwnedCombatChoice(option: StoryOption) {
|
||||
return (
|
||||
battleOutcome === 'fight_victory' || battleOutcome === 'spar_complete'
|
||||
option.functionId.startsWith('battle_') ||
|
||||
option.functionId === 'inventory_use'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,7 +95,6 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
resolvedChoice: ResolvedChoiceState,
|
||||
sync?: EscapePlaybackSync,
|
||||
) => Promise<GameState>;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
buildStoryFromResponse: BuildStoryFromResponse;
|
||||
@@ -234,53 +149,32 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
let fallbackState = baseChoiceState;
|
||||
|
||||
try {
|
||||
if (isBackendOwnedCombatChoice(params.option)) {
|
||||
throw new Error(
|
||||
`战斗与物品动作必须由后端结算,禁止进入本地 continuation:${params.option.functionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const history = baseChoiceState.storyHistory;
|
||||
const resolvedChoice = params.buildResolvedChoiceState(
|
||||
baseChoiceState,
|
||||
params.option,
|
||||
params.character,
|
||||
);
|
||||
if (resolvedChoice.optionKind === 'battle' || resolvedChoice.optionKind === 'escape') {
|
||||
throw new Error(
|
||||
`战斗与逃脱动作必须由后端结算,禁止进入本地 continuation:${params.option.functionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const projectedState = resolvedChoice.afterSequence;
|
||||
const shouldUseDeterministicCombatFlow =
|
||||
resolvedChoice.optionKind === 'battle' ||
|
||||
resolvedChoice.optionKind === 'escape';
|
||||
const shouldUseLocalNpcVictory = Boolean(
|
||||
baseChoiceState.currentBattleNpcId &&
|
||||
resolvedChoice.optionKind === 'battle' &&
|
||||
isLocalNpcBattleVictoryOutcome(projectedState.currentNpcBattleOutcome),
|
||||
);
|
||||
const projectedBattleReward = shouldUseLocalNpcVictory
|
||||
? null
|
||||
: await buildHostileNpcBattleReward(
|
||||
baseChoiceState,
|
||||
projectedState,
|
||||
resolvedChoice.optionKind,
|
||||
params.getResolvedSceneHostileNpcs,
|
||||
);
|
||||
const projectedStateWithBattleReward = projectedBattleReward
|
||||
? appendStoryEngineCarrierMemory(
|
||||
{
|
||||
...projectedState,
|
||||
playerInventory: addInventoryItems(
|
||||
projectedState.playerInventory,
|
||||
projectedBattleReward.items,
|
||||
),
|
||||
} as GameState,
|
||||
projectedBattleReward.items,
|
||||
)
|
||||
: projectedState;
|
||||
const projectedStateWithBattleReward = projectedState;
|
||||
fallbackState = projectedStateWithBattleReward;
|
||||
const projectedAvailableOptions = params.getAvailableOptionsForState(
|
||||
projectedStateWithBattleReward,
|
||||
params.character,
|
||||
);
|
||||
const combatResolutionContextText = buildCombatResolutionContextText({
|
||||
baseState: baseChoiceState,
|
||||
afterSequence: projectedStateWithBattleReward,
|
||||
optionKind: resolvedChoice.optionKind,
|
||||
projectedBattleReward,
|
||||
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
|
||||
});
|
||||
const combatResolutionContextText = null;
|
||||
const historyForStoryGeneration = combatResolutionContextText
|
||||
? [
|
||||
...history,
|
||||
@@ -289,38 +183,27 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
]
|
||||
: history;
|
||||
|
||||
const responsePromise = shouldUseLocalNpcVictory || shouldUseDeterministicCombatFlow
|
||||
? Promise.resolve(null)
|
||||
: generateNextStep(
|
||||
params.gameState.worldType!,
|
||||
params.character,
|
||||
params.getStoryGenerationHostileNpcs(projectedStateWithBattleReward),
|
||||
historyForStoryGeneration,
|
||||
params.option.actionText,
|
||||
params.buildStoryContextFromState(projectedStateWithBattleReward, {
|
||||
lastFunctionId: params.option.functionId,
|
||||
observeSignsRequested:
|
||||
params.option.functionId === 'idle_observe_signs',
|
||||
recentActionResult: combatResolutionContextText,
|
||||
}),
|
||||
projectedAvailableOptions
|
||||
? { availableOptions: projectedAvailableOptions }
|
||||
: undefined,
|
||||
);
|
||||
const responseSettledPromise = responsePromise.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
const responsePromise = generateNextStep(
|
||||
params.gameState.worldType!,
|
||||
params.character,
|
||||
params.getStoryGenerationHostileNpcs(projectedStateWithBattleReward),
|
||||
historyForStoryGeneration,
|
||||
params.option.actionText,
|
||||
params.buildStoryContextFromState(projectedStateWithBattleReward, {
|
||||
lastFunctionId: params.option.functionId,
|
||||
observeSignsRequested:
|
||||
params.option.functionId === 'idle_observe_signs',
|
||||
recentActionResult: combatResolutionContextText,
|
||||
}),
|
||||
projectedAvailableOptions
|
||||
? { availableOptions: projectedAvailableOptions }
|
||||
: undefined,
|
||||
);
|
||||
const playbackSync: EscapePlaybackSync | undefined =
|
||||
resolvedChoice.optionKind === 'escape' && !shouldUseDeterministicCombatFlow
|
||||
? { waitForStoryResponse: responseSettledPromise }
|
||||
: undefined;
|
||||
const actionPromise = params.playResolvedChoice(
|
||||
baseChoiceState,
|
||||
params.option,
|
||||
params.character,
|
||||
resolvedChoice,
|
||||
playbackSync,
|
||||
);
|
||||
const [actionResult, responseResult] = await Promise.allSettled([
|
||||
actionPromise,
|
||||
@@ -331,186 +214,14 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
throw actionResult.reason;
|
||||
}
|
||||
|
||||
let afterSequence = shouldUseLocalNpcVictory
|
||||
? resolvedChoice.afterSequence
|
||||
: actionResult.value;
|
||||
if (projectedBattleReward) {
|
||||
afterSequence = appendStoryEngineCarrierMemory(
|
||||
{
|
||||
...afterSequence,
|
||||
playerInventory: addInventoryItems(
|
||||
afterSequence.playerInventory,
|
||||
projectedBattleReward.items,
|
||||
),
|
||||
} as GameState,
|
||||
projectedBattleReward.items,
|
||||
);
|
||||
}
|
||||
const afterSequence = actionResult.value;
|
||||
fallbackState = afterSequence;
|
||||
|
||||
if (shouldUseLocalNpcVictory) {
|
||||
const victory = params.finalizeNpcBattleResult(
|
||||
afterSequence,
|
||||
params.character,
|
||||
baseChoiceState.currentNpcBattleMode!,
|
||||
afterSequence.currentNpcBattleOutcome,
|
||||
);
|
||||
if (victory) {
|
||||
const historyBase =
|
||||
baseChoiceState.currentNpcBattleMode === 'spar'
|
||||
? (afterSequence.sparStoryHistoryBefore ?? [])
|
||||
: baseChoiceState.storyHistory;
|
||||
const nextHistory = [
|
||||
...historyBase,
|
||||
createHistoryMoment(params.option.actionText, 'action'),
|
||||
createHistoryMoment(victory.resultText, 'result'),
|
||||
];
|
||||
const nextState = {
|
||||
...victory.nextState,
|
||||
storyHistory: nextHistory,
|
||||
};
|
||||
const postBattleState = buildPostBattleVictoryState(nextState);
|
||||
const postBattle = buildPostBattleVictoryStory(
|
||||
postBattleState,
|
||||
victory.resultText,
|
||||
params.getAvailableOptionsForState(postBattleState, params.character) ?? [],
|
||||
);
|
||||
fallbackState = postBattle.state;
|
||||
params.setGameState(postBattle.state);
|
||||
params.setCurrentStory(postBattle.story);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldUseDeterministicCombatFlow) {
|
||||
const defeatedHostileNpcIds =
|
||||
resolvedChoice.optionKind === 'escape' || baseChoiceState.currentBattleNpcId
|
||||
? []
|
||||
: params
|
||||
.getResolvedSceneHostileNpcs(baseChoiceState)
|
||||
.map((hostileNpc) => hostileNpc.id)
|
||||
.filter(
|
||||
(hostileNpcId) =>
|
||||
!params
|
||||
.getResolvedSceneHostileNpcs(afterSequence)
|
||||
.some((hostileNpc) => hostileNpc.id === hostileNpcId),
|
||||
);
|
||||
const resultText = buildLocalCombatResultText({
|
||||
option: params.option,
|
||||
battlePlan: resolvedChoice.battlePlan,
|
||||
afterSequence,
|
||||
combatResolutionContextText,
|
||||
});
|
||||
const nextHistory = [
|
||||
...baseChoiceState.storyHistory,
|
||||
createHistoryMoment(params.option.actionText, 'action'),
|
||||
createHistoryMoment(resultText, 'result'),
|
||||
];
|
||||
const nextState = params.incrementRuntimeStats(
|
||||
{
|
||||
...params.updateQuestLog(afterSequence, (quests) =>
|
||||
applyQuestProgressFromHostileNpcDefeat(
|
||||
quests,
|
||||
baseChoiceState.currentScenePreset?.id ?? null,
|
||||
defeatedHostileNpcIds,
|
||||
),
|
||||
),
|
||||
storyHistory: nextHistory,
|
||||
},
|
||||
{
|
||||
hostileNpcsDefeated: defeatedHostileNpcIds.length,
|
||||
},
|
||||
);
|
||||
|
||||
if (projectedBattleReward) {
|
||||
params.setBattleReward(projectedBattleReward);
|
||||
}
|
||||
|
||||
if (nextState.playerHp <= 0) {
|
||||
const deathState = {
|
||||
...nextState,
|
||||
animationState: AnimationState.DIE,
|
||||
playerActionMode: 'idle' as const,
|
||||
inBattle: false,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
};
|
||||
fallbackState = deathState;
|
||||
params.setGameState(deathState);
|
||||
await sleep(PLAYER_REVIVE_DELAY_MS);
|
||||
const revivedState = {
|
||||
...buildRevivedFirstSceneState(deathState),
|
||||
storyHistory: [
|
||||
...nextHistory,
|
||||
createHistoryMoment('你在第一个场景第一幕重新醒来。', 'result'),
|
||||
],
|
||||
};
|
||||
fallbackState = revivedState;
|
||||
const revivedDeferredOptions =
|
||||
params.buildFallbackStoryForState(revivedState, params.character).options;
|
||||
params.setGameState(revivedState);
|
||||
params.setCurrentStory(
|
||||
buildDeathStory(revivedState, revivedDeferredOptions),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
resolvedChoice.optionKind === 'battle' &&
|
||||
(
|
||||
nextState.currentNpcBattleOutcome === 'fight_victory' ||
|
||||
nextState.currentNpcBattleOutcome === 'spar_complete' ||
|
||||
(!baseChoiceState.currentBattleNpcId && !nextState.inBattle)
|
||||
)
|
||||
) {
|
||||
const postBattleState = buildPostBattleVictoryState(nextState);
|
||||
const postBattle = buildPostBattleVictoryStory(
|
||||
postBattleState,
|
||||
resultText,
|
||||
params.getAvailableOptionsForState(postBattleState, params.character) ?? [],
|
||||
);
|
||||
fallbackState = postBattle.state;
|
||||
params.setGameState(postBattle.state);
|
||||
params.setCurrentStory(postBattle.story);
|
||||
return;
|
||||
}
|
||||
|
||||
const availableOptions = params.getAvailableOptionsForState(
|
||||
nextState,
|
||||
params.character,
|
||||
);
|
||||
fallbackState = nextState;
|
||||
params.setGameState(nextState);
|
||||
params.setCurrentStory(
|
||||
buildDeterministicStoryForState({
|
||||
state: nextState,
|
||||
character: params.character,
|
||||
resultText,
|
||||
availableOptions,
|
||||
buildFallbackStoryForState: params.buildFallbackStoryForState,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (responseResult.status === 'rejected') {
|
||||
throw responseResult.reason;
|
||||
}
|
||||
|
||||
const response = responseResult.value!;
|
||||
const defeatedHostileNpcIds =
|
||||
baseChoiceState.currentBattleNpcId ||
|
||||
resolvedChoice.optionKind === 'escape'
|
||||
? []
|
||||
: params
|
||||
.getResolvedSceneHostileNpcs(baseChoiceState)
|
||||
.map((hostileNpc) => hostileNpc.id)
|
||||
.filter(
|
||||
(hostileNpcId) =>
|
||||
!params
|
||||
.getResolvedSceneHostileNpcs(afterSequence)
|
||||
.some((hostileNpc) => hostileNpc.id === hostileNpcId),
|
||||
);
|
||||
const nextHistory = combatResolutionContextText
|
||||
? [
|
||||
...historyForStoryGeneration,
|
||||
@@ -524,13 +235,7 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
|
||||
const nextState = params.incrementRuntimeStats(
|
||||
{
|
||||
...params.updateQuestLog(afterSequence, (quests) =>
|
||||
applyQuestProgressFromHostileNpcDefeat(
|
||||
quests,
|
||||
baseChoiceState.currentScenePreset?.id ?? null,
|
||||
defeatedHostileNpcIds,
|
||||
),
|
||||
),
|
||||
...afterSequence,
|
||||
lastObserveSignsSceneId:
|
||||
params.option.functionId === 'idle_observe_signs'
|
||||
? (afterSequence.currentScenePreset?.id ?? null)
|
||||
@@ -541,16 +246,11 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
: afterSequence.lastObserveSignsReport ?? null,
|
||||
storyHistory: nextHistory,
|
||||
},
|
||||
{
|
||||
hostileNpcsDefeated: defeatedHostileNpcIds.length,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const recoveredState = applyStoryReasoningRecovery(nextState);
|
||||
params.setGameState(recoveredState);
|
||||
if (projectedBattleReward) {
|
||||
params.setBattleReward(projectedBattleReward);
|
||||
}
|
||||
|
||||
params.setCurrentStory(
|
||||
params.buildStoryFromResponse(
|
||||
|
||||
@@ -1,34 +1,16 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
rollHostileNpcLootMock,
|
||||
resolveServerRuntimeChoiceMock,
|
||||
} = vi.hoisted(() => ({
|
||||
rollHostileNpcLootMock: vi.fn(),
|
||||
const { resolveServerRuntimeChoiceMock } = vi.hoisted(() => ({
|
||||
resolveServerRuntimeChoiceMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../data/hostileNpcPresets', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../../data/hostileNpcPresets')>(
|
||||
'../../data/hostileNpcPresets',
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
rollHostileNpcLoot: rollHostileNpcLootMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('.', () => ({
|
||||
resolveRpgRuntimeChoice: resolveServerRuntimeChoiceMock,
|
||||
}));
|
||||
|
||||
import type { Character, GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import { WorldType } from '../../types/core';
|
||||
import {
|
||||
buildCombatResolutionContextText,
|
||||
buildHostileNpcBattleReward,
|
||||
buildReasonedOptionCatalog,
|
||||
runServerRuntimeChoiceAction,
|
||||
shouldOpenLocalRuntimeNpcModal,
|
||||
} from './storyChoiceRuntime';
|
||||
@@ -56,10 +38,10 @@ function createCharacter(): Character {
|
||||
} as unknown as Character;
|
||||
}
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
function createStory(text: string, options: StoryOption[] = []): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -140,23 +122,9 @@ function createState(overrides: Partial<GameState> = {}): GameState {
|
||||
|
||||
describe('storyChoiceRuntime', () => {
|
||||
beforeEach(() => {
|
||||
rollHostileNpcLootMock.mockReset();
|
||||
resolveServerRuntimeChoiceMock.mockReset();
|
||||
});
|
||||
|
||||
it('deduplicates option catalogs by function id for post-battle recovery', () => {
|
||||
const options = buildReasonedOptionCatalog([
|
||||
createOption('npc_chat'),
|
||||
createOption('npc_chat'),
|
||||
createOption('npc_help'),
|
||||
]);
|
||||
|
||||
expect(options.map((option) => option.functionId)).toEqual([
|
||||
'npc_chat',
|
||||
'npc_help',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps npc chat, trade and gift on the local runtime npc interaction path', () => {
|
||||
expect(
|
||||
shouldOpenLocalRuntimeNpcModal(
|
||||
@@ -190,117 +158,6 @@ describe('storyChoiceRuntime', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('builds escape and victory context text for local battle resolution', () => {
|
||||
const baseState = createState({
|
||||
inBattle: true,
|
||||
sceneHostileNpcs: [
|
||||
{ id: 'wolf', name: '山狼' },
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
});
|
||||
|
||||
expect(
|
||||
buildCombatResolutionContextText({
|
||||
baseState,
|
||||
afterSequence: {
|
||||
...baseState,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
},
|
||||
optionKind: 'escape',
|
||||
projectedBattleReward: null,
|
||||
getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs,
|
||||
}),
|
||||
).toContain('你已成功逃脱');
|
||||
|
||||
expect(
|
||||
buildCombatResolutionContextText({
|
||||
baseState: {
|
||||
...baseState,
|
||||
currentBattleNpcId: null,
|
||||
},
|
||||
afterSequence: {
|
||||
...baseState,
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
},
|
||||
optionKind: 'battle',
|
||||
projectedBattleReward: {
|
||||
id: 'reward-1',
|
||||
defeatedHostileNpcs: [{ id: 'wolf', name: '山狼' }],
|
||||
items: [
|
||||
{ id: 'loot-1', category: '材料', name: '狼牙', quantity: 1, rarity: 'common', tags: [] },
|
||||
],
|
||||
},
|
||||
getResolvedSceneHostileNpcs: (state) => state.sceneHostileNpcs,
|
||||
}),
|
||||
).toContain('战利品:狼牙。');
|
||||
});
|
||||
|
||||
it('builds defeated hostile rewards from locally resolved battle states', async () => {
|
||||
rollHostileNpcLootMock.mockResolvedValue([
|
||||
{
|
||||
id: 'loot-1',
|
||||
category: '材料',
|
||||
name: '狼牙',
|
||||
quantity: 1,
|
||||
rarity: 'common',
|
||||
tags: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const reward = await buildHostileNpcBattleReward(
|
||||
createState({
|
||||
inBattle: true,
|
||||
sceneHostileNpcs: [
|
||||
{ id: 'wolf', name: '山狼' },
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
currentBattleNpcId: null,
|
||||
}),
|
||||
createState({
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
}),
|
||||
'battle',
|
||||
(state) => state.sceneHostileNpcs,
|
||||
);
|
||||
|
||||
expect(rollHostileNpcLootMock).toHaveBeenCalledTimes(1);
|
||||
expect(reward?.items[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
name: '狼牙',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps defeated hostile reward render keys unique for duplicate monster ids', async () => {
|
||||
rollHostileNpcLootMock.mockResolvedValue([]);
|
||||
|
||||
const reward = await buildHostileNpcBattleReward(
|
||||
createState({
|
||||
inBattle: true,
|
||||
sceneHostileNpcs: [
|
||||
{ id: 'monster-16', name: '雷翼甲' },
|
||||
{ id: 'monster-16', name: '雷翼乙', xMeters: 4.1, yOffset: 32 },
|
||||
] as GameState['sceneHostileNpcs'],
|
||||
currentBattleNpcId: null,
|
||||
}),
|
||||
createState({
|
||||
inBattle: false,
|
||||
sceneHostileNpcs: [],
|
||||
}),
|
||||
'battle',
|
||||
(state) => state.sceneHostileNpcs,
|
||||
);
|
||||
|
||||
expect(reward?.defeatedHostileNpcs).toHaveLength(2);
|
||||
expect(reward?.defeatedHostileNpcs.map((npc) => npc.id)).toEqual([
|
||||
'monster-16',
|
||||
'monster-16',
|
||||
]);
|
||||
expect(new Set(reward?.defeatedHostileNpcs.map((npc) => npc.renderKey)).size)
|
||||
.toBe(2);
|
||||
});
|
||||
|
||||
it('applies server runtime responses and falls back locally when the request fails', async () => {
|
||||
const gameState = createState();
|
||||
const currentStory = createStory('当前故事');
|
||||
@@ -452,9 +309,9 @@ describe('storyChoiceRuntime', () => {
|
||||
expect(setGameState).toHaveBeenLastCalledWith(finalState);
|
||||
});
|
||||
|
||||
it('routes server defeat outcomes into death and revive flow instead of victory settlement', async () => {
|
||||
it('uses the server-returned defeat revive snapshot without local death reconstruction', async () => {
|
||||
const gameState = createState({
|
||||
worldType: 'WUXIA',
|
||||
worldType: WorldType.WUXIA,
|
||||
inBattle: true,
|
||||
playerHp: 6,
|
||||
playerMaxHp: 30,
|
||||
@@ -467,7 +324,7 @@ describe('storyChoiceRuntime', () => {
|
||||
imageSrc: '/scene-a.png',
|
||||
connectedSceneIds: [],
|
||||
connections: [],
|
||||
forwardSceneId: null,
|
||||
forwardSceneId: undefined,
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
@@ -488,16 +345,45 @@ describe('storyChoiceRuntime', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
const finalState = createState({
|
||||
const serverRevivedState = createState({
|
||||
...gameState,
|
||||
inBattle: false,
|
||||
playerHp: 0,
|
||||
currentEncounter: null,
|
||||
playerHp: 30,
|
||||
playerMana: 10,
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'wolf',
|
||||
npcName: '山狼',
|
||||
npcDescription: '林间伏击的野兽',
|
||||
npcAvatar: '狼',
|
||||
context: '复活后的首场景威胁',
|
||||
hostile: true,
|
||||
},
|
||||
sceneHostileNpcs: [],
|
||||
currentNpcBattleOutcome: 'fight_defeat',
|
||||
currentNpcBattleOutcome: null,
|
||||
currentScenePreset: {
|
||||
id: 'wuxia-bamboo-road',
|
||||
name: '竹林古道',
|
||||
description: '风穿竹影,路面狭长。',
|
||||
imageSrc: '/scene-a.png',
|
||||
connectedSceneIds: ['wuxia-mountain-gate'],
|
||||
connections: [
|
||||
{
|
||||
sceneId: 'wuxia-mountain-gate',
|
||||
relativePosition: 'forward',
|
||||
summary: '沿主路继续深入前方区域',
|
||||
},
|
||||
],
|
||||
forwardSceneId: 'wuxia-mountain-gate',
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
});
|
||||
const setGameState = vi.fn();
|
||||
const setCurrentStory = vi.fn();
|
||||
const serverDeathStory = createStory('你在战斗中倒下,随后在竹林古道重新醒来。', [
|
||||
createOption('story_continue_adventure'),
|
||||
]);
|
||||
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
response: {
|
||||
@@ -512,9 +398,9 @@ describe('storyChoiceRuntime', () => {
|
||||
},
|
||||
},
|
||||
hydratedSnapshot: {
|
||||
gameState: finalState,
|
||||
gameState: serverRevivedState,
|
||||
},
|
||||
nextStory: createStory('不会进入胜利文本'),
|
||||
nextStory: serverDeathStory,
|
||||
});
|
||||
|
||||
await runServerRuntimeChoiceAction({
|
||||
@@ -527,10 +413,7 @@ describe('storyChoiceRuntime', () => {
|
||||
setIsLoading: vi.fn(),
|
||||
setGameState,
|
||||
setCurrentStory: setCurrentStory as (story: StoryMoment) => void,
|
||||
buildFallbackStoryForState: () =>
|
||||
createStory('fallback', [
|
||||
createOption('idle_explore_forward'),
|
||||
]),
|
||||
buildFallbackStoryForState: () => createStory('fallback'),
|
||||
turnVisualMs: 1,
|
||||
});
|
||||
|
||||
@@ -541,21 +424,8 @@ describe('storyChoiceRuntime', () => {
|
||||
inBattle: false,
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('重新醒来'),
|
||||
options: [
|
||||
expect.objectContaining({
|
||||
functionId: 'story_continue_adventure',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(setCurrentStory).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: '不会进入胜利文本',
|
||||
}),
|
||||
);
|
||||
expect(setGameState).toHaveBeenLastCalledWith(serverRevivedState);
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(serverDeathStory);
|
||||
});
|
||||
|
||||
it('commits server-returned next-scene state after idle_travel_next_scene resolution', async () => {
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
buildEncounterEntryState,
|
||||
hasEncounterEntity,
|
||||
} from '../../data/encounterTransition';
|
||||
import { rollHostileNpcLoot } from '../../data/hostileNpcPresets';
|
||||
import { addInventoryItems } from '../../data/npcInteractions';
|
||||
import {
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
createSceneEncounterPreview,
|
||||
@@ -18,12 +16,6 @@ import {
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import { resolveRpgRuntimeChoice } from '.';
|
||||
import {
|
||||
buildDeathStory,
|
||||
buildPostBattleVictoryState,
|
||||
buildPostBattleVictoryStory,
|
||||
buildRevivedFirstSceneState,
|
||||
} from './postBattleFlow';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
|
||||
type RuntimeStatsIncrements = Partial<
|
||||
@@ -48,68 +40,6 @@ function sleep(ms: number) {
|
||||
return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const PLAYER_REVIVE_DELAY_MS = 3000;
|
||||
|
||||
export function buildReasonedOptionCatalog(options: StoryOption[]) {
|
||||
const seenFunctionIds = new Set<string>();
|
||||
|
||||
return options.filter((option) => {
|
||||
if (seenFunctionIds.has(option.functionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenFunctionIds.add(option.functionId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCombatResolutionContextText(params: {
|
||||
baseState: GameState;
|
||||
afterSequence: GameState;
|
||||
optionKind: 'battle' | 'escape' | 'idle';
|
||||
projectedBattleReward: BattleRewardSummary | null;
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
}) {
|
||||
const {
|
||||
baseState,
|
||||
afterSequence,
|
||||
optionKind,
|
||||
projectedBattleReward,
|
||||
getResolvedSceneHostileNpcs,
|
||||
} = params;
|
||||
|
||||
if (optionKind === 'escape') {
|
||||
const hostileNames = getResolvedSceneHostileNpcs(baseState)
|
||||
.map((hostileNpc) => hostileNpc.name)
|
||||
.join('、');
|
||||
return hostileNames
|
||||
? `你已成功逃脱,与${hostileNames}的交战已经被甩开,对方暂时落在身后,当前不再处于战斗状态。`
|
||||
: '你已成功逃脱刚才的交战,当前不再处于战斗状态。';
|
||||
}
|
||||
|
||||
if (
|
||||
!baseState.inBattle ||
|
||||
afterSequence.inBattle ||
|
||||
Boolean(baseState.currentBattleNpcId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostileNames = getResolvedSceneHostileNpcs(baseState)
|
||||
.map((hostileNpc) => hostileNpc.name)
|
||||
.join('、');
|
||||
const lootText =
|
||||
projectedBattleReward?.items.length
|
||||
? `战利品:${projectedBattleReward.items
|
||||
.map((item) => item.name)
|
||||
.join('、')}。`
|
||||
: '';
|
||||
|
||||
return hostileNames
|
||||
? `你已经击败${hostileNames},眼前这一轮交战已经结束。${lootText}`
|
||||
: `眼前这一轮交战已经结束,当前不再处于战斗状态。${lootText}`;
|
||||
}
|
||||
|
||||
export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) {
|
||||
return (
|
||||
(
|
||||
@@ -124,63 +54,6 @@ export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildHostileNpcBattleReward(
|
||||
state: GameState,
|
||||
afterSequence: GameState,
|
||||
optionKind: 'battle' | 'escape' | 'idle',
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'],
|
||||
): Promise<BattleRewardSummary | null> {
|
||||
if (
|
||||
optionKind === 'escape' ||
|
||||
!state.worldType ||
|
||||
state.currentBattleNpcId ||
|
||||
!state.inBattle ||
|
||||
afterSequence.inBattle
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeHostileNpcs = getResolvedSceneHostileNpcs(state);
|
||||
const nextHostileNpcs = getResolvedSceneHostileNpcs(afterSequence);
|
||||
const defeatedHostileNpcs = activeHostileNpcs.filter(
|
||||
(hostileNpc) =>
|
||||
!nextHostileNpcs.some(
|
||||
(nextHostileNpc) => nextHostileNpc.id === hostileNpc.id,
|
||||
),
|
||||
);
|
||||
|
||||
if (defeatedHostileNpcs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rolledItems = await rollHostileNpcLoot(
|
||||
state,
|
||||
defeatedHostileNpcs.map((hostileNpc) => ({
|
||||
id: hostileNpc.id,
|
||||
name: hostileNpc.name,
|
||||
})),
|
||||
);
|
||||
|
||||
return {
|
||||
id: `battle-reward-${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`,
|
||||
defeatedHostileNpcs: defeatedHostileNpcs.map((hostileNpc, index) => ({
|
||||
id: hostileNpc.id,
|
||||
name: hostileNpc.name,
|
||||
// 中文注释:同一场战斗可能击败多个同 preset 怪物,奖励弹层 key 不能只用怪物 id。
|
||||
renderKey: [
|
||||
hostileNpc.id,
|
||||
hostileNpc.name,
|
||||
hostileNpc.xMeters,
|
||||
hostileNpc.yOffset ?? 0,
|
||||
index,
|
||||
].join(':'),
|
||||
})),
|
||||
items: addInventoryItems([], rolledItems),
|
||||
};
|
||||
}
|
||||
|
||||
export async function runCampTravelHomeChoice(params: {
|
||||
gameState: GameState;
|
||||
option: StoryOption;
|
||||
@@ -337,47 +210,6 @@ export async function runServerRuntimeChoiceAction(params: {
|
||||
});
|
||||
}
|
||||
|
||||
const battle = response?.presentation.battle;
|
||||
if (battle && hydratedSnapshot.gameState.playerHp <= 0) {
|
||||
const deathState = {
|
||||
...hydratedSnapshot.gameState,
|
||||
animationState: AnimationState.DIE,
|
||||
playerActionMode: 'idle' as const,
|
||||
inBattle: false,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
};
|
||||
params.setGameState(deathState);
|
||||
await sleep(PLAYER_REVIVE_DELAY_MS);
|
||||
const revivedState = buildRevivedFirstSceneState(deathState);
|
||||
const revivedDeferredOptions =
|
||||
params.buildFallbackStoryForState(revivedState, params.character).options;
|
||||
params.setGameState(revivedState);
|
||||
params.setCurrentStory(
|
||||
buildDeathStory(revivedState, revivedDeferredOptions),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
battle?.outcome === 'victory' ||
|
||||
battle?.outcome === 'spar_complete'
|
||||
) {
|
||||
const resultText =
|
||||
response?.presentation.resultText || nextStory.text || params.option.actionText;
|
||||
const postBattleState = buildPostBattleVictoryState(
|
||||
hydratedSnapshot.gameState,
|
||||
);
|
||||
const postBattle = buildPostBattleVictoryStory(
|
||||
postBattleState,
|
||||
resultText,
|
||||
nextStory.options,
|
||||
);
|
||||
params.setGameState(postBattle.state);
|
||||
params.setCurrentStory(postBattle.story);
|
||||
return;
|
||||
}
|
||||
|
||||
params.setGameState(hydratedSnapshot.gameState);
|
||||
params.setCurrentStory(nextStory);
|
||||
} catch (error) {
|
||||
@@ -459,14 +291,15 @@ async function playServerBattlePresentation(params: {
|
||||
const finalTarget = params.finalState.sceneHostileNpcs.find(
|
||||
(hostileNpc) => hostileNpc.id === targetId,
|
||||
);
|
||||
const playerDefeated = battle.outcome === 'defeat';
|
||||
const targetDefeated =
|
||||
battle.outcome === 'victory' ||
|
||||
battle.outcome === 'spar_complete' ||
|
||||
(battle.outcome !== 'defeat' && !finalTarget && (battle.damageDealt ?? 0) > 0);
|
||||
params.setGameState({
|
||||
...actingState,
|
||||
playerHp: params.finalState.playerHp,
|
||||
playerMana: params.finalState.playerMana,
|
||||
playerHp: playerDefeated ? 0 : params.finalState.playerHp,
|
||||
playerMana: playerDefeated ? params.baseState.playerMana : params.finalState.playerMana,
|
||||
playerSkillCooldowns: params.finalState.playerSkillCooldowns,
|
||||
activeBuildBuffs: params.finalState.activeBuildBuffs,
|
||||
sceneHostileNpcs: actingState.sceneHostileNpcs.map((hostileNpc) => {
|
||||
@@ -483,9 +316,12 @@ async function playServerBattlePresentation(params: {
|
||||
});
|
||||
await sleep(Math.max(180, Math.round(params.turnVisualMs * 0.45)));
|
||||
|
||||
if (params.finalState.playerHp <= 0) {
|
||||
if (playerDefeated || params.finalState.playerHp <= 0) {
|
||||
// 中文注释:这里只是 presentation 的临时倒地视觉,
|
||||
// 正式复活位置、血蓝和故事仍以随后提交的服务端 snapshot 为准。
|
||||
params.setGameState({
|
||||
...params.finalState,
|
||||
...actingState,
|
||||
playerHp: 0,
|
||||
animationState: AnimationState.DIE,
|
||||
playerActionMode: 'idle',
|
||||
inBattle: false,
|
||||
|
||||
@@ -1,60 +1,5 @@
|
||||
import { getCharacterById } from '../../data/characterPresets';
|
||||
import {
|
||||
NPC_CHAT_FUNCTION,
|
||||
STORY_OPENING_CAMP_DIALOGUE_FUNCTION,
|
||||
} from '../../data/functionCatalog';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
describeNpcAffinityInWords,
|
||||
getNpcConversationDirective,
|
||||
isNpcFirstMeaningfulContact,
|
||||
} from '../../data/npcInteractions';
|
||||
import { buildSceneEntityCatalogText } from '../../data/scenePresets';
|
||||
import { hasMixedNarrativeLanguage } from '../../services/narrativeLanguage';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
} from '../../services/storyEngine/actorNarrativeProfile';
|
||||
import { applyAdaptiveTuningToPromptContext } from '../../services/storyEngine/adaptiveNarrativeTuner';
|
||||
import { compileCampaignFromWorldProfile } from '../../services/storyEngine/campaignPackCompiler';
|
||||
import {
|
||||
buildCampEvent,
|
||||
evaluateCampEventOpportunity,
|
||||
} from '../../services/storyEngine/campEventDirector';
|
||||
import {
|
||||
advanceChapterState,
|
||||
resolveCurrentChapterState,
|
||||
} from '../../services/storyEngine/chapterDirector';
|
||||
import {
|
||||
advanceCompanionArc,
|
||||
buildCompanionArcStates,
|
||||
} from '../../services/storyEngine/companionArcDirector';
|
||||
import { buildGoalStackState } from '../../services/storyEngine/goalDirector';
|
||||
import { resolveCurrentJourneyBeat } from '../../services/storyEngine/journeyBeatPlanner';
|
||||
import { buildVisibilitySliceFromFacts } from '../../services/storyEngine/knowledgeContract';
|
||||
import { buildKnowledgeGraph } from '../../services/storyEngine/knowledgeGraph';
|
||||
import { buildRecentCarrierEchoes } from '../../services/storyEngine/narrativeCarrierCatalog';
|
||||
import { buildChapterRecap } from '../../services/storyEngine/recapDigest';
|
||||
import { resolveScenarioPack } from '../../services/storyEngine/scenarioPackRegistry';
|
||||
import { buildSceneNarrativeDirective } from '../../services/storyEngine/sceneNarrativeDirector';
|
||||
import {
|
||||
buildSetpieceDirective,
|
||||
evaluateSetpieceOpportunity,
|
||||
} from '../../services/storyEngine/setpieceDirector';
|
||||
import { buildChronicleSummary } from '../../services/storyEngine/storyChronicle';
|
||||
import { buildThemePackFromWorldProfile } from '../../services/storyEngine/themePack';
|
||||
import {
|
||||
buildEncounterVisibilitySlice,
|
||||
createEmptyStoryEngineMemoryState,
|
||||
} from '../../services/storyEngine/visibilityEngine';
|
||||
import { buildFallbackWorldStoryGraph } from '../../services/storyEngine/worldStoryGraph';
|
||||
import type { GameState } from '../../types';
|
||||
import { getCharacterChatRecord } from './characterChat';
|
||||
import { getNpcEncounterKey } from './storyGenerationState';
|
||||
|
||||
const OPENING_CAMP_DIALOGUE_FUNCTION_ID =
|
||||
STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
|
||||
|
||||
export type StoryContextBuilderExtras = {
|
||||
pendingSceneEncounter?: boolean;
|
||||
@@ -66,560 +11,35 @@ export type StoryContextBuilderExtras = {
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
};
|
||||
|
||||
function buildPartyRelationshipNotes(state: GameState) {
|
||||
const lines: string[] = [];
|
||||
const seenCharacterIds = new Set<string>();
|
||||
|
||||
const appendNote = (characterId: string, roleLabel: string) => {
|
||||
if (seenCharacterIds.has(characterId)) return;
|
||||
const character = getCharacterById(characterId);
|
||||
const summary = getCharacterChatRecord(state, characterId).summary.trim();
|
||||
if (hasMixedNarrativeLanguage(summary)) return;
|
||||
if (!character || !summary) return;
|
||||
|
||||
seenCharacterIds.add(characterId);
|
||||
lines.push(
|
||||
`- ${character.name} (${character.title} / ${roleLabel}): ${summary}`,
|
||||
);
|
||||
};
|
||||
|
||||
state.companions.forEach((companion) =>
|
||||
appendNote(companion.characterId, '当前同行'),
|
||||
);
|
||||
state.roster.forEach((companion) =>
|
||||
appendNote(companion.characterId, '营地待命'),
|
||||
);
|
||||
|
||||
return lines.length > 0 ? lines.join('\n') : null;
|
||||
}
|
||||
|
||||
function describeScenePressureLevel(
|
||||
pressureLevel: 'low' | 'medium' | 'high' | 'extreme' | null | undefined,
|
||||
) {
|
||||
switch (pressureLevel) {
|
||||
case 'low':
|
||||
return '低';
|
||||
case 'medium':
|
||||
return '中';
|
||||
case 'high':
|
||||
return '高';
|
||||
case 'extreme':
|
||||
return '极高';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRecentConversationEventText(state: GameState) {
|
||||
const recentText = state.storyHistory
|
||||
.slice(-6)
|
||||
.map((item) => item.text)
|
||||
.join('\n');
|
||||
if (
|
||||
/击败|怪物|战斗|切磋|交手|脱身/u.test(recentText)
|
||||
) {
|
||||
return '你们刚经历过一场交锋或切磋,空气里的紧张感还没有完全散去。';
|
||||
}
|
||||
if (/携手|相助|帮你|并肩/u.test(recentText)) {
|
||||
return '你们刚并肩配合过一次,彼此之间的距离感稍微淡了一些。';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferConversationSituation(
|
||||
state: GameState,
|
||||
extras: Pick<
|
||||
StoryContextBuilderExtras,
|
||||
'lastFunctionId' | 'openingCampDialogue'
|
||||
>,
|
||||
) {
|
||||
if (state.inBattle) return 'shared_danger_coordination' as const;
|
||||
if (extras.lastFunctionId === OPENING_CAMP_DIALOGUE_FUNCTION_ID)
|
||||
return 'camp_first_contact' as const;
|
||||
if (
|
||||
state.currentEncounter?.specialBehavior === 'camp_companion' &&
|
||||
extras.openingCampDialogue?.trim()
|
||||
) {
|
||||
return 'camp_followup' as const;
|
||||
}
|
||||
const recentText = state.storyHistory
|
||||
.slice(-6)
|
||||
.map((item) => item.text)
|
||||
.join('\n');
|
||||
if (
|
||||
/击败|怪物|战斗|切磋|交手|脱身/u.test(recentText)
|
||||
) {
|
||||
return 'post_battle_breath' as const;
|
||||
}
|
||||
if (extras.lastFunctionId === NPC_CHAT_FUNCTION.id)
|
||||
return 'private_followup' as const;
|
||||
return 'first_contact_cautious' as const;
|
||||
}
|
||||
|
||||
function inferConversationPressure(
|
||||
state: GameState,
|
||||
situation: ReturnType<typeof inferConversationSituation>,
|
||||
) {
|
||||
const hpRatio = state.playerHp / Math.max(state.playerMaxHp, 1);
|
||||
if (state.inBattle || hpRatio < 0.35) return 'high' as const;
|
||||
if (
|
||||
situation === 'post_battle_breath' ||
|
||||
situation === 'shared_danger_coordination'
|
||||
)
|
||||
return 'medium' as const;
|
||||
if (situation === 'camp_first_contact' || situation === 'camp_followup')
|
||||
return 'low' as const;
|
||||
return 'medium' as const;
|
||||
}
|
||||
|
||||
function describeConversationSituation(
|
||||
situation: ReturnType<typeof inferConversationSituation>,
|
||||
) {
|
||||
switch (situation) {
|
||||
case 'camp_first_contact':
|
||||
return '这是营地里第一次真正静下来对话的时刻,语气要保持谨慎、观察和轻微试探。';
|
||||
case 'camp_followup':
|
||||
return '营地里的第一轮试探已经发生过了,这一轮应当顺着刚才的话头稍微往深处接。';
|
||||
case 'post_battle_breath':
|
||||
return '一场交锋刚结束,眼前危险稍缓,但双方都还带着余悸和紧绷。';
|
||||
case 'shared_danger_coordination':
|
||||
return '危险还没过去,对话应当短、准、直接,优先服务眼前判断。';
|
||||
case 'private_followup':
|
||||
return '这已经不是严格意义上的初见,更适合作为刚才未说完那句话的延续。';
|
||||
default:
|
||||
return '双方才刚真正对上话,此刻仍在判断彼此能信到什么程度。';
|
||||
}
|
||||
}
|
||||
|
||||
function describeConversationTalkPriority(
|
||||
situation: ReturnType<typeof inferConversationSituation>,
|
||||
) {
|
||||
switch (situation) {
|
||||
case 'camp_first_contact':
|
||||
return '优先写眼前印象、彼此态度和营地气氛,不要一上来就把动机讲透。';
|
||||
case 'camp_followup':
|
||||
return '先接住上一轮还没说透的话头,再决定要不要继续往下追问。';
|
||||
case 'post_battle_breath':
|
||||
return '先谈刚刚那次交锋以及彼此的判断,再视情况往更深处推进。';
|
||||
case 'shared_danger_coordination':
|
||||
return '先说最有用的判断、危险和下一步,不要扩成大段背景说明。';
|
||||
case 'private_followup':
|
||||
return '承接当前话头和关系变化,不要把对话又写回刚见面时的节奏。';
|
||||
default:
|
||||
return '先试探态度和现场判断,不要急着把来意和秘密一次摊开。';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEncounterNarrativeProfile(state: GameState) {
|
||||
const encounter = state.currentEncounter;
|
||||
if (!encounter || encounter.kind !== 'npc') {
|
||||
return null;
|
||||
}
|
||||
if (encounter.narrativeProfile) {
|
||||
return encounter.narrativeProfile;
|
||||
}
|
||||
if (!state.customWorldProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const role =
|
||||
state.customWorldProfile.storyNpcs.find((npc) =>
|
||||
npc.id === encounter.id || npc.name === encounter.npcName,
|
||||
)
|
||||
?? state.customWorldProfile.playableNpcs.find((npc) =>
|
||||
npc.id === encounter.id || npc.name === encounter.npcName,
|
||||
);
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const themePack =
|
||||
state.customWorldProfile.themePack
|
||||
?? buildThemePackFromWorldProfile(state.customWorldProfile);
|
||||
const storyGraph =
|
||||
state.customWorldProfile.storyGraph
|
||||
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
|
||||
|
||||
return normalizeActorNarrativeProfile(
|
||||
role.narrativeProfile,
|
||||
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveActiveThreadIds(
|
||||
state: GameState,
|
||||
encounterNarrativeProfile: ReturnType<typeof resolveEncounterNarrativeProfile>,
|
||||
) {
|
||||
if (state.storyEngineMemory?.activeThreadIds?.length) {
|
||||
return state.storyEngineMemory.activeThreadIds.slice(0, 4);
|
||||
}
|
||||
if (encounterNarrativeProfile?.relatedThreadIds.length) {
|
||||
return encounterNarrativeProfile.relatedThreadIds.slice(0, 4);
|
||||
}
|
||||
if (!state.customWorldProfile) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const themePack =
|
||||
state.customWorldProfile.themePack
|
||||
?? buildThemePackFromWorldProfile(state.customWorldProfile);
|
||||
const storyGraph =
|
||||
state.customWorldProfile.storyGraph
|
||||
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
|
||||
|
||||
return storyGraph.visibleThreads.slice(0, 3).map((thread) => thread.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时 story prompt context 的正式投影已经迁到 server-rs。
|
||||
* 前端只保留 session 与少量请求元信息,方便旧调用面继续复用同一个函数签名。
|
||||
*/
|
||||
export function buildStoryContextFromState(
|
||||
state: GameState,
|
||||
extras: StoryContextBuilderExtras = {},
|
||||
): StoryGenerationContext {
|
||||
const conversationSituation = inferConversationSituation(state, extras);
|
||||
const conversationPressure = inferConversationPressure(
|
||||
state,
|
||||
conversationSituation,
|
||||
);
|
||||
const recentSharedEvent = buildRecentConversationEventText(state);
|
||||
const encounterNpcState =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const encounter = state.currentEncounter;
|
||||
return extras.encounterNpcStateOverride
|
||||
?? state.npcStates[getNpcEncounterKey(encounter)]
|
||||
?? buildInitialNpcState(encounter, state.worldType, state);
|
||||
})()
|
||||
: null;
|
||||
const encounterDirective =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const encounter = state.currentEncounter;
|
||||
return encounterNpcState
|
||||
? getNpcConversationDirective(encounter, encounterNpcState)
|
||||
: null;
|
||||
})()
|
||||
: null;
|
||||
const isFirstMeaningfulContact =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const encounter = state.currentEncounter;
|
||||
return encounterNpcState
|
||||
? isNpcFirstMeaningfulContact(encounter, encounterNpcState)
|
||||
: false;
|
||||
})()
|
||||
: false;
|
||||
const firstContactRelationStance = (() => {
|
||||
if (
|
||||
!isFirstMeaningfulContact ||
|
||||
!state.currentEncounter ||
|
||||
state.currentEncounter.kind !== 'npc'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stance = encounterNpcState?.relationState?.stance ?? null;
|
||||
if (
|
||||
stance === 'guarded' ||
|
||||
stance === 'neutral' ||
|
||||
stance === 'cooperative' ||
|
||||
stance === 'bonded'
|
||||
) {
|
||||
return stance;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const encounterAffinityText =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const encounter = state.currentEncounter;
|
||||
return encounterNpcState
|
||||
? describeNpcAffinityInWords(encounter, encounterNpcState.affinity, {
|
||||
recruited: encounterNpcState.recruited,
|
||||
})
|
||||
: null;
|
||||
})()
|
||||
: null;
|
||||
const baseSceneDescription = state.currentScenePreset?.description ?? null;
|
||||
const sceneMutationDescription = [
|
||||
state.currentScenePreset?.mutationStateText
|
||||
? `最新世界变化:${state.currentScenePreset.mutationStateText}`
|
||||
: null,
|
||||
describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)
|
||||
? `当前区域压力等级:${describeScenePressureLevel(state.currentScenePreset?.currentPressureLevel)}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
const observeSignsSceneDescription =
|
||||
extras.observeSignsRequested && state.worldType
|
||||
? [
|
||||
baseSceneDescription,
|
||||
sceneMutationDescription,
|
||||
'当前可观察实体池:',
|
||||
buildSceneEntityCatalogText(
|
||||
state.worldType,
|
||||
state.currentScenePreset?.id ?? null,
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: [baseSceneDescription, sceneMutationDescription].filter(Boolean).join('\n');
|
||||
const storyEngineMemory =
|
||||
state.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
|
||||
const knowledgeFacts =
|
||||
state.customWorldProfile?.knowledgeFacts
|
||||
?? (state.customWorldProfile ? buildKnowledgeGraph(state.customWorldProfile) : []);
|
||||
const encounterNarrativeProfile = resolveEncounterNarrativeProfile(state);
|
||||
const activeThreadIds = resolveActiveThreadIds(
|
||||
{
|
||||
...state,
|
||||
storyEngineMemory,
|
||||
} as GameState,
|
||||
encounterNarrativeProfile,
|
||||
);
|
||||
const visibilitySlice =
|
||||
state.currentEncounter?.kind === 'npc'
|
||||
? (() => {
|
||||
const relevantFacts = knowledgeFacts.filter((fact) =>
|
||||
fact.ownerActorIds.includes(state.currentEncounter?.id ?? '')
|
||||
|| fact.ownerActorIds.includes(state.currentEncounter?.npcName ?? '')
|
||||
|| fact.relatedThreadIds.some((threadId) => activeThreadIds.includes(threadId)),
|
||||
);
|
||||
return relevantFacts.length > 0
|
||||
? buildVisibilitySliceFromFacts({
|
||||
facts: relevantFacts,
|
||||
discoveredFactIds: [
|
||||
...storyEngineMemory.discoveredFactIds,
|
||||
...(encounterNpcState?.revealedFacts ?? []),
|
||||
...(encounterNpcState?.seenBackstoryChapterIds ?? []).map(
|
||||
(chapterId) =>
|
||||
relevantFacts.find((fact) =>
|
||||
fact.aliases?.includes(chapterId) || fact.id.includes(chapterId),
|
||||
)?.id ?? '',
|
||||
),
|
||||
],
|
||||
activeThreadIds,
|
||||
disclosureStage: encounterDirective?.disclosureStage ?? null,
|
||||
isFirstMeaningfulContact,
|
||||
})
|
||||
: buildEncounterVisibilitySlice({
|
||||
narrativeProfile: encounterNarrativeProfile,
|
||||
backstoryReveal: state.currentEncounter.backstoryReveal ?? null,
|
||||
disclosureStage: encounterDirective?.disclosureStage ?? null,
|
||||
isFirstMeaningfulContact,
|
||||
seenBackstoryChapterIds: encounterNpcState?.seenBackstoryChapterIds ?? [],
|
||||
storyEngineMemory,
|
||||
activeThreadIds,
|
||||
});
|
||||
})()
|
||||
: null;
|
||||
const sceneNarrativeDirective = buildSceneNarrativeDirective({
|
||||
return {
|
||||
runtimeSessionId: state.runtimeSessionId ?? null,
|
||||
runtimeActionVersion: state.runtimeActionVersion,
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
playerMana: state.playerMana,
|
||||
playerMaxMana: state.playerMaxMana,
|
||||
inBattle: state.inBattle,
|
||||
playerX: state.playerX,
|
||||
playerFacing: state.playerFacing,
|
||||
playerAnimation: state.animationState,
|
||||
skillCooldowns: state.playerSkillCooldowns,
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
sceneName: state.currentScenePreset?.name ?? null,
|
||||
encounterId: state.currentEncounter?.id ?? null,
|
||||
encounterName: state.currentEncounter?.npcName ?? null,
|
||||
recentActions: state.storyHistory.slice(-3).map((moment) => moment.text),
|
||||
activeThreadIds,
|
||||
visibilitySlice,
|
||||
encounterNarrativeProfile,
|
||||
disclosureStage: encounterDirective?.disclosureStage ?? null,
|
||||
isFirstMeaningfulContact,
|
||||
affinity: encounterNpcState?.affinity ?? null,
|
||||
});
|
||||
const chapterState = advanceChapterState({
|
||||
previousChapter: state.chapterState ?? storyEngineMemory.currentChapter ?? null,
|
||||
nextChapter: resolveCurrentChapterState({
|
||||
state: {
|
||||
...state,
|
||||
storyEngineMemory,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const journeyBeat = resolveCurrentJourneyBeat({
|
||||
state: {
|
||||
...state,
|
||||
chapterState,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
currentChapter: chapterState,
|
||||
},
|
||||
} as GameState,
|
||||
chapterState,
|
||||
});
|
||||
const companionArcStates = advanceCompanionArc({
|
||||
previous: storyEngineMemory.companionArcStates,
|
||||
next: buildCompanionArcStates({
|
||||
state,
|
||||
reactions: storyEngineMemory.recentCompanionReactions,
|
||||
}),
|
||||
});
|
||||
const currentCampEvent = evaluateCampEventOpportunity({
|
||||
state,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
companionArcStates,
|
||||
})
|
||||
? buildCampEvent({
|
||||
state,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
companionArcStates,
|
||||
})
|
||||
: null;
|
||||
const setpieceDirective = evaluateSetpieceOpportunity({
|
||||
state,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
})
|
||||
? buildSetpieceDirective({
|
||||
state,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
})
|
||||
: null;
|
||||
const recentWorldMutations = storyEngineMemory.worldMutations ?? [];
|
||||
const recentChronicleSummary = buildChronicleSummary({
|
||||
...state,
|
||||
chapterState,
|
||||
storyEngineMemory: {
|
||||
...storyEngineMemory,
|
||||
currentChapter: chapterState,
|
||||
companionArcStates,
|
||||
},
|
||||
} as GameState);
|
||||
const compiledPacks = state.customWorldProfile
|
||||
? compileCampaignFromWorldProfile({ profile: state.customWorldProfile })
|
||||
: null;
|
||||
const goalStack = buildGoalStackState({
|
||||
quests: state.quests,
|
||||
worldType: state.worldType,
|
||||
currentSceneId: state.currentScenePreset?.id ?? null,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
setpieceDirective,
|
||||
currentCampEvent,
|
||||
currentSceneName: state.currentScenePreset?.name ?? null,
|
||||
});
|
||||
const activeScenarioPack =
|
||||
resolveScenarioPack(state.activeScenarioPackId)
|
||||
?? compiledPacks?.scenarioPack
|
||||
?? null;
|
||||
const activeCampaignPack = compiledPacks?.campaignPack ?? null;
|
||||
|
||||
const fallbackChapterRecap = buildChapterRecap({
|
||||
state: { ...state, chapterState } as GameState,
|
||||
});
|
||||
const safeEncounterRelationshipSummary =
|
||||
state.currentEncounter?.characterId
|
||||
? getCharacterChatRecord(state, state.currentEncounter.characterId)
|
||||
.summary
|
||||
.trim()
|
||||
: '';
|
||||
|
||||
return applyAdaptiveTuningToPromptContext({
|
||||
context: {
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
playerMana: state.playerMana,
|
||||
playerMaxMana: state.playerMaxMana,
|
||||
inBattle: state.inBattle,
|
||||
playerX: state.playerX,
|
||||
playerFacing: state.playerFacing,
|
||||
playerAnimation: state.animationState,
|
||||
skillCooldowns: state.playerSkillCooldowns,
|
||||
sceneId: state.currentScenePreset?.id ?? null,
|
||||
sceneName: state.currentScenePreset?.name ?? null,
|
||||
sceneDescription: observeSignsSceneDescription,
|
||||
pendingSceneEncounter: extras.pendingSceneEncounter ?? false,
|
||||
lastFunctionId: extras.lastFunctionId ?? null,
|
||||
observeSignsRequested: extras.observeSignsRequested ?? false,
|
||||
recentActionResult: extras.recentActionResult ?? null,
|
||||
lastObserveSignsReport:
|
||||
state.lastObserveSignsSceneId === (state.currentScenePreset?.id ?? null)
|
||||
? (state.lastObserveSignsReport ?? null)
|
||||
: null,
|
||||
encounterKind: state.currentEncounter?.kind ?? null,
|
||||
encounterName: state.currentEncounter?.npcName ?? null,
|
||||
encounterDescription: state.currentEncounter?.npcDescription ?? null,
|
||||
encounterContext: state.currentEncounter?.context ?? null,
|
||||
encounterId: state.currentEncounter?.id ?? null,
|
||||
encounterCharacterId: state.currentEncounter?.characterId ?? null,
|
||||
encounterGender: state.currentEncounter?.gender ?? null,
|
||||
encounterCustomProfile: state.currentEncounter
|
||||
? {
|
||||
title: state.currentEncounter.title ?? '',
|
||||
description: state.currentEncounter.npcDescription ?? '',
|
||||
backstory: state.currentEncounter.backstory ?? '',
|
||||
personality: state.currentEncounter.personality ?? '',
|
||||
motivation: state.currentEncounter.motivation ?? '',
|
||||
combatStyle: state.currentEncounter.combatStyle ?? '',
|
||||
relationshipHooks: [...(state.currentEncounter.relationshipHooks ?? [])],
|
||||
tags: [...(state.currentEncounter.tags ?? [])],
|
||||
backstoryReveal: state.currentEncounter.backstoryReveal,
|
||||
skills: [...(state.currentEncounter.skills ?? [])],
|
||||
initialItems: [...(state.currentEncounter.initialItems ?? [])],
|
||||
imageSrc: state.currentEncounter.imageSrc,
|
||||
visual: state.currentEncounter.visual,
|
||||
narrativeProfile: state.currentEncounter.narrativeProfile,
|
||||
}
|
||||
: null,
|
||||
encounterAffinity: encounterDirective?.affinity ?? null,
|
||||
encounterAffinityText,
|
||||
encounterStanceProfile: encounterNpcState?.stanceProfile ?? null,
|
||||
encounterConversationStyle: encounterDirective?.style ?? null,
|
||||
encounterDisclosureStage: encounterDirective?.disclosureStage ?? null,
|
||||
encounterWarmthStage: encounterDirective?.warmthStage ?? null,
|
||||
encounterAnswerMode: encounterDirective?.answerMode ?? null,
|
||||
encounterAllowedTopics: encounterDirective?.allowTopics ?? null,
|
||||
encounterBlockedTopics: encounterDirective?.blockedTopics ?? null,
|
||||
isFirstMeaningfulContact,
|
||||
firstContactRelationStance,
|
||||
conversationSituation,
|
||||
conversationPressure,
|
||||
recentSharedEvent:
|
||||
recentSharedEvent ?? describeConversationSituation(conversationSituation),
|
||||
talkPriority: describeConversationTalkPriority(conversationSituation),
|
||||
visibilitySlice,
|
||||
sceneNarrativeDirective,
|
||||
campaignState: state.campaignState ?? storyEngineMemory.campaignState ?? null,
|
||||
actState: storyEngineMemory.actState ?? null,
|
||||
chapterState,
|
||||
journeyBeat,
|
||||
goalStack,
|
||||
currentCampEvent,
|
||||
setpieceDirective,
|
||||
activeScenarioPack,
|
||||
activeCampaignPack,
|
||||
encounterNarrativeProfile,
|
||||
knowledgeFacts,
|
||||
activeThreadIds,
|
||||
companionArcStates,
|
||||
companionResolutions: storyEngineMemory.companionResolutions ?? [],
|
||||
consequenceLedger: storyEngineMemory.consequenceLedger ?? [],
|
||||
authorialConstraintPack: storyEngineMemory.authorialConstraintPack ?? null,
|
||||
playerStyleProfile: storyEngineMemory.playerStyleProfile ?? null,
|
||||
recentCompanionReactions: storyEngineMemory.recentCompanionReactions ?? [],
|
||||
recentCarrierEchoes: buildRecentCarrierEchoes(state),
|
||||
recentWorldMutations,
|
||||
recentFactionTensionStates: storyEngineMemory.factionTensionStates ?? [],
|
||||
recentChronicleSummary:
|
||||
recentChronicleSummary.trim() &&
|
||||
!hasMixedNarrativeLanguage(recentChronicleSummary)
|
||||
? recentChronicleSummary
|
||||
: fallbackChapterRecap,
|
||||
narrativeQaReport: storyEngineMemory.narrativeQaReport ?? null,
|
||||
releaseGateReport: storyEngineMemory.releaseGateReport ?? null,
|
||||
simulationRunResults: storyEngineMemory.simulationRunResults ?? [],
|
||||
branchBudgetPressure: storyEngineMemory.branchBudgetStatus?.pressure ?? null,
|
||||
encounterRelationshipSummary: state.currentEncounter?.characterId
|
||||
? !hasMixedNarrativeLanguage(safeEncounterRelationshipSummary)
|
||||
? safeEncounterRelationshipSummary || null
|
||||
: null
|
||||
: null,
|
||||
partyRelationshipNotes: buildPartyRelationshipNotes(state),
|
||||
customWorldProfile: state.customWorldProfile ?? null,
|
||||
openingCampBackground: extras.openingCampBackground ?? null,
|
||||
openingCampDialogue: extras.openingCampDialogue ?? null,
|
||||
},
|
||||
profile: storyEngineMemory.playerStyleProfile ?? null,
|
||||
});
|
||||
sceneDescription: state.currentScenePreset?.description ?? null,
|
||||
pendingSceneEncounter: extras.pendingSceneEncounter ?? false,
|
||||
lastFunctionId: extras.lastFunctionId ?? null,
|
||||
observeSignsRequested: extras.observeSignsRequested ?? false,
|
||||
recentActionResult: extras.recentActionResult ?? null,
|
||||
customWorldProfile: null,
|
||||
openingCampBackground: extras.openingCampBackground ?? null,
|
||||
openingCampDialogue: extras.openingCampDialogue ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -156,6 +156,51 @@ function createBaseState(): GameState {
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 10,
|
||||
playerInventory: [createInventoryItem('player-potion', 'Potion')],
|
||||
runtimeNpcInteraction: {
|
||||
npcId: 'npc-trader',
|
||||
npcName: 'Trader Lin',
|
||||
playerCurrency: 10,
|
||||
currencyName: '铜钱',
|
||||
trade: {
|
||||
buyItems: [
|
||||
{
|
||||
itemId: 'npc-herb',
|
||||
item: createInventoryItem('npc-herb', 'Herb'),
|
||||
mode: 'buy',
|
||||
unitPrice: 3,
|
||||
maxQuantity: 1,
|
||||
canSubmit: true,
|
||||
reason: null,
|
||||
},
|
||||
],
|
||||
sellItems: [
|
||||
{
|
||||
itemId: 'player-potion',
|
||||
item: createInventoryItem('player-potion', 'Potion'),
|
||||
mode: 'sell',
|
||||
unitPrice: 1,
|
||||
maxQuantity: 1,
|
||||
canSubmit: true,
|
||||
reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
gift: {
|
||||
items: [
|
||||
{
|
||||
itemId: 'jade-token',
|
||||
item: createInventoryItem('jade-token', 'Jade Token', {
|
||||
rarity: 'rare',
|
||||
category: '专属',
|
||||
tags: ['merchant'],
|
||||
}),
|
||||
affinityGain: 16,
|
||||
canSubmit: true,
|
||||
reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
@@ -202,7 +247,7 @@ function createInteractionOption(action: Extract<NonNullable<StoryOption['intera
|
||||
}
|
||||
|
||||
describe('storyGenerationState', () => {
|
||||
it('opens the trade modal with the first npc and player inventory items selected', () => {
|
||||
it('opens the trade modal with server-selected npc and player items', () => {
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
createBaseState(),
|
||||
createInteractionOption('trade'),
|
||||
@@ -218,14 +263,39 @@ describe('storyGenerationState', () => {
|
||||
expect(decision.modal.selectedQuantity).toBe(1);
|
||||
});
|
||||
|
||||
it('skips zero-quantity player items when opening the trade modal', () => {
|
||||
it('prefers the first server-submittable sell item when opening the trade modal', () => {
|
||||
const baseState = createBaseState();
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
{
|
||||
...createBaseState(),
|
||||
playerInventory: [
|
||||
createInventoryItem('empty-slot', 'Empty Slot', { quantity: 0 }),
|
||||
createInventoryItem('player-herb', 'Herb'),
|
||||
],
|
||||
...baseState,
|
||||
runtimeNpcInteraction: {
|
||||
...baseState.runtimeNpcInteraction!,
|
||||
trade: {
|
||||
buyItems: baseState.runtimeNpcInteraction!.trade.buyItems,
|
||||
sellItems: [
|
||||
{
|
||||
itemId: 'empty-slot',
|
||||
item: createInventoryItem('empty-slot', 'Empty Slot', {
|
||||
quantity: 0,
|
||||
}),
|
||||
mode: 'sell',
|
||||
unitPrice: 1,
|
||||
maxQuantity: 0,
|
||||
canSubmit: false,
|
||||
reason: '背包数量不足。',
|
||||
},
|
||||
{
|
||||
itemId: 'player-herb',
|
||||
item: createInventoryItem('player-herb', 'Herb'),
|
||||
mode: 'sell',
|
||||
unitPrice: 2,
|
||||
maxQuantity: 1,
|
||||
canSubmit: true,
|
||||
reason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
createInteractionOption('trade'),
|
||||
);
|
||||
@@ -257,21 +327,9 @@ describe('storyGenerationState', () => {
|
||||
expect(decision.modal.selectedReleaseNpcId).toBe('npc-1');
|
||||
});
|
||||
|
||||
it('opens the gift modal with the preferred gift candidate selected', () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
playerInventory: [
|
||||
createInventoryItem('empty-slot', 'Empty Slot', { quantity: 0 }),
|
||||
createInventoryItem('jade-token', 'Jade Token', {
|
||||
rarity: 'rare',
|
||||
category: '专属',
|
||||
tags: ['merchant'],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
it('opens the gift modal with the server-selected gift candidate', () => {
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
state,
|
||||
createBaseState(),
|
||||
createInteractionOption('gift'),
|
||||
);
|
||||
|
||||
@@ -284,9 +342,13 @@ describe('storyGenerationState', () => {
|
||||
});
|
||||
|
||||
it('does not open the gift modal when there are no gift candidates', () => {
|
||||
const baseState = createBaseState();
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
playerInventory: [],
|
||||
...baseState,
|
||||
runtimeNpcInteraction: {
|
||||
...baseState.runtimeNpcInteraction!,
|
||||
gift: { items: [] },
|
||||
},
|
||||
};
|
||||
|
||||
const decision = resolveNpcInteractionDecision(
|
||||
|
||||
@@ -10,11 +10,7 @@
|
||||
import {
|
||||
applyQuestProgressFromSceneReached,
|
||||
} from '../../data/questFlow';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
getPreferredGiftItemId,
|
||||
MAX_COMPANIONS,
|
||||
} from '../../data/npcInteractions';
|
||||
import { MAX_COMPANIONS } from '../../data/npcInteractions';
|
||||
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
|
||||
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
|
||||
import { getScenePresetById } from '../../data/scenePresets';
|
||||
@@ -53,11 +49,10 @@ export function getNpcEncounterKey(encounter: Encounter) {
|
||||
return encounter.id ?? encounter.npcName;
|
||||
}
|
||||
|
||||
function getResolvedNpcState(state: GameState, encounter: Encounter) {
|
||||
return (
|
||||
state.npcStates[getNpcEncounterKey(encounter)] ??
|
||||
buildInitialNpcState(encounter, state.worldType)
|
||||
);
|
||||
function findPreferredTradeItemId(
|
||||
items: Array<{ itemId: string; canSubmit: boolean }>,
|
||||
) {
|
||||
return items.find(item => item.canSubmit)?.itemId ?? items[0]?.itemId ?? null;
|
||||
}
|
||||
|
||||
export function resolveNpcInteractionDecision(
|
||||
@@ -73,29 +68,29 @@ export function resolveNpcInteractionDecision(
|
||||
}
|
||||
|
||||
const encounter = state.currentEncounter;
|
||||
const npcState = getResolvedNpcState(state, encounter);
|
||||
|
||||
switch (option.functionId) {
|
||||
case NPC_TRADE_FUNCTION.id:
|
||||
return {
|
||||
kind: 'trade_modal',
|
||||
modal: buildNpcTradeModalState(
|
||||
state,
|
||||
encounter,
|
||||
option.actionText,
|
||||
npcState.inventory,
|
||||
findPreferredTradeItemId(
|
||||
state.runtimeNpcInteraction?.trade.buyItems ?? [],
|
||||
),
|
||||
findPreferredTradeItemId(
|
||||
state.runtimeNpcInteraction?.trade.sellItems ?? [],
|
||||
),
|
||||
),
|
||||
};
|
||||
case NPC_GIFT_FUNCTION.id:
|
||||
{
|
||||
const selectedGiftItemId = getPreferredGiftItemId(
|
||||
state.playerInventory,
|
||||
encounter,
|
||||
{
|
||||
worldType: state.worldType,
|
||||
customWorldProfile: state.customWorldProfile,
|
||||
},
|
||||
);
|
||||
const selectedGiftItemId =
|
||||
state.runtimeNpcInteraction?.gift.items.find(item => item.canSubmit)
|
||||
?.itemId ??
|
||||
state.runtimeNpcInteraction?.gift.items[0]?.itemId ??
|
||||
null;
|
||||
if (!selectedGiftItemId) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
@@ -103,7 +98,6 @@ export function resolveNpcInteractionDecision(
|
||||
return {
|
||||
kind: 'gift_modal',
|
||||
modal: buildNpcGiftModalState(
|
||||
state,
|
||||
encounter,
|
||||
option.actionText,
|
||||
selectedGiftItemId,
|
||||
|
||||
@@ -60,6 +60,8 @@ type StoryInteractionCoordinatorParams = {
|
||||
export function createStoryInteractionCoordinatorConfig(
|
||||
params: StoryInteractionCoordinatorParams,
|
||||
) {
|
||||
// 中文注释:sharedRuntime 是宝箱流和背包流共享的最小运行时上下文,
|
||||
// 这两类动作不需要拿到完整 NPC / 对话链配置,因此先抽一层轻量公共配置。
|
||||
const sharedRuntime = {
|
||||
currentStory: params.currentStory,
|
||||
setGameState: params.setGameState,
|
||||
@@ -87,6 +89,8 @@ export function createStoryInteractionCoordinatorConfig(
|
||||
cloneInventoryItemForOwner:
|
||||
params.runtimeSupport.cloneInventoryItemForOwner,
|
||||
runtime: {
|
||||
// 中文注释:NPC 交互流需要最完整的故事上下文,
|
||||
// 包括对话故事构建、继续生成、打字机延迟和敌对 NPC 推断。
|
||||
currentStory: params.currentStory,
|
||||
setCurrentStory: params.setCurrentStory,
|
||||
setAiError: params.setAiError,
|
||||
@@ -100,6 +104,8 @@ export function createStoryInteractionCoordinatorConfig(
|
||||
},
|
||||
},
|
||||
npcEncounterActions: {
|
||||
// 中文注释:npcEncounterActions 是最重的一组配置,
|
||||
// 它同时服务“进入 NPC 遭遇”“提交 NPC 动作”“战斗后恢复对话”等整条分支。
|
||||
gameState: params.gameState,
|
||||
currentStory: params.currentStory,
|
||||
setGameState: params.setGameState,
|
||||
|
||||
@@ -118,6 +118,8 @@ describe('storyRequestCoordinator', () => {
|
||||
const buildStoryContextFromState = vi.fn(
|
||||
(_state, extras) =>
|
||||
({
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 3,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 30,
|
||||
@@ -177,7 +179,8 @@ describe('storyRequestCoordinator', () => {
|
||||
history,
|
||||
'继续交谈',
|
||||
expect.objectContaining({
|
||||
sceneId: 'inn_room',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 3,
|
||||
lastFunctionId: 'npc_chat',
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type {
|
||||
RuntimeStoryEquipmentSlotView,
|
||||
RuntimeStoryForgeRecipeView,
|
||||
RuntimeStoryInventoryItemView,
|
||||
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryState';
|
||||
import type {
|
||||
Encounter,
|
||||
GoalHandoff,
|
||||
GoalPulseEvent,
|
||||
@@ -52,22 +57,11 @@ export interface InventoryFlowUi {
|
||||
useInventoryItem: (itemId: string) => Promise<boolean>;
|
||||
equipInventoryItem: (itemId: string) => Promise<boolean>;
|
||||
unequipItem: (slot: 'weapon' | 'armor' | 'relic') => Promise<boolean>;
|
||||
forgeRecipes: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'synthesis' | 'forge';
|
||||
description: string;
|
||||
resultLabel: string;
|
||||
currencyCost: number;
|
||||
currencyText: string;
|
||||
requirements: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
quantity: number;
|
||||
owned: number;
|
||||
}>;
|
||||
canCraft: boolean;
|
||||
}>;
|
||||
playerCurrency: number | null;
|
||||
currencyText: string | null;
|
||||
backpackItems: RuntimeStoryInventoryItemView[];
|
||||
equipmentSlots: RuntimeStoryEquipmentSlotView[];
|
||||
forgeRecipes: RuntimeStoryForgeRecipeView[];
|
||||
craftRecipe: (recipeId: string) => Promise<boolean>;
|
||||
dismantleItem: (itemId: string) => Promise<boolean>;
|
||||
reforgeItem: (itemId: string) => Promise<boolean>;
|
||||
|
||||
@@ -62,6 +62,8 @@ export function createClearStoryInteractionUi(params: {
|
||||
clearNpcInteractionUi: () => void;
|
||||
}) {
|
||||
return () => {
|
||||
// 中文注释:story 选择面板和 NPC 交互面板是两套独立 UI;
|
||||
// 清理运行时交互态时必须同时重置,避免战斗/对话切换后残留旧弹层。
|
||||
params.clearStoryChoiceUi();
|
||||
params.clearNpcInteractionUi();
|
||||
};
|
||||
@@ -120,6 +122,8 @@ export function useRpgRuntimeInteractionFlow({
|
||||
}
|
||||
|
||||
if (isNpcEncounter(gameState.currentEncounter)) {
|
||||
// 中文注释:当场景里已经解析出 NPC 遭遇,且当前不在战斗/加载中时,
|
||||
// 自动进入 NPC 交互态,让开场相遇和旅行后遭遇都能无缝落到对话/互动面板。
|
||||
enterNpcInteraction(
|
||||
gameState.currentEncounter,
|
||||
`与${gameState.currentEncounter.npcName}搭话`,
|
||||
@@ -180,6 +184,8 @@ export function useRpgRuntimeInteractionFlow({
|
||||
);
|
||||
},
|
||||
};
|
||||
// 中文注释:choice coordinator 只关心“点下某个 story option 后怎么结算”,
|
||||
// NPC 战斗结束后要不要回到对话态,则通过 runtimeSupport 在这里桥接进去。
|
||||
const choiceRuntimeSupport: ChoiceRuntimeSupport = {
|
||||
...runtimeSupport,
|
||||
handleNpcBattleConversationContinuation: ({
|
||||
@@ -248,6 +254,8 @@ export function useRpgRuntimeInteractionFlow({
|
||||
return false;
|
||||
}
|
||||
|
||||
// 中文注释:聊天提交是 fire-and-forget,
|
||||
// 调用方只需要知道“当前能不能发给 NPC”,不需要阻塞等待整轮对话结束。
|
||||
void handleNpcChatTurn(encounter, input);
|
||||
return true;
|
||||
},
|
||||
@@ -263,6 +271,8 @@ export function useRpgRuntimeInteractionFlow({
|
||||
return false;
|
||||
}
|
||||
|
||||
// 中文注释:NPC 聊天的“换一组回应建议”当前通过轮转 options 实现,
|
||||
// 不额外发请求,优先复用本轮已经拿到的候选动作。
|
||||
interactionConfig.npcEncounterActions.setCurrentStory({
|
||||
...story,
|
||||
options: [...restOptions, firstOption],
|
||||
|
||||
@@ -22,13 +22,9 @@ import { resolveFunctionOption } from '../../data/stateFunctions';
|
||||
import { streamNpcChatTurn } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import {
|
||||
advanceSceneActRuntimeState,
|
||||
getSceneConnectionDirectionText,
|
||||
resolveLimitedPrimaryNpcChatState,
|
||||
resolveSceneActProgression,
|
||||
} from '../../services/customWorldSceneActRuntime';
|
||||
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
|
||||
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
@@ -513,44 +509,41 @@ export function createStoryNpcEncounterActions({
|
||||
nextNpcInventory = removeInventoryItem(nextNpcInventory, item.id, 1);
|
||||
}
|
||||
|
||||
const nextState: GameState = appendStoryEngineCarrierMemory(
|
||||
incrementRuntimeStats(
|
||||
{
|
||||
...state,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
currentEncounter: restoredEncounter,
|
||||
npcInteractionActive: true,
|
||||
sceneHostileNpcs: [],
|
||||
playerInventory: addInventoryItems(state.playerInventory, lootItems),
|
||||
quests: progressedQuests,
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
[battleNpcId]: {
|
||||
...markNpcFirstMeaningfulContactResolved(npcState),
|
||||
affinity: npcState.affinity,
|
||||
relationState: buildRelationState(npcState.affinity),
|
||||
recruited: false,
|
||||
inventory: nextNpcInventory,
|
||||
},
|
||||
const nextState: GameState = incrementRuntimeStats(
|
||||
{
|
||||
...state,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
currentEncounter: restoredEncounter,
|
||||
npcInteractionActive: true,
|
||||
sceneHostileNpcs: [],
|
||||
playerInventory: addInventoryItems(state.playerInventory, lootItems),
|
||||
quests: progressedQuests,
|
||||
npcStates: {
|
||||
...state.npcStates,
|
||||
[battleNpcId]: {
|
||||
...markNpcFirstMeaningfulContactResolved(npcState),
|
||||
affinity: npcState.affinity,
|
||||
relationState: buildRelationState(npcState.affinity),
|
||||
recruited: false,
|
||||
inventory: nextNpcInventory,
|
||||
},
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: state.animationState,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
},
|
||||
{
|
||||
hostileNpcsDefeated: defeatedHostileNpcIds.length,
|
||||
},
|
||||
),
|
||||
lootItems,
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: state.animationState,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
},
|
||||
{
|
||||
hostileNpcsDefeated: defeatedHostileNpcIds.length,
|
||||
},
|
||||
);
|
||||
|
||||
const lootText =
|
||||
@@ -985,57 +978,6 @@ export function createStoryNpcEncounterActions({
|
||||
encounter: Encounter,
|
||||
playerCharacter: Character,
|
||||
) => {
|
||||
const progression = resolveSceneActProgression({
|
||||
profile: gameState.customWorldProfile,
|
||||
sceneId: gameState.currentScenePreset?.id ?? null,
|
||||
storyEngineMemory: gameState.storyEngineMemory,
|
||||
});
|
||||
|
||||
if (!progression) {
|
||||
return {
|
||||
deferredRuntimeState: null,
|
||||
options: currentStory?.deferredOptions?.length
|
||||
? currentStory.deferredOptions
|
||||
: buildPostNpcChatOptionCatalog(encounter, playerCharacter),
|
||||
};
|
||||
}
|
||||
|
||||
if (!progression.isLastAct) {
|
||||
const nextActState = advanceSceneActRuntimeState({ progress: progression });
|
||||
const nextStoryEngineMemory = nextActState
|
||||
? {
|
||||
...(gameState.storyEngineMemory ??
|
||||
createEmptyStoryEngineMemoryState()),
|
||||
currentSceneActState: nextActState,
|
||||
}
|
||||
: gameState.storyEngineMemory;
|
||||
const nextState = {
|
||||
...gameState,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
inBattle: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
storyEngineMemory: nextStoryEngineMemory,
|
||||
};
|
||||
const nextOptions = collapseNpcChatOptions(
|
||||
getAvailableOptionsForState(nextState, playerCharacter) ?? [],
|
||||
);
|
||||
|
||||
return {
|
||||
deferredRuntimeState: {
|
||||
currentScenePreset: nextState.currentScenePreset,
|
||||
storyEngineMemory: nextState.storyEngineMemory,
|
||||
},
|
||||
options:
|
||||
nextOptions.length > 0
|
||||
? nextOptions
|
||||
: buildPostNpcChatOptionCatalog(encounter, playerCharacter),
|
||||
};
|
||||
}
|
||||
|
||||
const travelOptions = buildSceneConnectionTravelOptions(gameState);
|
||||
|
||||
return {
|
||||
@@ -1794,12 +1736,8 @@ export function createStoryNpcEncounterActions({
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
currentScenePreset:
|
||||
progressionResult.deferredRuntimeState?.currentScenePreset ??
|
||||
gameState.currentScenePreset,
|
||||
storyEngineMemory:
|
||||
progressionResult.deferredRuntimeState?.storyEngineMemory ??
|
||||
gameState.storyEngineMemory,
|
||||
currentScenePreset: gameState.currentScenePreset,
|
||||
storyEngineMemory: gameState.storyEngineMemory,
|
||||
};
|
||||
|
||||
setGameState(nextState);
|
||||
|
||||
@@ -80,6 +80,8 @@ export function useRpgRuntimeStory({
|
||||
buildStoryContextFromState,
|
||||
});
|
||||
|
||||
// 中文注释:controller 负责“当前故事是什么”,
|
||||
// flow 负责“用户点下去以后发生什么”,两者在这里被装成统一运行时 story 出口。
|
||||
const runtimeController = useRpgRuntimeStoryController({
|
||||
gameState,
|
||||
setGameState,
|
||||
@@ -125,6 +127,7 @@ export function useRpgRuntimeStory({
|
||||
turnVisualMs: TURN_VISUAL_MS,
|
||||
});
|
||||
|
||||
// 中文注释:这里返回的对象就是 runtime shell / adventure panel 直接消费的故事域 API。
|
||||
return {
|
||||
currentStory: runtimeController.currentStory,
|
||||
isLoading: runtimeController.isLoading,
|
||||
|
||||
@@ -43,6 +43,8 @@ function createGameState(params: {
|
||||
} = {}): GameState {
|
||||
return {
|
||||
worldType: WorldType.CUSTOM,
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 4,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
currentScene: 'Story',
|
||||
@@ -89,6 +91,8 @@ function buildStoryContextFromState(
|
||||
_state: GameState,
|
||||
): StoryGenerationContext {
|
||||
return {
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 4,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
@@ -187,8 +191,8 @@ describe('useRpgRuntimeStoryController', () => {
|
||||
expect.objectContaining({ id: 'hero' }),
|
||||
[],
|
||||
expect.objectContaining({
|
||||
sceneId: 'scene-opening',
|
||||
sceneName: '证券交易所大厅',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 4,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
|
||||
@@ -57,6 +57,8 @@ export function useRpgRuntimeStoryController(params: {
|
||||
[],
|
||||
);
|
||||
|
||||
// 中文注释:presentation 层负责把服务端/AI 返回的原始故事数据
|
||||
// 编译成前端当前可直接展示的 StoryMoment。
|
||||
const buildStoryFromResponse = useCallback(
|
||||
(
|
||||
state: GameState,
|
||||
@@ -135,6 +137,8 @@ export function useRpgRuntimeStoryController(params: {
|
||||
gameState.currentScenePreset?.id ?? 'scene',
|
||||
gameState.storyHistory.length,
|
||||
].join(':');
|
||||
// 中文注释:开场剧情只允许同一份“玩家 + 场景 + 历史长度”请求飞一次,
|
||||
// 防止 React 严格模式、状态抖动或异步回填触发重复开局生成。
|
||||
if (openingStoryRequestKeyRef.current === requestKey) {
|
||||
return;
|
||||
}
|
||||
@@ -162,6 +166,8 @@ export function useRpgRuntimeStoryController(params: {
|
||||
}
|
||||
|
||||
console.error('Failed to start opening RPG story:', error);
|
||||
// 中文注释:即使 AI / 服务端首段故事失败,也要兜底出一个本地可玩的故事壳,
|
||||
// 否则冒险面板会直接卡死在无 story 的空白状态。
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
|
||||
})
|
||||
@@ -195,6 +201,8 @@ export function useRpgRuntimeStoryController(params: {
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
preparedOpeningAdventure: null,
|
||||
// 中文注释:这几个 opening adventure 相关字段先按空实现保留,
|
||||
// 目的是兼容旧调用面,同时避免新 runtime 链再把预制开场逻辑塞回 controller。
|
||||
startOpeningAdventure: async () => undefined,
|
||||
resetPreparedOpeningAdventure: () => undefined,
|
||||
buildStoryContextFromState,
|
||||
|
||||
@@ -97,6 +97,8 @@ export function useRpgRuntimeStoryFlow({
|
||||
buildOpeningCampChatContext,
|
||||
resetPreparedOpeningAdventure,
|
||||
} = runtimeController;
|
||||
// 中文注释:interactionConfig 是“剧情交互协调器”的配置快照;
|
||||
// 后续选项刷新、动作提交、fallback 叙事都会共用这套上下文。
|
||||
const interactionConfig = createStoryInteractionCoordinatorConfig({
|
||||
gameState,
|
||||
setGameState,
|
||||
@@ -131,6 +133,8 @@ export function useRpgRuntimeStoryFlow({
|
||||
gameState,
|
||||
currentStory,
|
||||
});
|
||||
// 中文注释:这一层把“战斗/NPC/背包/地图旅行”等具体交互入口分发到对应流程,
|
||||
// 保证冒险面板只调用统一的 handleChoice / handleNpcChatInput 等接口。
|
||||
const {
|
||||
handleChoice,
|
||||
battleRewardUi,
|
||||
@@ -175,6 +179,7 @@ export function useRpgRuntimeStoryFlow({
|
||||
clearCharacterChatModal,
|
||||
});
|
||||
|
||||
// 中文注释:最终返回的是已经过目标选项协调、交互分发和 story state 收束后的稳定输出。
|
||||
return {
|
||||
displayedOptions,
|
||||
canRefreshOptions,
|
||||
|
||||
@@ -19,6 +19,8 @@ export function createClearStoryRuntimeUi(params: {
|
||||
clearCharacterChatModal: () => void;
|
||||
}) {
|
||||
return () => {
|
||||
// 中文注释:story runtime 的“清场”不只是清掉故事文本,
|
||||
// 还要把目标 UI、交互 UI、错误态、加载态和角色私聊弹层一起回收。
|
||||
params.clearStoryGoalOptionUi();
|
||||
params.clearStoryInteractionUi();
|
||||
params.setAiError(null);
|
||||
@@ -81,6 +83,8 @@ export function useRpgRuntimeStoryState(params: {
|
||||
buildFallbackStoryForState: params.buildFallbackStoryForState,
|
||||
});
|
||||
|
||||
// 中文注释:quest 相关按钮属于运行时 story UI 的一部分,
|
||||
// 但真正的状态迁移统一交给 sessionActions,当前层只负责对外暴露稳定接口。
|
||||
return {
|
||||
questUi: {
|
||||
acknowledgeQuestCompletion,
|
||||
|
||||
@@ -4,7 +4,10 @@ import { DEFAULT_MUSIC_VOLUME } from '../../../packages/shared/src/contracts/run
|
||||
import { useAuthUi } from '../../components/auth/AuthUiContext';
|
||||
import type { CustomWorldRuntimeLaunchOptions } from '../../components/platform-entry/platformEntryTypes';
|
||||
import type { RpgRuntimeShellProps } from '../../components/rpg-runtime-shell/types';
|
||||
import { activateRosterCompanion, benchActiveCompanion } from '../../data/companionRoster';
|
||||
import {
|
||||
activateRosterCompanion,
|
||||
benchActiveCompanion,
|
||||
} from '../../data/companionRoster';
|
||||
import { syncGameStatePlayTime } from '../../data/runtimeStats';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { useBackgroundMusic } from '../useBackgroundMusic';
|
||||
@@ -33,10 +36,14 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
||||
handleCharacterSelect: selectCharacter,
|
||||
} = useRpgSessionBootstrap();
|
||||
|
||||
// 中文注释:战斗播放与结算仍然沿用独立 combat flow;
|
||||
// runtime session 只消费它暴露出来的“选项结算结果”和“动画播放入口”。
|
||||
const combatFlow = useCombatFlow({
|
||||
setGameState,
|
||||
});
|
||||
|
||||
// 中文注释:剧情流是运行时主链的另一半。
|
||||
// 这里把 GameState 交给 runtime story,由它负责剧情文本、选项、NPC 交互与任务 UI。
|
||||
const storyFlow = useRpgRuntimeStory({
|
||||
gameState,
|
||||
setGameState,
|
||||
@@ -46,6 +53,8 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
||||
|
||||
const { companionRenderStates, buildCompanionRenderStates } =
|
||||
useNpcInteractionFlow(gameState);
|
||||
// 中文注释:持久化层统一负责继续游戏、自动存档与退出保存,
|
||||
// session 只把当前运行态快照和 story 水位传进去。
|
||||
const persistence = useRpgSessionPersistence({
|
||||
authenticatedUserId: authUi?.user?.id ?? null,
|
||||
gameState,
|
||||
@@ -70,6 +79,8 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
||||
return;
|
||||
}
|
||||
|
||||
// 中文注释:游玩时长统计不跟每一帧绑定,而是固定 15 秒增量同步,
|
||||
// 这样既能累计活跃时长,也不会因为高频 setState 拉高运行态噪音。
|
||||
const intervalId = window.setInterval(() => {
|
||||
setGameState((currentState) => {
|
||||
if (
|
||||
@@ -90,6 +101,8 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
||||
customWorldProfile: Parameters<typeof selectCustomWorld>[0],
|
||||
options?: CustomWorldRuntimeLaunchOptions,
|
||||
) => {
|
||||
// 中文注释:切换世界前先清空上一局 story 控制器,
|
||||
// 避免旧世界的 currentStory / 选项残留到新开局。
|
||||
storyFlow.resetStoryState();
|
||||
selectCustomWorld(customWorldProfile, {
|
||||
mode: options?.mode,
|
||||
@@ -100,6 +113,8 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
||||
const handleCharacterSelect = (
|
||||
character: Parameters<typeof selectCharacter>[0],
|
||||
) => {
|
||||
// 中文注释:角色确认意味着正式进入新 run,
|
||||
// 这里同样先清理 story 层,保证开场剧情重新按当前角色生成。
|
||||
storyFlow.resetStoryState();
|
||||
selectCharacter(character);
|
||||
};
|
||||
@@ -110,6 +125,8 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
||||
};
|
||||
|
||||
const handleContinueGame = (snapshot?: HydratedSavedGameSnapshot | null) => {
|
||||
// 中文注释:继续游戏是异步恢复链,内部会重新向服务端刷新 runtime story,
|
||||
// 所以这里显式丢给 persistence 异步执行。
|
||||
void persistence.continueSavedGame(snapshot);
|
||||
};
|
||||
|
||||
@@ -120,9 +137,9 @@ export function useRpgRuntimeSession(): RpgRuntimeShellProps {
|
||||
};
|
||||
|
||||
const handleSaveAndExit = () => {
|
||||
const syncedGameState = syncGameStatePlayTime(gameState);
|
||||
// 中文注释:退出保存只请求服务端基于已存快照创建 checkpoint;
|
||||
// 游玩时长的最终刷新由后端 checkpoint 负责,不再上传本地同步后的 GameState。
|
||||
void persistence.saveCurrentGame({
|
||||
gameState: syncedGameState,
|
||||
bottomTab,
|
||||
currentStory: storyFlow.currentStory,
|
||||
});
|
||||
|
||||
@@ -1,176 +1,29 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildCustomWorldRuntimeCharacters,
|
||||
createCharacterSkillCooldowns,
|
||||
getCharacterMaxHp,
|
||||
getCharacterMaxMana,
|
||||
setRuntimeCharacterOverrides,
|
||||
} from '../../data/characterPresets';
|
||||
import { setRuntimeCustomWorldProfile } from '../../data/customWorldRuntime';
|
||||
import { getInitialPlayerCurrency } from '../../data/economy';
|
||||
import {
|
||||
applyEquipmentLoadoutToState,
|
||||
buildInitialEquipmentLoadout,
|
||||
createEmptyEquipmentLoadout,
|
||||
} from '../../data/equipmentEffects';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
buildInitialPlayerInventory,
|
||||
} from '../../data/npcInteractions';
|
||||
import { createEmptyEquipmentLoadout } from '../../data/equipmentEffects';
|
||||
import { createInitialPlayerProgressionState } from '../../data/playerProgression';
|
||||
import { createInitialGameRuntimeStats } from '../../data/runtimeStats';
|
||||
import {
|
||||
ensureSceneEncounterPreview,
|
||||
RESOLVED_ENTITY_X_METERS,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import {
|
||||
buildEncounterFromSceneNpc,
|
||||
getScenePreset,
|
||||
getScenePresetById,
|
||||
getWorldCampScenePreset,
|
||||
} from '../../data/scenePresets';
|
||||
import {
|
||||
findCustomWorldRoleByReference,
|
||||
resolveCustomWorldRoleIdReference,
|
||||
resolveCustomWorldRoleIdReferences,
|
||||
} from '../../services/customWorldRoleReferences';
|
||||
import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime';
|
||||
import { getScenePreset } from '../../data/scenePresets';
|
||||
import { beginRpgRuntimeStorySession } from '../../services/rpg-runtime/rpgRuntimeStoryClient';
|
||||
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CustomWorldProfile,
|
||||
Encounter,
|
||||
EquipmentLoadout,
|
||||
GameState,
|
||||
GameRuntimeMode,
|
||||
InventoryItem,
|
||||
SceneActBlueprint,
|
||||
SceneChapterBlueprint,
|
||||
SceneNpc,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import type { BottomTab } from './rpgSessionTypes';
|
||||
|
||||
const PLAYER_BASE_MAX_HP = 180;
|
||||
|
||||
function mergeStarterInventoryItems<
|
||||
T extends { category: string; name: string },
|
||||
>(explicitItems: T[], fallbackItems: T[]) {
|
||||
const merged = new Map<string, T>();
|
||||
|
||||
[...explicitItems, ...fallbackItems].forEach((item) => {
|
||||
merged.set(`${item.category}:${item.name}`, item);
|
||||
});
|
||||
|
||||
return [...merged.values()];
|
||||
}
|
||||
|
||||
function normalizeExplicitStarterCategory(category: string) {
|
||||
const normalized = category.trim();
|
||||
return normalized === '专属物' ? '专属物品' : normalized;
|
||||
}
|
||||
|
||||
function inferExplicitStarterSlot(category: string) {
|
||||
const normalized = normalizeExplicitStarterCategory(category);
|
||||
if (normalized === '武器') return 'weapon' as const;
|
||||
if (normalized === '护甲') return 'armor' as const;
|
||||
if (
|
||||
normalized === '饰品' ||
|
||||
normalized === '稀有品' ||
|
||||
normalized === '专属物品'
|
||||
) {
|
||||
return 'relic' as const;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildExplicitCustomWorldRoleStarterState(
|
||||
profile: CustomWorldProfile,
|
||||
character: Character,
|
||||
) {
|
||||
const role =
|
||||
profile.playableNpcs.find((entry) => entry.id === character.id) ??
|
||||
profile.storyNpcs.find((entry) => entry.id === character.id) ??
|
||||
profile.playableNpcs.find((entry) => entry.name === character.name) ??
|
||||
profile.storyNpcs.find((entry) => entry.name === character.name) ??
|
||||
null;
|
||||
|
||||
const inventory = role
|
||||
? role.initialItems.map((item, index) => {
|
||||
const category = normalizeExplicitStarterCategory(item.category);
|
||||
return {
|
||||
id: `custom-role-item:${role.id}:${index + 1}`,
|
||||
category,
|
||||
name: item.name,
|
||||
quantity: Math.max(1, item.quantity),
|
||||
rarity: item.rarity,
|
||||
tags: [...item.tags],
|
||||
description: item.description,
|
||||
equipmentSlotId: inferExplicitStarterSlot(category),
|
||||
runtimeMetadata: {
|
||||
origin: 'ai_compiled' as const,
|
||||
generationChannel: 'discovery' as const,
|
||||
seedKey: `${role.id}:${index + 1}`,
|
||||
relationAnchor: {
|
||||
type: 'npc' as const,
|
||||
npcId: role.id,
|
||||
npcName: role.name,
|
||||
roleText: role.role,
|
||||
},
|
||||
sourceReason: `${role.name}在自定义世界开局时自带的初始物品。`,
|
||||
},
|
||||
} satisfies InventoryItem;
|
||||
})
|
||||
: [];
|
||||
|
||||
const equipment: EquipmentLoadout = createEmptyEquipmentLoadout();
|
||||
inventory.forEach((item) => {
|
||||
const slot = item.equipmentSlotId;
|
||||
if (!slot || equipment[slot]) {
|
||||
return;
|
||||
}
|
||||
equipment[slot] = item;
|
||||
});
|
||||
|
||||
return {
|
||||
inventory,
|
||||
equipment,
|
||||
};
|
||||
}
|
||||
|
||||
function createInitialCampEncounter(
|
||||
worldType: WorldType | null,
|
||||
playerCharacter: Character,
|
||||
): Encounter | null {
|
||||
if (!worldType) return null;
|
||||
|
||||
const campScenePreset =
|
||||
getWorldCampScenePreset(worldType) ?? getScenePreset(worldType, 0);
|
||||
const npcCandidates = (campScenePreset?.npcs ?? [])
|
||||
.filter((npc: SceneNpc) => Boolean(npc.characterId))
|
||||
.filter((npc: SceneNpc) => npc.characterId !== playerCharacter.id);
|
||||
if (npcCandidates.length === 0) return null;
|
||||
|
||||
const npc =
|
||||
npcCandidates[Math.floor(Math.random() * npcCandidates.length)] ?? null;
|
||||
if (!npc) return null;
|
||||
|
||||
return {
|
||||
id: npc.id,
|
||||
kind: 'npc',
|
||||
characterId: npc.characterId,
|
||||
npcName: npc.name,
|
||||
npcDescription: npc.description,
|
||||
npcAvatar: npc.avatar,
|
||||
context: npc.role,
|
||||
gender: npc.gender,
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
};
|
||||
}
|
||||
|
||||
function createInitialGameState(): GameState {
|
||||
function createSelectionGameState(): GameState {
|
||||
return {
|
||||
worldType: null,
|
||||
customWorldProfile: null,
|
||||
@@ -222,261 +75,20 @@ function createInitialGameState(): GameState {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOpeningActScenePreset(
|
||||
profile: CustomWorldProfile | null,
|
||||
): NonNullable<GameState['currentScenePreset']> | null {
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const openingChapter = profile.sceneChapterBlueprints?.[0] ?? null;
|
||||
const openingSceneIds = [
|
||||
openingChapter?.acts[0]?.sceneId,
|
||||
openingChapter?.sceneId,
|
||||
...(openingChapter?.linkedLandmarkIds ?? []),
|
||||
]
|
||||
.map((sceneId) => sceneId?.trim() ?? '')
|
||||
.filter(Boolean);
|
||||
|
||||
for (const sceneId of openingSceneIds) {
|
||||
const directScene = resolveCustomWorldScenePresetByConfiguredId(
|
||||
profile,
|
||||
sceneId,
|
||||
);
|
||||
if (directScene) {
|
||||
return directScene;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackLandmarkIndex = profile.landmarks.findIndex(
|
||||
(landmark) => landmark.sceneNpcIds.length > 0,
|
||||
);
|
||||
if (fallbackLandmarkIndex >= 0) {
|
||||
return getScenePresetById(
|
||||
WorldType.CUSTOM,
|
||||
`custom-scene-landmark-${fallbackLandmarkIndex + 1}`,
|
||||
);
|
||||
}
|
||||
|
||||
const firstLandmarkId = profile.landmarks[0]?.id?.trim() ?? '';
|
||||
if (firstLandmarkId) {
|
||||
const firstLandmarkScene = getScenePresetById(
|
||||
WorldType.CUSTOM,
|
||||
'custom-scene-landmark-1',
|
||||
);
|
||||
if (firstLandmarkScene) {
|
||||
return firstLandmarkScene;
|
||||
}
|
||||
}
|
||||
|
||||
return profile.landmarks.length > 0
|
||||
? getScenePresetById(WorldType.CUSTOM, 'custom-scene-landmark-1')
|
||||
: null;
|
||||
}
|
||||
|
||||
function resolveOpeningSceneActBlueprint(
|
||||
profile: CustomWorldProfile | null,
|
||||
): { chapter: SceneChapterBlueprint; act: SceneActBlueprint } | null {
|
||||
const openingChapter = profile?.sceneChapterBlueprints?.[0] ?? null;
|
||||
const openingAct = openingChapter?.acts[0] ?? null;
|
||||
return openingChapter && openingAct
|
||||
? { chapter: openingChapter, act: openingAct }
|
||||
: null;
|
||||
}
|
||||
|
||||
function resolveCustomWorldScenePresetByConfiguredId(
|
||||
profile: CustomWorldProfile,
|
||||
sceneId: string | null | undefined,
|
||||
): NonNullable<GameState['currentScenePreset']> | null {
|
||||
const normalizedSceneId = sceneId?.trim() ?? '';
|
||||
if (!normalizedSceneId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directScene = getScenePresetById(WorldType.CUSTOM, normalizedSceneId);
|
||||
if (directScene) {
|
||||
return directScene;
|
||||
}
|
||||
|
||||
const campId = profile.camp?.id?.trim() ?? '';
|
||||
if (
|
||||
normalizedSceneId === campId ||
|
||||
normalizedSceneId === 'custom-scene-camp'
|
||||
) {
|
||||
return getScenePresetById(WorldType.CUSTOM, 'custom-scene-camp');
|
||||
}
|
||||
|
||||
const landmarkIndex = profile.landmarks.findIndex(
|
||||
(landmark) => landmark.id === normalizedSceneId,
|
||||
);
|
||||
if (landmarkIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getScenePresetById(
|
||||
WorldType.CUSTOM,
|
||||
`custom-scene-landmark-${landmarkIndex + 1}`,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveOpeningActNpcIdPriority(
|
||||
profile: CustomWorldProfile,
|
||||
openingAct: SceneActBlueprint,
|
||||
) {
|
||||
return resolveCustomWorldRoleIdReferences(profile, [
|
||||
openingAct.oppositeNpcId,
|
||||
openingAct.primaryNpcId,
|
||||
...openingAct.encounterNpcIds,
|
||||
]);
|
||||
}
|
||||
|
||||
function doRoleReferencesMatch(
|
||||
profile: CustomWorldProfile | null,
|
||||
left: string | null | undefined,
|
||||
right: string | null | undefined,
|
||||
) {
|
||||
const normalizedLeft = resolveCustomWorldRoleIdReference(profile, left);
|
||||
const normalizedRight = resolveCustomWorldRoleIdReference(profile, right);
|
||||
return Boolean(normalizedLeft && normalizedLeft === normalizedRight);
|
||||
}
|
||||
|
||||
function findSceneNpcByRuntimeRoleId(
|
||||
scenePreset: GameState['currentScenePreset'],
|
||||
profile: CustomWorldProfile | null,
|
||||
roleId: string,
|
||||
) {
|
||||
return (
|
||||
scenePreset?.npcs?.find(
|
||||
(npc) =>
|
||||
doRoleReferencesMatch(profile, npc.id, roleId) ||
|
||||
doRoleReferencesMatch(profile, npc.characterId, roleId) ||
|
||||
doRoleReferencesMatch(profile, npc.name, roleId) ||
|
||||
doRoleReferencesMatch(profile, npc.title, roleId),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function buildOpeningEncounterFromCustomWorldRole(
|
||||
profile: CustomWorldProfile,
|
||||
roleId: string,
|
||||
): Encounter | null {
|
||||
const role =
|
||||
findCustomWorldRoleByReference(profile, roleId);
|
||||
if (!role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isHostile = role.initialAffinity < 0;
|
||||
return {
|
||||
id: role.id,
|
||||
kind: 'npc',
|
||||
characterId: role.id,
|
||||
npcName: role.name,
|
||||
npcDescription: role.description,
|
||||
npcAvatar: role.imageSrc ?? role.name.slice(0, 1) ?? '?',
|
||||
context: role.role,
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
initialAffinity: role.initialAffinity,
|
||||
hostile: isHostile,
|
||||
title: role.title,
|
||||
backstory: role.backstory,
|
||||
personality: role.personality,
|
||||
motivation: role.motivation,
|
||||
combatStyle: role.combatStyle,
|
||||
relationshipHooks: [...role.relationshipHooks],
|
||||
tags: [...role.tags],
|
||||
backstoryReveal: role.backstoryReveal,
|
||||
skills: role.skills.map((skill) => ({ ...skill })),
|
||||
initialItems: role.initialItems.map((item) => ({
|
||||
...item,
|
||||
tags: [...item.tags],
|
||||
})),
|
||||
imageSrc: role.imageSrc,
|
||||
visual: (role as { visual?: Encounter['visual'] }).visual,
|
||||
narrativeProfile: role.narrativeProfile,
|
||||
attributeProfile: role.attributeProfile,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOpeningActEncounter(params: {
|
||||
profile: CustomWorldProfile | null;
|
||||
scenePreset: GameState['currentScenePreset'];
|
||||
playerCharacter: Character;
|
||||
}) {
|
||||
const opening = resolveOpeningSceneActBlueprint(params.profile);
|
||||
if (!opening || !params.profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const npcId of resolveOpeningActNpcIdPriority(params.profile, opening.act)) {
|
||||
if (
|
||||
doRoleReferencesMatch(
|
||||
params.profile,
|
||||
npcId,
|
||||
params.playerCharacter.id,
|
||||
) ||
|
||||
doRoleReferencesMatch(
|
||||
params.profile,
|
||||
npcId,
|
||||
params.playerCharacter.name,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sceneNpc = findSceneNpcByRuntimeRoleId(
|
||||
params.scenePreset,
|
||||
params.profile,
|
||||
npcId,
|
||||
);
|
||||
if (sceneNpc && sceneNpc.characterId !== params.playerCharacter.id) {
|
||||
return {
|
||||
...buildEncounterFromSceneNpc(sceneNpc, RESOLVED_ENTITY_X_METERS),
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
};
|
||||
}
|
||||
|
||||
const roleEncounter = buildOpeningEncounterFromCustomWorldRole(
|
||||
params.profile,
|
||||
npcId,
|
||||
);
|
||||
if (roleEncounter) {
|
||||
return roleEncounter;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildOpeningStoryEngineMemory(
|
||||
profile: CustomWorldProfile | null,
|
||||
sceneId: string | null | undefined,
|
||||
) {
|
||||
const storyEngineMemory = createEmptyStoryEngineMemoryState();
|
||||
|
||||
return {
|
||||
...storyEngineMemory,
|
||||
currentSceneActState:
|
||||
buildInitialSceneActRuntimeState({
|
||||
profile,
|
||||
sceneId,
|
||||
storyEngineMemory,
|
||||
}) ?? storyEngineMemory.currentSceneActState ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RPG session bootstrap 主实现。
|
||||
* 工作包 C 起由新域 hook 承载世界选择、选角确认与新开局初始化。
|
||||
*/
|
||||
export function useRpgSessionBootstrap() {
|
||||
const [gameState, setGameState] = useState<GameState>(() =>
|
||||
createInitialGameState(),
|
||||
createSelectionGameState(),
|
||||
);
|
||||
const [bottomTab, setBottomTab] = useState<BottomTab>('adventure');
|
||||
const [isMapOpen, setIsMapOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 中文注释:当前运行中的自定义世界 profile 需要同步给静态数据层,
|
||||
// 这样角色预设、场景预设、运行时引用解析才能读取到同一份世界真相。
|
||||
setRuntimeCustomWorldProfile(gameState.customWorldProfile);
|
||||
setRuntimeCharacterOverrides(
|
||||
gameState.customWorldProfile
|
||||
@@ -486,9 +98,11 @@ export function useRpgSessionBootstrap() {
|
||||
}, [gameState.customWorldProfile]);
|
||||
|
||||
const resetGame = () => {
|
||||
// 中文注释:reset 不只清 GameState,还要把底部 tab 和地图弹层一起还原,
|
||||
// 避免返回入口后 UI 仍停留在上一次冒险的局部状态。
|
||||
setBottomTab('adventure');
|
||||
setIsMapOpen(false);
|
||||
setGameState(createInitialGameState());
|
||||
setGameState(createSelectionGameState());
|
||||
};
|
||||
|
||||
const handleCustomWorldSelect = (
|
||||
@@ -505,11 +119,12 @@ export function useRpgSessionBootstrap() {
|
||||
);
|
||||
const initialScenePreset = getScenePreset(resolvedWorldType, 0) ?? null;
|
||||
setIsMapOpen(false);
|
||||
setGameState((prev) =>
|
||||
ensureSceneEncounterPreview({
|
||||
...prev,
|
||||
worldType: resolvedWorldType,
|
||||
customWorldProfile,
|
||||
setGameState((prev) => ({
|
||||
// 中文注释:世界刚选中时只进入“已装入世界,但尚未选角”的中间态;
|
||||
// 正式开局 GameState 必须等待角色确认后由 server-rs 统一生成。
|
||||
...prev,
|
||||
worldType: resolvedWorldType,
|
||||
customWorldProfile,
|
||||
runtimeMode,
|
||||
runtimePersistenceDisabled,
|
||||
currentScenePreset: initialScenePreset,
|
||||
@@ -532,153 +147,38 @@ export function useRpgSessionBootstrap() {
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
}),
|
||||
);
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBackToWorldSelect = () => {
|
||||
setBottomTab('adventure');
|
||||
setIsMapOpen(false);
|
||||
setGameState(createInitialGameState());
|
||||
setGameState(createSelectionGameState());
|
||||
};
|
||||
|
||||
const handleCharacterSelect = (character: Character) => {
|
||||
setBottomTab('adventure');
|
||||
setIsMapOpen(false);
|
||||
|
||||
setGameState((prev) => {
|
||||
const resolvedWorldType = prev.worldType;
|
||||
const resolvedCustomWorldProfile = prev.customWorldProfile;
|
||||
const initialScenePreset =
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? (resolveOpeningActScenePreset(resolvedCustomWorldProfile) ??
|
||||
getWorldCampScenePreset(resolvedWorldType) ??
|
||||
getScenePreset(resolvedWorldType, 0))
|
||||
: resolvedWorldType
|
||||
? (getWorldCampScenePreset(resolvedWorldType) ??
|
||||
getScenePreset(resolvedWorldType, 0))
|
||||
: null;
|
||||
const initialEncounter =
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? resolveOpeningActEncounter({
|
||||
profile: resolvedCustomWorldProfile,
|
||||
scenePreset: initialScenePreset,
|
||||
playerCharacter: character,
|
||||
})
|
||||
: createInitialCampEncounter(resolvedWorldType, character);
|
||||
const initialNpcState = initialEncounter
|
||||
? buildInitialNpcState(initialEncounter, resolvedWorldType, prev)
|
||||
: null;
|
||||
const initialEquipment = buildInitialEquipmentLoadout(
|
||||
character,
|
||||
resolvedCustomWorldProfile,
|
||||
);
|
||||
const explicitStarterItems =
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? buildExplicitCustomWorldRoleStarterState(
|
||||
resolvedCustomWorldProfile!,
|
||||
character,
|
||||
)
|
||||
: null;
|
||||
const mergedStarterEquipment = {
|
||||
weapon:
|
||||
explicitStarterItems?.equipment.weapon ?? initialEquipment.weapon,
|
||||
armor: explicitStarterItems?.equipment.armor ?? initialEquipment.armor,
|
||||
relic: explicitStarterItems?.equipment.relic ?? initialEquipment.relic,
|
||||
};
|
||||
const playerMaxHp = getCharacterMaxHp(
|
||||
character,
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
);
|
||||
const launchState = gameState;
|
||||
const resolvedWorldType = launchState.worldType;
|
||||
if (!resolvedWorldType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const openingState = applyEquipmentLoadoutToState(
|
||||
{
|
||||
...prev,
|
||||
playerCharacter: character,
|
||||
runtimeMode:
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? (prev.runtimeMode ?? 'play')
|
||||
: (prev.runtimeMode ?? 'play'),
|
||||
runtimePersistenceDisabled:
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? prev.runtimePersistenceDisabled === true
|
||||
: prev.runtimePersistenceDisabled,
|
||||
runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }),
|
||||
playerProgression: createInitialPlayerProgressionState(),
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
storyEngineMemory:
|
||||
resolvedWorldType === WorldType.CUSTOM
|
||||
? buildOpeningStoryEngineMemory(
|
||||
resolvedCustomWorldProfile,
|
||||
initialScenePreset?.id,
|
||||
)
|
||||
: createEmptyStoryEngineMemoryState(),
|
||||
chapterState: null,
|
||||
campaignState: null,
|
||||
activeScenarioPackId: prev.customWorldProfile?.scenarioPackId ?? null,
|
||||
activeCampaignPackId: prev.customWorldProfile?.campaignPackId ?? null,
|
||||
characterChats: {},
|
||||
currentEncounter: initialEncounter,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: initialScenePreset,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
animationState: AnimationState.IDLE,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: playerMaxHp,
|
||||
playerMaxHp: playerMaxHp,
|
||||
playerMana: getCharacterMaxMana(character),
|
||||
playerMaxMana: getCharacterMaxMana(character),
|
||||
playerSkillCooldowns: createCharacterSkillCooldowns(character),
|
||||
activeBuildBuffs: [],
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: getInitialPlayerCurrency(
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
),
|
||||
playerInventory: mergeStarterInventoryItems<InventoryItem>(
|
||||
explicitStarterItems?.inventory ?? [],
|
||||
buildInitialPlayerInventory(
|
||||
character,
|
||||
resolvedWorldType,
|
||||
resolvedCustomWorldProfile,
|
||||
),
|
||||
),
|
||||
playerEquipment: createEmptyEquipmentLoadout(),
|
||||
npcStates:
|
||||
initialEncounter && initialNpcState
|
||||
? {
|
||||
[initialEncounter.id!]: initialNpcState,
|
||||
}
|
||||
: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
},
|
||||
mergedStarterEquipment,
|
||||
);
|
||||
|
||||
return resolvedWorldType === WorldType.CUSTOM
|
||||
? openingState
|
||||
: ensureSceneEncounterPreview(openingState);
|
||||
void beginRpgRuntimeStorySession({
|
||||
worldType: resolvedWorldType,
|
||||
customWorldProfile: launchState.customWorldProfile,
|
||||
character,
|
||||
runtimeMode: launchState.runtimeMode ?? 'play',
|
||||
disablePersistence: launchState.runtimePersistenceDisabled === true,
|
||||
}).then((response) => {
|
||||
// 中文注释:开局正式 GameState 由 server-rs 生成并持久化;
|
||||
// 前端只接收后端快照,避免浏览器继续承担初始背包、装备、遭遇和 NPC 状态裁决。
|
||||
setGameState(response.snapshot.gameState);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -699,3 +199,4 @@ export function useRpgSessionBootstrap() {
|
||||
export type RpgSessionBootstrapResult = ReturnType<
|
||||
typeof useRpgSessionBootstrap
|
||||
>;
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { isAbortError } from '../../services/apiClient';
|
||||
import { rpgSnapshotClient } from '../../services/rpg-runtime';
|
||||
import {
|
||||
getRpgRuntimeSessionId,
|
||||
rpgSnapshotClient,
|
||||
} from '../../services/rpg-runtime';
|
||||
import type { GameState, StoryMoment } from '../../types';
|
||||
import { resumeServerRuntimeStory } from '../rpg-runtime-story/runtimeStoryCoordinator';
|
||||
import type { BottomTab } from './rpgSessionTypes';
|
||||
@@ -10,6 +13,8 @@ import type { BottomTab } from './rpgSessionTypes';
|
||||
const AUTO_SAVE_DELAY_MS = 400;
|
||||
|
||||
function canPersistSnapshot(gameState: GameState, story: StoryMoment | null) {
|
||||
// 中文注释:preview / test 模式、非 Story 场景、未选角、以及流式输出中的故事都不应入正式存档,
|
||||
// 否则容易把临时态或半成品叙事写进继续游戏链路。
|
||||
return (
|
||||
gameState.runtimePersistenceDisabled !== true &&
|
||||
gameState.runtimeMode !== 'preview' &&
|
||||
@@ -30,6 +35,8 @@ function normalizeBottomTab(bottomTab: string | null | undefined): BottomTab {
|
||||
}
|
||||
|
||||
function resolveRemoteSnapshotState(snapshot: HydratedSavedGameSnapshot) {
|
||||
// 中文注释:远端快照允许缺少局部 UI 状态;
|
||||
// 这里统一补底部 tab 的兜底值,避免恢复后落到非法面板名。
|
||||
return {
|
||||
gameState: snapshot.gameState,
|
||||
currentStory: snapshot.currentStory ?? null,
|
||||
@@ -75,6 +82,8 @@ export function useRpgSessionPersistence({
|
||||
const saveRequestIdRef = useRef(0);
|
||||
|
||||
const abortActiveSave = useCallback(() => {
|
||||
// 中文注释:自动存档是“后写覆盖前写”的串行语义;
|
||||
// 新一次保存开始前,主动打断旧请求,避免旧快照回写覆盖最新状态。
|
||||
saveControllerRef.current?.abort();
|
||||
saveControllerRef.current = null;
|
||||
setIsPersistingSnapshot(false);
|
||||
@@ -83,9 +92,8 @@ export function useRpgSessionPersistence({
|
||||
const persistSnapshot = useCallback(
|
||||
async (params: {
|
||||
payload: {
|
||||
gameState: GameState;
|
||||
sessionId: string;
|
||||
bottomTab: BottomTab;
|
||||
currentStory: StoryMoment | null;
|
||||
};
|
||||
logLabel: string;
|
||||
}) => {
|
||||
@@ -103,11 +111,12 @@ export function useRpgSessionPersistence({
|
||||
setPersistenceError(null);
|
||||
|
||||
try {
|
||||
// 中文注释:这里不再上传整份本地快照;
|
||||
// 前端只告诉后端“当前 session 需要 checkpoint”,真实 GameState 由服务端快照表读取。
|
||||
const snapshot = await rpgSnapshotClient.putSnapshot(
|
||||
{
|
||||
gameState: params.payload.gameState,
|
||||
sessionId: params.payload.sessionId,
|
||||
bottomTab: params.payload.bottomTab,
|
||||
currentStory: params.payload.currentStory,
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
@@ -158,6 +167,8 @@ export function useRpgSessionPersistence({
|
||||
hydrateControllerRef.current = controller;
|
||||
setIsHydratingSnapshot(true);
|
||||
|
||||
// 中文注释:登录后第一时间探测一次远端快照,
|
||||
// 让入口页能够准确判断“继续游戏”按钮是否可见。
|
||||
void rpgSnapshotClient
|
||||
.getSnapshot({ signal: controller.signal })
|
||||
.then((snapshot) => {
|
||||
@@ -207,12 +218,13 @@ export function useRpgSessionPersistence({
|
||||
|
||||
if (!canPersist) return;
|
||||
|
||||
// 中文注释:自动存档做一个很短的去抖,
|
||||
// 避免同一轮状态连锁更新时重复打多次快照请求。
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void persistSnapshot({
|
||||
payload: {
|
||||
gameState,
|
||||
sessionId: getRpgRuntimeSessionId(gameState),
|
||||
bottomTab,
|
||||
currentStory,
|
||||
},
|
||||
logLabel: 'failed to autosave remote snapshot',
|
||||
});
|
||||
@@ -235,11 +247,12 @@ export function useRpgSessionPersistence({
|
||||
return false;
|
||||
}
|
||||
|
||||
// 中文注释:手动存档和自动存档走同一套底层 persist 逻辑,
|
||||
// 差别只在于调用方可显式覆盖本次 checkpoint 的 session 与 UI tab。
|
||||
const snapshot = await persistSnapshot({
|
||||
payload: {
|
||||
gameState: nextGameState,
|
||||
sessionId: getRpgRuntimeSessionId(nextGameState),
|
||||
bottomTab: nextBottomTab,
|
||||
currentStory: nextStory,
|
||||
},
|
||||
logLabel: 'failed to save remote snapshot',
|
||||
});
|
||||
@@ -300,6 +313,8 @@ export function useRpgSessionPersistence({
|
||||
resetStoryState();
|
||||
const fallbackHydration = resolveRemoteSnapshotState(snapshot);
|
||||
|
||||
// 中文注释:继续游戏不是简单把旧 currentStory 塞回去,
|
||||
// 还要向服务端刷新一遍 runtime story,拿到当前服务端判定的可选动作与视图模型。
|
||||
const resumedState = await resumeServerRuntimeStory(snapshot).catch(
|
||||
(error) => {
|
||||
if (!isAbortError(error)) {
|
||||
|
||||
@@ -23,6 +23,8 @@ vi.mock('../services/rpg-entry', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../services/rpg-runtime', () => ({
|
||||
getRpgRuntimeSessionId: (gameState: Pick<GameState, 'runtimeSessionId'>) =>
|
||||
gameState.runtimeSessionId?.trim() || 'runtime-main',
|
||||
rpgSnapshotClient: {
|
||||
getSnapshot: storageMocks.getSaveSnapshot,
|
||||
putSnapshot: storageMocks.putSaveSnapshot,
|
||||
@@ -30,7 +32,11 @@ vi.mock('../services/rpg-runtime', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function SettingsHarness({ authenticatedUserId }: { authenticatedUserId: string | null }) {
|
||||
function SettingsHarness({
|
||||
authenticatedUserId,
|
||||
}: {
|
||||
authenticatedUserId: string | null;
|
||||
}) {
|
||||
const settings = useGameSettings(authenticatedUserId);
|
||||
|
||||
return (
|
||||
@@ -50,14 +56,18 @@ function SettingsHarness({ authenticatedUserId }: { authenticatedUserId: string
|
||||
|
||||
function PersistenceHarness({
|
||||
authenticatedUserId,
|
||||
gameState = {} as GameState,
|
||||
currentStory = null as StoryMoment | null,
|
||||
}: {
|
||||
authenticatedUserId: string | null;
|
||||
gameState?: GameState;
|
||||
currentStory?: StoryMoment | null;
|
||||
}) {
|
||||
const persistence = useRpgSessionPersistence({
|
||||
authenticatedUserId,
|
||||
gameState: {} as GameState,
|
||||
gameState,
|
||||
bottomTab: 'adventure' as BottomTab,
|
||||
currentStory: null as StoryMoment | null,
|
||||
currentStory,
|
||||
isLoading: false,
|
||||
setGameState: () => {},
|
||||
setBottomTab: () => {},
|
||||
@@ -67,7 +77,9 @@ function PersistenceHarness({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="saved-game">{persistence.hasSavedGame ? 'yes' : 'no'}</div>
|
||||
<div data-testid="saved-game">
|
||||
{persistence.hasSavedGame ? 'yes' : 'no'}
|
||||
</div>
|
||||
<div data-testid="hydrating">
|
||||
{persistence.isHydratingSnapshot ? 'yes' : 'no'}
|
||||
</div>
|
||||
@@ -161,3 +173,64 @@ test('unauthenticated runtime skips remote snapshot hydration', async () => {
|
||||
expect(screen.getByTestId('saved-game').textContent).toBe('no');
|
||||
expect(storageMocks.getSaveSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('runtime autosave requests backend checkpoint without uploading local state', async () => {
|
||||
vi.useFakeTimers();
|
||||
storageMocks.getSaveSnapshot.mockResolvedValue(null);
|
||||
storageMocks.putSaveSnapshot.mockResolvedValue({
|
||||
version: 2,
|
||||
savedAt: '2026-04-28T10:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
runtimeSessionId: 'runtime-main',
|
||||
currentScene: 'Story',
|
||||
},
|
||||
});
|
||||
const gameState = {
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimePersistenceDisabled: false,
|
||||
runtimeMode: 'play',
|
||||
currentScene: 'Story',
|
||||
worldType: 'CUSTOM',
|
||||
playerCharacter: { id: 'hero_001' },
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
} as unknown as GameState;
|
||||
const story = { text: '开场', options: [], streaming: false } as StoryMoment;
|
||||
|
||||
render(
|
||||
<PersistenceHarness
|
||||
authenticatedUserId="user-1"
|
||||
gameState={gameState}
|
||||
currentStory={story}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(storageMocks.getSaveSnapshot).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storageMocks.putSaveSnapshot).toHaveBeenCalledWith(
|
||||
{
|
||||
sessionId: 'runtime-main',
|
||||
bottomTab: 'adventure',
|
||||
},
|
||||
expect.objectContaining({
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {
|
||||
type RuntimeSaveCheckpointInput as SharedRuntimeSaveCheckpointInput,
|
||||
type SavedGameSnapshot as SharedSavedGameSnapshot,
|
||||
type SavedGameSnapshotInput as SharedSavedGameSnapshotInput,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import type {GameState, StoryMoment} from '../types';
|
||||
import type {BottomTab} from '../types/navigation';
|
||||
import type { GameState, StoryMoment } from '../types';
|
||||
import type { BottomTab } from '../types/navigation';
|
||||
|
||||
export type SavedGameSnapshot = SharedSavedGameSnapshot<
|
||||
GameState,
|
||||
@@ -16,3 +17,6 @@ export type SavedGameSnapshotInput = SharedSavedGameSnapshotInput<
|
||||
BottomTab,
|
||||
StoryMoment
|
||||
>;
|
||||
|
||||
export type RuntimeSaveCheckpointInput =
|
||||
SharedRuntimeSaveCheckpointInput<BottomTab>;
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
import {
|
||||
buildSchemaSummary,
|
||||
describeTopAttributes,
|
||||
formatAttributeList,
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
} from '../data/attributeResolver';
|
||||
import {
|
||||
buildCharacterBackstoryPromptContext,
|
||||
getCharacterPublicBackstorySummary,
|
||||
getLockedCharacterBackstoryChapters,
|
||||
} from '../data/characterPresets';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
CharacterChatTurn,
|
||||
CustomWorldProfile,
|
||||
FacingDirection,
|
||||
StoryMoment,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { buildCustomWorldReferenceText } from '../services/customWorld';
|
||||
import { buildStoryPromptHistory } from '../services/storyHistory';
|
||||
|
||||
export interface CharacterChatTargetStatus {
|
||||
roleLabel?: string | null;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
mana: number;
|
||||
maxMana: number;
|
||||
affinity?: number | null;
|
||||
}
|
||||
|
||||
export interface CharacterChatPromptContext {
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
playerMana: number;
|
||||
playerMaxMana: number;
|
||||
inBattle: boolean;
|
||||
playerFacing: FacingDirection;
|
||||
playerAnimation: AnimationState;
|
||||
sceneName?: string | null;
|
||||
sceneDescription?: string | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
}
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SYSTEM_PROMPT = `你是像素动作 RPG 里的同行角色。
|
||||
只回复这名角色此刻会对玩家说的话。
|
||||
不要输出角色名、引号、旁白、动作提示、Markdown、JSON 或解释。
|
||||
保持人设,结合最近剧情和关系变化,回复简洁自然。`;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT = `生成恰好 3 条玩家回复建议。
|
||||
只输出纯文本,共 3 行,每行一条。
|
||||
不要加编号、项目符号、Markdown 或额外说明。
|
||||
三条建议语气要有区分:关心、追问、轻松或拉近关系。`;
|
||||
|
||||
export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名角色之间不断变化的关系。
|
||||
只输出一段简洁文字。
|
||||
包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`;
|
||||
|
||||
function describeWorld(world: WorldType) {
|
||||
if (world === WorldType.WUXIA) return '边城模板';
|
||||
if (world === WorldType.XIANXIA) return '灵潮模板';
|
||||
return '自定义世界';
|
||||
}
|
||||
|
||||
function describeCustomWorldSection(customWorldProfile?: CustomWorldProfile | null) {
|
||||
return customWorldProfile
|
||||
? `自定义世界参考:\n${buildCustomWorldReferenceText(customWorldProfile)}`
|
||||
: null;
|
||||
}
|
||||
|
||||
function describeGender(gender: Character['gender']) {
|
||||
if (gender === 'female') return '女';
|
||||
if (gender === 'male') return '男';
|
||||
return '未知';
|
||||
}
|
||||
|
||||
function describeFacing(facing: FacingDirection) {
|
||||
return facing === 'left' ? '左' : '右';
|
||||
}
|
||||
|
||||
function describeHpBand(ratio: number) {
|
||||
if (ratio >= 0.95) return '几乎无伤';
|
||||
if (ratio >= 0.75) return '状态稳健';
|
||||
if (ratio >= 0.55) return '略有消耗';
|
||||
if (ratio >= 0.35) return '伤势明显';
|
||||
if (ratio >= 0.15) return '伤势沉重';
|
||||
return '濒临极限';
|
||||
}
|
||||
|
||||
function describeManaBand(ratio: number) {
|
||||
if (ratio >= 0.9) return '充盈';
|
||||
if (ratio >= 0.7) return '稳定';
|
||||
if (ratio >= 0.45) return '尚可';
|
||||
if (ratio >= 0.2) return '偏低';
|
||||
if (ratio > 0) return '接近枯竭';
|
||||
return '耗尽';
|
||||
}
|
||||
|
||||
function describeStoryHistory(history: StoryMoment[]) {
|
||||
const promptHistory = buildStoryPromptHistory(history);
|
||||
|
||||
if (!promptHistory.previousSummary && promptHistory.recentOriginalRounds.length === 0) {
|
||||
return '近期剧情:暂无。';
|
||||
}
|
||||
|
||||
return [
|
||||
promptHistory.previousSummary
|
||||
? `更早剧情摘要:\n${promptHistory.previousSummary}`
|
||||
: '更早剧情摘要:暂无。',
|
||||
promptHistory.recentOriginalRounds.length > 0
|
||||
? `最近 3 轮剧情:\n${promptHistory.recentOriginalRounds
|
||||
.map((item, index) => `- 第 ${index + 1} 轮:\n${item}`)
|
||||
.join('\n')}`
|
||||
: '最近 3 轮剧情:暂无。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeBackstoryContext(label: string, snippets: string[]) {
|
||||
const normalized = snippets
|
||||
.map(snippet => snippet.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return [`${label}:暂无公开信息。`];
|
||||
}
|
||||
|
||||
return normalized.map((snippet, index) =>
|
||||
`${label}${index === 0 ? '(公开层)' : `(已解锁片段 ${index})`}:${snippet}`,
|
||||
);
|
||||
}
|
||||
|
||||
function describeCharacterInfo(
|
||||
label: string,
|
||||
character: Character,
|
||||
world: WorldType,
|
||||
customWorldProfile?: CustomWorldProfile | null,
|
||||
options: {
|
||||
affinity?: number | null;
|
||||
includeUnlockProgress?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const schema = resolveAttributeSchema(world, customWorldProfile);
|
||||
const attributeProfile = resolveCharacterAttributeProfile(character, world, customWorldProfile);
|
||||
const skills = character.skills.length > 0
|
||||
? character.skills
|
||||
.map(
|
||||
skill => `${skill.name}(伤害 ${skill.damage}/灵力 ${skill.manaCost}/冷却 ${skill.cooldownTurns})`,
|
||||
)
|
||||
.join(' | ')
|
||||
: '无';
|
||||
const backgroundLines = options.affinity == null
|
||||
? [getCharacterPublicBackstorySummary(character, world)]
|
||||
: buildCharacterBackstoryPromptContext(character, options.affinity, world);
|
||||
const nextLockedChapter = options.includeUnlockProgress && options.affinity != null
|
||||
? getLockedCharacterBackstoryChapters(character, options.affinity, world)[0] ?? null
|
||||
: null;
|
||||
const schemaSummary = buildSchemaSummary(schema)
|
||||
.map(slot => `${slot.name}(${slot.definition})`)
|
||||
.join(' | ');
|
||||
const topAttributes = describeTopAttributes(attributeProfile, schema).join('、') || '无';
|
||||
const attributeDetails = formatAttributeList(attributeProfile, schema)
|
||||
.map(entry => `${entry.slot.name} ${entry.value}`)
|
||||
.join(' | ');
|
||||
|
||||
return [
|
||||
`${label}姓名:${character.name}`,
|
||||
`${label}称号:${character.title}`,
|
||||
`${label}性别:${describeGender(character.gender ?? 'unknown')}`,
|
||||
`${label}描述:${character.description}`,
|
||||
...describeBackstoryContext(`${label}背景`, backgroundLines),
|
||||
nextLockedChapter
|
||||
? `${label}未解锁背景:${nextLockedChapter.title}(需好感 ${nextLockedChapter.affinityRequired},当前只知道:${nextLockedChapter.teaser})`
|
||||
: null,
|
||||
`${label}性格:${character.personality}`,
|
||||
`${label}世界属性框架:${schemaSummary}`,
|
||||
`${label}主要属性:${topAttributes}`,
|
||||
`${label}属性详情:${attributeDetails}`,
|
||||
`${label}技能:${skills}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeChatContext(world: WorldType, context: CharacterChatPromptContext) {
|
||||
const hpRatio = context.playerHp / Math.max(context.playerMaxHp, 1);
|
||||
const manaRatio = context.playerMana / Math.max(context.playerMaxMana, 1);
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
`玩家战斗状态:${context.inBattle ? '战斗中' : '非战斗'}`,
|
||||
`场景:${context.sceneName ?? '当前区域'}`,
|
||||
`场景描述:${context.sceneDescription ?? '周围气氛仍未安定。'}`,
|
||||
`玩家状态:生命 ${context.playerHp}/${context.playerMaxHp}(${describeHpBand(hpRatio)}),灵力 ${context.playerMana}/${context.playerMaxMana}(${describeManaBand(manaRatio)}),朝向 ${describeFacing(context.playerFacing)},动作 ${context.playerAnimation}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function describeTargetStatus(status: CharacterChatTargetStatus) {
|
||||
const hpRatio = status.hp / Math.max(status.maxHp, 1);
|
||||
const manaRatio = status.mana / Math.max(status.maxMana, 1);
|
||||
|
||||
return [
|
||||
`对方身份:${status.roleLabel ?? '同行角色'}`,
|
||||
`对方状态:生命 ${status.hp}/${status.maxHp}(${describeHpBand(hpRatio)}),灵力 ${status.mana}/${status.maxMana}(${describeManaBand(manaRatio)})`,
|
||||
status.affinity != null ? `当前好感:${status.affinity}` : null,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function describeCharacterChatHistory(history: CharacterChatTurn[]) {
|
||||
if (history.length === 0) {
|
||||
return '聊天记录:暂无。';
|
||||
}
|
||||
|
||||
return [
|
||||
'聊天记录:',
|
||||
...history.slice(-12).map(turn => `- ${turn.speaker === 'player' ? '玩家' : '角色'}:${turn.text}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
}: {
|
||||
world: WorldType;
|
||||
playerCharacter: Character;
|
||||
targetCharacter: Character;
|
||||
storyHistory: StoryMoment[];
|
||||
context: CharacterChatPromptContext;
|
||||
conversationHistory: CharacterChatTurn[];
|
||||
conversationSummary: string;
|
||||
playerMessage: string;
|
||||
targetStatus: CharacterChatTargetStatus;
|
||||
}) {
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
describeChatContext(world, context),
|
||||
describeCustomWorldSection(context.customWorldProfile),
|
||||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||||
affinity: targetStatus.affinity ?? null,
|
||||
includeUnlockProgress: true,
|
||||
}),
|
||||
describeTargetStatus(targetStatus),
|
||||
describeStoryHistory(storyHistory),
|
||||
conversationSummary ? `之前聊天摘要:${conversationSummary}` : '之前聊天摘要:暂无。',
|
||||
describeCharacterChatHistory(conversationHistory),
|
||||
`玩家刚刚对 ${targetCharacter.name} 说:${playerMessage}`,
|
||||
`现在请以 ${targetCharacter.name} 的身份,直接回复玩家。`,
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatSuggestionPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
}: {
|
||||
world: WorldType;
|
||||
playerCharacter: Character;
|
||||
targetCharacter: Character;
|
||||
storyHistory: StoryMoment[];
|
||||
context: CharacterChatPromptContext;
|
||||
conversationHistory: CharacterChatTurn[];
|
||||
conversationSummary: string;
|
||||
targetStatus: CharacterChatTargetStatus;
|
||||
}) {
|
||||
const latestCharacterReply = [...conversationHistory]
|
||||
.reverse()
|
||||
.find(turn => turn.speaker === 'character')?.text ?? null;
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
describeChatContext(world, context),
|
||||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||||
affinity: targetStatus.affinity ?? null,
|
||||
includeUnlockProgress: true,
|
||||
}),
|
||||
describeTargetStatus(targetStatus),
|
||||
describeStoryHistory(storyHistory),
|
||||
conversationSummary ? `之前聊天摘要:${conversationSummary}` : '之前聊天摘要:暂无。',
|
||||
describeCharacterChatHistory(conversationHistory),
|
||||
latestCharacterReply
|
||||
? `角色刚刚的回复:${latestCharacterReply}`
|
||||
: `玩家正准备与 ${targetCharacter.name} 开始一段新的私聊。`,
|
||||
'生成 3 条可以直接发送的简短玩家回复候选。',
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
export function buildCharacterPanelChatSummaryPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
}: {
|
||||
world: WorldType;
|
||||
playerCharacter: Character;
|
||||
targetCharacter: Character;
|
||||
storyHistory: StoryMoment[];
|
||||
context: CharacterChatPromptContext;
|
||||
conversationHistory: CharacterChatTurn[];
|
||||
previousSummary: string;
|
||||
targetStatus: CharacterChatTargetStatus;
|
||||
}) {
|
||||
return [
|
||||
`世界:${describeWorld(world)}`,
|
||||
describeChatContext(world, context),
|
||||
describeCharacterInfo('玩家 / ', playerCharacter, world, context.customWorldProfile),
|
||||
describeCharacterInfo('对方 / ', targetCharacter, world, context.customWorldProfile, {
|
||||
affinity: targetStatus.affinity ?? null,
|
||||
includeUnlockProgress: true,
|
||||
}),
|
||||
describeTargetStatus(targetStatus),
|
||||
describeStoryHistory(storyHistory),
|
||||
previousSummary ? `旧摘要:${previousSummary}` : '旧摘要:暂无。',
|
||||
describeCharacterChatHistory(conversationHistory),
|
||||
'请把旧摘要与最新聊天合并成一段更新后的关系摘要,供后续剧情推理使用。',
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* 自定义世界角色资产工坊的“默认描述文本种子”主源。
|
||||
*
|
||||
* 这份脚本只负责一件事:
|
||||
* - 从当前角色对象已有字段里挑出最合适的文本,
|
||||
* 作为资产工坊输入框的初始默认值
|
||||
*
|
||||
* 它不负责:
|
||||
* - 直接调用 LLM 重新编译默认描述
|
||||
* - 直接生成图像模型 prompt
|
||||
* - 直接生成动作模型 prompt
|
||||
*
|
||||
* 当前真实调用状态:
|
||||
* - CustomWorldRoleAssetStudioModal 的初始默认值主链,来自本文件
|
||||
* - 也就是说,资产工坊页面打开时看到的“形象描述 / 动作描述”
|
||||
* 当前直接取这里的本地字段映射
|
||||
*/
|
||||
export type PromptDefaultRole = {
|
||||
name: string;
|
||||
title: string;
|
||||
role: string;
|
||||
visualDescription?: string;
|
||||
actionDescription?: string;
|
||||
sceneVisualDescription?: string;
|
||||
description?: string;
|
||||
backstory?: string;
|
||||
personality?: string;
|
||||
motivation?: string;
|
||||
combatStyle?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type CustomWorldRolePromptBundle = {
|
||||
visualPromptText: string;
|
||||
animationPromptText: string;
|
||||
scenePromptText: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 对角色字段做轻量清洗,确保作为输入框默认值时不会带多余空白。
|
||||
*/
|
||||
function cleanSeedText(value: string | undefined, maxLength: number) {
|
||||
return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按优先级选择第一条可用文本。
|
||||
*
|
||||
* 这里是非常轻量的本地回退逻辑,不做任何“重新创作”或 prompt 扩写。
|
||||
*/
|
||||
function pickFirstDescription(
|
||||
values: Array<string | undefined>,
|
||||
maxLength: number,
|
||||
) {
|
||||
for (const value of values) {
|
||||
const normalized = cleanSeedText(value, maxLength);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 资产工坊默认文本映射规则。
|
||||
*
|
||||
* 规则分层:
|
||||
* - visualPromptText: 优先使用角色 visualDescription,其次 description
|
||||
* - animationPromptText: 优先使用 actionDescription,其次 combatStyle
|
||||
* - scenePromptText: 优先使用 sceneVisualDescription,其次 backstory
|
||||
*
|
||||
* 注意:
|
||||
* - 返回值只是“输入框默认文案”
|
||||
* - 正式图像 / 动作模型 prompt 还会在后端继续编译
|
||||
*/
|
||||
export function buildDefaultRolePromptBundle(
|
||||
role: PromptDefaultRole,
|
||||
): CustomWorldRolePromptBundle {
|
||||
return {
|
||||
visualPromptText: pickFirstDescription(
|
||||
[role.visualDescription, role.description],
|
||||
220,
|
||||
),
|
||||
animationPromptText: pickFirstDescription(
|
||||
[role.actionDescription, role.combatStyle],
|
||||
180,
|
||||
),
|
||||
scenePromptText: pickFirstDescription(
|
||||
[role.sceneVisualDescription, role.backstory],
|
||||
220,
|
||||
),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,13 +45,8 @@ import {
|
||||
streamCharacterPanelChatReply,
|
||||
streamNpcRecruitDialogue,
|
||||
} from './ai';
|
||||
import {
|
||||
buildOfflineCharacterPanelChatReply,
|
||||
buildOfflineCharacterPanelChatSuggestions,
|
||||
buildOfflineNpcRecruitDialogue,
|
||||
} from './aiFallbacks';
|
||||
import type { StoryGenerationContext } from './aiTypes';
|
||||
import type { CharacterChatTargetStatus } from './characterChatPrompt';
|
||||
import type { CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
|
||||
|
||||
const [
|
||||
BACKSTORY_UNLOCK_AFFINITY_EASED,
|
||||
@@ -105,6 +100,8 @@ function createContext(
|
||||
overrides: Partial<StoryGenerationContext> = {},
|
||||
): StoryGenerationContext {
|
||||
return {
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 3,
|
||||
playerHp: 30,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 12,
|
||||
@@ -410,7 +407,57 @@ function createCustomWorldResponse(
|
||||
};
|
||||
}
|
||||
|
||||
describe('ai orchestration fallbacks', () => {
|
||||
function createApiEnvelopeResponse(data: unknown) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
data,
|
||||
error: null,
|
||||
meta: {
|
||||
apiVersion: '2026-04-08',
|
||||
},
|
||||
}),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
function createSseResponse(text: string) {
|
||||
const encoder = new TextEncoder();
|
||||
const chunks = [
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
choices: [{ delta: { content: text } }],
|
||||
})}\n\n`,
|
||||
),
|
||||
];
|
||||
let index = 0;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
body: {
|
||||
getReader() {
|
||||
return {
|
||||
async read() {
|
||||
if (index >= chunks.length) {
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
const value = chunks[index];
|
||||
index += 1;
|
||||
return { done: false, value };
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
text: async () => '',
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe('ai runtime client orchestration', () => {
|
||||
const playerCharacter = createCharacter();
|
||||
const targetCharacter = createCharacter({
|
||||
id: 'ally',
|
||||
@@ -431,9 +478,15 @@ describe('ai orchestration fallbacks', () => {
|
||||
streamPlainTextCompletionMock.mockReset();
|
||||
});
|
||||
|
||||
it('falls back to the offline story response when story generation loses connectivity', async () => {
|
||||
it('requests initial story from the runtime api server', async () => {
|
||||
const availableOptions = [createStoryOption()];
|
||||
requestChatMessageContentMock.mockRejectedValue(connectivityError);
|
||||
fetchMock.mockResolvedValue(
|
||||
createApiEnvelopeResponse({
|
||||
storyText: '山路尽头传来新的动静。',
|
||||
options: availableOptions,
|
||||
encounter: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await generateInitialStory(
|
||||
WorldType.WUXIA,
|
||||
@@ -443,12 +496,22 @@ describe('ai orchestration fallbacks', () => {
|
||||
{ availableOptions },
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/story/initial',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 3,
|
||||
requestOptions: { availableOptions },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(response.storyText).toBe('山路尽头传来新的动静。');
|
||||
expect(response.options).toEqual(availableOptions);
|
||||
expect(response.options).not.toBe(availableOptions);
|
||||
expect(response.storyText.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('repairs mixed-language story text before returning the story response', async () => {
|
||||
it('requests next story step from the runtime api server', async () => {
|
||||
const availableOptions = [
|
||||
createStoryOption({
|
||||
functionId: 'idle_explore_forward',
|
||||
@@ -456,117 +519,46 @@ describe('ai orchestration fallbacks', () => {
|
||||
text: '继续沿山道探路。',
|
||||
}),
|
||||
];
|
||||
requestChatMessageContentMock
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
storyText: 'The forest is quiet. 你听见远处的风声。',
|
||||
encounter: null,
|
||||
options: [
|
||||
{
|
||||
functionId: 'idle_explore_forward',
|
||||
actionText: 'Move forward carefully.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
storyText: '林间重新安静下来,你听见远处的风声。',
|
||||
encounter: null,
|
||||
options: [
|
||||
{
|
||||
functionId: 'idle_explore_forward',
|
||||
actionText: '继续沿山道探路。',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await generateInitialStory(
|
||||
WorldType.WUXIA,
|
||||
playerCharacter,
|
||||
monsters,
|
||||
context,
|
||||
{ availableOptions },
|
||||
);
|
||||
|
||||
expect(response.storyText).toBe('林间重新安静下来,你听见远处的风声。');
|
||||
expect(response.options[0]?.actionText).toBe('继续沿山道探路。');
|
||||
expect(requestChatMessageContentMock).toHaveBeenCalledTimes(2);
|
||||
expect(requestChatMessageContentMock.mock.calls[1]?.[2]).toEqual(
|
||||
expect.objectContaining({
|
||||
debugLabel: 'story-language-repair',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores generated encounter payloads during post-battle continuations when no new scene encounter is pending', async () => {
|
||||
const availableOptions = [
|
||||
createStoryOption({
|
||||
functionId: 'idle_explore_forward',
|
||||
actionText: '先稳住呼吸,再看看前面的动静。',
|
||||
text: '先稳住呼吸,再看看前面的动静。',
|
||||
}),
|
||||
];
|
||||
const sceneWithNpc = getScenePresetsByWorld(WorldType.WUXIA).find(
|
||||
(scene) => (scene.npcs?.length ?? 0) > 0,
|
||||
);
|
||||
const targetNpcId = sceneWithNpc?.npcs?.[0]?.id;
|
||||
if (!sceneWithNpc || !targetNpcId) {
|
||||
throw new Error('Expected a wuxia scene with at least one npc preset.');
|
||||
}
|
||||
|
||||
requestChatMessageContentMock.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
storyText: '山道总算安静下来,你收住气息,重新判断前路。',
|
||||
encounter: {
|
||||
kind: 'npc',
|
||||
npcId: targetNpcId,
|
||||
},
|
||||
options: [
|
||||
{
|
||||
functionId: 'idle_explore_forward',
|
||||
actionText: '先稳住呼吸,再看看前面的动静。',
|
||||
},
|
||||
],
|
||||
fetchMock.mockResolvedValue(
|
||||
createApiEnvelopeResponse({
|
||||
storyText: '林间重新安静下来,你听见远处的风声。',
|
||||
encounter: null,
|
||||
options: availableOptions,
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await generateNextStep(
|
||||
WorldType.WUXIA,
|
||||
playerCharacter,
|
||||
[],
|
||||
[
|
||||
{
|
||||
text: '挥刀抢攻',
|
||||
options: [],
|
||||
historyRole: 'action',
|
||||
},
|
||||
{
|
||||
text: '山道客已经败下阵来。',
|
||||
options: [],
|
||||
historyRole: 'result',
|
||||
},
|
||||
],
|
||||
'挥刀抢攻',
|
||||
createContext({
|
||||
sceneId: sceneWithNpc.id,
|
||||
sceneName: sceneWithNpc.name,
|
||||
sceneDescription: sceneWithNpc.description,
|
||||
pendingSceneEncounter: false,
|
||||
}),
|
||||
monsters,
|
||||
storyHistory,
|
||||
'继续向前',
|
||||
context,
|
||||
{ availableOptions },
|
||||
);
|
||||
|
||||
expect(response.encounter).toBeUndefined();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/story/continue',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 3,
|
||||
choice: '继续向前',
|
||||
requestOptions: { availableOptions },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(response.storyText).toBe('林间重新安静下来,你听见远处的风声。');
|
||||
expect(response.options).toEqual(availableOptions);
|
||||
const userPrompt = requestChatMessageContentMock.mock.calls.at(-1)?.[1];
|
||||
expect(userPrompt).toContain('encounter 必须为 null');
|
||||
expect(userPrompt).toContain('战斗结束后的续写');
|
||||
});
|
||||
|
||||
it('returns offline character chat suggestions when the plain-text client reports connectivity errors', async () => {
|
||||
requestPlainTextCompletionMock.mockRejectedValue(connectivityError);
|
||||
it('requests character chat suggestions from the runtime api server', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
createApiEnvelopeResponse({
|
||||
text: '先说你真正担心的事。\n这件事你还瞒了我什么?\n先别急,我们慢慢说。',
|
||||
}),
|
||||
);
|
||||
|
||||
const suggestions = await generateCharacterPanelChatSuggestions(
|
||||
WorldType.WUXIA,
|
||||
@@ -579,21 +571,33 @@ describe('ai orchestration fallbacks', () => {
|
||||
targetStatus,
|
||||
);
|
||||
|
||||
expect(suggestions).toEqual(
|
||||
buildOfflineCharacterPanelChatSuggestions(targetCharacter),
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/chat/character/suggestions',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
targetCharacter,
|
||||
conversationHistory: [],
|
||||
conversationSummary: '',
|
||||
targetStatus,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(suggestions).toEqual([
|
||||
'先说你真正担心的事。',
|
||||
'这件事你还瞒了我什么?',
|
||||
'先别急,我们慢慢说。',
|
||||
]);
|
||||
});
|
||||
|
||||
it('streams the offline character chat reply and forwards it to onUpdate when connectivity fails', async () => {
|
||||
it('streams character chat reply from the runtime api server', async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const playerMessage = 'Tell me what you are really worried about.';
|
||||
const conversationSummary = 'Lan has started to trust the player more.';
|
||||
const fallbackReply = buildOfflineCharacterPanelChatReply(
|
||||
targetCharacter,
|
||||
playerMessage,
|
||||
conversationSummary,
|
||||
fetchMock.mockResolvedValue(
|
||||
createSseResponse('我会认真回答你,但这件事没你想得那么简单。'),
|
||||
);
|
||||
streamPlainTextCompletionMock.mockRejectedValue(connectivityError);
|
||||
|
||||
const reply = await streamCharacterPanelChatReply(
|
||||
WorldType.WUXIA,
|
||||
@@ -608,16 +612,33 @@ describe('ai orchestration fallbacks', () => {
|
||||
{ onUpdate },
|
||||
);
|
||||
|
||||
expect(reply).toBe(fallbackReply);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/chat/character/reply/stream',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
targetCharacter,
|
||||
conversationHistory: [],
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(reply).toBe('我会认真回答你,但这件事没你想得那么简单。');
|
||||
expect(onUpdate).toHaveBeenCalledOnce();
|
||||
expect(onUpdate).toHaveBeenCalledWith(fallbackReply);
|
||||
expect(onUpdate).toHaveBeenCalledWith(
|
||||
'我会认真回答你,但这件事没你想得那么简单。',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the extracted NPC recruit fallback when recruit dialogue streaming loses connectivity', async () => {
|
||||
it('streams npc recruit dialogue from the runtime api server', async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const encounter = createEncounter();
|
||||
const fallbackReply = buildOfflineNpcRecruitDialogue(encounter);
|
||||
streamPlainTextCompletionMock.mockRejectedValue(connectivityError);
|
||||
fetchMock.mockResolvedValue(
|
||||
createSseResponse('你:和我一起走下去吧。\nLan:好,我答应你。'),
|
||||
);
|
||||
|
||||
const reply = await streamNpcRecruitDialogue(
|
||||
WorldType.WUXIA,
|
||||
@@ -631,9 +652,23 @@ describe('ai orchestration fallbacks', () => {
|
||||
{ onUpdate },
|
||||
);
|
||||
|
||||
expect(reply).toBe(fallbackReply);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/chat/npc/recruit/stream',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
encounter,
|
||||
invitationText: 'Join us.',
|
||||
recruitSummary: 'The party is ready to travel together.',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(reply).toBe('你:和我一起走下去吧。\nLan:好,我答应你。');
|
||||
expect(onUpdate).toHaveBeenCalledOnce();
|
||||
expect(onUpdate).toHaveBeenCalledWith(fallbackReply);
|
||||
expect(onUpdate).toHaveBeenCalledWith(
|
||||
'你:和我一起走下去吧。\nLan:好,我答应你。',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects custom world output when the model does not generate enough NPCs and scenes', async () => {
|
||||
|
||||
@@ -35,45 +35,34 @@ import {
|
||||
import {
|
||||
AIResponse,
|
||||
Character,
|
||||
CharacterChatTurn,
|
||||
CustomWorldCreatorIntent,
|
||||
CustomWorldGenerationMode,
|
||||
CustomWorldProfile,
|
||||
Encounter,
|
||||
SceneEncounterResult,
|
||||
SceneHostileNpc,
|
||||
SceneNpc,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
ThemePack,
|
||||
WorldStoryGraph,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
buildOfflineCharacterPanelChatReply as buildOfflineCharacterPanelChatReplyFromFallback,
|
||||
buildOfflineCharacterPanelChatSuggestions as buildOfflineCharacterPanelChatSuggestionsFromFallback,
|
||||
buildOfflineCharacterPanelChatSummary as buildOfflineCharacterPanelChatSummaryFromFallback,
|
||||
buildOfflineNpcChatDialogue as buildOfflineNpcChatDialogueFromFallback,
|
||||
buildOfflineNpcRecruitDialogue as buildOfflineNpcRecruitDialogueFromFallback,
|
||||
} from './aiFallbacks';
|
||||
import type {
|
||||
CustomWorldSceneImageRequest,
|
||||
CustomWorldSceneImageResult,
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
TextStreamOptions,
|
||||
} from './aiTypes';
|
||||
import { fetchWithApiAuth } from './apiClient';
|
||||
import {
|
||||
buildCharacterPanelChatPrompt,
|
||||
buildCharacterPanelChatSuggestionPrompt,
|
||||
buildCharacterPanelChatSummaryPrompt,
|
||||
CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
|
||||
CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT,
|
||||
CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
|
||||
CharacterChatPromptContext,
|
||||
CharacterChatTargetStatus,
|
||||
} from './characterChatPrompt';
|
||||
generateCharacterPanelChatSuggestions as generateCharacterPanelChatSuggestionsFromServer,
|
||||
generateCharacterPanelChatSummary as generateCharacterPanelChatSummaryFromServer,
|
||||
generateInitialStory as generateInitialStoryFromServer,
|
||||
generateNextStep as generateNextStepFromServer,
|
||||
streamCharacterPanelChatReply as streamCharacterPanelChatReplyFromServer,
|
||||
streamNpcChatDialogue as streamNpcChatDialogueFromServer,
|
||||
streamNpcRecruitDialogue as streamNpcRecruitDialogueFromServer,
|
||||
} from './aiService';
|
||||
import { fetchWithApiAuth } from './apiClient';
|
||||
import {
|
||||
buildCustomWorldRawProfileFromFramework,
|
||||
type CustomWorldGenerationFramework,
|
||||
@@ -105,20 +94,8 @@ import {
|
||||
requestPlainTextCompletion as requestPlainTextCompletionFromClient,
|
||||
streamPlainTextCompletion as streamPlainTextCompletionFromClient,
|
||||
} from './llmClient';
|
||||
import {
|
||||
parseJsonResponseText as parseJsonResponseTextFromParser,
|
||||
parseLineListContent as parseLineListContentFromParser,
|
||||
} from './llmParsers';
|
||||
import { hasMixedNarrativeLanguage } from './narrativeLanguage';
|
||||
import {
|
||||
buildNpcRecruitDialoguePrompt,
|
||||
buildStrictNpcChatDialoguePrompt,
|
||||
buildUserPrompt,
|
||||
describeWorld,
|
||||
NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
|
||||
NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
|
||||
SYSTEM_PROMPT,
|
||||
} from './prompt';
|
||||
import { parseJsonResponseText as parseJsonResponseTextFromParser } from './llmParsers';
|
||||
import type { CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
@@ -1388,23 +1365,6 @@ function cloneStoryOption(option: StoryOption): StoryOption {
|
||||
};
|
||||
}
|
||||
|
||||
function buildCharacterChatPromptContext(
|
||||
context: StoryGenerationContext,
|
||||
): CharacterChatPromptContext {
|
||||
return {
|
||||
playerHp: context.playerHp,
|
||||
playerMaxHp: context.playerMaxHp,
|
||||
playerMana: context.playerMana,
|
||||
playerMaxMana: context.playerMaxMana,
|
||||
inBattle: context.inBattle,
|
||||
playerFacing: context.playerFacing,
|
||||
playerAnimation: context.playerAnimation,
|
||||
sceneName: context.sceneName ?? null,
|
||||
sceneDescription: context.sceneDescription ?? null,
|
||||
customWorldProfile: context.customWorldProfile ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOptionsFromProvidedOptions(
|
||||
items: RawOptionItem[],
|
||||
availableOptions: StoryOption[],
|
||||
@@ -1505,357 +1465,9 @@ function getFallbackOptions(
|
||||
);
|
||||
}
|
||||
|
||||
function buildOfflineResponse(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
choice?: string,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): AIResponse {
|
||||
const scene = getScenePresetById(world, context.sceneId);
|
||||
const fallbackEncounter = context.pendingSceneEncounter
|
||||
? normalizeEncounterResult(
|
||||
scene?.npcs[0]
|
||||
? { kind: 'npc', npcId: scene.npcs[0].id }
|
||||
: { kind: 'none' },
|
||||
world,
|
||||
context,
|
||||
)
|
||||
: undefined;
|
||||
const resolution = buildEncounterDrivenResolution(
|
||||
world,
|
||||
monsters,
|
||||
context,
|
||||
fallbackEncounter,
|
||||
);
|
||||
const constrainedOptions =
|
||||
requestOptions.availableOptions?.map(cloneStoryOption) ??
|
||||
requestOptions.optionCatalog?.map(cloneStoryOption);
|
||||
const options =
|
||||
constrainedOptions ??
|
||||
getFallbackOptions(world, character, resolution.monsters, {
|
||||
...context,
|
||||
inBattle: resolution.inBattle,
|
||||
});
|
||||
const primaryMonster =
|
||||
resolution.monsters.find((monster) => monster.hp > 0) ??
|
||||
resolution.monsters[0];
|
||||
const encounterName = context.encounterName || '前方的人影';
|
||||
export const generateInitialStoryStrict = generateInitialStoryFromServer;
|
||||
|
||||
if (!resolution.inBattle || !primaryMonster) {
|
||||
return {
|
||||
storyText: constrainedOptions
|
||||
? choice
|
||||
? `${encounterName}的态度与周围气氛都出现了新的变化,你需要立刻判断接下来如何应对。`
|
||||
: `${context.sceneName || describeWorld(world)}的气氛仍在缓慢推进,眼前的${encounterName}正等待你的下一步反应。`
|
||||
: choice
|
||||
? `主角暂时脱离了正面厮杀,四周重新安静下来,${context.sceneName || describeWorld(world)}的前路正等着继续探索。`
|
||||
: `主角踏入${describeWorld(world)}世界的${context.sceneName || '前方区域'},眼前暂时没有新的敌对角色逼近。`,
|
||||
options,
|
||||
encounter: resolution.encounter,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
storyText: choice
|
||||
? `主角刚做出新的动作,前方的${primaryMonster.name}${primaryMonster.action},局势仍在持续绷紧。`
|
||||
: `主角刚踏入战场,前方的${primaryMonster.name}${primaryMonster.action},战斗压力已经逼到眼前。`,
|
||||
options,
|
||||
encounter: resolution.encounter,
|
||||
};
|
||||
}
|
||||
|
||||
function buildStoryLanguageRepairPrompt(response: AIResponse) {
|
||||
return [
|
||||
'请把下面 JSON 中的 storyText 与 options[].actionText 修复为自然中文。',
|
||||
'只改写叙事和选项文案,不要改变 encounter、options 数量、顺序或 functionId。',
|
||||
'如果英文只是角色名或地点名,可以保留名字本体;其余英文句子、英文解释和中英混杂表达都必须改成中文。',
|
||||
JSON.stringify(
|
||||
{
|
||||
storyText: response.storyText,
|
||||
encounter: response.encounter ?? null,
|
||||
options: response.options.map((option) => ({
|
||||
functionId: option.functionId,
|
||||
actionText: option.actionText,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
function needsStoryLanguageRepair(response: AIResponse) {
|
||||
return hasMixedNarrativeLanguage(response.storyText);
|
||||
}
|
||||
|
||||
function buildStoryLanguageFallbackText(
|
||||
context: StoryGenerationContext,
|
||||
inBattle: boolean,
|
||||
) {
|
||||
if (inBattle) {
|
||||
return '敌意仍压在眼前,战斗局势还没有真正松开。';
|
||||
}
|
||||
|
||||
if (context.encounterName) {
|
||||
return `${context.encounterName}的态度与周围气氛都出现了新的变化,你需要立刻判断接下来如何应对。`;
|
||||
}
|
||||
|
||||
return `${context.sceneName || '眼前区域'}里的气氛又有了新的变化,你需要继续判断下一步。`;
|
||||
}
|
||||
|
||||
function finalizeStoryNarrativeLanguage(
|
||||
response: AIResponse,
|
||||
context: StoryGenerationContext,
|
||||
inBattle: boolean,
|
||||
): AIResponse {
|
||||
if (!needsStoryLanguageRepair(response)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return {
|
||||
...response,
|
||||
storyText: buildStoryLanguageFallbackText(context, inBattle),
|
||||
};
|
||||
}
|
||||
|
||||
async function repairStoryNarrativeLanguage(
|
||||
response: AIResponse,
|
||||
worldType: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
requestOptions: StoryRequestOptions,
|
||||
) {
|
||||
const responseBattleState = buildEncounterDrivenResolution(
|
||||
worldType,
|
||||
monsters,
|
||||
context,
|
||||
response.encounter,
|
||||
).inBattle;
|
||||
|
||||
if (!needsStoryLanguageRepair(response)) {
|
||||
return finalizeStoryNarrativeLanguage(
|
||||
response,
|
||||
context,
|
||||
responseBattleState,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const repairedContent = await requestChatMessageContent(
|
||||
STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT,
|
||||
buildStoryLanguageRepairPrompt(response),
|
||||
{
|
||||
debugLabel: 'story-language-repair',
|
||||
},
|
||||
);
|
||||
const repairedResponse = normalizeResponse(
|
||||
parseJsonResponseTextFromParser(repairedContent),
|
||||
worldType,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
const repairedBattleState = buildEncounterDrivenResolution(
|
||||
worldType,
|
||||
monsters,
|
||||
context,
|
||||
repairedResponse.encounter,
|
||||
).inBattle;
|
||||
return finalizeStoryNarrativeLanguage(
|
||||
repairedResponse,
|
||||
context,
|
||||
repairedBattleState,
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Failed to repair mixed-language story response:', error);
|
||||
return finalizeStoryNarrativeLanguage(
|
||||
response,
|
||||
context,
|
||||
responseBattleState,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeResponse(
|
||||
raw: unknown,
|
||||
worldType: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): AIResponse {
|
||||
const parsedEncounter = normalizeEncounterResult(
|
||||
(raw as Record<string, unknown> | null)?.encounter,
|
||||
worldType,
|
||||
context,
|
||||
);
|
||||
const resolution = buildEncounterDrivenResolution(
|
||||
worldType,
|
||||
monsters,
|
||||
context,
|
||||
parsedEncounter,
|
||||
);
|
||||
const responseContext = {
|
||||
...context,
|
||||
inBattle: resolution.inBattle,
|
||||
};
|
||||
const fallbackOptions =
|
||||
requestOptions.availableOptions?.map(cloneStoryOption) ??
|
||||
requestOptions.optionCatalog?.map(cloneStoryOption) ??
|
||||
getFallbackOptions(
|
||||
worldType,
|
||||
character,
|
||||
resolution.monsters,
|
||||
responseContext,
|
||||
);
|
||||
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return {
|
||||
storyText: responseContext.inBattle
|
||||
? '前方敌意仍在持续逼近,局势只允许继续交锋或抽身脱离。'
|
||||
: '周围暂时平静下来,你可以继续探索或前往别处。',
|
||||
options: fallbackOptions,
|
||||
encounter: resolution.encounter,
|
||||
};
|
||||
}
|
||||
|
||||
const data = raw as Record<string, unknown>;
|
||||
const rawOptions = Array.isArray(data.options) ? data.options : [];
|
||||
const optionItems = rawOptions
|
||||
.map((option) => {
|
||||
if (!option || typeof option !== 'object') return null;
|
||||
const item = option as Record<string, unknown>;
|
||||
const functionId =
|
||||
typeof item.functionId === 'string' ? item.functionId.trim() : '';
|
||||
if (!functionId) return null;
|
||||
return {
|
||||
functionId,
|
||||
actionText:
|
||||
typeof item.actionText === 'string'
|
||||
? item.actionText.trim()
|
||||
: undefined,
|
||||
} satisfies RawOptionItem;
|
||||
})
|
||||
.filter(Boolean) as RawOptionItem[];
|
||||
|
||||
const options = requestOptions.availableOptions
|
||||
? resolveOptionsFromProvidedOptions(
|
||||
optionItems,
|
||||
requestOptions.availableOptions,
|
||||
)
|
||||
: requestOptions.optionCatalog
|
||||
? resolveOptionsFromOptionCatalog(
|
||||
optionItems,
|
||||
requestOptions.optionCatalog,
|
||||
)
|
||||
: resolveOptionsFromFunctionIds(
|
||||
optionItems,
|
||||
worldType,
|
||||
character,
|
||||
resolution.monsters,
|
||||
responseContext,
|
||||
);
|
||||
|
||||
return {
|
||||
storyText:
|
||||
typeof data.storyText === 'string' && data.storyText.trim()
|
||||
? data.storyText.trim()
|
||||
: responseContext.inBattle
|
||||
? '敌人仍在前方压迫而来,战斗还没有结束。'
|
||||
: '前路重新安静下来,可以继续决定接下来的探索方向。',
|
||||
options: options.length > 0 ? options : fallbackOptions,
|
||||
encounter: resolution.encounter,
|
||||
};
|
||||
}
|
||||
|
||||
async function requestCompletion(
|
||||
userPrompt: string,
|
||||
worldType: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): Promise<AIResponse> {
|
||||
const content = await requestChatMessageContent(SYSTEM_PROMPT, userPrompt, {
|
||||
debugLabel: 'story-completion',
|
||||
});
|
||||
|
||||
const response = normalizeResponse(
|
||||
parseJsonResponseTextFromParser(content),
|
||||
worldType,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
|
||||
return repairStoryNarrativeLanguage(
|
||||
response,
|
||||
worldType,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateInitialStoryStrict(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): Promise<AIResponse> {
|
||||
return requestCompletion(
|
||||
buildUserPrompt(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
[],
|
||||
context,
|
||||
undefined,
|
||||
requestOptions.availableOptions,
|
||||
requestOptions.optionCatalog,
|
||||
),
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateNextStepStrict(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
choice: string,
|
||||
context: StoryGenerationContext,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): Promise<AIResponse> {
|
||||
return requestCompletion(
|
||||
buildUserPrompt(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
choice,
|
||||
requestOptions.availableOptions,
|
||||
requestOptions.optionCatalog,
|
||||
),
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
export const generateNextStepStrict = generateNextStepFromServer;
|
||||
|
||||
export async function generateCustomWorldSceneImage({
|
||||
profile,
|
||||
@@ -2218,297 +1830,19 @@ export async function generateCustomWorldProfile(
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamCharacterPanelChatReply(
|
||||
world: WorldType,
|
||||
playerCharacter: Character,
|
||||
targetCharacter: Character,
|
||||
storyHistory: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
conversationHistory: CharacterChatTurn[],
|
||||
conversationSummary: string,
|
||||
playerMessage: string,
|
||||
targetStatus: CharacterChatTargetStatus,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const userPrompt = buildCharacterPanelChatPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context: buildCharacterChatPromptContext(context),
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
});
|
||||
export const streamCharacterPanelChatReply =
|
||||
streamCharacterPanelChatReplyFromServer;
|
||||
|
||||
try {
|
||||
const reply = await streamPlainTextCompletionFromClient(
|
||||
CHARACTER_PANEL_CHAT_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
options,
|
||||
);
|
||||
return (
|
||||
reply.trim() ||
|
||||
buildOfflineCharacterPanelChatReplyFromFallback(
|
||||
targetCharacter,
|
||||
playerMessage,
|
||||
conversationSummary,
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
if (isLlmConnectivityErrorFromClient(error)) {
|
||||
const fallbackText = buildOfflineCharacterPanelChatReplyFromFallback(
|
||||
targetCharacter,
|
||||
playerMessage,
|
||||
conversationSummary,
|
||||
);
|
||||
options.onUpdate?.(fallbackText);
|
||||
return fallbackText;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export const generateCharacterPanelChatSuggestions =
|
||||
generateCharacterPanelChatSuggestionsFromServer;
|
||||
|
||||
export async function generateCharacterPanelChatSuggestions(
|
||||
world: WorldType,
|
||||
playerCharacter: Character,
|
||||
targetCharacter: Character,
|
||||
storyHistory: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
conversationHistory: CharacterChatTurn[],
|
||||
conversationSummary: string,
|
||||
targetStatus: CharacterChatTargetStatus,
|
||||
) {
|
||||
const fallbackSuggestions =
|
||||
buildOfflineCharacterPanelChatSuggestionsFromFallback(targetCharacter);
|
||||
const userPrompt = buildCharacterPanelChatSuggestionPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context: buildCharacterChatPromptContext(context),
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
});
|
||||
export const generateCharacterPanelChatSummary =
|
||||
generateCharacterPanelChatSummaryFromServer;
|
||||
|
||||
try {
|
||||
const text = await requestPlainTextCompletionFromClient(
|
||||
CHARACTER_PANEL_CHAT_SUGGESTION_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
);
|
||||
const parsedSuggestions = parseLineListContentFromParser(text, 3);
|
||||
if (parsedSuggestions.length === 0) {
|
||||
return fallbackSuggestions;
|
||||
}
|
||||
return [...parsedSuggestions, ...fallbackSuggestions].slice(0, 3);
|
||||
} catch (error) {
|
||||
if (isLlmConnectivityErrorFromClient(error)) {
|
||||
return fallbackSuggestions;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export const generateInitialStory = generateInitialStoryFromServer;
|
||||
|
||||
export async function generateCharacterPanelChatSummary(
|
||||
world: WorldType,
|
||||
playerCharacter: Character,
|
||||
targetCharacter: Character,
|
||||
storyHistory: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
conversationHistory: CharacterChatTurn[],
|
||||
previousSummary: string,
|
||||
targetStatus: CharacterChatTargetStatus,
|
||||
) {
|
||||
const fallbackSummary = buildOfflineCharacterPanelChatSummaryFromFallback(
|
||||
targetCharacter,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
);
|
||||
const userPrompt = buildCharacterPanelChatSummaryPrompt({
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context: buildCharacterChatPromptContext(context),
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
});
|
||||
export const generateNextStep = generateNextStepFromServer;
|
||||
|
||||
try {
|
||||
const text = await requestPlainTextCompletionFromClient(
|
||||
CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
);
|
||||
return text.trim() || fallbackSummary;
|
||||
} catch (error) {
|
||||
if (isLlmConnectivityErrorFromClient(error)) {
|
||||
return fallbackSummary;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export const streamNpcChatDialogue = streamNpcChatDialogueFromServer;
|
||||
|
||||
export async function generateInitialStory(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
context: StoryGenerationContext,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): Promise<AIResponse> {
|
||||
try {
|
||||
return await requestCompletion(
|
||||
buildUserPrompt(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
[],
|
||||
context,
|
||||
undefined,
|
||||
requestOptions.availableOptions,
|
||||
requestOptions.optionCatalog,
|
||||
),
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isLlmConnectivityErrorFromClient(error)) {
|
||||
return buildOfflineResponse(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
undefined,
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateNextStep(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
choice: string,
|
||||
context: StoryGenerationContext,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): Promise<AIResponse> {
|
||||
try {
|
||||
return await requestCompletion(
|
||||
buildUserPrompt(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
choice,
|
||||
requestOptions.availableOptions,
|
||||
requestOptions.optionCatalog,
|
||||
),
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isLlmConnectivityErrorFromClient(error)) {
|
||||
return buildOfflineResponse(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
choice,
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamNpcChatDialogue(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
topic: string,
|
||||
resultSummary: string,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const userPrompt = buildStrictNpcChatDialoguePrompt(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
topic,
|
||||
resultSummary,
|
||||
);
|
||||
|
||||
try {
|
||||
return await streamPlainTextCompletionFromClient(
|
||||
NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
options,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isLlmConnectivityErrorFromClient(error)) {
|
||||
const fallbackText = buildOfflineNpcChatDialogueFromFallback(
|
||||
encounter,
|
||||
topic,
|
||||
);
|
||||
options.onUpdate?.(fallbackText);
|
||||
return fallbackText;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamNpcRecruitDialogue(
|
||||
world: WorldType,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
monsters: SceneHostileNpc[],
|
||||
history: StoryMoment[],
|
||||
context: StoryGenerationContext,
|
||||
invitationText: string,
|
||||
recruitSummary: string,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
const userPrompt = buildNpcRecruitDialoguePrompt(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
invitationText,
|
||||
recruitSummary,
|
||||
);
|
||||
|
||||
try {
|
||||
return await streamPlainTextCompletionFromClient(
|
||||
NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT,
|
||||
userPrompt,
|
||||
options,
|
||||
);
|
||||
} catch (error) {
|
||||
if (isLlmConnectivityErrorFromClient(error)) {
|
||||
const fallbackText =
|
||||
buildOfflineNpcRecruitDialogueFromFallback(encounter);
|
||||
options.onUpdate?.(fallbackText);
|
||||
return fallbackText;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export const streamNpcRecruitDialogue = streamNpcRecruitDialogueFromServer;
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
NpcRecruitDialogueRequest,
|
||||
PlainTextResponse,
|
||||
} from '../../packages/shared/src/contracts/rpgRuntimeChat';
|
||||
import type { RuntimeStoryAiRequest } from '../../packages/shared/src/contracts/rpgRuntimeStoryState';
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
GenerateCustomWorldProfileInput,
|
||||
@@ -32,21 +33,13 @@ import type {
|
||||
TextStreamOptions,
|
||||
} from './aiTypes';
|
||||
import { fetchWithApiAuth, requestJson } from './apiClient';
|
||||
import { type CharacterChatTargetStatus } from './characterChatPrompt';
|
||||
import { type CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
|
||||
import { parseLineListContent } from './llmParsers';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
|
||||
type LegacyAiModule = typeof import('./ai');
|
||||
|
||||
let legacyAiModulePromise: Promise<LegacyAiModule> | null = null;
|
||||
|
||||
async function loadLegacyAiModule() {
|
||||
if (!legacyAiModulePromise) {
|
||||
legacyAiModulePromise = import('./ai');
|
||||
}
|
||||
|
||||
return legacyAiModulePromise;
|
||||
function getRuntimeSessionIdFromContext(context: StoryGenerationContext) {
|
||||
return context.runtimeSessionId?.trim() || undefined;
|
||||
}
|
||||
|
||||
async function requestPlainText(
|
||||
@@ -169,29 +162,27 @@ export async function generateInitialStory(
|
||||
context: StoryGenerationContext,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): Promise<AIResponse> {
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateInitialStory(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
const sessionId = getRuntimeSessionIdFromContext(context);
|
||||
const payload: RuntimeStoryAiRequest | Record<string, unknown> = sessionId
|
||||
? {
|
||||
sessionId,
|
||||
clientVersion: context.runtimeActionVersion,
|
||||
requestOptions,
|
||||
}
|
||||
: {
|
||||
worldType: world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
};
|
||||
|
||||
return requestJson<AIResponse>(
|
||||
`${RUNTIME_API_BASE}/story/initial`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
worldType: world,
|
||||
character,
|
||||
monsters,
|
||||
context,
|
||||
requestOptions,
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'剧情开局生成失败',
|
||||
);
|
||||
@@ -206,25 +197,18 @@ export async function generateNextStep(
|
||||
context: StoryGenerationContext,
|
||||
requestOptions: StoryRequestOptions = {},
|
||||
): Promise<AIResponse> {
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateNextStep(
|
||||
world,
|
||||
character,
|
||||
monsters,
|
||||
history,
|
||||
choice,
|
||||
context,
|
||||
requestOptions,
|
||||
);
|
||||
}
|
||||
|
||||
return requestJson<AIResponse>(
|
||||
`${RUNTIME_API_BASE}/story/continue`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
const sessionId = getRuntimeSessionIdFromContext(context);
|
||||
const payload: RuntimeStoryAiRequest | Record<string, unknown> = sessionId
|
||||
? {
|
||||
sessionId,
|
||||
clientVersion: context.runtimeActionVersion,
|
||||
choice,
|
||||
lastFunctionId: context.lastFunctionId,
|
||||
observeSignsRequested: context.observeSignsRequested,
|
||||
recentActionResult: context.recentActionResult,
|
||||
requestOptions,
|
||||
}
|
||||
: {
|
||||
worldType: world,
|
||||
character,
|
||||
monsters,
|
||||
@@ -232,7 +216,14 @@ export async function generateNextStep(
|
||||
choice,
|
||||
context,
|
||||
requestOptions,
|
||||
}),
|
||||
};
|
||||
|
||||
return requestJson<AIResponse>(
|
||||
`${RUNTIME_API_BASE}/story/continue`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'剧情续写失败',
|
||||
);
|
||||
@@ -248,30 +239,25 @@ export async function generateCharacterPanelChatSuggestions(
|
||||
conversationSummary: string,
|
||||
targetStatus: CharacterChatTargetStatus,
|
||||
) {
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateCharacterPanelChatSuggestions(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
worldType: world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
} satisfies CharacterChatSuggestionsRequest;
|
||||
const sessionId = getRuntimeSessionIdFromContext(context);
|
||||
const payload = sessionId
|
||||
? ({
|
||||
sessionId,
|
||||
targetCharacter,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
} satisfies CharacterChatSuggestionsRequest)
|
||||
: ({
|
||||
worldType: world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
targetStatus,
|
||||
} satisfies CharacterChatSuggestionsRequest);
|
||||
|
||||
const { text } = await requestPlainText(
|
||||
`${RUNTIME_API_BASE}/chat/character/suggestions`,
|
||||
@@ -291,30 +277,25 @@ export async function generateCharacterPanelChatSummary(
|
||||
previousSummary: string,
|
||||
targetStatus: CharacterChatTargetStatus,
|
||||
) {
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.generateCharacterPanelChatSummary(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
worldType: world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
} satisfies CharacterChatSummaryRequest;
|
||||
const sessionId = getRuntimeSessionIdFromContext(context);
|
||||
const payload = sessionId
|
||||
? ({
|
||||
sessionId,
|
||||
targetCharacter,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
} satisfies CharacterChatSummaryRequest)
|
||||
: ({
|
||||
worldType: world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
previousSummary,
|
||||
targetStatus,
|
||||
} satisfies CharacterChatSummaryRequest);
|
||||
|
||||
const { text } = await requestPlainText(
|
||||
`${RUNTIME_API_BASE}/chat/character/summary`,
|
||||
@@ -336,33 +317,27 @@ export async function streamCharacterPanelChatReply(
|
||||
targetStatus: CharacterChatTargetStatus,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.streamCharacterPanelChatReply(
|
||||
world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
worldType: world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
} satisfies CharacterChatReplyRequest;
|
||||
const sessionId = getRuntimeSessionIdFromContext(context);
|
||||
const payload = sessionId
|
||||
? ({
|
||||
sessionId,
|
||||
targetCharacter,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
} satisfies CharacterChatReplyRequest)
|
||||
: ({
|
||||
worldType: world,
|
||||
playerCharacter,
|
||||
targetCharacter,
|
||||
storyHistory,
|
||||
context,
|
||||
conversationHistory,
|
||||
conversationSummary,
|
||||
playerMessage,
|
||||
targetStatus,
|
||||
} satisfies CharacterChatReplyRequest);
|
||||
|
||||
const reply = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/character/reply/stream`,
|
||||
@@ -383,31 +358,24 @@ export async function streamNpcChatDialogue(
|
||||
resultSummary: string,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.streamNpcChatDialogue(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
topic,
|
||||
resultSummary,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
worldType: world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
topic,
|
||||
resultSummary,
|
||||
} satisfies NpcChatDialogueRequest;
|
||||
const sessionId = getRuntimeSessionIdFromContext(context);
|
||||
const payload = sessionId
|
||||
? ({
|
||||
sessionId,
|
||||
encounter,
|
||||
topic,
|
||||
resultSummary,
|
||||
} satisfies NpcChatDialogueRequest)
|
||||
: ({
|
||||
worldType: world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
topic,
|
||||
resultSummary,
|
||||
} satisfies NpcChatDialogueRequest);
|
||||
|
||||
const dialogue = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/npc/dialogue/stream`,
|
||||
@@ -442,14 +410,9 @@ export async function streamNpcChatTurn(
|
||||
npcInitiatesConversation?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const payload = {
|
||||
worldType: world,
|
||||
character,
|
||||
player: character,
|
||||
const sessionId = getRuntimeSessionIdFromContext(context);
|
||||
const commonChatPayload = {
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
conversationHistory: conversationHistory ?? [],
|
||||
dialogue: conversationHistory ?? [],
|
||||
playerMessage,
|
||||
@@ -457,7 +420,7 @@ export async function streamNpcChatTurn(
|
||||
npcInitiatesConversation: options.npcInitiatesConversation ?? false,
|
||||
questOfferContext: options.questOfferContext
|
||||
? {
|
||||
state: options.questOfferContext.state,
|
||||
state: sessionId ? {} : options.questOfferContext.state,
|
||||
encounter,
|
||||
turnCount: options.questOfferContext.turnCount,
|
||||
}
|
||||
@@ -471,7 +434,21 @@ export async function streamNpcChatTurn(
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
} satisfies NpcChatTurnRequest;
|
||||
};
|
||||
const payload = sessionId
|
||||
? ({
|
||||
sessionId,
|
||||
...commonChatPayload,
|
||||
} satisfies NpcChatTurnRequest)
|
||||
: ({
|
||||
worldType: world,
|
||||
character,
|
||||
player: character,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
...commonChatPayload,
|
||||
} satisfies NpcChatTurnRequest);
|
||||
|
||||
const response = await fetchWithApiAuth(
|
||||
`${RUNTIME_API_BASE}/chat/npc/turn/stream`,
|
||||
@@ -570,31 +547,24 @@ export async function streamNpcRecruitDialogue(
|
||||
recruitSummary: string,
|
||||
options: TextStreamOptions = {},
|
||||
) {
|
||||
if (typeof window === 'undefined') {
|
||||
const aiClient = await loadLegacyAiModule();
|
||||
return aiClient.streamNpcRecruitDialogue(
|
||||
world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
invitationText,
|
||||
recruitSummary,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
worldType: world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
invitationText,
|
||||
recruitSummary,
|
||||
} satisfies NpcRecruitDialogueRequest;
|
||||
const sessionId = getRuntimeSessionIdFromContext(context);
|
||||
const payload = sessionId
|
||||
? ({
|
||||
sessionId,
|
||||
encounter,
|
||||
invitationText,
|
||||
recruitSummary,
|
||||
} satisfies NpcRecruitDialogueRequest)
|
||||
: ({
|
||||
worldType: world,
|
||||
character,
|
||||
encounter,
|
||||
monsters,
|
||||
history,
|
||||
context,
|
||||
invitationText,
|
||||
recruitSummary,
|
||||
} satisfies NpcRecruitDialogueRequest);
|
||||
|
||||
const dialogue = await requestPlainTextStream(
|
||||
`${RUNTIME_API_BASE}/chat/npc/recruit/stream`,
|
||||
|
||||
@@ -88,6 +88,8 @@ export interface CustomWorldSceneImageResult {
|
||||
}
|
||||
|
||||
export interface StoryGenerationContext {
|
||||
runtimeSessionId?: string | null;
|
||||
runtimeActionVersion?: number;
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
playerMana: number;
|
||||
|
||||
44
src/services/big-fish-gallery/bigFishGalleryClient.test.ts
Normal file
44
src/services/big-fish-gallery/bigFishGalleryClient.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { ApiClientError } from '../apiClient';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../apiClient', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../apiClient')>();
|
||||
return {
|
||||
...actual,
|
||||
requestJson: requestJsonMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { listBigFishGallery } from './bigFishGalleryClient';
|
||||
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
|
||||
test('listBigFishGallery returns empty items when public gallery is not ready', async () => {
|
||||
requestJsonMock.mockRejectedValueOnce(
|
||||
new ApiClientError({
|
||||
message: '读取大鱼吃小鱼广场失败',
|
||||
status: 400,
|
||||
code: 'HTTP_400',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(listBigFishGallery()).resolves.toEqual({ items: [] });
|
||||
});
|
||||
|
||||
test('listBigFishGallery keeps non-gallery-read errors visible', async () => {
|
||||
const error = new ApiClientError({
|
||||
message: '服务暂不可用',
|
||||
status: 503,
|
||||
code: 'HTTP_503',
|
||||
});
|
||||
requestJsonMock.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(listBigFishGallery()).rejects.toBe(error);
|
||||
});
|
||||
@@ -26,7 +26,10 @@ export async function listBigFishGallery() {
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiClientError && error.status === 404) {
|
||||
if (
|
||||
error instanceof ApiClientError &&
|
||||
(error.status === 400 || error.status === 404)
|
||||
) {
|
||||
return { items: [] };
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../prompts/characterChatPrompts';
|
||||
@@ -61,6 +61,7 @@ test('custom world agent ui state reads from query first and persists to session
|
||||
activeSessionId: 'session-1',
|
||||
activeOperationId: 'operation-1',
|
||||
customWorldGenerationSource: 'agent-draft-foundation',
|
||||
ownerUserId: 'user-1',
|
||||
});
|
||||
|
||||
currentUrl = '/play';
|
||||
@@ -75,6 +76,48 @@ test('custom world agent ui state reads from query first and persists to session
|
||||
expect(readCustomWorldAgentUiState(env)).toEqual({});
|
||||
});
|
||||
|
||||
test('custom world agent ui state hydrates query owner from matching stored session only', () => {
|
||||
const sessionStorage = createMemoryStorage();
|
||||
sessionStorage.setItem(
|
||||
'genarrative.custom-world-agent-ui.v1',
|
||||
JSON.stringify({
|
||||
activeSessionId: 'session-1',
|
||||
ownerUserId: 'user-1',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
readCustomWorldAgentUiState({
|
||||
location: {
|
||||
pathname: '/',
|
||||
search: '?customWorldSessionId=session-1',
|
||||
},
|
||||
history: null,
|
||||
sessionStorage,
|
||||
}),
|
||||
).toEqual({
|
||||
activeSessionId: 'session-1',
|
||||
activeOperationId: null,
|
||||
customWorldGenerationSource: null,
|
||||
ownerUserId: 'user-1',
|
||||
});
|
||||
|
||||
expect(
|
||||
readCustomWorldAgentUiState({
|
||||
location: {
|
||||
pathname: '/',
|
||||
search: '?customWorldSessionId=session-2',
|
||||
},
|
||||
history: null,
|
||||
sessionStorage,
|
||||
}),
|
||||
).toEqual({
|
||||
activeSessionId: 'session-2',
|
||||
activeOperationId: null,
|
||||
customWorldGenerationSource: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('custom world agent ui state only auto restores stored pointers on RPG creation paths', () => {
|
||||
const sessionStorage = createMemoryStorage();
|
||||
sessionStorage.setItem(
|
||||
|
||||
@@ -115,18 +115,38 @@ export function readCustomWorldAgentUiState(
|
||||
params.get(CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY),
|
||||
),
|
||||
};
|
||||
const storedValue = resolved.sessionStorage?.getItem(
|
||||
CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY,
|
||||
);
|
||||
|
||||
if (
|
||||
stateFromQuery.activeSessionId ||
|
||||
stateFromQuery.activeOperationId ||
|
||||
stateFromQuery.customWorldGenerationSource
|
||||
) {
|
||||
return stateFromQuery;
|
||||
let storedOwnerUserId: string | null = null;
|
||||
if (storedValue) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedValue) as CustomWorldAgentUiState;
|
||||
const storedSessionId = normalizeValue(parsed.activeSessionId);
|
||||
if (
|
||||
storedSessionId &&
|
||||
storedSessionId === stateFromQuery.activeSessionId
|
||||
) {
|
||||
storedOwnerUserId = normalizeValue(parsed.ownerUserId);
|
||||
}
|
||||
} catch {
|
||||
resolved.sessionStorage?.removeItem(CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...stateFromQuery,
|
||||
// URL 只承载可分享的 session 指针,用户归属仍仅来自本机 sessionStorage。
|
||||
...(storedOwnerUserId ? { ownerUserId: storedOwnerUserId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const storedValue = resolved.sessionStorage?.getItem(
|
||||
CUSTOM_WORLD_AGENT_UI_STATE_STORAGE_KEY,
|
||||
);
|
||||
if (!storedValue) {
|
||||
return {};
|
||||
}
|
||||
|
||||
66
src/services/miniGameDraftGenerationProgress.test.ts
Normal file
66
src/services/miniGameDraftGenerationProgress.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
buildMiniGameDraftGenerationProgress,
|
||||
type MiniGameDraftGenerationState,
|
||||
} from './miniGameDraftGenerationProgress';
|
||||
|
||||
describe('miniGameDraftGenerationProgress', () => {
|
||||
test('big fish draft generation exposes multiple draft steps', () => {
|
||||
const state: MiniGameDraftGenerationState = {
|
||||
kind: 'big-fish',
|
||||
phase: 'big-fish-draft',
|
||||
startedAtMs: 1000,
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 1500);
|
||||
|
||||
expect(progress).not.toBeNull();
|
||||
expect(progress?.steps).toHaveLength(3);
|
||||
expect(progress?.steps.map((step) => step.id)).toEqual([
|
||||
'big-fish-draft',
|
||||
'big-fish-levels',
|
||||
'big-fish-runtime',
|
||||
]);
|
||||
expect(progress?.steps[0]?.label).toBe('整理玩法骨架');
|
||||
});
|
||||
|
||||
test('big fish generation progresses to level and runtime phases over time', () => {
|
||||
const state: MiniGameDraftGenerationState = {
|
||||
kind: 'big-fish',
|
||||
phase: 'big-fish-draft',
|
||||
startedAtMs: 1000,
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const levelProgress = buildMiniGameDraftGenerationProgress(state, 3200);
|
||||
const runtimeProgress = buildMiniGameDraftGenerationProgress(state, 6200);
|
||||
|
||||
expect(levelProgress?.phaseId).toBe('big-fish-levels');
|
||||
expect(levelProgress?.phaseLabel).toBe('编译等级蓝图');
|
||||
expect(runtimeProgress?.phaseId).toBe('big-fish-runtime');
|
||||
expect(runtimeProgress?.phaseLabel).toBe('校准场地与参数');
|
||||
});
|
||||
|
||||
test('big fish ready copy directs user to continue generating assets on result page', () => {
|
||||
const state: MiniGameDraftGenerationState = {
|
||||
kind: 'big-fish',
|
||||
phase: 'ready',
|
||||
startedAtMs: 1000,
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 2000);
|
||||
|
||||
expect(progress?.phaseDetail).toBe(
|
||||
'玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,11 +11,11 @@ export type MiniGameDraftGenerationKind = 'puzzle' | 'big-fish';
|
||||
export type MiniGameDraftGenerationPhase =
|
||||
| 'idle'
|
||||
| 'compile'
|
||||
| 'big-fish-draft'
|
||||
| 'big-fish-levels'
|
||||
| 'big-fish-runtime'
|
||||
| 'puzzle-images'
|
||||
| 'puzzle-select-image'
|
||||
| 'big-fish-main-images'
|
||||
| 'big-fish-motions'
|
||||
| 'big-fish-background'
|
||||
| 'ready'
|
||||
| 'failed';
|
||||
|
||||
@@ -64,29 +64,23 @@ const PUZZLE_STEPS = [
|
||||
|
||||
const BIG_FISH_STEPS = [
|
||||
{
|
||||
id: 'compile',
|
||||
label: '编译玩法草稿',
|
||||
detail: '生成关卡角色描述、生态背景与运行参数。',
|
||||
id: 'big-fish-draft',
|
||||
label: '整理玩法骨架',
|
||||
detail: '收拢玩法承诺、成长阶梯与风险节奏。',
|
||||
weight: 30,
|
||||
},
|
||||
{
|
||||
id: 'big-fish-levels',
|
||||
label: '编译等级蓝图',
|
||||
detail: '生成每级角色描述、形象描述与动作描述。',
|
||||
weight: 45,
|
||||
},
|
||||
{
|
||||
id: 'big-fish-runtime',
|
||||
label: '校准场地与参数',
|
||||
detail: '整理背景蓝图与运行参数,准备结果页。',
|
||||
weight: 25,
|
||||
},
|
||||
{
|
||||
id: 'big-fish-main-images',
|
||||
label: '生成角色图片',
|
||||
detail: '为每个成长阶段生成主形象。',
|
||||
weight: 30,
|
||||
},
|
||||
{
|
||||
id: 'big-fish-motions',
|
||||
label: '生成动作素材',
|
||||
detail: '补齐漂浮与游动动作素材。',
|
||||
weight: 30,
|
||||
},
|
||||
{
|
||||
id: 'big-fish-background',
|
||||
label: '生成场地背景',
|
||||
detail: '生成玩法场地背景图。',
|
||||
weight: 15,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
|
||||
function clampProgress(value: number) {
|
||||
@@ -138,7 +132,7 @@ export function createMiniGameDraftGenerationState(
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
kind,
|
||||
phase: 'compile',
|
||||
phase: kind === 'big-fish' ? 'big-fish-draft' : 'compile',
|
||||
startedAtMs: Date.now(),
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
@@ -146,6 +140,16 @@ export function createMiniGameDraftGenerationState(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBigFishPhaseByElapsedMs(elapsedMs: number): MiniGameDraftGenerationPhase {
|
||||
if (elapsedMs >= 4_500) {
|
||||
return 'big-fish-runtime';
|
||||
}
|
||||
if (elapsedMs >= 1_800) {
|
||||
return 'big-fish-levels';
|
||||
}
|
||||
return 'big-fish-draft';
|
||||
}
|
||||
|
||||
export function buildMiniGameDraftGenerationProgress(
|
||||
state: MiniGameDraftGenerationState | null,
|
||||
nowMs = Date.now(),
|
||||
@@ -154,46 +158,66 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
return null;
|
||||
}
|
||||
|
||||
const steps = getStepDefinitions(state.kind);
|
||||
const activeStepIndex = getActiveStepIndex(steps, state.phase);
|
||||
const elapsedMs = Math.max(0, nowMs - state.startedAtMs);
|
||||
const normalizedState =
|
||||
state.kind === 'big-fish' &&
|
||||
state.phase !== 'failed' &&
|
||||
state.phase !== 'ready'
|
||||
? {
|
||||
...state,
|
||||
phase: resolveBigFishPhaseByElapsedMs(elapsedMs),
|
||||
}
|
||||
: state;
|
||||
|
||||
const steps = getStepDefinitions(normalizedState.kind);
|
||||
const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase);
|
||||
const completedWeight = steps
|
||||
.slice(0, state.phase === 'ready' ? steps.length : activeStepIndex)
|
||||
.slice(0, normalizedState.phase === 'ready' ? steps.length : activeStepIndex)
|
||||
.reduce((sum, step) => sum + step.weight, 0);
|
||||
const activeStep = steps[activeStepIndex] ?? steps[0];
|
||||
const assetRatio =
|
||||
state.totalAssetCount > 0
|
||||
? Math.min(1, state.completedAssetCount / state.totalAssetCount)
|
||||
: state.phase === 'ready'
|
||||
normalizedState.totalAssetCount > 0
|
||||
? Math.min(1, normalizedState.completedAssetCount / normalizedState.totalAssetCount)
|
||||
: normalizedState.phase === 'ready'
|
||||
? 1
|
||||
: 0;
|
||||
: normalizedState.kind === 'big-fish'
|
||||
? 0.55
|
||||
: 0;
|
||||
const overallProgress =
|
||||
state.phase === 'failed'
|
||||
normalizedState.phase === 'failed'
|
||||
? Math.max(1, completedWeight)
|
||||
: state.phase === 'ready'
|
||||
: normalizedState.phase === 'ready'
|
||||
? 100
|
||||
: completedWeight + activeStep.weight * assetRatio;
|
||||
|
||||
return {
|
||||
phaseId: state.phase,
|
||||
phaseId: normalizedState.phase,
|
||||
phaseLabel:
|
||||
state.phase === 'failed'
|
||||
normalizedState.phase === 'failed'
|
||||
? '生成失败'
|
||||
: state.phase === 'ready'
|
||||
: normalizedState.phase === 'ready'
|
||||
? '生成完成'
|
||||
: activeStep.label,
|
||||
phaseDetail:
|
||||
state.error ??
|
||||
(state.phase === 'ready'
|
||||
? '完整草稿与资产已准备完成。'
|
||||
normalizedState.error ??
|
||||
(normalizedState.phase === 'ready'
|
||||
? normalizedState.kind === 'big-fish'
|
||||
? '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。'
|
||||
: '完整草稿与资产已准备完成。'
|
||||
: activeStep.detail),
|
||||
batchLabel: activeStep.label,
|
||||
overallProgress: clampProgress(overallProgress),
|
||||
completedWeight: clampProgress(overallProgress),
|
||||
totalWeight: 100,
|
||||
elapsedMs: Math.max(0, nowMs - state.startedAtMs),
|
||||
estimatedRemainingMs: state.phase === 'ready' ? 0 : null,
|
||||
elapsedMs,
|
||||
estimatedRemainingMs:
|
||||
normalizedState.phase === 'ready'
|
||||
? 0
|
||||
: normalizedState.kind === 'big-fish'
|
||||
? Math.max(0, 7_000 - elapsedMs)
|
||||
: null,
|
||||
activeStepIndex,
|
||||
steps: buildMiniGameProgressSteps(steps, activeStepIndex, state),
|
||||
steps: buildMiniGameProgressSteps(steps, activeStepIndex, normalizedState),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AnimationState, type Character, WorldType } from '../types';
|
||||
import { buildExpandedCustomWorldProfile } from './customWorldBuilder';
|
||||
import { buildUserPrompt } from './prompt';
|
||||
import { buildSceneNarrativeDirective } from './storyEngine/sceneNarrativeDirector';
|
||||
import { buildEncounterVisibilitySlice } from './storyEngine/visibilityEngine';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'hero',
|
||||
name: '林澈',
|
||||
title: '行旅客',
|
||||
description: '一名谨慎前行的旅人。',
|
||||
backstory: '从北境一路追着旧案残线而来。',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 9,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: '谨慎、克制、先看局势。',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildUserPrompt', () => {
|
||||
it('does not leak full custom-world backstory on first contact', () => {
|
||||
const profile = buildExpandedCustomWorldProfile(
|
||||
{
|
||||
id: 'prompt-world',
|
||||
name: '裂潮边城',
|
||||
subtitle: '旧案回响',
|
||||
summary: '一座在裂潮与旧案回响之间摇摇欲坠的边城。',
|
||||
tone: '紧张、克制、暗流涌动',
|
||||
playerGoal: '查清边城裂潮背后的封桥旧令',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: ['巡边司', '潮商会'],
|
||||
coreConflicts: ['裂潮再度逼近边路', '封桥旧案再被人提起'],
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-1',
|
||||
name: '沈砺',
|
||||
title: '灰炬向导',
|
||||
role: '向导',
|
||||
description: '熟悉裂潮边路的灰炬向导。',
|
||||
backstory: '曾在旧撤离线里失去一整支同行队。',
|
||||
personality: '谨慎寡言,先看风向再开口。',
|
||||
motivation: '想查清旧撤离线为何再次失控。',
|
||||
combatStyle: '短弓牵制后贴近补刀。',
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: ['旧撤离线', '名单'],
|
||||
tags: ['裂潮', '向导'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '他只说自己熟悉边路。',
|
||||
chapters: [
|
||||
{ id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他总盯着风向和路标。', content: '他先把注意力放在边路是否还能走。', contextSnippet: '他总先谈路和风。' },
|
||||
{ id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '旧撤离线像在他身上留下了什么。', content: '那次撤离失控后,他一直没再离开这片边路。', contextSnippet: '撤离旧事还没过去。' },
|
||||
{ id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '名单上的名字让他一直不肯放手。', content: '他一直在比对那份回响名单与旧撤离线。', contextSnippet: '他一直在查名单。' },
|
||||
{ id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '他知道裂潮里有人故意改过路标。', content: '他怀疑有人借裂潮重启旧案。', contextSnippet: '有人在利用裂潮。' },
|
||||
],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [],
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'story-1',
|
||||
name: '梁砺',
|
||||
title: '断桥巡守',
|
||||
role: '巡守',
|
||||
description: '守着断桥与旧哨火的巡守。',
|
||||
backstory: '旧案爆发时,他是最后一个封桥的人。',
|
||||
personality: '警觉直接,不喜欢绕弯。',
|
||||
motivation: '不想让旧案再次借裂潮翻上来。',
|
||||
combatStyle: '长兵先压,再卡住路口。',
|
||||
initialAffinity: 6,
|
||||
relationshipHooks: ['封桥', '旧哨火'],
|
||||
tags: ['巡守', '断桥'],
|
||||
backstoryReveal: {
|
||||
publicSummary: '他只承认自己还在守桥。',
|
||||
chapters: [
|
||||
{ id: 'surface', title: '表层来意', affinityRequired: 0, teaser: '他只说桥还不能放开。', content: '他总先谈桥和路。', contextSnippet: '桥还不能放开。' },
|
||||
{ id: 'scar', title: '旧事裂痕', affinityRequired: 30, teaser: '封桥那夜明显留下了后劲。', content: '他始终忘不了那夜桥上的名单。', contextSnippet: '封桥旧事还压着他。' },
|
||||
{ id: 'bind', title: '隐藏执念', affinityRequired: 60, teaser: '他像还在替谁守着一个错误。', content: '他一直替旧命令继续守线。', contextSnippet: '他还在守旧命令。' },
|
||||
{ id: 'truth', title: '最终底牌', affinityRequired: 90, teaser: '有人逼他在封桥和救人之间选过。', content: '那夜真正下封桥令的人还没有露面。', contextSnippet: '封桥命令另有来头。' },
|
||||
],
|
||||
},
|
||||
skills: [],
|
||||
initialItems: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '旧哨铜钥',
|
||||
category: '稀有品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: '钥身磨得发亮。',
|
||||
tags: ['旧哨火'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
items: [],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '断桥旧哨',
|
||||
description: '旧哨火和断桥一起守着边城北口。',
|
||||
sceneNpcIds: ['story-1'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
'玩家想要一个裂潮边城与旧案回响交织的世界。',
|
||||
);
|
||||
|
||||
const npc = profile.storyNpcs[0]!;
|
||||
const visibilitySlice = buildEncounterVisibilitySlice({
|
||||
narrativeProfile: npc.narrativeProfile,
|
||||
backstoryReveal: npc.backstoryReveal,
|
||||
disclosureStage: 'guarded',
|
||||
isFirstMeaningfulContact: true,
|
||||
seenBackstoryChapterIds: [],
|
||||
storyEngineMemory: {
|
||||
discoveredFactIds: [],
|
||||
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
},
|
||||
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
|
||||
});
|
||||
const prompt = buildUserPrompt(
|
||||
WorldType.CUSTOM,
|
||||
createCharacter(),
|
||||
[],
|
||||
[],
|
||||
{
|
||||
playerHp: 30,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 10,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
sceneId: 'custom-scene-landmark-1',
|
||||
sceneName: '断桥旧哨',
|
||||
sceneDescription: '风里尽是旧哨火和潮声。',
|
||||
encounterKind: 'npc',
|
||||
encounterId: npc.id,
|
||||
encounterName: npc.name,
|
||||
encounterDescription: npc.description,
|
||||
encounterContext: npc.role,
|
||||
encounterAffinity: npc.initialAffinity,
|
||||
encounterAffinityText: '对你仍有戒备,也在观察你会怎么试探。',
|
||||
encounterDisclosureStage: 'guarded',
|
||||
encounterWarmthStage: 'distant',
|
||||
encounterAnswerMode: 'situational_only',
|
||||
encounterAllowedTopics: ['眼前危险', '现场判断', '模糊钩子'],
|
||||
encounterBlockedTopics: ['完整来历', '真正目标', '旧事全貌'],
|
||||
isFirstMeaningfulContact: true,
|
||||
firstContactRelationStance: 'guarded',
|
||||
recentSharedEvent: '你们还只是刚刚真正把话对上。',
|
||||
talkPriority: '优先谈桥口、来意和眼前压力,不要直接摊开旧案全貌。',
|
||||
encounterCustomProfile: npc,
|
||||
encounterNarrativeProfile: npc.narrativeProfile,
|
||||
visibilitySlice,
|
||||
sceneNarrativeDirective: buildSceneNarrativeDirective({
|
||||
sceneId: 'custom-scene-landmark-1',
|
||||
sceneName: '断桥旧哨',
|
||||
encounterId: npc.id,
|
||||
encounterName: npc.name,
|
||||
recentActions: [],
|
||||
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
|
||||
visibilitySlice,
|
||||
encounterNarrativeProfile: npc.narrativeProfile,
|
||||
disclosureStage: 'guarded',
|
||||
isFirstMeaningfulContact: true,
|
||||
affinity: npc.initialAffinity,
|
||||
}),
|
||||
activeThreadIds: npc.narrativeProfile?.relatedThreadIds ?? [],
|
||||
customWorldProfile: profile,
|
||||
},
|
||||
);
|
||||
|
||||
expect(prompt).toContain(npc.narrativeProfile?.publicMask ?? '');
|
||||
expect(prompt).toContain(npc.narrativeProfile?.immediatePressure ?? '');
|
||||
expect(prompt).not.toContain(npc.backstory);
|
||||
expect(prompt).not.toContain(npc.backstoryReveal.chapters[3]!.content);
|
||||
expect(prompt).not.toContain(npc.initialItems[0]!.name);
|
||||
});
|
||||
|
||||
it('requires an empty encounter payload during non-pending follow-up reasoning such as post-battle continuation', () => {
|
||||
const prompt = buildUserPrompt(
|
||||
WorldType.WUXIA,
|
||||
createCharacter(),
|
||||
[],
|
||||
[
|
||||
{
|
||||
text: '挥刀抢攻',
|
||||
options: [],
|
||||
historyRole: 'action',
|
||||
},
|
||||
{
|
||||
text: '山道客已经败下阵来。',
|
||||
options: [],
|
||||
historyRole: 'result',
|
||||
},
|
||||
],
|
||||
{
|
||||
playerHp: 26,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 8,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
sceneId: 'forest_road',
|
||||
sceneName: '山道',
|
||||
sceneDescription: '风从林梢压下来,地上还留着刚才交手的痕迹。',
|
||||
pendingSceneEncounter: false,
|
||||
},
|
||||
'挥刀抢攻',
|
||||
);
|
||||
|
||||
expect(prompt).toContain('encounter 必须为 null');
|
||||
expect(prompt).toContain('战斗结束后的续写');
|
||||
});
|
||||
|
||||
it('does not feed mixed-language history and directive snippets back into story prompts', () => {
|
||||
const prompt = buildUserPrompt(
|
||||
WorldType.WUXIA,
|
||||
createCharacter(),
|
||||
[],
|
||||
[
|
||||
{
|
||||
text: 'Move forward carefully.',
|
||||
options: [],
|
||||
historyRole: 'action',
|
||||
},
|
||||
{
|
||||
text: 'The wind is cold. 你听见山道尽头有脚步声。',
|
||||
options: [],
|
||||
historyRole: 'result',
|
||||
},
|
||||
],
|
||||
{
|
||||
playerHp: 26,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 8,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
playerAnimation: AnimationState.ATTACK,
|
||||
skillCooldowns: {},
|
||||
sceneId: 'forest_road',
|
||||
sceneName: '山道',
|
||||
sceneDescription: '风从林梢压下来。',
|
||||
pendingSceneEncounter: false,
|
||||
conversationSituation: 'post_battle_breath',
|
||||
conversationPressure: 'medium',
|
||||
recentSharedEvent:
|
||||
'A fight just ended. Both sides are still catching their breath.',
|
||||
talkPriority:
|
||||
'Focus on the most useful judgment, danger, and next step.',
|
||||
partyRelationshipNotes:
|
||||
'Lan is becoming more open in private conversation.',
|
||||
recentChronicleSummary: 'Baseline summary from previous run.',
|
||||
sceneNarrativeDirective: {
|
||||
primaryPressure: 'Danger is still active near the camp.',
|
||||
activeThreadIds: ['thread-old-case'],
|
||||
foregroundActorIds: [],
|
||||
foregroundCarrierIds: [],
|
||||
revealBudget: 'low',
|
||||
emotionalCadence: 'tense',
|
||||
},
|
||||
},
|
||||
'Move forward carefully.',
|
||||
);
|
||||
|
||||
expect(prompt).not.toContain('A fight just ended');
|
||||
expect(prompt).not.toContain('Focus on the most useful judgment');
|
||||
expect(prompt).not.toContain('Baseline summary');
|
||||
expect(prompt).not.toContain('Move forward carefully');
|
||||
expect(prompt).not.toContain('thread-old-case');
|
||||
expect(prompt).not.toContain('Danger is still active');
|
||||
expect(prompt).toContain('战后缓气');
|
||||
expect(prompt).toContain('紧绷');
|
||||
expect(prompt).toContain('这一轮的局势已经出现了新的变化。');
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../prompts/storyPromptBuilders';
|
||||
@@ -5,7 +5,9 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p
|
||||
import {
|
||||
advanceLocalPuzzleLevel,
|
||||
dragLocalPuzzlePiece,
|
||||
isLocalPuzzleRun,
|
||||
startLocalPuzzleRun,
|
||||
submitLocalPuzzleLeaderboard,
|
||||
swapLocalPuzzlePieces,
|
||||
} from './puzzleLocalRuntime';
|
||||
|
||||
@@ -314,4 +316,25 @@ describe('puzzleLocalRuntime', () => {
|
||||
expect(hasAnyOriginalNeighborPair(secondRun.currentLevel?.board.pieces ?? [])).toBe(false);
|
||||
expect(hasAnyOriginalNeighborPair(thirdRun.currentLevel?.board.pieces ?? [])).toBe(false);
|
||||
});
|
||||
|
||||
test('本地 run 通关后用本地排行榜兜底,不再依赖后端 runId', () => {
|
||||
const clearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork));
|
||||
|
||||
expect(isLocalPuzzleRun(clearedRun)).toBe(true);
|
||||
expect(clearedRun.currentLevel?.leaderboardEntries).toEqual([]);
|
||||
|
||||
const leaderboardRun = submitLocalPuzzleLeaderboard(clearedRun, '本地玩家');
|
||||
|
||||
expect(leaderboardRun.leaderboardEntries).toEqual([
|
||||
{
|
||||
rank: 1,
|
||||
nickname: '本地玩家',
|
||||
elapsedMs: clearedRun.currentLevel?.elapsedMs ?? 0,
|
||||
isCurrentPlayer: true,
|
||||
},
|
||||
]);
|
||||
expect(leaderboardRun.currentLevel?.leaderboardEntries).toEqual(
|
||||
leaderboardRun.leaderboardEntries,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
PuzzleBoardSnapshot,
|
||||
PuzzleCellPosition,
|
||||
PuzzleGridSize,
|
||||
PuzzleLeaderboardEntry,
|
||||
PuzzleMergedGroupState,
|
||||
PuzzlePieceState,
|
||||
PuzzleRunSnapshot,
|
||||
@@ -10,6 +11,8 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
|
||||
const LOCAL_PUZZLE_RUN_ID_PREFIX = 'local-puzzle-run-';
|
||||
|
||||
function resolvePuzzleGridSize(clearedLevelCount: number): PuzzleGridSize {
|
||||
return clearedLevelCount >= 3 ? 4 : 3;
|
||||
}
|
||||
@@ -399,6 +402,20 @@ function buildLocalNextProfileId(entryProfileId: string, levelIndex: number) {
|
||||
return `${entryProfileId}::local-level-${levelIndex}`;
|
||||
}
|
||||
|
||||
function buildLocalLeaderboardEntries(
|
||||
nickname: string,
|
||||
elapsedMs: number,
|
||||
): PuzzleLeaderboardEntry[] {
|
||||
return [
|
||||
{
|
||||
rank: 1,
|
||||
nickname,
|
||||
elapsedMs,
|
||||
isCurrentPlayer: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 第一版单机兜底没有后端推荐池时,才沿用当前作品图片生成可推进的临时关卡名。
|
||||
function buildLocalLevelName(previousLevelName: string, levelIndex: number) {
|
||||
return `${previousLevelName.replace(/ · 第 \d+ 关$/, '')} · 第 ${levelIndex} 关`;
|
||||
@@ -447,7 +464,7 @@ function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
|
||||
export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot {
|
||||
const gridSize = resolvePuzzleGridSize(0);
|
||||
const runId = `local-puzzle-run-${item.profileId}-${Date.now()}`;
|
||||
const runId = `${LOCAL_PUZZLE_RUN_ID_PREFIX}${item.profileId}-${Date.now()}`;
|
||||
const startedAtMs = Date.now();
|
||||
return {
|
||||
runId,
|
||||
@@ -658,3 +675,45 @@ export function dragLocalPuzzlePiece(
|
||||
export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
|
||||
return buildFallbackLocalLevel(run);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前拼图运行态是否为前端本地兜底 run。
|
||||
* 这类 run 没有后端持久化记录,不能再调用依赖真实 runId 的排行榜接口。
|
||||
*/
|
||||
export function isLocalPuzzleRun(run: PuzzleRunSnapshot | null | undefined) {
|
||||
return Boolean(run?.runId?.startsWith(LOCAL_PUZZLE_RUN_ID_PREFIX));
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地拼图 run 的排行榜兜底。
|
||||
* 当前版本只写入当前玩家成绩,避免结算阶段继续请求后端导致“run 不存在”。
|
||||
*/
|
||||
export function submitLocalPuzzleLeaderboard(
|
||||
run: PuzzleRunSnapshot,
|
||||
nickname: string,
|
||||
): PuzzleRunSnapshot {
|
||||
const currentLevel = run.currentLevel;
|
||||
if (
|
||||
!currentLevel ||
|
||||
currentLevel.status !== 'cleared' ||
|
||||
currentLevel.elapsedMs === null
|
||||
) {
|
||||
return run;
|
||||
}
|
||||
if ((currentLevel.leaderboardEntries ?? []).length > 0) {
|
||||
return run;
|
||||
}
|
||||
|
||||
const leaderboardEntries = buildLocalLeaderboardEntries(
|
||||
nickname,
|
||||
currentLevel.elapsedMs,
|
||||
);
|
||||
return {
|
||||
...run,
|
||||
leaderboardEntries,
|
||||
currentLevel: {
|
||||
...currentLevel,
|
||||
leaderboardEntries,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export {
|
||||
executeRpgCreationAction,
|
||||
getRpgCreationCardDetail,
|
||||
getRpgCreationOperation,
|
||||
getRpgCreationResultView,
|
||||
getRpgCreationSession,
|
||||
rpgCreationAgentClient,
|
||||
sendRpgCreationMessage,
|
||||
@@ -23,10 +24,7 @@ export type {
|
||||
GenerateCustomWorldProfileInput,
|
||||
GenerateCustomWorldProfileOptions,
|
||||
} from './rpgCreationGenerationClient';
|
||||
export {
|
||||
generateCustomWorldProfile as generateLegacyCustomWorldProfile,
|
||||
generateRpgWorldProfile,
|
||||
} from './rpgCreationGenerationClient';
|
||||
export { generateRpgWorldProfile } from './rpgCreationGenerationClient';
|
||||
export {
|
||||
deleteRpgWorldProfile,
|
||||
getRpgWorldGalleryDetail,
|
||||
@@ -39,6 +37,7 @@ export {
|
||||
} from './rpgCreationLibraryClient';
|
||||
export {
|
||||
buildRpgCreationPreviewFromResultPreview,
|
||||
buildRpgCreationPreviewFromResultView,
|
||||
buildRpgCreationPreviewFromSession,
|
||||
rpgCreationPreviewAdapter,
|
||||
} from './rpgCreationPreviewAdapter';
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
CreateRpgAgentSessionRequest,
|
||||
CreateRpgAgentSessionResponse,
|
||||
GetRpgAgentCardDetailResponse,
|
||||
RpgCreationResultView,
|
||||
RpgAgentDraftCardDetail,
|
||||
RpgAgentOperationRecord,
|
||||
RpgAgentSessionSnapshot,
|
||||
@@ -46,6 +47,16 @@ export async function getRpgCreationSession(sessionId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function getRpgCreationResultView(sessionId: string) {
|
||||
return requestRpgCreationRuntimeJson<RpgCreationResultView>(
|
||||
`${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/result-view`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取世界结果页视图失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendRpgCreationMessage(
|
||||
sessionId: string,
|
||||
payload: SendRpgAgentMessageRequest,
|
||||
@@ -133,6 +144,7 @@ export async function getRpgCreationCardDetail(
|
||||
export const rpgCreationAgentClient = {
|
||||
createSession: createRpgCreationSession,
|
||||
getSession: getRpgCreationSession,
|
||||
getResultView: getRpgCreationResultView,
|
||||
sendMessage: sendRpgCreationMessage,
|
||||
streamMessage: streamRpgCreationMessage,
|
||||
executeAction: executeRpgCreationAction,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import { generateRpgWorldProfile } from './rpgCreationGenerationClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
vi.mock('../ai', () => ({
|
||||
generateCustomWorldProfile: vi.fn(() => {
|
||||
throw new Error('不应再调用前端 legacy AI 生成链');
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('rpgCreationGenerationClient node runtime', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({
|
||||
id: 'server-rs-profile-1',
|
||||
name: '服务端世界',
|
||||
subtitle: '副标题',
|
||||
summary: '概述',
|
||||
tone: '基调',
|
||||
playerGoal: '目标',
|
||||
settingText: '设定',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses server-rs profile generation instead of importing legacy ai', async () => {
|
||||
const profile = await generateRpgWorldProfile('一个在 Node 测试中生成的世界');
|
||||
|
||||
expect(profile.id).toBe('server-rs-profile-1');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world/profile',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
settingText: '一个在 Node 测试中生成的世界',
|
||||
}),
|
||||
}),
|
||||
'生成自定义世界失败',
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user