1
This commit is contained in:
@@ -1383,9 +1383,6 @@ export function AdventureEntityModal({
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] leading-5 text-zinc-500">
|
||||
{attribute.definition}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -331,7 +331,7 @@ export function CharacterAttributeGrid({
|
||||
boostedCombatStats,
|
||||
resourceLabels,
|
||||
)
|
||||
: slot.combatUseText,
|
||||
: '',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -364,9 +364,11 @@ export function CharacterAttributeGrid({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] leading-relaxed text-sky-200/85">
|
||||
{effectText}
|
||||
</div>
|
||||
{effectText ? (
|
||||
<div className="mt-2 text-[10px] leading-relaxed text-sky-200/85">
|
||||
{effectText}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -561,9 +561,6 @@ export function CharacterPanel({
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] leading-5 text-zinc-500">
|
||||
{attribute.definition}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -545,16 +545,6 @@ function buildLandmarkSearchText(
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function buildAttributeSlotSummary(
|
||||
slot: CustomWorldProfile['attributeSchema']['slots'][number],
|
||||
) {
|
||||
return compactTextList([
|
||||
slot.combatUseText,
|
||||
slot.socialUseText,
|
||||
slot.explorationUseText,
|
||||
]).join(' / ');
|
||||
}
|
||||
|
||||
export function CustomWorldEntityCatalog({
|
||||
profile,
|
||||
previewCharacters,
|
||||
@@ -984,11 +974,6 @@ export function CustomWorldEntityCatalog({
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
||||
角色维度
|
||||
</div>
|
||||
{profile.attributeSchema?.schemaName ? (
|
||||
<div className="text-xs leading-5 text-zinc-500">
|
||||
{profile.attributeSchema.schemaName}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3 xl:grid-cols-6">
|
||||
{attributeSlots.map((slot) => (
|
||||
@@ -999,9 +984,6 @@ export function CustomWorldEntityCatalog({
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{slot.name}
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-[11px] leading-5 text-zinc-400">
|
||||
{buildAttributeSlotSummary(slot) || slot.definition}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
@@ -198,7 +197,6 @@ function createProfile(): CustomWorldProfile {
|
||||
attributeSchema: {
|
||||
id: 'schema-1',
|
||||
worldId: 'world-1',
|
||||
schemaName: '潮雾六维',
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: 'WUXIA',
|
||||
@@ -211,62 +209,26 @@ function createProfile(): CustomWorldProfile {
|
||||
{
|
||||
slotId: 'axis_a',
|
||||
name: '骨势',
|
||||
definition: '扛住压力并正面推进的底子。',
|
||||
positiveSignals: ['硬顶'],
|
||||
negativeSignals: ['畏缩'],
|
||||
combatUseText: '正面承压与破阵。',
|
||||
socialUseText: '在谈判里稳住立场。',
|
||||
explorationUseText: '穿过危险地形。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_b',
|
||||
name: '身法',
|
||||
definition: '抢位、转场与把握节奏的能力。',
|
||||
positiveSignals: ['灵动'],
|
||||
negativeSignals: ['迟滞'],
|
||||
combatUseText: '移动换位。',
|
||||
socialUseText: '捕捉话锋。',
|
||||
explorationUseText: '快速穿行。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_c',
|
||||
name: '眼脉',
|
||||
definition: '看破破绽、拆解局势的能力。',
|
||||
positiveSignals: ['洞察'],
|
||||
negativeSignals: ['误判'],
|
||||
combatUseText: '识破招式。',
|
||||
socialUseText: '辨别谎言。',
|
||||
explorationUseText: '发现线索。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_d',
|
||||
name: '心焰',
|
||||
definition: '决断、压迫与坚持意志的能力。',
|
||||
positiveSignals: ['果断'],
|
||||
negativeSignals: ['犹疑'],
|
||||
combatUseText: '强行压制。',
|
||||
socialUseText: '立威推进。',
|
||||
explorationUseText: '面对险境不退。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_e',
|
||||
name: '尘缘',
|
||||
definition: '处理人情、承诺和关系牵引的能力。',
|
||||
positiveSignals: ['守信'],
|
||||
negativeSignals: ['冷漠'],
|
||||
combatUseText: '协作配合。',
|
||||
socialUseText: '建立信任。',
|
||||
explorationUseText: '借助人脉。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_f',
|
||||
name: '玄息',
|
||||
definition: '调息、稳态和久战的能力。',
|
||||
positiveSignals: ['沉稳'],
|
||||
negativeSignals: ['浮躁'],
|
||||
combatUseText: '续战恢复。',
|
||||
socialUseText: '保持耐心。',
|
||||
explorationUseText: '长线跋涉。',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -753,7 +715,7 @@ test('基本设定目标打开独立编辑面板', () => {
|
||||
expect(screen.queryByText('编辑世界信息')).toBeNull();
|
||||
});
|
||||
|
||||
test('世界信息面板可以编辑六个角色维度信息', async () => {
|
||||
test('基本设定面板只编辑六个角色维度名称', async () => {
|
||||
const user = userEvent.setup();
|
||||
const savedProfileRef: { current: CustomWorldProfile | null } = {
|
||||
current: null,
|
||||
@@ -762,7 +724,7 @@ test('世界信息面板可以编辑六个角色维度信息', async () => {
|
||||
render(
|
||||
<RpgCreationEntityEditorModal
|
||||
profile={createProfile()}
|
||||
target={{ kind: 'world' }}
|
||||
target={{ kind: 'foundation' }}
|
||||
onClose={() => {}}
|
||||
onProfileChange={(profile) => {
|
||||
savedProfileRef.current = profile;
|
||||
@@ -775,33 +737,15 @@ test('世界信息面板可以编辑六个角色维度信息', async () => {
|
||||
await user.clear(nameInputs[0]!);
|
||||
await user.type(nameInputs[0]!, '潮骨');
|
||||
|
||||
const definitionFields = screen.getAllByLabelText('定义');
|
||||
await user.clear(definitionFields[0]!);
|
||||
await user.type(definitionFields[0]!, '顶住潮压并正面推进的角色底色。');
|
||||
|
||||
const positiveSignalFields = screen.getAllByLabelText('正向信号');
|
||||
fireEvent.change(positiveSignalFields[0]!, {
|
||||
target: { value: '硬顶, 护阵' },
|
||||
});
|
||||
|
||||
const combatFields = screen.getAllByLabelText('战斗体现');
|
||||
await user.clear(combatFields[0]!);
|
||||
await user.type(combatFields[0]!, '正面压线与护住阵脚。');
|
||||
expect(screen.queryByLabelText('定义')).toBeNull();
|
||||
expect(screen.queryByLabelText('正向信号')).toBeNull();
|
||||
expect(screen.queryByLabelText('战斗体现')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /保存修改/u }));
|
||||
|
||||
expect(savedProfileRef.current?.attributeSchema.slots[0]?.name).toBe(
|
||||
'潮骨',
|
||||
);
|
||||
expect(savedProfileRef.current?.attributeSchema.slots[0]?.definition).toBe(
|
||||
'顶住潮压并正面推进的角色底色。',
|
||||
);
|
||||
expect(
|
||||
savedProfileRef.current?.attributeSchema.slots[0]?.positiveSignals,
|
||||
).toEqual(['硬顶', '护阵']);
|
||||
expect(savedProfileRef.current?.attributeSchema.slots[0]?.combatUseText).toBe(
|
||||
'正面压线与护住阵脚。',
|
||||
);
|
||||
});
|
||||
|
||||
test('可扮演角色列表使用缩略卡片并点击进入编辑', async () => {
|
||||
|
||||
@@ -55,8 +55,8 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
||||
expect(screen.getByText('角色 3')).toBeTruthy();
|
||||
expect(screen.getByText('地点 4')).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /角色扮演 RPG/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /大鱼吃小鱼/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /拼图玩法/u })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||
|
||||
rerender(
|
||||
<CustomWorldCreationHub
|
||||
|
||||
@@ -43,8 +43,8 @@ test('creation hub draft card renders compiled work summary fields', () => {
|
||||
expect(html).toContain('玩家是失职返乡的守灯人');
|
||||
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
|
||||
expect(html).toContain('角色扮演 RPG');
|
||||
expect(html).toContain('大鱼吃小鱼');
|
||||
expect(html).toContain('拼图玩法');
|
||||
expect(html).not.toContain('大鱼吃小鱼');
|
||||
});
|
||||
|
||||
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import {
|
||||
PLATFORM_CREATION_TYPES,
|
||||
getVisiblePlatformCreationTypes,
|
||||
type PlatformCreationTypeId,
|
||||
} from '../platform-entry/platformEntryCreationTypes';
|
||||
|
||||
@@ -16,6 +16,10 @@ export function CustomWorldCreationStartCard({
|
||||
error = null,
|
||||
onCreateType,
|
||||
}: CustomWorldCreationStartCardProps) {
|
||||
// 创作首页首屏卡带与创作类型弹层保持同一份展示口径,
|
||||
// 避免某个玩法只在其中一个入口被隐藏而出现状态漂移。
|
||||
const visibleCreationTypes = getVisiblePlatformCreationTypes();
|
||||
|
||||
return (
|
||||
// 移动端限制模块高度,模板入口改为横向滚动,避免挤占作品列表首屏空间。
|
||||
<div className="platform-surface platform-surface--hero relative max-h-[33svh] overflow-hidden px-3 py-3 sm:max-h-none sm:px-5 sm:py-5 xl:px-5 xl:py-4">
|
||||
@@ -34,7 +38,7 @@ export function CustomWorldCreationStartCard({
|
||||
</div>
|
||||
|
||||
<div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-5 xl:gap-2.5">
|
||||
{PLATFORM_CREATION_TYPES.map((item) => {
|
||||
{visibleCreationTypes.map((item) => {
|
||||
const disabled = item.locked || busy;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { PLATFORM_CREATION_TYPES } from './platformEntryCreationTypes';
|
||||
import { getVisiblePlatformCreationTypes } from './platformEntryCreationTypes';
|
||||
|
||||
export interface PlatformEntryCreationTypeModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -14,7 +14,7 @@ export interface PlatformEntryCreationTypeModalProps {
|
||||
}
|
||||
|
||||
function CreationTypeCard(props: {
|
||||
item: (typeof PLATFORM_CREATION_TYPES)[number];
|
||||
item: ReturnType<typeof getVisiblePlatformCreationTypes>[number];
|
||||
busy: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
@@ -81,9 +81,7 @@ export function PlatformEntryCreationTypeModal({
|
||||
|
||||
// 平台入口只渲染当前允许展示的创作类型;
|
||||
// 被隐藏的玩法仍保留既有实现与路由,不在这里删除能力本体。
|
||||
const visibleCreationTypes = PLATFORM_CREATION_TYPES.filter(
|
||||
(item) => !item.hidden,
|
||||
);
|
||||
const visibleCreationTypes = getVisiblePlatformCreationTypes();
|
||||
|
||||
return (
|
||||
<UnifiedModal
|
||||
|
||||
@@ -122,6 +122,7 @@ import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultA
|
||||
import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController';
|
||||
import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal';
|
||||
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
|
||||
import { isPlatformCreationTypeVisible } from './platformEntryCreationTypes';
|
||||
import {
|
||||
PlatformEntryHomeView,
|
||||
type PlatformHomeTab,
|
||||
@@ -473,6 +474,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
const [deletingCreationWorkId, setDeletingCreationWorkId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const isBigFishCreationVisible = isPlatformCreationTypeVisible('big-fish');
|
||||
const hadReadableProtectedDataRef = useRef(false);
|
||||
const hasInitialAgentSession = Boolean(
|
||||
readCustomWorldAgentUiState().activeSessionId &&
|
||||
@@ -660,7 +662,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
await Promise.allSettled([
|
||||
platformBootstrap.refreshPublishedGallery(),
|
||||
platformBootstrap.refreshCustomWorldWorks(),
|
||||
refreshBigFishGallery(),
|
||||
isBigFishCreationVisible
|
||||
? refreshBigFishGallery()
|
||||
: Promise.resolve([] as BigFishWorkSummary[]),
|
||||
refreshPuzzleGallery(),
|
||||
]);
|
||||
return latestSession;
|
||||
@@ -716,9 +720,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
}, [agentResultPreview]);
|
||||
|
||||
const featuredGalleryEntries = useMemo(() => {
|
||||
const bigFishPublicEntries = bigFishGalleryEntries.map(
|
||||
mapBigFishWorkToPlatformGalleryCard,
|
||||
);
|
||||
const bigFishPublicEntries = isBigFishCreationVisible
|
||||
? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard)
|
||||
: [];
|
||||
const puzzlePublicEntries = puzzleGalleryEntries.map(
|
||||
mapPuzzleWorkToPlatformGalleryCard,
|
||||
);
|
||||
@@ -727,6 +731,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
[...bigFishPublicEntries, ...puzzlePublicEntries],
|
||||
).slice(0, 6);
|
||||
}, [
|
||||
isBigFishCreationVisible,
|
||||
bigFishGalleryEntries,
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
puzzleGalleryEntries,
|
||||
@@ -736,11 +741,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
mergePlatformPublicGalleryEntries(
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
[
|
||||
...bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard),
|
||||
...(isBigFishCreationVisible
|
||||
? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard)
|
||||
: []),
|
||||
...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard),
|
||||
],
|
||||
),
|
||||
[
|
||||
isBigFishCreationVisible,
|
||||
bigFishGalleryEntries,
|
||||
platformBootstrap.publishedGalleryEntries,
|
||||
puzzleGalleryEntries,
|
||||
@@ -986,7 +994,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
const bigFishError = bigFishFlow.error;
|
||||
const setBigFishError = bigFishFlow.setError;
|
||||
const isBigFishBusy = bigFishFlow.isBusy;
|
||||
const setIsBigFishBusy = bigFishFlow.setIsBusy;
|
||||
const streamingBigFishReplyText = bigFishFlow.streamingReplyText;
|
||||
const isStreamingBigFishReply = bigFishFlow.isStreamingReply;
|
||||
|
||||
@@ -1892,10 +1899,17 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionStage === 'platform') {
|
||||
void refreshBigFishGallery();
|
||||
if (isBigFishCreationVisible) {
|
||||
void refreshBigFishGallery();
|
||||
}
|
||||
void refreshPuzzleGallery();
|
||||
}
|
||||
}, [refreshBigFishGallery, refreshPuzzleGallery, selectionStage]);
|
||||
}, [
|
||||
isBigFishCreationVisible,
|
||||
refreshBigFishGallery,
|
||||
refreshPuzzleGallery,
|
||||
selectionStage,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -1914,6 +1928,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isBigFishCreationVisible &&
|
||||
(platformBootstrap.platformTab === 'create' ||
|
||||
selectionStage === 'platform') &&
|
||||
platformBootstrap.canReadProtectedData
|
||||
@@ -1921,6 +1936,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
void refreshBigFishShelf();
|
||||
}
|
||||
}, [
|
||||
isBigFishCreationVisible,
|
||||
platformBootstrap.canReadProtectedData,
|
||||
platformBootstrap.platformTab,
|
||||
refreshBigFishShelf,
|
||||
@@ -1955,7 +1971,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
resolveRpgCreationErrorMessage(error, '读取创作作品列表失败。'),
|
||||
);
|
||||
});
|
||||
void refreshBigFishShelf();
|
||||
if (isBigFishCreationVisible) {
|
||||
void refreshBigFishShelf();
|
||||
}
|
||||
void refreshPuzzleShelf();
|
||||
}}
|
||||
createError={
|
||||
@@ -1991,20 +2009,32 @@ export function PlatformEntryFlowShellImpl({
|
||||
handleExperienceRpgWork(item);
|
||||
}}
|
||||
rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries}
|
||||
bigFishItems={bigFishWorks}
|
||||
onOpenBigFishDetail={(item) => {
|
||||
runProtectedAction(() => {
|
||||
void openBigFishDraft(item);
|
||||
});
|
||||
}}
|
||||
onExperienceBigFish={(item) => {
|
||||
runProtectedAction(() => {
|
||||
void startBigFishRunFromWork(item);
|
||||
});
|
||||
}}
|
||||
onDeleteBigFish={(item) => {
|
||||
handleDeleteBigFishWork(item);
|
||||
}}
|
||||
bigFishItems={isBigFishCreationVisible ? bigFishWorks : []}
|
||||
onOpenBigFishDetail={
|
||||
isBigFishCreationVisible
|
||||
? (item) => {
|
||||
runProtectedAction(() => {
|
||||
void openBigFishDraft(item);
|
||||
});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onExperienceBigFish={
|
||||
isBigFishCreationVisible
|
||||
? (item) => {
|
||||
runProtectedAction(() => {
|
||||
void startBigFishRunFromWork(item);
|
||||
});
|
||||
}
|
||||
: null
|
||||
}
|
||||
onDeleteBigFish={
|
||||
isBigFishCreationVisible
|
||||
? (item) => {
|
||||
handleDeleteBigFishWork(item);
|
||||
}
|
||||
: null
|
||||
}
|
||||
puzzleItems={puzzleWorks}
|
||||
onOpenPuzzleDetail={(item) => {
|
||||
runProtectedAction(() => {
|
||||
|
||||
@@ -14,6 +14,21 @@ export type PlatformCreationTypeCard = {
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 返回当前平台入口允许展示的创作类型。
|
||||
* 平台层的入口、首屏卡带与初始化请求都应基于这份结果统一判断。
|
||||
*/
|
||||
export function getVisiblePlatformCreationTypes() {
|
||||
return PLATFORM_CREATION_TYPES.filter((item) => !item.hidden);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某个创作类型当前是否仍暴露在平台入口中。
|
||||
*/
|
||||
export function isPlatformCreationTypeVisible(id: PlatformCreationTypeId) {
|
||||
return PLATFORM_CREATION_TYPES.some((item) => item.id === id && !item.hidden);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创作页与类型弹层共用同一份模板元数据,避免多入口文案和可用状态漂移。
|
||||
* `hidden` 只控制平台入口是否展示,不影响既有玩法链路和路由能力。
|
||||
|
||||
@@ -5007,83 +5007,19 @@ function WorldAttributeSchemaEditor({
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionPanel title="角色维度" subtitle={value.schemaName || '世界能力维度'}>
|
||||
<div className="space-y-3">
|
||||
<SectionPanel title="角色维度">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{value.slots.map((slot) => (
|
||||
<div
|
||||
key={slot.slotId}
|
||||
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-[10rem_minmax(0,1fr)]">
|
||||
<Field label="维度名称">
|
||||
<TextInput
|
||||
value={slot.name}
|
||||
onChange={(name) => updateSlot(slot.slotId, { name })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="定义">
|
||||
<TextArea
|
||||
value={slot.definition}
|
||||
onChange={(definition) =>
|
||||
updateSlot(slot.slotId, { definition })
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<Field label="正向信号">
|
||||
<TextArea
|
||||
value={commaText(slot.positiveSignals)}
|
||||
onChange={(text) =>
|
||||
updateSlot(slot.slotId, {
|
||||
positiveSignals: parseCommaText(text),
|
||||
})
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="负向信号">
|
||||
<TextArea
|
||||
value={commaText(slot.negativeSignals)}
|
||||
onChange={(text) =>
|
||||
updateSlot(slot.slotId, {
|
||||
negativeSignals: parseCommaText(text),
|
||||
})
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
<Field label="战斗体现">
|
||||
<TextArea
|
||||
value={slot.combatUseText}
|
||||
onChange={(combatUseText) =>
|
||||
updateSlot(slot.slotId, { combatUseText })
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="社交体现">
|
||||
<TextArea
|
||||
value={slot.socialUseText}
|
||||
onChange={(socialUseText) =>
|
||||
updateSlot(slot.slotId, { socialUseText })
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="探索体现">
|
||||
<TextArea
|
||||
value={slot.explorationUseText}
|
||||
onChange={(explorationUseText) =>
|
||||
updateSlot(slot.slotId, { explorationUseText })
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="维度名称">
|
||||
<TextInput
|
||||
value={slot.name}
|
||||
onChange={(name) => updateSlot(slot.slotId, { name })}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -133,62 +133,26 @@ test('custom world character selection stays stable when character ids are empty
|
||||
{
|
||||
slotId: 'axis_a',
|
||||
name: '潮骨',
|
||||
definition: '扛住潮压与正面冲击的底子。',
|
||||
positiveSignals: [],
|
||||
negativeSignals: [],
|
||||
combatUseText: '顶住正面浪涌。',
|
||||
socialUseText: '给人能扛事的可靠感。',
|
||||
explorationUseText: '在风浪里稳住自己。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_b',
|
||||
name: '浪步',
|
||||
definition: '顺潮借势、换位穿行的能力。',
|
||||
positiveSignals: [],
|
||||
negativeSignals: [],
|
||||
combatUseText: '借势切线。',
|
||||
socialUseText: '谈吐灵活。',
|
||||
explorationUseText: '穿越复杂地形。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_c',
|
||||
name: '舟识',
|
||||
definition: '辨流向、识潮眼的能力。',
|
||||
positiveSignals: [],
|
||||
negativeSignals: [],
|
||||
combatUseText: '抓住变化时机。',
|
||||
socialUseText: '看懂局势留白。',
|
||||
explorationUseText: '辨认水路与遗痕。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_d',
|
||||
name: '潮魄',
|
||||
definition: '在剧烈变化中仍敢推进的胆气。',
|
||||
positiveSignals: [],
|
||||
negativeSignals: [],
|
||||
combatUseText: '顶着压力推进。',
|
||||
socialUseText: '在冲突里压住场子。',
|
||||
explorationUseText: '面对异变继续前探。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_e',
|
||||
name: '契汐',
|
||||
definition: '与人和约定形成牵引的能力。',
|
||||
positiveSignals: [],
|
||||
negativeSignals: [],
|
||||
combatUseText: '借协同形成连锁。',
|
||||
socialUseText: '结盟、安抚与交换。',
|
||||
explorationUseText: '从旧约中打开局面。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_f',
|
||||
name: '回澜',
|
||||
definition: '在漫长消耗中回稳状态的能力。',
|
||||
positiveSignals: [],
|
||||
negativeSignals: [],
|
||||
combatUseText: '久战不乱。',
|
||||
socialUseText: '遇事沉静。',
|
||||
explorationUseText: '在恶劣天气里保有余力。',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -14,7 +14,6 @@ import { ApiClientError } from '../../services/apiClient';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
createBigFishCreationSession,
|
||||
executeBigFishCreationAction,
|
||||
getBigFishCreationSession,
|
||||
} from '../../services/big-fish-creation';
|
||||
import { listBigFishGallery } from '../../services/big-fish-gallery';
|
||||
@@ -1175,6 +1174,21 @@ test('create hub exposes direct template entry, keeps AIRP and visual novel lock
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('platform create hub does not prefetch hidden big fish platform data', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /角色扮演 RPG/u }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||
expect(listBigFishWorks).not.toHaveBeenCalled();
|
||||
expect(listBigFishGallery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('opening RPG agent workspace does not refetch session snapshot in a render loop', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -1540,14 +1554,13 @@ test('creation hub clears all private work shelves immediately after logout stat
|
||||
const createPanel = getPlatformTabPanel('create');
|
||||
|
||||
expect(await within(createPanel).findByText('RPG 退出缓存作品')).toBeTruthy();
|
||||
expect(await within(createPanel).findByText('大鱼退出缓存作品')).toBeTruthy();
|
||||
expect(within(createPanel).queryByText('大鱼退出缓存作品')).toBeNull();
|
||||
expect(await within(createPanel).findByText('拼图退出缓存作品')).toBeTruthy();
|
||||
|
||||
rerender(<TestWrapper authValue={loggedOutAuth} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(createPanel).queryByText('RPG 退出缓存作品')).toBeNull();
|
||||
expect(within(createPanel).queryByText('大鱼退出缓存作品')).toBeNull();
|
||||
expect(within(createPanel).queryByText('拼图退出缓存作品')).toBeNull();
|
||||
});
|
||||
expect(within(createPanel).getByText('还没有作品')).toBeTruthy();
|
||||
@@ -1597,7 +1610,7 @@ test('published puzzle works appear on home and category public shelves', async
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('published big fish works appear on home and category public shelves', async () => {
|
||||
test('published big fish works stay hidden from platform home and category shelves', async () => {
|
||||
const user = userEvent.setup();
|
||||
const publishedBigFishWork: BigFishWorkSummary = {
|
||||
workId: 'big-fish-work-public-1',
|
||||
@@ -1623,20 +1636,17 @@ test('published big fish works appear on home and category public shelves', asyn
|
||||
render(<TestWrapper />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText('机械深海 大鱼吃小鱼').length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(listBigFishGallery).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByText('机械深海 大鱼吃小鱼')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '分类' }));
|
||||
|
||||
const categoryPanel = getPlatformTabPanel('category');
|
||||
expect(within(categoryPanel).queryByText('机械深海 大鱼吃小鱼')).toBeNull();
|
||||
expect(
|
||||
within(categoryPanel).getAllByText('机械深海 大鱼吃小鱼').length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
within(categoryPanel).getAllByRole('button', { name: /大鱼/u }).length,
|
||||
).toBeGreaterThan(0);
|
||||
within(categoryPanel).queryAllByRole('button', { name: /大鱼/u }).length,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test('published puzzle detail returns to the source platform tab', async () => {
|
||||
@@ -1853,31 +1863,15 @@ test('new creation entry maps raw bearer token errors to user-facing auth copy',
|
||||
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
|
||||
});
|
||||
|
||||
test('big fish creation timeout exits busy state and shows a readable error', async () => {
|
||||
test('hidden big fish creation entry does not render in platform create hub', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(createBigFishCreationSession).mockRejectedValueOnce(
|
||||
Object.assign(new Error('请求超时:15000ms'), {
|
||||
name: 'TimeoutError',
|
||||
}),
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
|
||||
const button = screen.getByRole('button', { name: /大鱼吃小鱼/u });
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(getPlatformTabPanel('create')).getAllByText(
|
||||
'开启大鱼吃小鱼创作工作台超时,请确认运行时后端已启动后重试。',
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
expect((button as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(screen.queryByText(/正在加载大鱼吃小鱼共创工作区/u)).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||
expect(createBigFishCreationSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('puzzle creation timeout exits busy state and shows a readable error', async () => {
|
||||
@@ -2086,163 +2080,6 @@ test('public code search opens a published big fish work by BF code', async () =
|
||||
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('big fish draft card restores the bound agent session and opens the result view', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listBigFishWorks).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
workId: 'big-fish-work-big-fish-session-1',
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
title: '机械深海 大鱼吃小鱼',
|
||||
subtitle: '机械微生物吞并进化 · 偏爽快节奏',
|
||||
summary: '机械微生物吞并进化',
|
||||
coverImageSrc: null,
|
||||
status: 'draft',
|
||||
updatedAt: '2026-04-22T12:10:00.000Z',
|
||||
publishReady: false,
|
||||
levelCount: 8,
|
||||
levelMainImageReadyCount: 0,
|
||||
levelMotionReadyCount: 0,
|
||||
backgroundReady: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
|
||||
const title = await screen.findByText('机械深海 大鱼吃小鱼');
|
||||
const card = title.closest('.platform-surface');
|
||||
if (!(card instanceof HTMLElement)) {
|
||||
throw new Error('Missing big fish draft card');
|
||||
}
|
||||
|
||||
await user.click(card);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getBigFishCreationSession).toHaveBeenCalledWith(
|
||||
'big-fish-session-1',
|
||||
);
|
||||
});
|
||||
|
||||
expect(await screen.findByText('大鱼吃小鱼结果页')).toBeTruthy();
|
||||
expect(screen.getByText('机械深海 大鱼吃小鱼')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('大鱼吃小鱼工作区:big-fish-session-1'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText(/大鱼吃小鱼共创/u)).toBeNull();
|
||||
expect(screen.getByText('我想做机械深海里微生物互相吞并进化。')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('big fish result publish action refreshes creation works', async () => {
|
||||
const user = userEvent.setup();
|
||||
const baseBigFishSession = (
|
||||
await getBigFishCreationSession('big-fish-session-1')
|
||||
).session;
|
||||
vi.mocked(getBigFishCreationSession).mockClear();
|
||||
vi.mocked(listBigFishWorks).mockClear();
|
||||
vi.mocked(listBigFishGallery).mockClear();
|
||||
const publishedBigFishSession = {
|
||||
...baseBigFishSession,
|
||||
stage: 'published',
|
||||
publishReady: true,
|
||||
assetCoverage: {
|
||||
levelMainImageReadyCount: 8,
|
||||
levelMotionReadyCount: 16,
|
||||
backgroundReady: true,
|
||||
requiredLevelCount: 8,
|
||||
publishReady: true,
|
||||
blockers: [],
|
||||
},
|
||||
updatedAt: '2026-04-22T12:20:00.000Z',
|
||||
};
|
||||
vi.mocked(executeBigFishCreationAction).mockResolvedValue({
|
||||
session: publishedBigFishSession,
|
||||
});
|
||||
vi.mocked(listBigFishWorks)
|
||||
.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
workId: 'big-fish-work-big-fish-session-1',
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
title: '机械深海 大鱼吃小鱼',
|
||||
subtitle: '机械微生物吞并进化 · 偏爽快节奏',
|
||||
summary: '机械微生物吞并进化',
|
||||
coverImageSrc: null,
|
||||
status: 'draft',
|
||||
updatedAt: '2026-04-22T12:10:00.000Z',
|
||||
publishReady: true,
|
||||
levelCount: 8,
|
||||
levelMainImageReadyCount: 8,
|
||||
levelMotionReadyCount: 16,
|
||||
backgroundReady: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
workId: 'big-fish-work-big-fish-session-1',
|
||||
sourceSessionId: 'big-fish-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
title: '机械深海 大鱼吃小鱼',
|
||||
subtitle: '机械微生物吞并进化 · 偏爽快节奏',
|
||||
summary: '机械微生物吞并进化',
|
||||
coverImageSrc: null,
|
||||
status: 'published',
|
||||
updatedAt: '2026-04-22T12:20:00.000Z',
|
||||
publishReady: true,
|
||||
levelCount: 8,
|
||||
levelMainImageReadyCount: 8,
|
||||
levelMotionReadyCount: 16,
|
||||
backgroundReady: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
const title = await screen.findByText('机械深海 大鱼吃小鱼');
|
||||
const card = title.closest('.platform-surface');
|
||||
if (!(card instanceof HTMLElement)) {
|
||||
throw new Error('Missing big fish draft card');
|
||||
}
|
||||
|
||||
await user.click(card);
|
||||
await waitFor(() => {
|
||||
expect(getBigFishCreationSession).toHaveBeenCalledWith(
|
||||
'big-fish-session-1',
|
||||
);
|
||||
});
|
||||
vi.mocked(listBigFishWorks).mockClear();
|
||||
|
||||
expect(await screen.findByText('大鱼吃小鱼结果页')).toBeTruthy();
|
||||
await user.click(await screen.findByRole('button', { name: '发布' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(executeBigFishCreationAction).toHaveBeenCalledWith(
|
||||
'big-fish-session-1',
|
||||
{
|
||||
action: 'big_fish_publish_game',
|
||||
},
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(listBigFishWorks).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(listBigFishGallery).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -156,7 +156,6 @@ export function getLeadingAttributeSlot(
|
||||
export function buildSchemaSummary(schema: WorldAttributeSchema, limit = 6) {
|
||||
return schema.slots.slice(0, limit).map(slot => ({
|
||||
name: slot.name,
|
||||
definition: slot.definition,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -50,18 +50,6 @@ function toText(value: unknown, fallback: string) {
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown, fallback: string[]) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [...fallback];
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.map(item => toOptionalText(item))
|
||||
.filter(Boolean);
|
||||
|
||||
return normalized.length > 0 ? [...new Set(normalized)] : [...fallback];
|
||||
}
|
||||
|
||||
export function coerceWorldAttributeSchema(
|
||||
raw: unknown,
|
||||
fallback: WorldAttributeSchema,
|
||||
@@ -79,7 +67,6 @@ export function coerceWorldAttributeSchema(
|
||||
schemaVersion: typeof raw.schemaVersion === 'number' && Number.isFinite(raw.schemaVersion) && raw.schemaVersion > 0
|
||||
? Math.max(1, Math.round(raw.schemaVersion))
|
||||
: fallback.schemaVersion,
|
||||
schemaName: toOptionalText(raw.schemaName) || fallback.schemaName,
|
||||
generatedFrom: {
|
||||
...fallback.generatedFrom,
|
||||
worldName: toText(rawGeneratedFrom.worldName, fallback.generatedFrom.worldName),
|
||||
@@ -93,12 +80,6 @@ export function coerceWorldAttributeSchema(
|
||||
...fallbackSlot,
|
||||
slotId: fallbackSlot.slotId,
|
||||
name: toText(rawSlot.name, fallbackSlot.name),
|
||||
definition: toText(rawSlot.definition, fallbackSlot.definition),
|
||||
positiveSignals: toStringArray(rawSlot.positiveSignals, fallbackSlot.positiveSignals),
|
||||
negativeSignals: toStringArray(rawSlot.negativeSignals, fallbackSlot.negativeSignals),
|
||||
combatUseText: toText(rawSlot.combatUseText, fallbackSlot.combatUseText),
|
||||
socialUseText: toText(rawSlot.socialUseText, fallbackSlot.socialUseText),
|
||||
explorationUseText: toText(rawSlot.explorationUseText, fallbackSlot.explorationUseText),
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -151,17 +132,6 @@ export function validateWorldAttributeSchema(schema: WorldAttributeSchema) {
|
||||
issues.push(`attribute name "${trimmedName}" contains banned legacy term`);
|
||||
}
|
||||
|
||||
if (!slot.definition.trim()) {
|
||||
issues.push(`slot ${slot.slotId} is missing a definition`);
|
||||
}
|
||||
|
||||
if (/攻击力|防御力|生命值|法力值/u.test(slot.definition)) {
|
||||
issues.push(`slot ${slot.slotId} definition is too derivative`);
|
||||
}
|
||||
|
||||
if (!slot.combatUseText.trim() || !slot.socialUseText.trim() || !slot.explorationUseText.trim()) {
|
||||
issues.push(`slot ${slot.slotId} must describe combat, social, and exploration usage`);
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
|
||||
@@ -85,7 +85,6 @@ export type BuildDamageBreakdown = {
|
||||
export type BuildContributionAttributeRow = {
|
||||
slotId: string;
|
||||
label: string;
|
||||
definition: string;
|
||||
similarity: number;
|
||||
weight: number;
|
||||
value: number;
|
||||
@@ -104,7 +103,6 @@ export type OutgoingDamageResult = {
|
||||
type BuildContributionTarget = {
|
||||
slotId: string;
|
||||
label: string;
|
||||
definition: string;
|
||||
};
|
||||
|
||||
type ResolvedTagAffinity = {
|
||||
@@ -312,7 +310,6 @@ function resolveContributionTargets(
|
||||
return schema.slots.map((slot) => ({
|
||||
slotId: slot.slotId,
|
||||
label: slot.name,
|
||||
definition: slot.definition,
|
||||
})) satisfies BuildContributionTarget[];
|
||||
}
|
||||
|
||||
@@ -550,7 +547,6 @@ export function getBuildContributionAttributeRows(
|
||||
return {
|
||||
slotId: target.slotId,
|
||||
label: target.label,
|
||||
definition: target.definition,
|
||||
similarity: roundNumber(
|
||||
row.attributeSimilarities?.[target.slotId] ?? 0,
|
||||
4,
|
||||
|
||||
@@ -60,12 +60,6 @@ function buildSlotSemanticVector(slot: WorldAttributeSlot, index: number) {
|
||||
const sourceText = [
|
||||
slot.slotId,
|
||||
slot.name,
|
||||
slot.definition,
|
||||
slot.combatUseText,
|
||||
slot.socialUseText,
|
||||
slot.explorationUseText,
|
||||
...(slot.positiveSignals ?? []),
|
||||
...(slot.negativeSignals ?? []),
|
||||
].join(' ');
|
||||
|
||||
const semanticVector: AttributeVector = {};
|
||||
|
||||
@@ -5,7 +5,8 @@ import type { FunctionDocumentationEntry } from '../types';
|
||||
* camp_travel_home_scene
|
||||
*
|
||||
* 从营地与同伴对话结束后,正式前往角色主线场景的控制 function。
|
||||
* 这里除了元信息,也直接收口了它的按钮构造与判定 helper。
|
||||
* 中文注释:前端只保留按钮构造、判定 helper 和视觉元信息;
|
||||
* 正式场景迁移、遭遇预览与快照写入统一由后端 resolver 承接。
|
||||
*/
|
||||
export const CAMP_TRAVEL_HOME_OPTION_VISUALS: StoryOption['visuals'] = {
|
||||
playerAnimation: AnimationState.RUN,
|
||||
@@ -41,10 +42,10 @@ export const CAMP_TRAVEL_HOME_FUNCTION: FunctionDocumentationEntry = {
|
||||
source: 'src/data/functionCatalog/flow/campTravelHomeScene.ts',
|
||||
summary: '营地开场后的专用旅行控制项。',
|
||||
detailedDescription:
|
||||
'它负责把开局同伴营地流程平稳切到角色真正的起始场景,并清理当前营地 encounter、战斗态和镜头残留状态。',
|
||||
'它负责把开局同伴营地流程平稳切到角色真正的起始场景;正式目标场景、encounter 清理、战斗态清理和镜头残留状态由后端 resolver 写入。',
|
||||
trigger: '常见于开局同伴营地对话后的跟进选项。',
|
||||
execution:
|
||||
'点击后不会走普通 state function 结算,而是执行一次定制的场景迁移和历史写入。',
|
||||
'点击后作为服务端 runtime function id 提交到 /api/runtime/story/actions/resolve,由后端执行定制场景迁移和历史写入。',
|
||||
result: '玩家会离开营地进入角色主场景,正式开始该角色的冒险线。',
|
||||
active: true,
|
||||
runtime: {
|
||||
@@ -52,11 +53,11 @@ export const CAMP_TRAVEL_HOME_FUNCTION: FunctionDocumentationEntry = {
|
||||
uiMode: 'none',
|
||||
visuals: CAMP_TRAVEL_HOME_OPTION_VISUALS,
|
||||
executor:
|
||||
'src/hooks/rpg-runtime-story/choiceActions.ts -> handleCampTravelHome',
|
||||
'server-rs/crates/api-server/src/runtime_story/compat.rs -> resolve_camp_travel_home_scene_action',
|
||||
animationNote:
|
||||
'先播放营地离场的 run 演出,再切到正式场景并生成 encounter preview。',
|
||||
'前端保留 run 视觉元信息;正式状态以服务端 hydrated snapshot 为准。',
|
||||
storyNote:
|
||||
'通过 commitGeneratedStateWithEncounterEntry 写入离营结果,并在新场景继续后续剧情。',
|
||||
'后端写入离营结果、生成 encounter preview,并在新场景继续后续剧情。',
|
||||
uiNote: '这是专用旅行流程,不会打开 modal。',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
import { SERVER_RUNTIME_FUNCTION_IDS } from '../../../packages/shared/src/contracts/rpgRuntimeStoryAction';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { SERVER_RUNTIME_FUNCTION_IDS } from '../../../packages/shared/src/contracts/rpgRuntimeStoryAction';
|
||||
import type { Encounter, GameState, InventoryItem } from '../../types';
|
||||
import {
|
||||
ALL_FUNCTION_DOCUMENTATION,
|
||||
buildCampTravelHomeOption,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
buildNpcPreviewTalkOption,
|
||||
buildNpcRecruitModalState,
|
||||
buildNpcTradeModalState,
|
||||
CAMP_TRAVEL_HOME_FUNCTION,
|
||||
CONTINUE_ADVENTURE_FUNCTION,
|
||||
getFunctionDocumentationById,
|
||||
isNpcPreviewTalkOption,
|
||||
@@ -18,7 +20,6 @@ import {
|
||||
shouldNpcRecruitOpenModal,
|
||||
} from './index';
|
||||
import { RPG_FUNCTION_RUNTIME_OVERVIEW } from './runtimeIndex';
|
||||
import type { Encounter, GameState, InventoryItem } from '../../types';
|
||||
|
||||
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
||||
return {
|
||||
@@ -103,6 +104,12 @@ describe('functionCatalog', () => {
|
||||
expect(campTravelOption.functionId).toBe('camp_travel_home_scene');
|
||||
expect(campTravelOption.actionText).toBe('前往 竹林古道');
|
||||
expect(campTravelOption.detailText).toBe('离开营地,前往 竹林古道。');
|
||||
expect(CAMP_TRAVEL_HOME_FUNCTION.runtime?.executor).toContain(
|
||||
'server-rs/crates/api-server/src/runtime_story/compat.rs',
|
||||
);
|
||||
expect(CAMP_TRAVEL_HOME_FUNCTION.detailedDescription).toContain(
|
||||
'后端 resolver',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds npc preview talk options from the current encounter', () => {
|
||||
|
||||
@@ -248,7 +248,7 @@ export function buildEncounterAttributeRumors(
|
||||
|
||||
return getSortedAttributeEntries(profile, schemaContext.schema)
|
||||
.slice(0, options.limit ?? 2)
|
||||
.map(entry => `${entry.slot.name}:${entry.slot.definition}`);
|
||||
.map(entry => entry.slot.name);
|
||||
}
|
||||
|
||||
export function buildGiftAffinityInsight(
|
||||
|
||||
@@ -10,7 +10,6 @@ export const WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS: Record<
|
||||
id: 'schema:wuxia:v1',
|
||||
worldId: WorldType.WUXIA,
|
||||
schemaVersion: 1,
|
||||
schemaName: '江湖六脉',
|
||||
generatedFrom: {
|
||||
worldType: WorldType.WUXIA,
|
||||
worldName: '武侠',
|
||||
@@ -22,62 +21,26 @@ export const WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS: Record<
|
||||
{
|
||||
slotId: 'axis_a',
|
||||
name: '骨势',
|
||||
definition: '扛压、顶冲、硬吃风险也不退的势头。',
|
||||
positiveSignals: ['扛压', '硬桥硬马', '稳住正面'],
|
||||
negativeSignals: ['虚浮', '怯退', '一碰就散'],
|
||||
combatUseText: '顶住正面压力、换伤不退、撑住阵线。',
|
||||
socialUseText: '在强压场面里不露怯,给人可靠或强硬之感。',
|
||||
explorationUseText: '穿越险路、硬顶机关、承受高压环境。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_b',
|
||||
name: '身法',
|
||||
definition: '腾挪、抢位、换线、把握出手节奏的能力。',
|
||||
positiveSignals: ['快', '轻灵', '抢位'],
|
||||
negativeSignals: ['迟缓', '失位', '笨重'],
|
||||
combatUseText: '切线换位、闪转腾挪、争夺先手。',
|
||||
socialUseText: '应变快,擅长观察气口并顺势接话。',
|
||||
explorationUseText: '攀越、潜入、追踪与复杂地形穿行。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_c',
|
||||
name: '眼脉',
|
||||
definition: '看破破绽、拆招、识局、看穿人心的能力。',
|
||||
positiveSignals: ['识局', '洞察', '拆招'],
|
||||
negativeSignals: ['迟钝', '误判', '看不透'],
|
||||
combatUseText: '抓破绽、拆套路、找出最该切入的位置。',
|
||||
socialUseText: '判断弦外之音、试探真假、识别来意。',
|
||||
explorationUseText: '识破机关、辨认痕迹、看懂异状。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_d',
|
||||
name: '心焰',
|
||||
definition: '决断、压迫、胆气、在局面中立住自身意志的能力。',
|
||||
positiveSignals: ['胆气', '决断', '压迫'],
|
||||
negativeSignals: ['犹疑', '软弱', '易被动摇'],
|
||||
combatUseText: '逼迫对手、强行推进、在关键时刻拍板。',
|
||||
socialUseText: '立威、定调、在谈判里压住场子。',
|
||||
explorationUseText: '在未知风险前保持决断,不被局势拖死。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_e',
|
||||
name: '尘缘',
|
||||
definition: '与人事、情面、承诺、牵引关系打交道的能力。',
|
||||
positiveSignals: ['通人情', '会安抚', '懂交换'],
|
||||
negativeSignals: ['生硬', '失礼', '不近人情'],
|
||||
combatUseText: '借势协同、读懂同伴与对手的关系脉络。',
|
||||
socialUseText: '安抚、求助、结盟、维系承诺与信任。',
|
||||
explorationUseText: '从传闻、人脉和地方关系里打开线索。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_f',
|
||||
name: '玄息',
|
||||
definition: '调息、稳态、久战、把自身维持在可用状态的能力。',
|
||||
positiveSignals: ['稳', '续战', '调息'],
|
||||
negativeSignals: ['紊乱', '易崩', '续不上'],
|
||||
combatUseText: '续战、回气、稳住节奏与状态。',
|
||||
socialUseText: '遇事不乱,语气和姿态都更沉稳可信。',
|
||||
explorationUseText: '长线跋涉、在恶劣环境下维持专注与状态。',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -85,7 +48,6 @@ export const WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS: Record<
|
||||
id: 'schema:xianxia:v1',
|
||||
worldId: WorldType.XIANXIA,
|
||||
schemaVersion: 1,
|
||||
schemaName: '灵界六轴',
|
||||
generatedFrom: {
|
||||
worldType: WorldType.XIANXIA,
|
||||
worldName: '仙侠',
|
||||
@@ -97,62 +59,26 @@ export const WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS: Record<
|
||||
{
|
||||
slotId: 'axis_a',
|
||||
name: '道骨',
|
||||
definition: '承载道压与高强度冲击的底子。',
|
||||
positiveSignals: ['承压', '根基稳', '扛得住'],
|
||||
negativeSignals: ['根基浅', '易溃', '承载不足'],
|
||||
combatUseText: '扛住灵压、正面承受高强度对撞。',
|
||||
socialUseText: '让人感到根基扎实,值得托付重事。',
|
||||
explorationUseText: '承受秘境、禁制与裂隙带来的压迫。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_b',
|
||||
name: '灵行',
|
||||
definition: '位移、御空、转场、抢占天时地利的能力。',
|
||||
positiveSignals: ['位移', '御空', '机动'],
|
||||
negativeSignals: ['迟滞', '失位', '转场慢'],
|
||||
combatUseText: '抢位、御空、快速重整战场位置。',
|
||||
socialUseText: '反应轻快,擅长顺势接住局面的变化。',
|
||||
explorationUseText: '穿越高危地形、裂隙、云海与复杂禁区。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_c',
|
||||
name: '识海',
|
||||
definition: '解析禁制、洞察因果、识破虚实的能力。',
|
||||
positiveSignals: ['洞察', '解构', '看破'],
|
||||
negativeSignals: ['迷失', '误判', '看不清'],
|
||||
combatUseText: '识破术理、找出因果节点与破绽。',
|
||||
socialUseText: '更容易辨认真话、虚言与隐藏动机。',
|
||||
explorationUseText: '解读阵纹、禁制、旧史与环境异象。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_d',
|
||||
name: '劫纹',
|
||||
definition: '在高危变化中强行推进、改写局势的能力。',
|
||||
positiveSignals: ['强推', '决断', '逆转'],
|
||||
negativeSignals: ['畏缩', '迟疑', '不敢碰变局'],
|
||||
combatUseText: '在高压窗口里压上去,逼出变化与突破。',
|
||||
socialUseText: '在关键谈判中拍板,推动他人表态。',
|
||||
explorationUseText: '面对异变与风险时敢于推进关键节点。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_e',
|
||||
name: '心契',
|
||||
definition: '与他者、器物、灵兽、誓约建立共鸣的能力。',
|
||||
positiveSignals: ['共鸣', '结契', '安抚'],
|
||||
negativeSignals: ['隔阂', '生硬', '难以共振'],
|
||||
combatUseText: '与器物、灵兽、同伴形成协同与共鸣。',
|
||||
socialUseText: '建立信任、誓约与更深层的关系连结。',
|
||||
explorationUseText: '借由共鸣打开封印、回应遗物或安抚异兽。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_f',
|
||||
name: '玄息',
|
||||
definition: '循环灵息、稳住心神、让自身持续在线的能力。',
|
||||
positiveSignals: ['稳态', '回转', '续航'],
|
||||
negativeSignals: ['紊乱', '枯竭', '失衡'],
|
||||
combatUseText: '维持灵息循环、拖住长线压力与消耗。',
|
||||
socialUseText: '气息沉稳,不轻易乱阵脚或露出破绽。',
|
||||
explorationUseText: '在漫长探索与灵潮侵蚀中维持可行动状态。',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -21,7 +21,6 @@ import { createStoryChoiceActions } from './choiceActions';
|
||||
|
||||
vi.mock('./storyChoiceRuntime', async () => {
|
||||
return {
|
||||
runCampTravelHomeChoice: vi.fn(),
|
||||
runServerRuntimeChoiceAction: runServerRuntimeChoiceActionMock,
|
||||
shouldOpenLocalRuntimeNpcModal: (option: StoryOption) =>
|
||||
(
|
||||
@@ -811,4 +810,97 @@ describe('createStoryChoiceActions', () => {
|
||||
expect(buildResolvedChoiceState).not.toHaveBeenCalled();
|
||||
expect(playResolvedChoice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes camp_travel_home_scene to the backend resolver instead of the legacy local travel branch', async () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
inBattle: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentEncounter: {
|
||||
kind: 'npc' as const,
|
||||
id: 'npc-camp',
|
||||
npcName: '营地同伴',
|
||||
npcDescription: '准备一起出发的同伴',
|
||||
npcAvatar: '伴',
|
||||
context: '营地',
|
||||
hostile: false,
|
||||
},
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
currentScenePreset: {
|
||||
id: 'wuxia-border-camp',
|
||||
name: '边关营地',
|
||||
description: '营火未熄。',
|
||||
imageSrc: '',
|
||||
connectedSceneIds: ['wuxia-palace-court'],
|
||||
connections: [],
|
||||
forwardSceneId: 'wuxia-palace-court',
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
} satisfies GameState;
|
||||
const option: StoryOption = {
|
||||
...createBattleOption('camp_travel_home_scene'),
|
||||
actionText: '前往宫苑内庭',
|
||||
text: '前往宫苑内庭',
|
||||
};
|
||||
const buildResolvedChoiceState = vi.fn();
|
||||
const playResolvedChoice = vi.fn();
|
||||
const commitGeneratedStateWithEncounterEntry = 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(() => []),
|
||||
getResolvedSceneHostileNpcs: vi.fn(() => []),
|
||||
buildNpcStory: vi.fn(() => createFallbackStory()),
|
||||
handleNpcBattleConversationContinuation: vi.fn(() => false),
|
||||
updateQuestLog: vi.fn((inputState: GameState) => inputState),
|
||||
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
|
||||
getCampCompanionTravelScene: vi.fn(() => {
|
||||
throw new Error('legacy camp travel resolver should not run');
|
||||
}),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(() => false),
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(() => false),
|
||||
isCampTravelHomeOption: vi.fn(() => true),
|
||||
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(commitGeneratedStateWithEncounterEntry).not.toHaveBeenCalled();
|
||||
expect(buildResolvedChoiceState).not.toHaveBeenCalled();
|
||||
expect(playResolvedChoice).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ import type {
|
||||
} from './progressionActions';
|
||||
import { runLocalStoryChoiceContinuation } from './storyChoiceContinuation';
|
||||
import {
|
||||
runCampTravelHomeChoice,
|
||||
runServerRuntimeChoiceAction,
|
||||
shouldOpenLocalRuntimeNpcModal,
|
||||
} from './storyChoiceRuntime';
|
||||
@@ -99,18 +98,13 @@ export function createStoryChoiceActions({
|
||||
handleNpcBattleConversationContinuation,
|
||||
updateQuestLog,
|
||||
incrementRuntimeStats,
|
||||
getCampCompanionTravelScene,
|
||||
enterNpcInteraction,
|
||||
handleNpcInteraction,
|
||||
handleTreasureInteraction,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
finalizeNpcBattleResult,
|
||||
isContinueAdventureOption,
|
||||
isCampTravelHomeOption,
|
||||
isRegularNpcEncounter,
|
||||
isNpcEncounter,
|
||||
npcPreviewTalkFunctionId,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
@@ -140,13 +134,13 @@ export function createStoryChoiceActions({
|
||||
handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation;
|
||||
updateQuestLog: UpdateQuestLog;
|
||||
incrementRuntimeStats: IncrementRuntimeStats;
|
||||
getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
|
||||
getCampCompanionTravelScene?: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
|
||||
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
|
||||
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
|
||||
handleTreasureInteraction: (
|
||||
option: StoryOption,
|
||||
) => void | Promise<void> | boolean | Promise<boolean>;
|
||||
commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry;
|
||||
commitGeneratedStateWithEncounterEntry?: CommitGeneratedStateWithEncounterEntry;
|
||||
finalizeNpcBattleResult: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
@@ -154,11 +148,11 @@ export function createStoryChoiceActions({
|
||||
battleOutcome: GameState['currentNpcBattleOutcome'],
|
||||
) => { nextState: GameState; resultText: string } | null;
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption?: (option: StoryOption) => boolean;
|
||||
isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
|
||||
isNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
|
||||
isNpcEncounter?: (encounter: GameState['currentEncounter']) => encounter is Encounter;
|
||||
npcPreviewTalkFunctionId: string;
|
||||
fallbackCompanionName: string;
|
||||
fallbackCompanionName?: string;
|
||||
turnVisualMs: number;
|
||||
}) {
|
||||
const handleChoice = async (option: StoryOption) => {
|
||||
@@ -191,25 +185,6 @@ export function createStoryChoiceActions({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCampTravelHomeOption(option)) {
|
||||
await runCampTravelHomeChoice({
|
||||
gameState,
|
||||
option,
|
||||
character,
|
||||
setBattleReward,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
setGameState,
|
||||
incrementRuntimeStats,
|
||||
getCampCompanionTravelScene,
|
||||
commitGeneratedStateWithEncounterEntry,
|
||||
isNpcEncounter,
|
||||
fallbackCompanionName,
|
||||
turnVisualMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldOpenLocalRuntimeNpcModal(option)) {
|
||||
setAiError(null);
|
||||
await handleNpcInteraction(option);
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import {
|
||||
buildEncounterEntryState,
|
||||
hasEncounterEntity,
|
||||
} from '../../data/encounterTransition';
|
||||
import {
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
createSceneEncounterPreview,
|
||||
resolveSceneEncounterPreview,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import {
|
||||
AnimationState,
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
@@ -18,24 +8,12 @@ import {
|
||||
import { resolveRpgRuntimeChoice } from '.';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
|
||||
type RuntimeStatsIncrements = Partial<
|
||||
Pick<
|
||||
GameState['runtimeStats'],
|
||||
'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'
|
||||
>
|
||||
>;
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
fallbackText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type IncrementRuntimeStats = (
|
||||
state: GameState,
|
||||
increments: RuntimeStatsIncrements,
|
||||
) => GameState;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -54,126 +32,6 @@ export function shouldOpenLocalRuntimeNpcModal(option: StoryOption) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function runCampTravelHomeChoice(params: {
|
||||
gameState: GameState;
|
||||
option: StoryOption;
|
||||
character: Character;
|
||||
setBattleReward: (reward: BattleRewardSummary | null) => void;
|
||||
setAiError: (message: string | null) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setGameState: (state: GameState) => void;
|
||||
incrementRuntimeStats: IncrementRuntimeStats;
|
||||
getCampCompanionTravelScene: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => GameState['currentScenePreset'] | null;
|
||||
commitGeneratedStateWithEncounterEntry: (
|
||||
entryState: GameState,
|
||||
resolvedState: GameState,
|
||||
character: Character,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
lastFunctionId?: string,
|
||||
) => Promise<void> | void;
|
||||
isNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
fallbackCompanionName: string;
|
||||
turnVisualMs: number;
|
||||
}) {
|
||||
const targetScene = params.getCampCompanionTravelScene(
|
||||
params.gameState,
|
||||
params.character,
|
||||
);
|
||||
if (!targetScene) {
|
||||
return false;
|
||||
}
|
||||
|
||||
params.setBattleReward(null);
|
||||
params.setAiError(null);
|
||||
|
||||
const companionName = params.isNpcEncounter(params.gameState.currentEncounter)
|
||||
? params.gameState.currentEncounter.npcName
|
||||
: params.fallbackCompanionName;
|
||||
const travelRunState: GameState = {
|
||||
...params.gameState,
|
||||
ambientIdleMode: undefined,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.RUN,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: true,
|
||||
inBattle: false,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
const travelBaseState: GameState = params.incrementRuntimeStats(
|
||||
{
|
||||
...params.gameState,
|
||||
ambientIdleMode: undefined,
|
||||
currentScenePreset: targetScene,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
animationState: AnimationState.IDLE,
|
||||
playerActionMode: 'idle' as const,
|
||||
activeCombatEffects: [],
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
lastObserveSignsSceneId: null,
|
||||
lastObserveSignsReport: null,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
},
|
||||
{
|
||||
scenesTraveled: 1,
|
||||
},
|
||||
);
|
||||
const travelPreviewState: GameState = {
|
||||
...travelBaseState,
|
||||
...createSceneEncounterPreview(travelBaseState),
|
||||
};
|
||||
const resolvedState = hasEncounterEntity(travelPreviewState)
|
||||
? resolveSceneEncounterPreview(travelPreviewState)
|
||||
: travelBaseState;
|
||||
const entryState = buildEncounterEntryState(
|
||||
resolvedState,
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
);
|
||||
|
||||
params.setIsLoading(true);
|
||||
params.setGameState(travelRunState);
|
||||
await sleep(params.turnVisualMs);
|
||||
|
||||
await params.commitGeneratedStateWithEncounterEntry(
|
||||
entryState,
|
||||
resolvedState,
|
||||
params.character,
|
||||
params.option.actionText,
|
||||
`You and ${companionName} leave camp and formally step into ${targetScene.name} to begin the adventure.`,
|
||||
params.option.functionId,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function runServerRuntimeChoiceAction(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
|
||||
@@ -91,7 +91,6 @@ function buildSavedProfile(options: {
|
||||
id: 'schema:test',
|
||||
worldId: 'CUSTOM',
|
||||
schemaVersion: 1,
|
||||
schemaName: '测试',
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '回潮群岛',
|
||||
|
||||
@@ -10,14 +10,12 @@ import {detectCustomWorldThemeMode} from './customWorldTheme';
|
||||
|
||||
function buildSchema(
|
||||
input: AttributeSchemaGenerationInput,
|
||||
schemaName: string,
|
||||
slots: WorldAttributeSlot[],
|
||||
): WorldAttributeSchema {
|
||||
return {
|
||||
id: `schema:${input.worldType.toLowerCase()}:${schemaName}`,
|
||||
id: `schema:${input.worldType.toLowerCase()}:${input.worldName}`,
|
||||
worldId: input.worldType === WorldType.CUSTOM ? `custom:${input.worldName}` : input.worldType,
|
||||
schemaVersion: 1,
|
||||
schemaName,
|
||||
generatedFrom: {
|
||||
worldType: input.worldType,
|
||||
worldName: input.worldName,
|
||||
@@ -40,62 +38,57 @@ function buildCustomThemeSlots(input: AttributeSchemaGenerationInput) {
|
||||
|
||||
if (themeMode === 'mythic') {
|
||||
return {
|
||||
schemaName: '叙境六维',
|
||||
slots: [
|
||||
{ slotId: 'axis_a', name: '体魄', definition: '承受正面压力与长期消耗的底子。', positiveSignals: ['稳固', '抗压'], negativeSignals: ['脆弱', '虚浮'], combatUseText: '扛住冲击、保持站位。', socialUseText: '给人可靠、能顶事的感觉。', explorationUseText: '在漫长旅途中维持可行动状态。' },
|
||||
{ slotId: 'axis_b', name: '身法', definition: '换位、腾挪、抢时机与穿行环境的能力。', positiveSignals: ['灵动', '迅捷'], negativeSignals: ['迟滞', '笨拙'], combatUseText: '变线、闪避、抢位和追击。', socialUseText: '反应快,懂得顺势调整说法。', explorationUseText: '穿越复杂地形与危险通路。' },
|
||||
{ slotId: 'axis_c', name: '识见', definition: '看清局势、拆解线索与判断轻重缓急的能力。', positiveSignals: ['洞察', '判断'], negativeSignals: ['误判', '迟钝'], combatUseText: '看穿敌方破绽与局势变化。', socialUseText: '识别真假、试探与隐藏立场。', explorationUseText: '整理线索、辨认路径与推断风险。' },
|
||||
{ slotId: 'axis_d', name: '胆魄', definition: '在高压局势里依然敢于推进和拍板的力量。', positiveSignals: ['果断', '压场'], negativeSignals: ['退缩', '犹疑'], combatUseText: '顶着压力推进战局。', socialUseText: '在僵局里定调并逼出回应。', explorationUseText: '面对未知异象仍敢继续前探。' },
|
||||
{ slotId: 'axis_e', name: '牵引', definition: '与人、物、线索和环境建立联动的能力。', positiveSignals: ['协同', '共鸣'], negativeSignals: ['脱节', '孤立'], combatUseText: '借协同和牵制形成连锁。', socialUseText: '建立合作、说服和互信。', explorationUseText: '从人情、物件和场景之间串起通路。' },
|
||||
{ slotId: 'axis_f', name: '定力', definition: '在变化与消耗中稳住节奏、拉回状态的能力。', positiveSignals: ['稳定', '续航'], negativeSignals: ['失衡', '崩乱'], combatUseText: '久战不乱,能重新控住节奏。', socialUseText: '情绪稳定,不轻易被带偏。', explorationUseText: '在长线推进中持续保持判断和行动力。' },
|
||||
{ slotId: 'axis_a', name: '体魄' },
|
||||
{ slotId: 'axis_b', name: '身法' },
|
||||
{ slotId: 'axis_c', name: '识见' },
|
||||
{ slotId: 'axis_d', name: '胆魄' },
|
||||
{ slotId: 'axis_e', name: '牵引' },
|
||||
{ slotId: 'axis_f', name: '定力' },
|
||||
] satisfies WorldAttributeSlot[],
|
||||
};
|
||||
}
|
||||
|
||||
if (themeMode === 'machina') {
|
||||
return {
|
||||
schemaName: '机潮六轴',
|
||||
slots: [
|
||||
{ slotId: 'axis_a', name: '机锋', definition: '承受硬碰撞与机械压力的结构强度。', positiveSignals: ['硬度', '结构'], negativeSignals: ['脆裂', '松散'], combatUseText: '扛住正面撞击与重压。', socialUseText: '给人可靠、稳固、难被撼动的感觉。', explorationUseText: '在高压、坍塌与工业险境中撑住阵脚。' },
|
||||
{ slotId: 'axis_b', name: '步准', definition: '换位、校准、抢时机与精准位移的能力。', positiveSignals: ['校准', '位移'], negativeSignals: ['迟滞', '失准'], combatUseText: '快速转位、抢射界、控节奏。', socialUseText: '反应精确,不轻易露怯。', explorationUseText: '穿越机关、轨道与复杂装置。' },
|
||||
{ slotId: 'axis_c', name: '算识', definition: '解析结构、演算路径、识别规律的能力。', positiveSignals: ['演算', '拆解'], negativeSignals: ['误算', '看不懂'], combatUseText: '读懂装置与敌方机制的薄弱点。', socialUseText: '判断局势、识别话术与利益结构。', explorationUseText: '解密、修复、校准与规划路径。' },
|
||||
{ slotId: 'axis_d', name: '潮压', definition: '在高噪高压中强行推进局势的能力。', positiveSignals: ['推进', '压迫'], negativeSignals: ['退缩', '失控'], combatUseText: '顶着火力和混乱继续施压。', socialUseText: '在混乱场合里定调并逼出表态。', explorationUseText: '面对失控装置时敢于推进关键步骤。' },
|
||||
{ slotId: 'axis_e', name: '协频', definition: '与同伴、器械、网络或环境建立协同的能力。', positiveSignals: ['协同', '接驳'], negativeSignals: ['脱节', '孤立'], combatUseText: '与队友和装置形成联动收益。', socialUseText: '建立合作、交换与稳定配合。', explorationUseText: '接驳系统、调和多方资源与线索。' },
|
||||
{ slotId: 'axis_f', name: '续载', definition: '维持负载、稳定输出、长线运转的能力。', positiveSignals: ['稳载', '续航'], negativeSignals: ['过热', '断载'], combatUseText: '稳住循环、维持持续输出与可操作状态。', socialUseText: '显得沉着、持重、不轻易失衡。', explorationUseText: '在长时间高负荷环境里持续工作。' },
|
||||
{ slotId: 'axis_a', name: '机锋' },
|
||||
{ slotId: 'axis_b', name: '步准' },
|
||||
{ slotId: 'axis_c', name: '算识' },
|
||||
{ slotId: 'axis_d', name: '潮压' },
|
||||
{ slotId: 'axis_e', name: '协频' },
|
||||
{ slotId: 'axis_f', name: '续载' },
|
||||
] satisfies WorldAttributeSlot[],
|
||||
};
|
||||
}
|
||||
|
||||
if (themeMode === 'tide') {
|
||||
return {
|
||||
schemaName: '潮境六脉',
|
||||
slots: [
|
||||
{ slotId: 'axis_a', name: '潮骨', definition: '扛住潮压与正面冲击的底子。', positiveSignals: ['承压', '稳'], negativeSignals: ['散', '弱'], combatUseText: '顶住正面浪涌与冲撞。', socialUseText: '给人能扛事的可靠感。', explorationUseText: '在风浪与湿重环境里稳住自己。' },
|
||||
{ slotId: 'axis_b', name: '浪步', definition: '顺潮借势、换位穿行的能力。', positiveSignals: ['借势', '轻快'], negativeSignals: ['笨拙', '慢'], combatUseText: '借势滑开、切线、拉开距离。', socialUseText: '谈吐灵活,懂得顺势而为。', explorationUseText: '穿越港口、水路、雾区与复杂地形。' },
|
||||
{ slotId: 'axis_c', name: '舟识', definition: '辨流向、识潮眼、看穿变化的能力。', positiveSignals: ['辨向', '识局'], negativeSignals: ['迷失', '误读'], combatUseText: '抓住潮势变化和敌人的失衡时机。', socialUseText: '看懂局势、试探真假与留白。', explorationUseText: '辨认水路、雾障、潮汐与遗留痕迹。' },
|
||||
{ slotId: 'axis_d', name: '潮魄', definition: '在剧烈变化中仍敢推进的胆气。', positiveSignals: ['胆气', '压前'], negativeSignals: ['畏缩', '犹疑'], combatUseText: '借高压局势硬推突破口。', socialUseText: '在谈判或冲突里顶住对方气势。', explorationUseText: '面对陌生水域与异变仍敢向前。' },
|
||||
{ slotId: 'axis_e', name: '契汐', definition: '与人、船、信物与约定形成牵引的能力。', positiveSignals: ['契合', '通人情'], negativeSignals: ['疏离', '难共鸣'], combatUseText: '借助协同与牵引打出连锁。', socialUseText: '善于结盟、安抚与做交换。', explorationUseText: '从航路、人情与旧约中打开局面。' },
|
||||
{ slotId: 'axis_f', name: '回澜', definition: '在漫长消耗中回稳状态、续住节奏的能力。', positiveSignals: ['回稳', '续航'], negativeSignals: ['紊乱', '断流'], combatUseText: '久战不乱,能把节奏重新拉回手里。', socialUseText: '遇事沉静,不易失态。', explorationUseText: '在漫长远行与恶劣天气里保有余力。' },
|
||||
{ slotId: 'axis_a', name: '潮骨' },
|
||||
{ slotId: 'axis_b', name: '浪步' },
|
||||
{ slotId: 'axis_c', name: '舟识' },
|
||||
{ slotId: 'axis_d', name: '潮魄' },
|
||||
{ slotId: 'axis_e', name: '契汐' },
|
||||
{ slotId: 'axis_f', name: '回澜' },
|
||||
] satisfies WorldAttributeSlot[],
|
||||
};
|
||||
}
|
||||
|
||||
if (themeMode === 'rift') {
|
||||
return {
|
||||
schemaName: '裂界六轴',
|
||||
slots: [
|
||||
{ slotId: 'axis_a', name: '界躯', definition: '承受裂界冲击与异压侵蚀的底子。', positiveSignals: ['承载', '抗压'], negativeSignals: ['脆弱', '崩裂'], combatUseText: '扛住高强度裂界冲击。', socialUseText: '让人感到能镇住危险局面。', explorationUseText: '在异压、失衡环境下维持完整。' },
|
||||
{ slotId: 'axis_b', name: '裂步', definition: '穿梭边界、抢位、转场的能力。', positiveSignals: ['转场', '抢位'], negativeSignals: ['迟滞', '卡顿'], combatUseText: '借裂隙切位、抢身位与节奏。', socialUseText: '对局势变化响应很快。', explorationUseText: '穿越裂缝、断层与高危通路。' },
|
||||
{ slotId: 'axis_c', name: '界识', definition: '识别边界规律、虚实与因果的能力。', positiveSignals: ['辨识', '推断'], negativeSignals: ['错判', '看不清'], combatUseText: '洞察异界规律和对手的真空点。', socialUseText: '看破隐藏立场与不完整真话。', explorationUseText: '解读旧迹、裂痕和禁域法则。' },
|
||||
{ slotId: 'axis_d', name: '界压', definition: '在失衡局势中强行立住意志与推进力。', positiveSignals: ['压上去', '定调'], negativeSignals: ['动摇', '失措'], combatUseText: '顶住异变推进攻势。', socialUseText: '在高压博弈中逼出答案。', explorationUseText: '面对危险异象仍敢推开下一层。' },
|
||||
{ slotId: 'axis_e', name: '缚契', definition: '与他者、异物、誓约建立束缚或联结的能力。', positiveSignals: ['联结', '束约'], negativeSignals: ['排斥', '难联动'], combatUseText: '借共鸣与束缚形成协同或压制。', socialUseText: '建立合作、誓约与安抚关系。', explorationUseText: '唤醒遗物、安抚异种、触发响应。' },
|
||||
{ slotId: 'axis_f', name: '回脉', definition: '在紊乱环境中把自身重新拉回稳态的能力。', positiveSignals: ['回稳', '续住'], negativeSignals: ['失衡', '崩坏'], combatUseText: '抗住异压后迅速回到可战状态。', socialUseText: '情绪与气势都更稳。', explorationUseText: '在裂界侵蚀与长线压力里保持在线。' },
|
||||
{ slotId: 'axis_a', name: '界躯' },
|
||||
{ slotId: 'axis_b', name: '裂步' },
|
||||
{ slotId: 'axis_c', name: '界识' },
|
||||
{ slotId: 'axis_d', name: '界压' },
|
||||
{ slotId: 'axis_e', name: '缚契' },
|
||||
{ slotId: 'axis_f', name: '回脉' },
|
||||
] satisfies WorldAttributeSlot[],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
schemaName: '叙境六维',
|
||||
slots: getTemplateWorldAttributeSchema(WorldType.WUXIA).slots,
|
||||
};
|
||||
}
|
||||
@@ -110,7 +103,7 @@ export function generateWorldAttributeSchema(input: AttributeSchemaGenerationInp
|
||||
}
|
||||
|
||||
const generated = buildCustomThemeSlots(input);
|
||||
const schema = buildSchema(input, generated.schemaName, generated.slots);
|
||||
const schema = buildSchema(input, generated.slots);
|
||||
const issues = validateWorldAttributeSchema(schema);
|
||||
|
||||
if (issues.length > 0) {
|
||||
|
||||
@@ -1478,7 +1478,7 @@ export function buildCustomWorldReferenceText(
|
||||
`开局归处:${profile.camp?.name ?? '未设定'}${profile.camp?.description ? `;${profile.camp.description}` : ''}`,
|
||||
`题材适配层:${themePack.displayName};制度词汇 ${themePack.institutionLexicon.slice(0, 4).join('、')};禁忌词 ${themePack.tabooLexicon.slice(0, 4).join('、')};载体类型 ${themePack.artifactClasses.slice(0, 4).join('、')}`,
|
||||
`当前激活线程:\n${activeThreads.map((thread) => `- ${thread.title}:${thread.summary}`).join('\n') || '- 暂无'}`,
|
||||
`世界属性轴:${profile.attributeSchema.slots.map((slot) => `${slot.name}:${slot.definition}`).join(';')}`,
|
||||
`世界属性轴:${profile.attributeSchema.slots.map((slot) => slot.name).join('、')}`,
|
||||
`可扮演角色档案:\n${playableNpcText || '- 暂无'}`,
|
||||
`世界场景角色档案:\n${storyNpcText || '- 暂无'}`,
|
||||
`关键场景档案:\n${landmarkText || '- 暂无'}`,
|
||||
|
||||
@@ -20,7 +20,6 @@ function createBaseProfile(): CustomWorldProfile {
|
||||
id: 'schema:test',
|
||||
worldId: 'CUSTOM',
|
||||
schemaVersion: 1,
|
||||
schemaName: '测试属性',
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '潮雾群岛',
|
||||
|
||||
@@ -47,7 +47,6 @@ const sessionWithPreview: CustomWorldAgentSessionSnapshot = {
|
||||
id: 'schema:draft:test',
|
||||
worldId: 'custom:草稿',
|
||||
schemaVersion: 1,
|
||||
schemaName: '草稿六维',
|
||||
generatedFrom: {
|
||||
worldType: 'CUSTOM',
|
||||
worldName: '只作为 fallback 的本地草稿名',
|
||||
@@ -59,62 +58,26 @@ const sessionWithPreview: CustomWorldAgentSessionSnapshot = {
|
||||
{
|
||||
slotId: 'axis_a',
|
||||
name: '稿骨',
|
||||
definition: '草稿承压维度。',
|
||||
positiveSignals: ['承压'],
|
||||
negativeSignals: ['虚浮'],
|
||||
combatUseText: '顶住正面压力。',
|
||||
socialUseText: '稳住对话姿态。',
|
||||
explorationUseText: '维持探索状态。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_b',
|
||||
name: '稿步',
|
||||
definition: '草稿换位维度。',
|
||||
positiveSignals: ['灵动'],
|
||||
negativeSignals: ['迟滞'],
|
||||
combatUseText: '快速换位。',
|
||||
socialUseText: '顺势接话。',
|
||||
explorationUseText: '穿越复杂路径。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_c',
|
||||
name: '稿识',
|
||||
definition: '草稿洞察维度。',
|
||||
positiveSignals: ['洞察'],
|
||||
negativeSignals: ['误判'],
|
||||
combatUseText: '看破破绽。',
|
||||
socialUseText: '识别隐藏动机。',
|
||||
explorationUseText: '整理线索。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_d',
|
||||
name: '稿魄',
|
||||
definition: '草稿推进维度。',
|
||||
positiveSignals: ['果断'],
|
||||
negativeSignals: ['犹疑'],
|
||||
combatUseText: '推进突破口。',
|
||||
socialUseText: '关键时刻定调。',
|
||||
explorationUseText: '面对未知继续前探。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_e',
|
||||
name: '稿契',
|
||||
definition: '草稿关系维度。',
|
||||
positiveSignals: ['协同'],
|
||||
negativeSignals: ['疏离'],
|
||||
combatUseText: '形成协同收益。',
|
||||
socialUseText: '建立信任交换。',
|
||||
explorationUseText: '从关系打开线索。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_f',
|
||||
name: '稿澜',
|
||||
definition: '草稿续航维度。',
|
||||
positiveSignals: ['回稳'],
|
||||
negativeSignals: ['紊乱'],
|
||||
combatUseText: '久战不乱。',
|
||||
socialUseText: '情绪稳定。',
|
||||
explorationUseText: '长线保持行动力。',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -17,12 +17,6 @@ export type AttributeVector = Record<string, number>;
|
||||
export interface WorldAttributeSlot {
|
||||
slotId: WorldAttributeSlotId;
|
||||
name: string;
|
||||
definition: string;
|
||||
positiveSignals: string[];
|
||||
negativeSignals: string[];
|
||||
combatUseText: string;
|
||||
socialUseText: string;
|
||||
explorationUseText: string;
|
||||
}
|
||||
|
||||
export interface WorldAttributeSchema {
|
||||
@@ -36,7 +30,6 @@ export interface WorldAttributeSchema {
|
||||
tone: string;
|
||||
conflictCore: string;
|
||||
};
|
||||
schemaName?: string;
|
||||
slots: WorldAttributeSlot[];
|
||||
}
|
||||
|
||||
@@ -155,6 +148,5 @@ export interface AttributeSchemaGenerationInput {
|
||||
}
|
||||
|
||||
export interface AttributeSchemaGenerationOutput {
|
||||
schemaName: string;
|
||||
slots: WorldAttributeSlot[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user