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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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');

View File

@@ -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';

View File

@@ -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: '送礼提升好感',
},
};

View File

@@ -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: '查看库存与价格',
},
};

View 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,
};

View File

@@ -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;
},
},
};

View File

@@ -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;
},
},
};

View File

@@ -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;
},
},
};

View File

@@ -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;
},
},
};

View File

@@ -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;
},
},
};

View File

@@ -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;
},
},
};

View File

@@ -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)
);
},
},
};

View File

@@ -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;
},
},
};

View File

@@ -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;
},
},
};

View File

@@ -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;
},
},
};

View File

@@ -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;
},
},
};

View File

@@ -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;
},
},
};

View File

@@ -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;
},
},
};

View File

@@ -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),
];

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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();
});
});

View File

@@ -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,

View File

@@ -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,

View 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',
},
}),
);
});
});

View File

@@ -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),

View File

@@ -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);
});
});

View File

@@ -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,
};
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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 () => {

View File

@@ -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');
});
});

View File

@@ -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),
};
}

View File

@@ -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(

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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,
};
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -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,

View File

@@ -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',
}),
{

View File

@@ -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>;

View File

@@ -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],

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,
);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
});

View File

@@ -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
>;

View File

@@ -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)) {

View File

@@ -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),
}),
);
});

View File

@@ -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>;

View File

@@ -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');
}

View File

@@ -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

View File

@@ -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 () => {

View File

@@ -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;

View File

@@ -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`,

View File

@@ -88,6 +88,8 @@ export interface CustomWorldSceneImageResult {
}
export interface StoryGenerationContext {
runtimeSessionId?: string | null;
runtimeActionVersion?: number;
playerHp: number;
playerMaxHp: number;
playerMana: number;

View 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);
});

View File

@@ -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;

View File

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

View File

@@ -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(

View File

@@ -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 {};
}

View 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(
'玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。',
);
});
});

View File

@@ -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),
};
}

View File

@@ -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('这一轮的局势已经出现了新的变化。');
});
});

View File

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

View File

@@ -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,
);
});
});

View File

@@ -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,
},
};
}

View File

@@ -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';

View File

@@ -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,

View File

@@ -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