1
This commit is contained in:
@@ -1,65 +0,0 @@
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { PixelIcon } from './PixelIcon';
|
||||
|
||||
interface DeveloperTeamModalProps {
|
||||
isOpen: boolean;
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DeveloperTeamModal({
|
||||
isOpen,
|
||||
message,
|
||||
onClose,
|
||||
}: DeveloperTeamModalProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[74] flex items-center justify-center bg-black/78 p-3 sm:p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||
transition={{ duration: 0.18, ease: 'easeOut' }}
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,48rem)] w-full max-w-[min(96vw,42rem)] flex-col overflow-hidden shadow-[0_28px_90px_rgba(0,0,0,0.58)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="mt-1 text-sm font-semibold text-white">{'\u5f00\u53d1\u56e2\u961f'}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
|
||||
aria-label="关闭开发团队弹窗"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
|
||||
<div
|
||||
className="pixel-nine-slice pixel-panel flex flex-col items-center gap-5"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 16, paddingY: 16 })}
|
||||
>
|
||||
<div className="whitespace-pre-line text-center text-sm leading-7 text-zinc-100">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import {lazy, Suspense} from 'react';
|
||||
|
||||
import type {SkillEffectPreviewProps} from './SkillEffectPreview';
|
||||
|
||||
const SkillEffectPreview = lazy(async () => {
|
||||
const module = await import('./SkillEffectPreview');
|
||||
|
||||
return {
|
||||
default: module.SkillEffectPreview,
|
||||
};
|
||||
});
|
||||
|
||||
function SkillEffectPreviewFallback() {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<div className="mb-3 space-y-2">
|
||||
<div className="h-4 w-28 rounded bg-white/10" />
|
||||
<div className="h-3 w-40 rounded bg-white/5" />
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black">
|
||||
<div className="h-[300px] animate-pulse bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.08),transparent_42%),linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LazySkillEffectPreview(props: SkillEffectPreviewProps) {
|
||||
return (
|
||||
<Suspense fallback={<SkillEffectPreviewFallback />}>
|
||||
<SkillEffectPreview {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
import {
|
||||
buildBodyPath,
|
||||
buildMedievalAtlasSpec,
|
||||
buildRaceAssetPath,
|
||||
clampMedievalAtlasFrame,
|
||||
getMedievalAtlasOptions,
|
||||
getMedievalPoseOptions,
|
||||
MEDIEVAL_BODY_COLORS,
|
||||
type MedievalAtlasSourceType,
|
||||
type MedievalNpcVisualOverride,
|
||||
type MedievalRace,
|
||||
} from '../data/medievalNpcVisuals';
|
||||
import type { Encounter } from '../types';
|
||||
import { type NpcLayoutConfig, type NpcLayoutPart } from './npcVisualShared';
|
||||
|
||||
export type GearSourceType = 'none' | MedievalAtlasSourceType;
|
||||
|
||||
export type EditableNpcVisualState = {
|
||||
race: MedievalRace;
|
||||
bodyColor: string;
|
||||
headIndex: number;
|
||||
hairColorIndex: number;
|
||||
hairStyleFrame: number;
|
||||
facialHairEnabled: boolean;
|
||||
facialHairColorIndex: number;
|
||||
facialHairStyleFrame: number;
|
||||
headgearType: GearSourceType;
|
||||
headgearFile: string;
|
||||
headgearFrame: number;
|
||||
mainHandType: GearSourceType;
|
||||
mainHandFile: string;
|
||||
mainHandFrame: number;
|
||||
offHandType: GearSourceType;
|
||||
offHandFile: string;
|
||||
offHandFrame: number;
|
||||
};
|
||||
|
||||
export type EditorNpcOption = {
|
||||
encounter: Encounter;
|
||||
sceneNames: string[];
|
||||
};
|
||||
|
||||
const NPC_LAYOUT_PARTS: NpcLayoutPart[] = [
|
||||
'body',
|
||||
'head',
|
||||
'facialHair',
|
||||
'hair',
|
||||
'headgear',
|
||||
'hand',
|
||||
'mainHand',
|
||||
'offHand',
|
||||
];
|
||||
|
||||
export function sanitizeFrameSelection(
|
||||
type: GearSourceType,
|
||||
file: string,
|
||||
frame: number,
|
||||
usage: 'headgear' | 'mainHand' | 'offHand',
|
||||
) {
|
||||
if (type === 'none' || !file) return 0;
|
||||
const poseOptions = getMedievalPoseOptions(type, file, usage);
|
||||
if (poseOptions.length === 0) return 0;
|
||||
if (poseOptions.some(option => option.value === frame)) {
|
||||
return clampMedievalAtlasFrame(type, file, frame);
|
||||
}
|
||||
const firstOption = poseOptions[0];
|
||||
return firstOption ? firstOption.value : 0;
|
||||
}
|
||||
|
||||
export function getDefaultFileForType(type: GearSourceType) {
|
||||
if (type === 'none') return '';
|
||||
return getMedievalAtlasOptions(type)[0]?.file ?? '';
|
||||
}
|
||||
|
||||
export function getDefaultFrameForSelection(
|
||||
type: GearSourceType,
|
||||
file: string,
|
||||
usage: 'headgear' | 'mainHand' | 'offHand',
|
||||
) {
|
||||
if (type === 'none' || !file) return 0;
|
||||
return getMedievalPoseOptions(type, file, usage)[0]?.value ?? 0;
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function isNpcLayoutConfig(value: unknown): value is NpcLayoutConfig {
|
||||
return (
|
||||
isRecord(value)
|
||||
&& NPC_LAYOUT_PARTS.every(part => {
|
||||
const coordinate = value[part];
|
||||
return (
|
||||
isRecord(coordinate)
|
||||
&& typeof coordinate.x === 'number'
|
||||
&& Number.isFinite(coordinate.x)
|
||||
&& typeof coordinate.y === 'number'
|
||||
&& Number.isFinite(coordinate.y)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function buildOverrideFromEditorState(
|
||||
state: EditableNpcVisualState,
|
||||
): MedievalNpcVisualOverride {
|
||||
return {
|
||||
race: state.race,
|
||||
bodySrc: buildBodyPath(
|
||||
state.bodyColor as (typeof MEDIEVAL_BODY_COLORS)[number],
|
||||
),
|
||||
headSrc: buildRaceAssetPath(state.race, 'head', state.headIndex),
|
||||
hairSrc: buildRaceAssetPath(state.race, 'hair', state.hairColorIndex),
|
||||
handSrc: buildRaceAssetPath(state.race, 'hand', 1),
|
||||
facialHairSrc: state.facialHairEnabled
|
||||
? buildRaceAssetPath(state.race, 'facialHair', state.facialHairColorIndex)
|
||||
: undefined,
|
||||
headgear:
|
||||
state.headgearType === 'none'
|
||||
? undefined
|
||||
: buildMedievalAtlasSpec(
|
||||
state.headgearType,
|
||||
state.headgearFile,
|
||||
sanitizeFrameSelection(
|
||||
state.headgearType,
|
||||
state.headgearFile,
|
||||
state.headgearFrame,
|
||||
'headgear',
|
||||
),
|
||||
),
|
||||
mainHand:
|
||||
state.mainHandType === 'none'
|
||||
? undefined
|
||||
: buildMedievalAtlasSpec(
|
||||
state.mainHandType,
|
||||
state.mainHandFile,
|
||||
sanitizeFrameSelection(
|
||||
state.mainHandType,
|
||||
state.mainHandFile,
|
||||
state.mainHandFrame,
|
||||
'mainHand',
|
||||
),
|
||||
),
|
||||
offHand:
|
||||
state.offHandType === 'none'
|
||||
? undefined
|
||||
: buildMedievalAtlasSpec(
|
||||
state.offHandType,
|
||||
state.offHandFile,
|
||||
sanitizeFrameSelection(
|
||||
state.offHandType,
|
||||
state.offHandFile,
|
||||
state.offHandFrame,
|
||||
'offHand',
|
||||
),
|
||||
),
|
||||
bodyFrames: [0, 1, 2, 3],
|
||||
headFrame: 0,
|
||||
hairFrame: state.hairStyleFrame,
|
||||
handFrame: 0,
|
||||
facialHairFrame: state.facialHairEnabled
|
||||
? state.facialHairStyleFrame
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNpcVisualSavePayload(
|
||||
overrideMap: Record<string, MedievalNpcVisualOverride>,
|
||||
npcId: string,
|
||||
editorState: EditableNpcVisualState,
|
||||
) {
|
||||
return {
|
||||
...overrideMap,
|
||||
[npcId]: buildOverrideFromEditorState(editorState),
|
||||
};
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { MedievalNpcVisualOverride } from '../data/medievalNpcVisuals';
|
||||
import type { EditableNpcVisualState } from './npcVisualEditorModel';
|
||||
import {
|
||||
NPC_LAYOUT_CONFIG_API_PATH,
|
||||
NPC_VISUAL_OVERRIDES_API_PATH,
|
||||
persistNpcLayoutConfig,
|
||||
persistNpcVisualOverrides,
|
||||
} from './npcVisualEditorPersistence';
|
||||
import type { NpcLayoutConfig } from './npcVisualShared';
|
||||
|
||||
function createEditorState(): EditableNpcVisualState {
|
||||
return {
|
||||
race: 'human',
|
||||
bodyColor: 'black',
|
||||
headIndex: 1,
|
||||
hairColorIndex: 1,
|
||||
hairStyleFrame: 0,
|
||||
facialHairEnabled: false,
|
||||
facialHairColorIndex: 1,
|
||||
facialHairStyleFrame: 0,
|
||||
headgearType: 'none',
|
||||
headgearFile: '',
|
||||
headgearFrame: 0,
|
||||
mainHandType: 'none',
|
||||
mainHandFile: '',
|
||||
mainHandFrame: 0,
|
||||
offHandType: 'none',
|
||||
offHandFile: '',
|
||||
offHandFrame: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createExistingOverride(): MedievalNpcVisualOverride {
|
||||
return {
|
||||
race: 'elf',
|
||||
bodySrc: '/body.png',
|
||||
headSrc: '/head.png',
|
||||
hairSrc: '/hair.png',
|
||||
handSrc: '/hand.png',
|
||||
bodyFrames: [0, 1, 2, 3],
|
||||
headFrame: 0,
|
||||
hairFrame: 1,
|
||||
handFrame: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createLayoutDraft(): NpcLayoutConfig {
|
||||
return {
|
||||
body: { x: 0, y: 0 },
|
||||
head: { x: 1, y: 2 },
|
||||
facialHair: { x: 3, y: 4 },
|
||||
hair: { x: 5, y: 6 },
|
||||
headgear: { x: 7, y: 8 },
|
||||
hand: { x: 9, y: 10 },
|
||||
mainHand: { x: 11, y: 12 },
|
||||
offHand: { x: 13, y: 14 },
|
||||
};
|
||||
}
|
||||
|
||||
describe('npcVisualEditorPersistence', () => {
|
||||
it('persists merged npc visual overrides and returns the writeback payload', async () => {
|
||||
const saveJson = vi.fn(async () => undefined);
|
||||
const result = await persistNpcVisualOverrides({
|
||||
overrideMap: {
|
||||
existing: createExistingOverride(),
|
||||
},
|
||||
npcId: 'npc-1',
|
||||
editorState: createEditorState(),
|
||||
saveJson,
|
||||
});
|
||||
|
||||
expect(saveJson).toHaveBeenCalledWith(
|
||||
NPC_VISUAL_OVERRIDES_API_PATH,
|
||||
expect.objectContaining({
|
||||
existing: createExistingOverride(),
|
||||
'npc-1': expect.objectContaining({
|
||||
race: 'human',
|
||||
bodyFrames: [0, 1, 2, 3],
|
||||
}),
|
||||
}),
|
||||
'保存角色形象覆盖配置失败',
|
||||
);
|
||||
expect(result.nextOverrideMap.existing).toEqual(createExistingOverride());
|
||||
expect(result.nextOverrideMap['npc-1']).toEqual(expect.objectContaining({ race: 'human' }));
|
||||
expect(result.saveMessage).toContain('npcVisualOverrides.json');
|
||||
});
|
||||
|
||||
it('persists layout config with a cloned payload for local writeback', async () => {
|
||||
const saveJson = vi.fn(async () => undefined);
|
||||
const layoutDraft = createLayoutDraft();
|
||||
const result = await persistNpcLayoutConfig({
|
||||
layoutDraft,
|
||||
saveJson,
|
||||
});
|
||||
|
||||
expect(saveJson).toHaveBeenCalledWith(
|
||||
NPC_LAYOUT_CONFIG_API_PATH,
|
||||
expect.objectContaining(layoutDraft),
|
||||
'保存角色布局配置失败',
|
||||
);
|
||||
expect(result.nextLayout).toEqual(layoutDraft);
|
||||
expect(result.nextLayout).not.toBe(layoutDraft);
|
||||
expect(result.saveMessage).toContain('角色布局');
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { MedievalNpcVisualOverride } from '../data/medievalNpcVisuals';
|
||||
import {
|
||||
buildEditorJsonApiPath,
|
||||
EDITOR_JSON_RESOURCE_IDS,
|
||||
} from '../editor/shared/editorApiClient';
|
||||
import { saveJsonObject } from '../editor/shared/jsonClient';
|
||||
import {
|
||||
buildNpcVisualSavePayload,
|
||||
type EditableNpcVisualState,
|
||||
} from './npcVisualEditorModel';
|
||||
import { cloneNpcLayoutConfig, type NpcLayoutConfig } from './npcVisualShared';
|
||||
|
||||
export const NPC_VISUAL_OVERRIDES_API_PATH = buildEditorJsonApiPath(
|
||||
EDITOR_JSON_RESOURCE_IDS.npcVisualOverrides,
|
||||
);
|
||||
export const NPC_LAYOUT_CONFIG_API_PATH = buildEditorJsonApiPath(
|
||||
EDITOR_JSON_RESOURCE_IDS.npcLayoutConfig,
|
||||
);
|
||||
|
||||
type SaveEditorJsonFn = typeof saveJsonObject;
|
||||
|
||||
export async function persistNpcVisualOverrides(params: {
|
||||
overrideMap: Record<string, MedievalNpcVisualOverride>;
|
||||
npcId: string;
|
||||
editorState: EditableNpcVisualState;
|
||||
saveJson?: SaveEditorJsonFn;
|
||||
}) {
|
||||
const {
|
||||
overrideMap,
|
||||
npcId,
|
||||
editorState,
|
||||
saveJson = saveJsonObject,
|
||||
} = params;
|
||||
const nextOverrideMap = buildNpcVisualSavePayload(overrideMap, npcId, editorState);
|
||||
|
||||
await saveJson(
|
||||
NPC_VISUAL_OVERRIDES_API_PATH,
|
||||
nextOverrideMap,
|
||||
'保存角色形象覆盖配置失败',
|
||||
);
|
||||
|
||||
return {
|
||||
nextOverrideMap,
|
||||
saveMessage: '已将角色形象覆盖配置保存到 src/data/npcVisualOverrides.json。',
|
||||
};
|
||||
}
|
||||
|
||||
export async function persistNpcLayoutConfig(params: {
|
||||
layoutDraft: NpcLayoutConfig;
|
||||
saveJson?: SaveEditorJsonFn;
|
||||
}) {
|
||||
const { layoutDraft, saveJson = saveJsonObject } = params;
|
||||
const nextLayout = cloneNpcLayoutConfig(layoutDraft);
|
||||
|
||||
await saveJson(
|
||||
NPC_LAYOUT_CONFIG_API_PATH,
|
||||
nextLayout,
|
||||
'保存角色布局配置失败',
|
||||
);
|
||||
|
||||
return {
|
||||
nextLayout,
|
||||
saveMessage: '已保存共享角色布局配置。',
|
||||
};
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
RpgCreationRoleAssetStudioModal,
|
||||
type RpgCreationRoleAssetStudioModalProps,
|
||||
} from './RpgCreationRoleAssetStudioModal';
|
||||
@@ -1 +0,0 @@
|
||||
export { SectionPanel as default } from './RpgCreationEntityEditorShared';
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
RpgCreationEntityEditorModal,
|
||||
type RpgCreationEntityEditorModalProps,
|
||||
} from './RpgCreationEntityEditorModal';
|
||||
@@ -1,4 +0,0 @@
|
||||
export {
|
||||
RpgCreationResultView,
|
||||
type RpgCreationResultViewProps,
|
||||
} from './RpgCreationResultView';
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
upsertRpgWorldProfile,
|
||||
} from '../../services/rpg-creation';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import {
|
||||
clearRpgProfileBrowseHistory as clearProfileBrowseHistory,
|
||||
deleteRpgEntryWorldProfile,
|
||||
@@ -517,6 +518,7 @@ beforeEach(() => {
|
||||
vi.mocked(createRpgCreationSession).mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(listRpgCreationWorks).mockResolvedValue([]);
|
||||
vi.mocked(executeRpgCreationAction).mockResolvedValue({
|
||||
operation: {
|
||||
@@ -669,6 +671,67 @@ test('create tab resumes agent workspace when draft has no compiled result yet',
|
||||
expect(screen.queryByText('世界档案')).toBeNull();
|
||||
});
|
||||
|
||||
test('opening a compiled draft with a missing agent session falls back to create hub', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listRpgCreationWorks)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
workId: 'draft:custom-world-agent-session-missing',
|
||||
sourceType: 'agent_session',
|
||||
status: 'draft',
|
||||
title: '潮雾列岛',
|
||||
subtitle: '世界底稿已生成',
|
||||
summary: '这是一份已经整理过首版结果页的草稿。',
|
||||
coverImageSrc: null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
updatedAt: '2026-04-20T11:00:00.000Z',
|
||||
publishedAt: null,
|
||||
stage: 'object_refining',
|
||||
stageLabel: '整理关键对象',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
roleVisualReadyCount: 0,
|
||||
roleAnimationReadyCount: 0,
|
||||
roleAssetSummaryLabel: null,
|
||||
sessionId: 'custom-world-agent-session-missing',
|
||||
profileId: null,
|
||||
canResume: true,
|
||||
canEnterWorld: false,
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
vi.mocked(getRpgCreationSession).mockRejectedValueOnce(
|
||||
new ApiClientError({
|
||||
message: 'custom world agent session not found',
|
||||
status: 404,
|
||||
code: 'NOT_FOUND',
|
||||
}),
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await user.click(screen.getByRole('button', { name: /继续完善/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(
|
||||
'这份共创草稿已失效,已为你返回创作中心,请重新开始创作。',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(window.location.search).toBe('');
|
||||
expect(listRpgCreationWorks).toHaveBeenCalledTimes(2);
|
||||
expect(screen.getByText('还没有作品')).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByText('Agent工作区:custom-world-agent-session-missing'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('clicking a public work while logged out routes through requireAuth', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
publishRpgEntryWorldProfile,
|
||||
unpublishRpgEntryWorldProfile,
|
||||
} from '../../services/rpg-entry';
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
normalizeRpgEntryAgentBackedProfile,
|
||||
@@ -79,6 +80,14 @@ type UseRpgEntryLibraryDetailParams = {
|
||||
markAutoSavedProfile: (profile: CustomWorldProfile) => void;
|
||||
};
|
||||
|
||||
function isMissingRpgEntryAgentSessionError(error: unknown) {
|
||||
return (
|
||||
error instanceof ApiClientError &&
|
||||
error.status === 404 &&
|
||||
error.code === 'NOT_FOUND'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 负责平台详情、创作作品入口和结果页打开路径。
|
||||
* 平台壳层只消费“打开哪个面板”的结果,不再自己拼接恢复流程细节。
|
||||
@@ -218,7 +227,6 @@ export function useRpgEntryLibraryDetail(
|
||||
const handleOpenCreationWork = useCallback(
|
||||
async (work: CustomWorldWorkSummary) => {
|
||||
if (work.status === 'draft' && work.sessionId) {
|
||||
persistAgentUiState(work.sessionId, null);
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldAutoSaveError(null);
|
||||
setCustomWorldAutoSaveState('idle');
|
||||
@@ -228,33 +236,57 @@ export function useRpgEntryLibraryDetail(
|
||||
const shouldOpenAgentWorkspace =
|
||||
work.playableNpcCount <= 0 && work.landmarkCount <= 0;
|
||||
|
||||
if (shouldOpenAgentWorkspace) {
|
||||
// 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。
|
||||
suppressAgentDraftResultAutoOpen();
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldResultViewSource(null);
|
||||
try {
|
||||
if (shouldOpenAgentWorkspace) {
|
||||
// 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。
|
||||
suppressAgentDraftResultAutoOpen();
|
||||
persistAgentUiState(work.sessionId, null);
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldResultViewSource(null);
|
||||
setPlatformTabToCreate();
|
||||
setSelectionStage('agent-workspace');
|
||||
return;
|
||||
}
|
||||
|
||||
releaseAgentDraftResultAutoOpenSuppression();
|
||||
const latestSession = await syncAgentSessionSnapshot(work.sessionId);
|
||||
const nextProfile = buildDraftResultProfile(latestSession);
|
||||
if (!nextProfile) {
|
||||
persistAgentUiState(work.sessionId, null);
|
||||
setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。');
|
||||
setPlatformTabToCreate();
|
||||
setSelectionStage('agent-workspace');
|
||||
return;
|
||||
}
|
||||
|
||||
persistAgentUiState(work.sessionId, null);
|
||||
setGeneratedCustomWorldProfile(
|
||||
normalizeRpgEntryAgentBackedProfile(nextProfile),
|
||||
);
|
||||
setCustomWorldResultViewSource('agent-draft');
|
||||
setPlatformTabToCreate();
|
||||
setSelectionStage('agent-workspace');
|
||||
setSelectionStage('custom-world-result');
|
||||
return;
|
||||
} catch (error) {
|
||||
if (isMissingRpgEntryAgentSessionError(error)) {
|
||||
// 失效会话不能继续保留在恢复状态里,否则刷新后会重复命中同一个坏 session。
|
||||
persistAgentUiState(null, null);
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldResultViewSource(null);
|
||||
await refreshCustomWorldWorks().catch(() => []);
|
||||
setPlatformError(
|
||||
'这份共创草稿已失效,已为你返回创作中心,请重新开始创作。',
|
||||
);
|
||||
} else {
|
||||
setPlatformError(
|
||||
resolveRpgEntryErrorMessage(error, '读取创作草稿失败。'),
|
||||
);
|
||||
}
|
||||
|
||||
setPlatformTabToCreate();
|
||||
setSelectionStage('platform');
|
||||
return;
|
||||
}
|
||||
|
||||
releaseAgentDraftResultAutoOpenSuppression();
|
||||
const latestSession = await syncAgentSessionSnapshot(work.sessionId);
|
||||
const nextProfile = buildDraftResultProfile(latestSession);
|
||||
if (!nextProfile) {
|
||||
setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。');
|
||||
setPlatformTabToCreate();
|
||||
setSelectionStage('agent-workspace');
|
||||
return;
|
||||
}
|
||||
|
||||
setGeneratedCustomWorldProfile(
|
||||
normalizeRpgEntryAgentBackedProfile(nextProfile),
|
||||
);
|
||||
setCustomWorldResultViewSource('agent-draft');
|
||||
setPlatformTabToCreate();
|
||||
setSelectionStage('custom-world-result');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!work.profileId) {
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
import { Character, ItemCatalogOverride, WorldType } from '../types';
|
||||
import { CharacterPresetOverride } from './characterPresets';
|
||||
import { MonsterPreset, MonsterPresetOverride } from './hostileNpcPresets';
|
||||
import { SceneNpcPresetOverride, ScenePreset, ScenePresetOverride } from './scenePresets';
|
||||
|
||||
function pushError(errors: string[], message: string) {
|
||||
errors.push(message);
|
||||
}
|
||||
|
||||
function isPositiveNumber(value: number | undefined) {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
function isKnownGender(value: unknown): value is 'male' | 'female' {
|
||||
return value === 'male' || value === 'female';
|
||||
}
|
||||
|
||||
function isNonEmptyStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every(item => typeof item === 'string' && item.trim().length > 0);
|
||||
}
|
||||
|
||||
function validateBuildBuffs(errors: string[], ownerId: string, label: string, buffs: unknown) {
|
||||
if (!Array.isArray(buffs)) {
|
||||
pushError(errors, `${ownerId} ${label} must be an array.`);
|
||||
return;
|
||||
}
|
||||
|
||||
buffs.forEach((buff, index) => {
|
||||
if (!buff || typeof buff !== 'object') {
|
||||
pushError(errors, `${ownerId} ${label}[${index}] must be an object.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const typedBuff = buff as {
|
||||
name?: unknown;
|
||||
tags?: unknown;
|
||||
durationTurns?: unknown;
|
||||
};
|
||||
|
||||
if (typeof typedBuff.name !== 'string' || !typedBuff.name.trim()) {
|
||||
pushError(errors, `${ownerId} ${label}[${index}] is missing a valid name.`);
|
||||
}
|
||||
|
||||
if (!isNonEmptyStringArray(typedBuff.tags)) {
|
||||
pushError(errors, `${ownerId} ${label}[${index}].tags must be a non-empty string array.`);
|
||||
}
|
||||
|
||||
if (typeof typedBuff.durationTurns !== 'number' || !Number.isFinite(typedBuff.durationTurns) || typedBuff.durationTurns <= 0) {
|
||||
pushError(errors, `${ownerId} ${label}[${index}].durationTurns must be > 0.`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function validateCharacterOverrides(
|
||||
overrideMap: Record<string, CharacterPresetOverride>,
|
||||
characters: Character[],
|
||||
scenesByWorld: Partial<Record<WorldType, Array<Pick<ScenePreset, 'id'>>>>,
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
const validCharacterIds = new Set(characters.map(character => character.id));
|
||||
const validSceneIdsByWorld = {
|
||||
[WorldType.WUXIA]: new Set((scenesByWorld[WorldType.WUXIA] ?? []).map(scene => scene.id)),
|
||||
[WorldType.XIANXIA]: new Set((scenesByWorld[WorldType.XIANXIA] ?? []).map(scene => scene.id)),
|
||||
[WorldType.CUSTOM]: new Set((scenesByWorld[WorldType.CUSTOM] ?? []).map(scene => scene.id)),
|
||||
};
|
||||
|
||||
Object.entries(overrideMap).forEach(([characterId, override]) => {
|
||||
if (!validCharacterIds.has(characterId)) {
|
||||
pushError(errors, `未知角色覆盖:${characterId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (override.gender !== undefined && !isKnownGender(override.gender)) {
|
||||
pushError(errors, `${characterId} gender must be "male" or "female".`);
|
||||
}
|
||||
|
||||
if (override.combatTags !== undefined && !isNonEmptyStringArray(override.combatTags)) {
|
||||
pushError(errors, `${characterId} combatTags must be a non-empty string array.`);
|
||||
}
|
||||
|
||||
if (override.skills) {
|
||||
override.skills.forEach((skill, index) => {
|
||||
const skillLabel = `${characterId} skill ${skill.id || index + 1}`;
|
||||
if (!skill.id?.trim()) pushError(errors, `${skillLabel} is missing id.`);
|
||||
if (!skill.name?.trim()) pushError(errors, `${skillLabel} is missing name.`);
|
||||
if (!isPositiveNumber(skill.range)) pushError(errors, `${skillLabel} range must be > 0.`);
|
||||
if (typeof skill.damage !== 'number' || skill.damage < 0) pushError(errors, `${skillLabel} damage must be >= 0.`);
|
||||
if (typeof skill.manaCost !== 'number' || skill.manaCost < 0) pushError(errors, `${skillLabel} manaCost must be >= 0.`);
|
||||
if (typeof skill.cooldownTurns !== 'number' || skill.cooldownTurns < 0) pushError(errors, `${skillLabel} cooldownTurns must be >= 0.`);
|
||||
if (skill.buildBuffs !== undefined) {
|
||||
validateBuildBuffs(errors, characterId, `skill ${skill.id || index + 1} buildBuffs`, skill.buildBuffs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (override.sceneBindings) {
|
||||
Object.entries(override.sceneBindings).forEach(([world, binding]) => {
|
||||
if (!binding) return;
|
||||
const worldType = world as WorldType;
|
||||
const validSceneIds = validSceneIdsByWorld[worldType];
|
||||
|
||||
if (binding.homeSceneId && !validSceneIds.has(binding.homeSceneId)) {
|
||||
pushError(errors, `${characterId} has invalid homeSceneId for ${worldType}: ${binding.homeSceneId}`);
|
||||
}
|
||||
|
||||
(binding.npcSceneIds ?? []).forEach(sceneId => {
|
||||
if (!validSceneIds.has(sceneId)) {
|
||||
pushError(errors, `${characterId} has invalid npcSceneId for ${worldType}: ${sceneId}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateMonsterOverrides(
|
||||
overrideMap: Record<string, MonsterPresetOverride>,
|
||||
monsters: MonsterPreset[],
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
const validMonsterIds = new Set(monsters.map(monster => monster.id));
|
||||
|
||||
Object.entries(overrideMap).forEach(([monsterId, override]) => {
|
||||
if (!validMonsterIds.has(monsterId)) {
|
||||
pushError(errors, `未知怪物覆盖:${monsterId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(override.baseStats ?? {}).forEach(([key, value]) => {
|
||||
const numericValue = typeof value === 'number' ? value : undefined;
|
||||
if (!isPositiveNumber(numericValue)) {
|
||||
pushError(errors, `${monsterId} baseStats.${key} must be > 0.`);
|
||||
}
|
||||
});
|
||||
|
||||
if (override.combatTags !== undefined && !isNonEmptyStringArray(override.combatTags)) {
|
||||
pushError(errors, `${monsterId} combatTags must be a non-empty string array.`);
|
||||
}
|
||||
|
||||
Object.entries(override.animations ?? {}).forEach(([animation, rawConfig]) => {
|
||||
const config = rawConfig as { frames?: number; fps?: number } | undefined;
|
||||
if (!config) return;
|
||||
if (!isPositiveNumber(config.frames)) pushError(errors, `${monsterId} ${animation}.frames must be > 0.`);
|
||||
if (!isPositiveNumber(config.fps)) pushError(errors, `${monsterId} ${animation}.fps must be > 0.`);
|
||||
});
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateSceneOverrides(
|
||||
overrideMap: Record<string, ScenePresetOverride>,
|
||||
scenes: ScenePreset[],
|
||||
_monstersByWorld: Partial<Record<WorldType, MonsterPreset[]>>,
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
const sceneById = new Map(scenes.map(scene => [scene.id, scene]));
|
||||
const validSceneIds = new Set(scenes.map(scene => scene.id));
|
||||
|
||||
Object.entries(overrideMap).forEach(([sceneId, override]) => {
|
||||
const scene = sceneById.get(sceneId);
|
||||
if (!scene) {
|
||||
pushError(errors, `未知场景覆盖:${sceneId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (override.forwardSceneId && !validSceneIds.has(override.forwardSceneId)) {
|
||||
pushError(errors, `${sceneId} has invalid forwardSceneId: ${override.forwardSceneId}`);
|
||||
}
|
||||
|
||||
(override.connectedSceneIds ?? []).forEach(targetSceneId => {
|
||||
if (!validSceneIds.has(targetSceneId)) {
|
||||
pushError(errors, `${sceneId} has invalid connectedSceneId: ${targetSceneId}`);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateSceneNpcOverrides(
|
||||
overrideMap: Record<string, SceneNpcPresetOverride>,
|
||||
validNpcIds: string[],
|
||||
characters: Character[],
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
const npcIdSet = new Set(validNpcIds);
|
||||
const characterIdSet = new Set(characters.map(character => character.id));
|
||||
|
||||
Object.entries(overrideMap).forEach(([npcId, override]) => {
|
||||
if (!npcIdSet.has(npcId)) {
|
||||
pushError(errors, `未知场景角色覆盖:${npcId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (override.gender !== undefined && !isKnownGender(override.gender)) {
|
||||
pushError(errors, `${npcId} gender must be "male" or "female".`);
|
||||
}
|
||||
|
||||
if (override.characterId && !characterIdSet.has(override.characterId)) {
|
||||
pushError(errors, `${npcId} has invalid characterId: ${override.characterId}`);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateItemOverrides(
|
||||
overrideMap: Record<string, ItemCatalogOverride>,
|
||||
validItemIds: string[],
|
||||
) {
|
||||
const errors: string[] = [];
|
||||
const itemIdSet = new Set(validItemIds);
|
||||
|
||||
Object.entries(overrideMap).forEach(([itemId, override]) => {
|
||||
if (!itemIdSet.has(itemId)) {
|
||||
pushError(errors, `未知物品覆盖:${itemId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (override.name !== undefined && !override.name.trim()) {
|
||||
pushError(errors, `${itemId} name cannot be empty.`);
|
||||
}
|
||||
|
||||
if (override.category !== undefined && !override.category.trim()) {
|
||||
pushError(errors, `${itemId} category cannot be empty.`);
|
||||
}
|
||||
|
||||
if (override.description !== undefined && !override.description.trim()) {
|
||||
pushError(errors, `${itemId} description cannot be empty.`);
|
||||
}
|
||||
|
||||
if (override.tags !== undefined && !isNonEmptyStringArray(override.tags)) {
|
||||
pushError(errors, `${itemId} tags must be a non-empty string array.`);
|
||||
}
|
||||
|
||||
if (override.buildProfile?.tags !== undefined && !isNonEmptyStringArray(override.buildProfile.tags)) {
|
||||
pushError(errors, `${itemId} buildProfile.tags must be a non-empty string array.`);
|
||||
}
|
||||
|
||||
if (override.buildProfile?.craftTags !== undefined && !isNonEmptyStringArray(override.buildProfile.craftTags)) {
|
||||
pushError(errors, `${itemId} buildProfile.craftTags must be a non-empty string array.`);
|
||||
}
|
||||
|
||||
if (override.useProfile?.buildBuffs !== undefined) {
|
||||
validateBuildBuffs(errors, itemId, 'useProfile.buildBuffs', override.useProfile.buildBuffs);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export const STORY_OPENING_CAMP_DIALOGUE_FUNCTION: FunctionDocumentationEntry =
|
||||
storyMode: 'special_sequence',
|
||||
uiMode: 'none',
|
||||
executor:
|
||||
'src/hooks/rpg-runtime-story/openingAdventure.ts + src/services/prompt.ts',
|
||||
'server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts + src/hooks/rpg-runtime-story/storyContextBuilder.ts',
|
||||
animationNote: '重点在对白本身,不额外驱动独立战斗/位移动画。',
|
||||
storyNote: '会把 prompt 切到营地开场对白模式,并要求输出结构化对话行。',
|
||||
uiNote: '不弹 modal,直接进入对白流。',
|
||||
|
||||
@@ -14,6 +14,7 @@ export * from './flow/campTravelHomeScene';
|
||||
export * from './flow/storyContinueAdventure';
|
||||
export * from './flow/storyOpeningCampDialogue';
|
||||
export * from './npc/npcChat';
|
||||
export * from './npc/npcChatQuestOffer';
|
||||
export * from './npc/npcFight';
|
||||
export * from './npc/npcGift';
|
||||
export * from './npc/npcHelp';
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
import { NPC_CHAT_FUNCTION } from './npcChat';
|
||||
import {
|
||||
NPC_CHAT_QUEST_OFFER_ABANDON_FUNCTION,
|
||||
NPC_CHAT_QUEST_OFFER_REPLACE_FUNCTION,
|
||||
NPC_CHAT_QUEST_OFFER_VIEW_FUNCTION,
|
||||
} from './npcChatQuestOffer';
|
||||
import { NPC_FIGHT_FUNCTION } from './npcFight';
|
||||
import { NPC_GIFT_FUNCTION } from './npcGift';
|
||||
import { NPC_HELP_FUNCTION } from './npcHelp';
|
||||
@@ -18,6 +23,9 @@ export const NPC_FUNCTION_DOCUMENTATION: FunctionDocumentationEntry[] = [
|
||||
NPC_SPAR_FUNCTION,
|
||||
NPC_HELP_FUNCTION,
|
||||
NPC_CHAT_FUNCTION,
|
||||
NPC_CHAT_QUEST_OFFER_VIEW_FUNCTION,
|
||||
NPC_CHAT_QUEST_OFFER_REPLACE_FUNCTION,
|
||||
NPC_CHAT_QUEST_OFFER_ABANDON_FUNCTION,
|
||||
NPC_GIFT_FUNCTION,
|
||||
NPC_RECRUIT_FUNCTION,
|
||||
NPC_QUEST_ACCEPT_FUNCTION,
|
||||
|
||||
@@ -24,7 +24,7 @@ export const NPC_CHAT_FUNCTION: FunctionDocumentationEntry = {
|
||||
storyMode: 'stream_then_defer',
|
||||
uiMode: 'none',
|
||||
executor:
|
||||
'src/hooks/rpg-runtime-story/npcEncounterActions.ts -> commitNpcChatState',
|
||||
'src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts -> commitNpcChatState',
|
||||
animationNote: '重点在流式对白和轻量站场,不额外打开窗口。',
|
||||
storyNote:
|
||||
'先生成聊天正文,再把真正的新选项放入 deferredOptions,等待 continue adventure。',
|
||||
|
||||
86
src/data/functionCatalog/npc/npcChatQuestOffer.ts
Normal file
86
src/data/functionCatalog/npc/npcChatQuestOffer.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* npc_chat_quest_offer_*
|
||||
*
|
||||
* NPC 聊天态里的临时委托处理 function。它们不是新的任务系统,
|
||||
* 而是高好感聊天中 pending quest offer 的查看、更换和放弃入口。
|
||||
*/
|
||||
const QUEST_OFFER_SOURCE = 'src/data/functionCatalog/npc/npcChatQuestOffer.ts';
|
||||
const QUEST_OFFER_EXECUTOR =
|
||||
'server-node/src/modules/quest/questStoryActionService.ts -> resolveQuestStoryAction';
|
||||
|
||||
export const NPC_CHAT_QUEST_OFFER_VIEW_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'npc_chat_quest_offer_view',
|
||||
domain: 'npc',
|
||||
title: '查看委托',
|
||||
source: QUEST_OFFER_SOURCE,
|
||||
summary: '查看当前聊天中 NPC 刚提出但尚未领取的委托。',
|
||||
detailedDescription:
|
||||
'它用于 pending quest offer 阶段,只打开或返回当前待领取任务详情,不把任务写入正式 quest log。',
|
||||
trigger: 'NPC 聊天触发待领取委托后,任务处理态选项中出现。',
|
||||
execution:
|
||||
'后端读取当前 pending quest offer,并返回可展示的任务详情与领取入口。',
|
||||
result: '玩家可以查看任务目标和奖励,确认领取前不会改变正式任务日志。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'local_only',
|
||||
uiMode: 'none',
|
||||
executor: QUEST_OFFER_EXECUTOR,
|
||||
animationNote: '不触发角色位移动画,重点是切换任务详情展示。',
|
||||
storyNote: '只保留当前委托上下文,不生成新的聊天剧情。',
|
||||
uiNote: '展示待领取任务详情,等待玩家领取、替换或返回聊天。',
|
||||
compactDetailText: '查看这份委托',
|
||||
},
|
||||
};
|
||||
|
||||
export const NPC_CHAT_QUEST_OFFER_REPLACE_FUNCTION: FunctionDocumentationEntry =
|
||||
{
|
||||
id: 'npc_chat_quest_offer_replace',
|
||||
domain: 'npc',
|
||||
title: '更换委托',
|
||||
source: QUEST_OFFER_SOURCE,
|
||||
summary: '让 NPC 重新生成一份聊天内待领取委托。',
|
||||
detailedDescription:
|
||||
'它不会本地改写现有任务文案,而是重新走任务生成链,替换当前 pending quest offer。',
|
||||
trigger: 'NPC 聊天任务处理态中,玩家不满意当前委托时出现。',
|
||||
execution:
|
||||
'后端调用任务生成链生成新 quest offer,并覆盖当前聊天态 pending offer。',
|
||||
result:
|
||||
'当前待领取委托被替换,聊天仍停留在任务处理态,正式 quest log 不变。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'local_effect_then_generate',
|
||||
uiMode: 'none',
|
||||
executor: QUEST_OFFER_EXECUTOR,
|
||||
animationNote: '不触发战斗或移动演出,只追加轻量聊天反馈。',
|
||||
storyNote: '重新生成 pending quest offer,并说明 NPC 换了一个委托。',
|
||||
uiNote: '继续显示查看、更换、放弃这组任务处理选项。',
|
||||
compactDetailText: '换一个委托',
|
||||
},
|
||||
};
|
||||
|
||||
export const NPC_CHAT_QUEST_OFFER_ABANDON_FUNCTION: FunctionDocumentationEntry =
|
||||
{
|
||||
id: 'npc_chat_quest_offer_abandon',
|
||||
domain: 'npc',
|
||||
title: '放弃委托',
|
||||
source: QUEST_OFFER_SOURCE,
|
||||
summary: '丢弃当前聊天中尚未领取的委托。',
|
||||
detailedDescription:
|
||||
'它只清理 pending quest offer,不影响已经写入 quest log 的正式任务,也不会扣除奖励或结算任务失败。',
|
||||
trigger: 'NPC 聊天任务处理态中,玩家暂时不想接这份委托时出现。',
|
||||
execution:
|
||||
'后端清空当前聊天态 pending quest offer,并恢复普通 NPC 聊天选项。',
|
||||
result: '待领取委托消失,玩家回到自由聊天或离开 NPC 的正常流程。',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'local_only',
|
||||
uiMode: 'none',
|
||||
executor: QUEST_OFFER_EXECUTOR,
|
||||
animationNote: '不触发额外演出,只回到普通聊天态。',
|
||||
storyNote: '追加玩家暂时不接委托的轻量反馈。',
|
||||
uiNote: '恢复普通 npc_chat 建议和自定义输入。',
|
||||
compactDetailText: '暂时不接',
|
||||
},
|
||||
};
|
||||
@@ -23,7 +23,7 @@ export const NPC_FIGHT_FUNCTION: FunctionDocumentationEntry = {
|
||||
storyMode: 'special_sequence',
|
||||
uiMode: 'none',
|
||||
executor:
|
||||
'src/hooks/rpg-runtime-story/npcEncounterActions.ts -> handleNpcInteraction',
|
||||
'src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts -> handleNpcInteraction',
|
||||
animationNote: '切到 NPC 战斗模式后,由战斗播放链路驱动后续动画。',
|
||||
storyNote: '不会先弹窗,直接把当前 encounter 切成战斗态并进入后续结算。',
|
||||
uiNote: '不弹 modal,直接进入战斗。',
|
||||
|
||||
@@ -22,7 +22,7 @@ export const NPC_HELP_FUNCTION: FunctionDocumentationEntry = {
|
||||
storyMode: 'local_effect_then_generate',
|
||||
uiMode: 'none',
|
||||
executor:
|
||||
'src/hooks/rpg-runtime-story/npcEncounterActions.ts -> handleNpcInteraction',
|
||||
'src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts -> handleNpcInteraction',
|
||||
animationNote: '不单独开窗口,直接在当前交互里结算帮助结果。',
|
||||
storyNote: '点击后立即按本地奖励规则结算,并继续生成新的故事状态。',
|
||||
uiNote: '不弹 modal,直接获得帮助反馈。',
|
||||
|
||||
@@ -21,7 +21,7 @@ export const NPC_LEAVE_FUNCTION: FunctionDocumentationEntry = {
|
||||
storyMode: 'local_effect_then_generate',
|
||||
uiMode: 'none',
|
||||
executor:
|
||||
'src/hooks/rpg-runtime-story/npcEncounterActions.ts -> handleNpcInteraction',
|
||||
'src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts -> handleNpcInteraction',
|
||||
animationNote: '通常只做轻量离场,不单独打开窗口。',
|
||||
storyNote: '点击后结束当前 NPC 交互,并回到新的探索剧情。',
|
||||
uiNote: '不弹 modal,直接退出互动。',
|
||||
|
||||
@@ -60,7 +60,7 @@ export const NPC_PREVIEW_TALK_FUNCTION: FunctionDocumentationEntry = {
|
||||
uiMode: 'npc_interaction_entry',
|
||||
visuals: NPC_PREVIEW_TALK_OPTION_VISUALS,
|
||||
executor:
|
||||
'src/hooks/rpg-runtime-story/choiceActions.ts + src/hooks/rpg-runtime-story/npcEncounterActions.ts',
|
||||
'src/hooks/rpg-runtime-story/choiceActions.ts + src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts',
|
||||
animationNote:
|
||||
'保持轻量 idle 站场,不做额外位移,重点是把交互焦点切到 NPC 身上。',
|
||||
storyNote:
|
||||
|
||||
@@ -21,7 +21,7 @@ export const NPC_SPAR_FUNCTION: FunctionDocumentationEntry = {
|
||||
storyMode: 'special_sequence',
|
||||
uiMode: 'none',
|
||||
executor:
|
||||
'src/hooks/rpg-runtime-story/npcEncounterActions.ts -> handleNpcInteraction',
|
||||
'src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts -> handleNpcInteraction',
|
||||
animationNote: '切到 spar 战斗模式后,由战斗播放链路驱动切磋演出。',
|
||||
storyNote: '不会先弹窗,直接进入点到为止的切磋流程。',
|
||||
uiNote: '不弹 modal,直接切磋。',
|
||||
|
||||
35
src/data/functionCatalog/state/battleAttackBasic.ts
Normal file
35
src/data/functionCatalog/state/battleAttackBasic.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* battle_attack_basic
|
||||
*
|
||||
* 后端单行为战斗模型的普通攻击入口。该 function 只登记文档和契约,
|
||||
* 不进入前端本地 state function 候选池。
|
||||
*/
|
||||
export const BATTLE_ATTACK_BASIC_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'battle_attack_basic',
|
||||
domain: 'state',
|
||||
title: '普通攻击',
|
||||
source: 'src/data/functionCatalog/state/battleAttackBasic.ts',
|
||||
summary: '后端单行为战斗模型中的基础攻击 function。',
|
||||
detailedDescription:
|
||||
'这个 function 代表一次明确的普通攻击点击,后端直接结算伤害、敌方反击和下一轮战斗选项,不再请求 AI 续写整段战斗剧情。',
|
||||
trigger: '仅在 battle 状态且场上仍有存活敌人时,由后端战斗 option 池下发。',
|
||||
execution:
|
||||
'前端透传 functionId,后端 combatResolutionService 直接按普通攻击规则结算本回合。',
|
||||
result: '刷新 HP、战斗日志和下一轮战斗 options;若敌人被击败,再进入脱战剧情推理。',
|
||||
state: 'battle',
|
||||
category: 'battle',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'local_only',
|
||||
uiMode: 'none',
|
||||
executor:
|
||||
'server-node/src/modules/combat/combatResolutionService.ts -> resolveCombatAction',
|
||||
animationNote: '播放一次基础攻击和受击反馈,不扩展成连续多段连击。',
|
||||
storyNote:
|
||||
'战斗未结束时只展示本次结算文本;战斗结束后才请求脱战剧情。',
|
||||
uiNote: '由后端战斗 option 池生成,不进入前端本地 state function 候选。',
|
||||
compactDetailText: '直接攻击眼前敌人',
|
||||
},
|
||||
};
|
||||
36
src/data/functionCatalog/state/battleUseSkill.ts
Normal file
36
src/data/functionCatalog/state/battleUseSkill.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { FunctionDocumentationEntry } from '../types';
|
||||
|
||||
/**
|
||||
* battle_use_skill
|
||||
*
|
||||
* 后端单行为战斗模型的技能释放入口。每个技能 option 复用同一个
|
||||
* functionId,具体技能必须由 runtimePayload.skillId 指定。
|
||||
*/
|
||||
export const BATTLE_USE_SKILL_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'battle_use_skill',
|
||||
domain: 'state',
|
||||
title: '释放技能',
|
||||
source: 'src/data/functionCatalog/state/battleUseSkill.ts',
|
||||
summary: '后端单行为战斗模型中的指定技能释放 function。',
|
||||
detailedDescription:
|
||||
'这个 functionId 可以对应多个技能 option 实例。前端只展示技能名和不可用原因,后端根据 runtimePayload.skillId 校验蓝量、冷却并结算本次技能效果。',
|
||||
trigger: '仅在 battle 状态下由后端按角色技能列表生成,可能携带 disabled 状态。',
|
||||
execution:
|
||||
'前端透传 runtimePayload.skillId,后端 combatResolutionService 校验技能并完成一次技能动作结算。',
|
||||
result:
|
||||
'更新 MP、技能冷却、敌我 HP 和下一轮战斗 options;若战斗结束,再触发脱战剧情推理。',
|
||||
state: 'battle',
|
||||
category: 'battle',
|
||||
active: true,
|
||||
runtime: {
|
||||
storyMode: 'local_only',
|
||||
uiMode: 'none',
|
||||
executor:
|
||||
'server-node/src/modules/combat/combatResolutionService.ts -> resolveCombatAction',
|
||||
animationNote: '根据技能 option 播放一次技能演出,不在本 function 内追加多回合动作。',
|
||||
storyNote:
|
||||
'战斗未结束时使用本次技能结算文本;只有战斗结束才请求新剧情。',
|
||||
uiNote: '每个技能是一个后端下发的独立 option,必须携带 skillId。',
|
||||
compactDetailText: '释放一个指定技能',
|
||||
},
|
||||
};
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { StateFunctionSource } 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';
|
||||
import { BATTLE_FEINT_STEP_FUNCTION_SOURCE } from './battleFeintStep';
|
||||
import { BATTLE_FINISHER_WINDOW_FUNCTION_SOURCE } from './battleFinisherWindow';
|
||||
import { BATTLE_GUARD_BREAK_FUNCTION_SOURCE } from './battleGuardBreak';
|
||||
import { BATTLE_PROBE_PRESSURE_FUNCTION_SOURCE } from './battleProbePressure';
|
||||
import { BATTLE_RECOVER_BREATH_FUNCTION_SOURCE } from './battleRecoverBreath';
|
||||
import { BATTLE_USE_SKILL_FUNCTION } from './battleUseSkill';
|
||||
import { IDLE_CALL_OUT_FUNCTION_SOURCE } from './idleCallOut';
|
||||
import { IDLE_EXPLORE_FORWARD_FUNCTION_SOURCE } from './idleExploreForward';
|
||||
import { IDLE_FOLLOW_CLUE_FUNCTION_SOURCE } from './idleFollowClue';
|
||||
@@ -40,7 +42,9 @@ export const STATE_FUNCTION_PROMPT_DESCRIPTIONS = Object.fromEntries(
|
||||
]),
|
||||
) as Record<string, string>;
|
||||
|
||||
export const STATE_FUNCTION_DOCUMENTATION = STATE_FUNCTION_SOURCES.map(
|
||||
(source) => source.documentation,
|
||||
);
|
||||
export const STATE_FUNCTION_DOCUMENTATION = [
|
||||
BATTLE_ATTACK_BASIC_FUNCTION,
|
||||
BATTLE_USE_SKILL_FUNCTION,
|
||||
...STATE_FUNCTION_SOURCES.map((source) => source.documentation),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { WorldType } from '../types';
|
||||
import { getSceneFriendlyNpcs, getSceneHostileNpcs,getScenePresetById } from './scenePresets';
|
||||
|
||||
export function buildSceneObserveSignsStoryText(
|
||||
worldType: WorldType | null,
|
||||
sceneId: string | null | undefined,
|
||||
) {
|
||||
if (!worldType) {
|
||||
return '你停下来倾听,但目前场景上下文不足,无法判断附近有什么。';
|
||||
}
|
||||
|
||||
const scene = getScenePresetById(worldType, sceneId);
|
||||
if (!scene) {
|
||||
return '你停下来倾听,但这个区域还没有露出任何可靠的痕迹。';
|
||||
}
|
||||
|
||||
const friendlyNpcs = getSceneFriendlyNpcs(scene);
|
||||
const hostileNpcs = getSceneHostileNpcs(scene);
|
||||
const npcSummary = friendlyNpcs.length > 0
|
||||
? `可能的角色:${friendlyNpcs.map(npc => `${npc.name}(${npc.role})`).join(',')}`
|
||||
: '可能的角色:暂无明确识别';
|
||||
const hostileSummary = hostileNpcs.length > 0
|
||||
? `可能的敌对角色:${hostileNpcs.map(npc => npc.name).join(',')}`
|
||||
: '可能的敌对角色:无明确威胁特征';
|
||||
const treasureSummary = scene.treasureHints.length > 0
|
||||
? `可能的宝藏线索:${scene.treasureHints.slice(0, 2).join(',')}`
|
||||
: '可能的宝藏线索:暂无发现';
|
||||
|
||||
const bossCandidate = hostileNpcs[0] ?? null;
|
||||
const bossSummary = bossCandidate
|
||||
? hostileNpcs.length >= 3
|
||||
? `Boss线索:${bossCandidate.name} 感觉是这里最强的敌对存在。${bossCandidate.description}`
|
||||
: `Boss线索:暂无明显首领,但${bossCandidate.name} 仍然是最需要警惕的危险威胁。`
|
||||
: 'Boss线索:暂无迹象指向该区域有明确首领。';
|
||||
|
||||
return `你稳住队伍,梳理${scene.name}周围隐藏的迹象。${npcSummary}。${hostileSummary}。${treasureSummary}。${bossSummary}`;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
type EditorNoticeTone = 'muted' | 'warning';
|
||||
|
||||
const TONE_CLASS_NAMES: Record<EditorNoticeTone, string> = {
|
||||
muted: 'text-xs text-zinc-400',
|
||||
warning: 'text-xs text-amber-200/90',
|
||||
};
|
||||
|
||||
export function EditorNotice({
|
||||
message,
|
||||
tone = 'muted',
|
||||
}: {
|
||||
message: string | null;
|
||||
tone?: EditorNoticeTone;
|
||||
}) {
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={TONE_CLASS_NAMES[tone]}>{message}</div>;
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { Save } from 'lucide-react';
|
||||
|
||||
import { EditorNotice } from './EditorNotice';
|
||||
|
||||
function safeNumber(value: number) {
|
||||
return Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function toNumber(value: string, fallback = 0) {
|
||||
const next = Number(value);
|
||||
return Number.isFinite(next) ? next : fallback;
|
||||
}
|
||||
|
||||
export type SelectFieldOption = {
|
||||
label: string;
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
export function TextField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1 text-xs font-medium text-zinc-300">{label}</div>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none transition focus:border-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function NumberField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
step = 1,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
min?: number;
|
||||
step?: number;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1 text-xs font-medium text-zinc-300">{label}</div>
|
||||
<input
|
||||
type="number"
|
||||
value={safeNumber(value)}
|
||||
min={min}
|
||||
step={step}
|
||||
onChange={(event) => onChange(toNumber(event.target.value, value))}
|
||||
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none transition focus:border-emerald-400/40"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextAreaField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
rows = 4,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1 text-xs font-medium text-zinc-300">{label}</div>
|
||||
<textarea
|
||||
rows={rows}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm leading-relaxed text-white outline-none transition focus:border-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
onChange: (value: string) => void;
|
||||
options: SelectFieldOption[];
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="mb-1 text-xs font-medium text-zinc-300">{label}</div>
|
||||
<select
|
||||
value={String(value)}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none transition focus:border-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={`${label}-${option.value}`} value={String(option.value)}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function SaveBar({
|
||||
saveLabel,
|
||||
onSave,
|
||||
isSaving,
|
||||
saveMessage,
|
||||
}: {
|
||||
saveLabel: string;
|
||||
onSave: () => void;
|
||||
isSaving: boolean;
|
||||
saveMessage: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-black transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
<span>{isSaving ? '保存中...' : saveLabel}</span>
|
||||
</button>
|
||||
<EditorNotice message={saveMessage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export function SectionCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={`rounded-2xl border border-white/10 bg-black/20 p-5 ${className}`}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<div className="text-sm font-semibold text-white">{title}</div>
|
||||
{description && (
|
||||
<div className="mt-1 text-xs leading-relaxed text-zinc-400">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
type StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { createStoryNpcEncounterActions } from './npcEncounterActions';
|
||||
import { createStoryNpcEncounterActions } from './useRpgRuntimeNpcInteraction';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export {
|
||||
createRpgRuntimeNpcEncounterActions as createStoryNpcEncounterActions,
|
||||
useRpgRuntimeNpcInteraction,
|
||||
type RpgRuntimeNpcInteractionResult,
|
||||
type UseRpgRuntimeNpcInteractionParams,
|
||||
} from './useRpgRuntimeNpcInteraction';
|
||||
@@ -1,230 +0,0 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import {
|
||||
hasEncounterEntity,
|
||||
interpolateEncounterTransitionState,
|
||||
} from '../../data/encounterTransition';
|
||||
import { STORY_OPENING_CAMP_DIALOGUE_FUNCTION } from '../../data/functionCatalog';
|
||||
import {
|
||||
CALL_OUT_ENTRY_X_METERS,
|
||||
RESOLVED_ENTITY_X_METERS,
|
||||
} from '../../data/sceneEncounterPreviews';
|
||||
import { getWorldCampScenePreset } from '../../data/scenePresets';
|
||||
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
|
||||
import { generateNextStep } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
|
||||
const ENCOUNTER_ENTRY_DURATION_MS = 1800;
|
||||
const ENCOUNTER_ENTRY_TICK_MS = 180;
|
||||
const OPENING_CAMP_DIALOGUE_FUNCTION_ID =
|
||||
STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
|
||||
|
||||
export type PreparedOpeningAdventure = {
|
||||
encounterKey: string;
|
||||
actionText: string;
|
||||
resultText: string;
|
||||
fallbackText: string;
|
||||
openingOptions: StoryOption[];
|
||||
};
|
||||
|
||||
export function buildPreparedOpeningAdventure({
|
||||
state,
|
||||
character,
|
||||
getNpcEncounterKey,
|
||||
appendHistory,
|
||||
buildCampCompanionOpeningOptions,
|
||||
buildCampCompanionOpeningResultText,
|
||||
buildInitialCompanionDialogueText,
|
||||
}: {
|
||||
state: GameState;
|
||||
character: Character;
|
||||
getNpcEncounterKey: (encounter: Encounter) => string;
|
||||
appendHistory: (
|
||||
state: GameState,
|
||||
actionText: string,
|
||||
resultText: string,
|
||||
) => GameState['storyHistory'];
|
||||
buildCampCompanionOpeningOptions: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
) => StoryOption[];
|
||||
buildCampCompanionOpeningResultText: (
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
worldType: GameState['worldType'],
|
||||
) => string;
|
||||
buildInitialCompanionDialogueText: (
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
worldType: GameState['worldType'],
|
||||
) => string;
|
||||
}): PreparedOpeningAdventure | null {
|
||||
const encounter = state.currentEncounter;
|
||||
if (
|
||||
!encounter ||
|
||||
encounter.kind !== 'npc' ||
|
||||
encounter.specialBehavior !== 'initial_companion'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const campScene = state.worldType
|
||||
? getWorldCampScenePreset(state.worldType)
|
||||
: null;
|
||||
const actionText = '开始冒险';
|
||||
const resultText = buildCampCompanionOpeningResultText(
|
||||
character,
|
||||
encounter,
|
||||
state.worldType,
|
||||
);
|
||||
const dialogueText = buildInitialCompanionDialogueText(
|
||||
character,
|
||||
encounter,
|
||||
state.worldType,
|
||||
);
|
||||
const resolvedEncounter: Encounter = {
|
||||
...encounter,
|
||||
specialBehavior: 'camp_companion',
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
};
|
||||
const resolvedState: GameState = {
|
||||
...state,
|
||||
currentScenePreset: campScene ?? state.currentScenePreset,
|
||||
currentEncounter: resolvedEncounter,
|
||||
npcInteractionActive: false,
|
||||
};
|
||||
const nextHistory = appendHistory(state, actionText, resultText);
|
||||
const stateWithHistory: GameState = {
|
||||
...resolvedState,
|
||||
storyHistory: nextHistory,
|
||||
};
|
||||
|
||||
return {
|
||||
encounterKey: getNpcEncounterKey(encounter),
|
||||
actionText,
|
||||
resultText,
|
||||
fallbackText: dialogueText,
|
||||
openingOptions: buildCampCompanionOpeningOptions(
|
||||
stateWithHistory,
|
||||
character,
|
||||
resolvedEncounter,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function playOpeningAdventureSequence({
|
||||
gameState,
|
||||
encounter,
|
||||
preparedStory,
|
||||
setGameState,
|
||||
setCurrentStory,
|
||||
setAiError,
|
||||
setIsLoading,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
character: Character;
|
||||
encounter: Encounter;
|
||||
preparedStory: PreparedOpeningAdventure;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
buildDialogueStoryMoment: (
|
||||
npcName: string,
|
||||
text: string,
|
||||
options: StoryOption[],
|
||||
streaming?: boolean,
|
||||
) => StoryMoment;
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: { lastFunctionId?: string | null },
|
||||
) => StoryGenerationContext;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
hasRenderableDialogueTurns: (text: string, npcName: string) => boolean;
|
||||
inferOpeningCampFollowupOptions: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
baseOptions: StoryOption[],
|
||||
openingBackground: string,
|
||||
openingDialogue: string,
|
||||
) => Promise<StoryOption[]>;
|
||||
getTypewriterDelay: (char: string) => number;
|
||||
}) {
|
||||
const { fallbackText, openingOptions } = preparedStory;
|
||||
const campScene = gameState.worldType
|
||||
? getWorldCampScenePreset(gameState.worldType)
|
||||
: null;
|
||||
const storyEncounter: Encounter = {
|
||||
...encounter,
|
||||
xMeters: RESOLVED_ENTITY_X_METERS,
|
||||
specialBehavior: 'camp_companion',
|
||||
};
|
||||
const resolvedState: GameState = {
|
||||
...gameState,
|
||||
currentScenePreset: campScene ?? gameState.currentScenePreset,
|
||||
currentEncounter: storyEncounter,
|
||||
npcInteractionActive: true,
|
||||
};
|
||||
|
||||
setAiError(null);
|
||||
setIsLoading(false);
|
||||
|
||||
try {
|
||||
setGameState(resolvedState);
|
||||
setCurrentStory({
|
||||
text: fallbackText,
|
||||
options: sortStoryOptionsByPriority(openingOptions),
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: encounter.npcName,
|
||||
text: fallbackText,
|
||||
},
|
||||
],
|
||||
streaming: false,
|
||||
npcChatState: {
|
||||
npcId: storyEncounter.id ?? storyEncounter.npcName,
|
||||
npcName: storyEncounter.npcName,
|
||||
turnCount: 0,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to play opening adventure sequence:', error);
|
||||
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
setGameState(resolvedState);
|
||||
setCurrentStory({
|
||||
text: fallbackText,
|
||||
options: sortStoryOptionsByPriority(openingOptions),
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: encounter.npcName,
|
||||
text: fallbackText,
|
||||
},
|
||||
],
|
||||
streaming: false,
|
||||
npcChatState: {
|
||||
npcId: storyEncounter.id ?? storyEncounter.npcName,
|
||||
npcName: storyEncounter.npcName,
|
||||
turnCount: 0,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { getWorldCampScenePreset } from '../../data/scenePresets';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type Encounter,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import {
|
||||
buildCampCompanionOpeningResultText,
|
||||
buildInitialCompanionDialogueText,
|
||||
createCampCompanionStoryHelpers,
|
||||
} from './storyCampCompanion';
|
||||
|
||||
function createCharacter(): Character {
|
||||
return {
|
||||
id: 'sword-princess',
|
||||
name: '测试同伴',
|
||||
title: '试剑公主',
|
||||
description: '在营地观察局势的试炼者。',
|
||||
backstory: '她在旅途中始终保留自己的真正目标。',
|
||||
avatar: '/hero.png',
|
||||
portrait: '/hero-portrait.png',
|
||||
assetFolder: 'hero',
|
||||
assetVariant: 'default',
|
||||
attributes: {
|
||||
strength: 12,
|
||||
agility: 10,
|
||||
intelligence: 8,
|
||||
spirit: 9,
|
||||
},
|
||||
personality: '谨慎冷静',
|
||||
skills: [],
|
||||
adventureOpenings: {
|
||||
[WorldType.WUXIA]: {
|
||||
reason: '调查旧路异动',
|
||||
goal: '查清前方局势',
|
||||
monologue: '风声里还藏着未说破的话。',
|
||||
surfaceHook: '我来这里,是为了确认旧路尽头到底出了什么事。',
|
||||
immediateConcern: '眼下的风向不对,我们不能直接把底牌亮出来。',
|
||||
guardedMotive: '我真正要找的东西,还不能让更多人知道。',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createOption(
|
||||
functionId: string,
|
||||
actionText = functionId,
|
||||
interaction?: StoryOption['interaction'],
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId,
|
||||
actionText,
|
||||
text: actionText,
|
||||
interaction,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
|
||||
return {
|
||||
id: 'camp-companion',
|
||||
kind: 'npc',
|
||||
characterId: 'sword-princess',
|
||||
npcName: '沈砺',
|
||||
npcDescription: '正靠在营地灯火旁观察风向。',
|
||||
npcAvatar: '/npc.png',
|
||||
context: '营地夜谈',
|
||||
specialBehavior: 'camp_companion',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createStory(text: string, options: StoryOption[] = []): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
function createGameState(overrides: Partial<GameState> = {}): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: createCharacter(),
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [],
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: getWorldCampScenePreset(WorldType.WUXIA),
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 30,
|
||||
playerMaxMana: 30,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('storyCampCompanion', () => {
|
||||
it('builds opening dialogue from the character adventure opening', () => {
|
||||
const text = buildInitialCompanionDialogueText(
|
||||
createCharacter(),
|
||||
createEncounter(),
|
||||
WorldType.WUXIA,
|
||||
);
|
||||
|
||||
expect(text).toContain('先和你打个招呼。');
|
||||
expect(text).toContain('我来这里,是为了确认旧路尽头到底出了什么事。');
|
||||
expect(text).toContain('眼下的风向不对,我们不能直接把底牌亮出来。');
|
||||
expect(text).toContain('我真正要找的东西,还不能让更多人知道。');
|
||||
expect(text).not.toContain('像是在等你把话接下去');
|
||||
});
|
||||
|
||||
it('summarizes the camp opening result with the current concern', () => {
|
||||
const text = buildCampCompanionOpeningResultText(
|
||||
createCharacter(),
|
||||
createEncounter(),
|
||||
WorldType.WUXIA,
|
||||
);
|
||||
|
||||
expect(text).toContain('沈砺 在');
|
||||
expect(text).toContain('眼下的风向不对');
|
||||
});
|
||||
|
||||
it('keeps the opening camp options focused on继续交谈', () => {
|
||||
const buildNpcStory = vi.fn(() =>
|
||||
createStory('营地开场', [
|
||||
createOption('npc_chat', '继续交谈'),
|
||||
createOption('npc_recruit', '邀请同行'),
|
||||
createOption('npc_trade', '查看货物'),
|
||||
]),
|
||||
);
|
||||
const helpers = createCampCompanionStoryHelpers({
|
||||
buildNpcStory,
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getNpcEncounterKey: vi.fn(() => 'camp-companion'),
|
||||
generateNextStep: vi.fn(),
|
||||
});
|
||||
|
||||
const options = helpers.buildCampCompanionOpeningOptions(
|
||||
createGameState(),
|
||||
createCharacter(),
|
||||
createEncounter(),
|
||||
);
|
||||
|
||||
expect(options.map((option) => option.functionId)).toEqual(['npc_chat']);
|
||||
});
|
||||
|
||||
it('uses AI follow-up options when the camp follow-up request succeeds and falls back on errors', async () => {
|
||||
const baseOptions = [
|
||||
createOption('npc_chat', '继续交谈', {
|
||||
kind: 'npc',
|
||||
npcId: 'camp-companion',
|
||||
action: 'chat',
|
||||
}),
|
||||
createOption('camp_travel_home_scene', '前往旧地点'),
|
||||
];
|
||||
const generateNextStep = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
storyText: '继续营地交谈',
|
||||
options: [
|
||||
createOption('npc_chat', '顺着刚才的话继续问下去'),
|
||||
createOption('camp_travel_home_scene', '先回云河渡'),
|
||||
],
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('llm failed'));
|
||||
const buildStoryContextFromState = vi.fn(() => ({
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 30,
|
||||
playerMaxMana: 30,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right' as const,
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
skillCooldowns: {},
|
||||
sceneId: 'camp',
|
||||
sceneName: '营地',
|
||||
sceneDescription: '营火微亮。',
|
||||
pendingSceneEncounter: false,
|
||||
}));
|
||||
const helpers = createCampCompanionStoryHelpers({
|
||||
buildNpcStory: vi.fn(),
|
||||
buildStoryContextFromState,
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getNpcEncounterKey: vi.fn(() => 'camp-companion'),
|
||||
generateNextStep,
|
||||
});
|
||||
const state = createGameState();
|
||||
const character = createCharacter();
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
const resolvedOptions = await helpers.inferOpeningCampFollowupOptions(
|
||||
state,
|
||||
character,
|
||||
baseOptions,
|
||||
'营地里风声微沉。',
|
||||
'你们刚交换完第一轮判断。',
|
||||
);
|
||||
const fallbackOptions = await helpers.inferOpeningCampFollowupOptions(
|
||||
state,
|
||||
character,
|
||||
baseOptions,
|
||||
'营地里风声微沉。',
|
||||
'你们刚交换完第一轮判断。',
|
||||
);
|
||||
|
||||
expect(buildStoryContextFromState).toHaveBeenCalledWith(
|
||||
state,
|
||||
expect.objectContaining({
|
||||
openingCampBackground: '营地里风声微沉。',
|
||||
openingCampDialogue: '你们刚交换完第一轮判断。',
|
||||
}),
|
||||
);
|
||||
expect(resolvedOptions).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
actionText: '顺着刚才的话继续问下去',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'camp-companion',
|
||||
action: 'chat',
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
functionId: 'camp_travel_home_scene',
|
||||
actionText: '先回云河渡',
|
||||
}),
|
||||
]);
|
||||
expect(fallbackOptions).toBe(baseOptions);
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reconstructs the opening camp chat context from story history and filters idle camp options', () => {
|
||||
const encounter = createEncounter();
|
||||
const buildNpcStory = vi.fn(() =>
|
||||
createStory('营地常态', [
|
||||
createOption('npc_chat', '继续交谈'),
|
||||
createOption('npc_leave', '结束对话'),
|
||||
createOption('npc_fight', '直接切磋'),
|
||||
createOption('npc_trade', '查看货物'),
|
||||
]),
|
||||
);
|
||||
const helpers = createCampCompanionStoryHelpers({
|
||||
buildNpcStory,
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
getStoryGenerationHostileNpcs: vi.fn(() => []),
|
||||
getNpcEncounterKey: vi.fn(() => 'camp-companion'),
|
||||
generateNextStep: vi.fn(),
|
||||
});
|
||||
const state = createGameState({
|
||||
currentEncounter: encounter,
|
||||
npcStates: {
|
||||
'camp-companion': {
|
||||
affinity: 16,
|
||||
helpUsed: false,
|
||||
chattedCount: 1,
|
||||
giftsGiven: 0,
|
||||
inventory: [],
|
||||
recruited: false,
|
||||
},
|
||||
},
|
||||
storyHistory: [
|
||||
{
|
||||
text: `在营地与 ${encounter.npcName} 交换开场判断`,
|
||||
options: [],
|
||||
historyRole: 'action',
|
||||
},
|
||||
{
|
||||
text: '你们先对了一遍眼前局势。',
|
||||
options: [],
|
||||
historyRole: 'result',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const chatContext = helpers.buildOpeningCampChatContext(
|
||||
state,
|
||||
createCharacter(),
|
||||
encounter,
|
||||
);
|
||||
const idleStory = helpers.buildCampCompanionIdleStory(
|
||||
state,
|
||||
createCharacter(),
|
||||
encounter,
|
||||
);
|
||||
|
||||
expect(chatContext).toEqual(
|
||||
expect.objectContaining({
|
||||
openingCampBackground: expect.stringContaining('沈砺 在'),
|
||||
openingCampDialogue: '你们先对了一遍眼前局势。',
|
||||
}),
|
||||
);
|
||||
expect(idleStory.options.map((option) => option.functionId)).toEqual([
|
||||
'npc_chat',
|
||||
'npc_trade',
|
||||
'camp_travel_home_scene',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,293 +0,0 @@
|
||||
import {
|
||||
getCharacterAdventureOpening,
|
||||
getCharacterHomeSceneId,
|
||||
} from '../../data/characterPresets';
|
||||
import {
|
||||
buildCampTravelHomeOption,
|
||||
NPC_CHAT_FUNCTION,
|
||||
NPC_FIGHT_FUNCTION,
|
||||
NPC_LEAVE_FUNCTION,
|
||||
} from '../../data/functionCatalog';
|
||||
import {
|
||||
buildInitialNpcState,
|
||||
buildNpcChatOpeningText,
|
||||
} from '../../data/npcInteractions';
|
||||
import {
|
||||
getForwardScenePreset,
|
||||
getScenePresetById,
|
||||
getTravelScenePreset,
|
||||
getWorldCampScenePreset,
|
||||
} from '../../data/scenePresets';
|
||||
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
|
||||
import type { StoryGenerationContext } from '../../services/aiService';
|
||||
import type {
|
||||
Character,
|
||||
Encounter,
|
||||
GameState,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { resolveStoryResponseOptions } from './storyResponseOptions';
|
||||
|
||||
type BuildNpcStory = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
) => StoryMoment;
|
||||
|
||||
type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
type GetStoryGenerationHostileNpcs = (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
|
||||
type GetNpcEncounterKey = (encounter: Encounter) => string;
|
||||
|
||||
type GenerateNextStep =
|
||||
(typeof import('../../services/aiService'))['generateNextStep'];
|
||||
|
||||
export function buildInitialCompanionDialogueText(
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
worldType: WorldType | null,
|
||||
) {
|
||||
const resolvedEncounter =
|
||||
encounter.characterId === character.id
|
||||
? encounter
|
||||
: {
|
||||
...encounter,
|
||||
characterId: encounter.characterId ?? character.id,
|
||||
};
|
||||
const initialNpcState = buildInitialNpcState(resolvedEncounter, worldType);
|
||||
return buildNpcChatOpeningText(
|
||||
resolvedEncounter,
|
||||
initialNpcState,
|
||||
worldType,
|
||||
character,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCampCompanionOpeningResultText(
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
worldType: WorldType | null,
|
||||
) {
|
||||
const opening = getCharacterAdventureOpening(character, worldType);
|
||||
const campSceneName = worldType
|
||||
? (getWorldCampScenePreset(worldType)?.name ?? '归处')
|
||||
: '归处';
|
||||
if (!opening) {
|
||||
return `${encounter.npcName} 已经来到你身边。在${campSceneName},你稍作停顿,决定下一步去向何方。`;
|
||||
}
|
||||
|
||||
return `${encounter.npcName} 在${campSceneName}来到你身边。你们首先就“${opening.immediateConcern ?? '前方不稳定的道路'}”交换了意见,而双方都暂时保留了部分真相。`;
|
||||
}
|
||||
|
||||
function getCampCompanionHomeScene(state: GameState, character: Character) {
|
||||
if (!state.worldType) return null;
|
||||
const sceneId = getCharacterHomeSceneId(state.worldType, character.id);
|
||||
return getScenePresetById(state.worldType, sceneId);
|
||||
}
|
||||
|
||||
export function createCampCompanionStoryHelpers(params: {
|
||||
buildNpcStory: BuildNpcStory;
|
||||
buildStoryContextFromState: BuildStoryContextFromState;
|
||||
getStoryGenerationHostileNpcs: GetStoryGenerationHostileNpcs;
|
||||
getNpcEncounterKey: GetNpcEncounterKey;
|
||||
generateNextStep: GenerateNextStep;
|
||||
}) {
|
||||
const getCampCompanionTravelScene = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => {
|
||||
if (!state.worldType) return null;
|
||||
|
||||
const campScene = getWorldCampScenePreset(state.worldType);
|
||||
const homeScene = getCampCompanionHomeScene(state, character);
|
||||
if (
|
||||
homeScene &&
|
||||
homeScene.id !== campScene?.id &&
|
||||
homeScene.id !== state.currentScenePreset?.id
|
||||
) {
|
||||
return homeScene;
|
||||
}
|
||||
|
||||
const fallbackSceneId =
|
||||
campScene?.id ?? state.currentScenePreset?.id ?? null;
|
||||
return (
|
||||
getForwardScenePreset(state.worldType, fallbackSceneId) ??
|
||||
getTravelScenePreset(state.worldType, fallbackSceneId) ??
|
||||
homeScene
|
||||
);
|
||||
};
|
||||
|
||||
const buildCampCompanionOpeningOptions = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
) => {
|
||||
const baseOptions = params.buildNpcStory(
|
||||
state,
|
||||
character,
|
||||
encounter,
|
||||
).options;
|
||||
return baseOptions
|
||||
.filter((option) => option.functionId === NPC_CHAT_FUNCTION.id)
|
||||
.slice(0, 3);
|
||||
};
|
||||
|
||||
const inferOpeningCampFollowupOptions = async (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
baseOptions: StoryOption[],
|
||||
openingBackground: string,
|
||||
openingDialogue: string,
|
||||
) => {
|
||||
if (!state.worldType || baseOptions.length === 0) {
|
||||
return baseOptions;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await params.generateNextStep(
|
||||
state.worldType,
|
||||
character,
|
||||
params.getStoryGenerationHostileNpcs(state),
|
||||
state.storyHistory,
|
||||
'继续承接营地中的这段交谈,并整理出眼下最自然的后续行动。',
|
||||
params.buildStoryContextFromState(state, {
|
||||
openingCampBackground: openingBackground,
|
||||
openingCampDialogue: openingDialogue,
|
||||
}),
|
||||
{
|
||||
availableOptions: baseOptions,
|
||||
},
|
||||
);
|
||||
|
||||
return resolveStoryResponseOptions({
|
||||
responseOptions: response.options,
|
||||
availableOptions: baseOptions,
|
||||
getSanitizedOptions: () => sortStoryOptionsByPriority(baseOptions),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to infer opening camp follow-up options:', error);
|
||||
return baseOptions;
|
||||
}
|
||||
};
|
||||
|
||||
const buildOpeningCampChatContext = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
) => {
|
||||
if (encounter.specialBehavior !== 'camp_companion') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const npcState =
|
||||
state.npcStates[params.getNpcEncounterKey(encounter)] ??
|
||||
buildInitialNpcState(encounter, state.worldType, state);
|
||||
if (npcState.chattedCount > 2) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const openingActionText = `在营地与 ${encounter.npcName} 交换开场判断`;
|
||||
let openingDialogue: string | null = null;
|
||||
|
||||
for (let index = 0; index < state.storyHistory.length - 1; index += 1) {
|
||||
const entry = state.storyHistory[index];
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
if (entry.historyRole !== 'action' || entry.text !== openingActionText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (
|
||||
let nextIndex = index + 1;
|
||||
nextIndex < state.storyHistory.length;
|
||||
nextIndex += 1
|
||||
) {
|
||||
const nextEntry = state.storyHistory[nextIndex];
|
||||
if (!nextEntry) {
|
||||
continue;
|
||||
}
|
||||
if (nextEntry.historyRole === 'action') {
|
||||
break;
|
||||
}
|
||||
if (nextEntry.text.trim()) {
|
||||
openingDialogue = nextEntry.text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (openingDialogue) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!openingDialogue) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
openingCampBackground: buildCampCompanionOpeningResultText(
|
||||
character,
|
||||
encounter,
|
||||
state.worldType,
|
||||
),
|
||||
openingCampDialogue: openingDialogue,
|
||||
};
|
||||
};
|
||||
|
||||
const buildCampCompanionIdleStory = (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
encounter: Encounter,
|
||||
overrideText?: string,
|
||||
): StoryMoment => {
|
||||
const targetScene = getCampCompanionTravelScene(state, character);
|
||||
const baseStory = params.buildNpcStory(
|
||||
state,
|
||||
character,
|
||||
encounter,
|
||||
overrideText,
|
||||
);
|
||||
const filteredOptions = baseStory.options.filter(
|
||||
(option) =>
|
||||
option.functionId !== NPC_LEAVE_FUNCTION.id &&
|
||||
option.functionId !== NPC_FIGHT_FUNCTION.id,
|
||||
);
|
||||
|
||||
if (!targetScene) {
|
||||
return {
|
||||
...baseStory,
|
||||
options: filteredOptions,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...baseStory,
|
||||
options: [
|
||||
...filteredOptions.slice(0, 2),
|
||||
buildCampTravelHomeOption(targetScene.name),
|
||||
...filteredOptions.slice(2),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
getCampCompanionTravelScene,
|
||||
buildCampCompanionOpeningOptions,
|
||||
inferOpeningCampFollowupOptions,
|
||||
buildOpeningCampChatContext,
|
||||
buildCampCompanionIdleStory,
|
||||
};
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
import type {
|
||||
Character,
|
||||
GameState,
|
||||
StoryDialogueTurn,
|
||||
StoryMoment,
|
||||
StoryOption,
|
||||
} from '../../types';
|
||||
import {
|
||||
buildFallbackStoryMoment,
|
||||
normalizeSkillProbabilities,
|
||||
} from '../combatStoryUtils';
|
||||
|
||||
const MIN_OPTION_POOL_SIZE = 6;
|
||||
|
||||
export function dedupeStoryOptions(options: StoryOption[]) {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return options.filter((option) => {
|
||||
const identity = `${option.functionId}::${option.actionText}::${option.text}`;
|
||||
if (seen.has(identity)) return false;
|
||||
seen.add(identity);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildLocalCharacterChatSummary(
|
||||
character: Character,
|
||||
history: Array<{ speaker: 'player' | 'character'; text: string }>,
|
||||
previousSummary: string,
|
||||
) {
|
||||
const latestTurns = history
|
||||
.slice(-4)
|
||||
.map(
|
||||
(turn) =>
|
||||
`${turn.speaker === 'player' ? '玩家' : character.name}:${turn.text}`,
|
||||
)
|
||||
.join(' ');
|
||||
|
||||
const currentSummary = latestTurns
|
||||
? `${character.name}在私下交谈里变得更愿意坦率回应。最近交流:${latestTurns}`
|
||||
: `${character.name}愿意继续私下交谈,对玩家的信任也在慢慢加深。`;
|
||||
if (!previousSummary) {
|
||||
return currentSummary.slice(0, 118);
|
||||
}
|
||||
|
||||
return `${previousSummary} ${currentSummary}`.slice(0, 118);
|
||||
}
|
||||
|
||||
export function buildLocalCharacterChatSuggestions(character: Character) {
|
||||
return [
|
||||
'我想听你把这件事再说得更明白一点。',
|
||||
`${character.name},你现在真正担心的是什么?`,
|
||||
'先把外面的局势放一放,我想更了解你一些。',
|
||||
];
|
||||
}
|
||||
|
||||
export function sanitizeOptions(
|
||||
options: StoryOption[],
|
||||
character: Character,
|
||||
state: GameState,
|
||||
) {
|
||||
const normalizedOptions = dedupeStoryOptions(
|
||||
options.map((option) => normalizeSkillProbabilities(option, character)),
|
||||
);
|
||||
|
||||
if (normalizedOptions.length === 0) {
|
||||
return buildFallbackStoryMoment(state, character).options;
|
||||
}
|
||||
|
||||
if (normalizedOptions.length >= MIN_OPTION_POOL_SIZE) {
|
||||
return normalizedOptions;
|
||||
}
|
||||
|
||||
return dedupeStoryOptions([
|
||||
...normalizedOptions,
|
||||
...buildFallbackStoryMoment(state, character).options,
|
||||
]).slice(0, MIN_OPTION_POOL_SIZE);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
const specialChars = [
|
||||
'\\',
|
||||
'^',
|
||||
'$',
|
||||
'*',
|
||||
'+',
|
||||
'?',
|
||||
'.',
|
||||
'(',
|
||||
')',
|
||||
'|',
|
||||
'[',
|
||||
']',
|
||||
'{',
|
||||
'}',
|
||||
];
|
||||
return specialChars.reduce(
|
||||
(escaped, char) => escaped.split(char).join('\\' + char),
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDialogueSpeakerName(rawSpeakerName: string) {
|
||||
return rawSpeakerName
|
||||
.trim()
|
||||
.replace(
|
||||
/^[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+/u,
|
||||
'',
|
||||
)
|
||||
.replace(
|
||||
/[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+$/u,
|
||||
'',
|
||||
)
|
||||
.replace(/^(?:\u540c\u4f34|\u961f\u53cb)\s*/u, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseDialogueTurns(
|
||||
text: string,
|
||||
npcName: string,
|
||||
): StoryDialogueTurn[] {
|
||||
const turns: StoryDialogueTurn[] = [];
|
||||
const dialogueColonPattern = '(?:\\uFF1A|:)';
|
||||
const playerPrefixPattern = new RegExp(
|
||||
'^(?:\\\\u4f60|\\\\u73a9\\\\u5bb6|\\\\u4e3b\\\\u89d2)\\\\s*' +
|
||||
dialogueColonPattern +
|
||||
'\\\\s*(.+)$',
|
||||
'u',
|
||||
);
|
||||
const npcPrefixPattern = new RegExp(
|
||||
'^' +
|
||||
escapeRegExp(npcName) +
|
||||
'\\\\s*' +
|
||||
dialogueColonPattern +
|
||||
'\\\\s*(.+)$',
|
||||
'u',
|
||||
);
|
||||
const namedSpeakerPattern = new RegExp(
|
||||
'^(.{1,24}?)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$',
|
||||
'u',
|
||||
);
|
||||
const lines = text
|
||||
.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const line of lines) {
|
||||
const playerMatch = line.match(playerPrefixPattern);
|
||||
const playerText = playerMatch?.[1]?.trim();
|
||||
if (playerText) {
|
||||
turns.push({ speaker: 'player', text: playerText });
|
||||
continue;
|
||||
}
|
||||
|
||||
const npcMatch = line.match(npcPrefixPattern);
|
||||
const npcText = npcMatch?.[1]?.trim();
|
||||
if (npcText) {
|
||||
turns.push({ speaker: 'npc', speakerName: npcName, text: npcText });
|
||||
continue;
|
||||
}
|
||||
|
||||
const namedSpeakerMatch = line.match(namedSpeakerPattern);
|
||||
if (namedSpeakerMatch) {
|
||||
const rawSpeakerName = namedSpeakerMatch[1];
|
||||
const rawSpeakerText = namedSpeakerMatch[2];
|
||||
if (!rawSpeakerName || !rawSpeakerText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const speakerName = normalizeDialogueSpeakerName(rawSpeakerName);
|
||||
const speakerText = rawSpeakerText.trim();
|
||||
|
||||
if (speakerName && speakerText) {
|
||||
turns.push({
|
||||
speaker: speakerName === npcName ? 'npc' : 'companion',
|
||||
speakerName,
|
||||
text: speakerText,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (line.startsWith('你:') || line.startsWith('你:')) {
|
||||
turns.push({ speaker: 'player', text: line.slice(2).trim() });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith(npcName + ':') || line.startsWith(npcName + ':')) {
|
||||
turns.push({
|
||||
speaker: 'npc',
|
||||
text: line.slice(npcName.length + 1).trim(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('主角:') || line.startsWith('主角:')) {
|
||||
turns.push({ speaker: 'player', text: line.slice(3).trim() });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (turns.length > 0) {
|
||||
const lastTurnIndex = turns.length - 1;
|
||||
const lastTurn = turns[lastTurnIndex];
|
||||
if (lastTurn) {
|
||||
turns[lastTurnIndex] = {
|
||||
...lastTurn,
|
||||
text: lastTurn.text + line,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return turns.filter((turn) => turn.text.length > 0);
|
||||
}
|
||||
|
||||
export function buildDialogueStoryMoment(
|
||||
npcName: string,
|
||||
text: string,
|
||||
options: StoryOption[],
|
||||
streaming = false,
|
||||
): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options,
|
||||
displayMode: 'dialogue',
|
||||
dialogue: parseDialogueTurns(text, npcName),
|
||||
streaming,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasRenderableDialogueTurns(text: string, npcName: string) {
|
||||
return parseDialogueTurns(text, npcName).length >= 2;
|
||||
}
|
||||
|
||||
export function getTypewriterDelay(char: string) {
|
||||
if (/[。!?!?]/u.test(char)) return 240;
|
||||
if (/[,、;;:]/u.test(char)) return 150;
|
||||
if (/\s/u.test(char)) return 45;
|
||||
return 90;
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import type {QuestGenerationContext} from '../services/aiTypes';
|
||||
import type {QuestOpportunity, QuestSceneSnapshot} from '../services/questTypes';
|
||||
import { buildQuestVisibilitySlice } from '../services/storyEngine/visibilityEngine';
|
||||
|
||||
function describeWorld(worldType: QuestGenerationContext['worldType']) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '边城模板';
|
||||
case 'XIANXIA':
|
||||
return '灵潮模板';
|
||||
case 'CUSTOM':
|
||||
return '自定义世界';
|
||||
default:
|
||||
return '未知世界';
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeRecentStoryMoments(context: QuestGenerationContext) {
|
||||
const moments = context.recentStoryMoments
|
||||
.slice(-4)
|
||||
.map(moment => `- ${moment.text}`)
|
||||
.join('\n');
|
||||
|
||||
return moments || '- 暂无近期剧情记录';
|
||||
}
|
||||
|
||||
function summarizeCurrentQuests(context: QuestGenerationContext) {
|
||||
const summary = context.currentQuestSummary?.map(quest =>
|
||||
`- ${quest.title}(${quest.status}),发布者 ${quest.issuerNpcId}`,
|
||||
).join('\n');
|
||||
|
||||
return summary || '- 当前没有进行中的任务';
|
||||
}
|
||||
|
||||
function summarizeCompanions(context: QuestGenerationContext) {
|
||||
const active = context.activeCompanions?.map(companion => companion.characterId).join('、') || '无';
|
||||
const roster = context.rosterCompanions?.map(companion => companion.characterId).join('、') || '无';
|
||||
return `当前同行角色:${active}\n队伍名册:${roster}`;
|
||||
}
|
||||
|
||||
function summarizePlayerState(context: QuestGenerationContext) {
|
||||
const playerName = context.playerCharacter?.name ?? '未知角色';
|
||||
const playerTitle = context.playerCharacter?.title ?? '未知称号';
|
||||
const hp = `${context.playerHp ?? 0}/${context.playerMaxHp ?? 0}`;
|
||||
const mana = `${context.playerMana ?? 0}/${context.playerMaxMana ?? 0}`;
|
||||
const inventory = context.playerInventory?.slice(0, 8).map(item => item.name).join('、') || '无';
|
||||
|
||||
return [
|
||||
`玩家:${playerName}(${playerTitle})`,
|
||||
`生命:${hp}`,
|
||||
`灵力:${mana}`,
|
||||
`背包快照:${inventory}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function summarizeScene(scene: QuestSceneSnapshot | null, context: QuestGenerationContext) {
|
||||
const hostileNpcIds = context.currentSceneHostileNpcIds?.join('、') || '无';
|
||||
const treasureHintCount = context.currentSceneTreasureHintCount ?? 0;
|
||||
|
||||
return [
|
||||
`场景:${scene?.name ?? context.currentSceneName ?? '未知区域'}`,
|
||||
`场景描述:${scene?.description ?? context.currentSceneDescription ?? '暂无'}`,
|
||||
`敌对角色 ID:${hostileNpcIds}`,
|
||||
`宝藏线索数量:${treasureHintCount}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function summarizeActiveThreads(context: QuestGenerationContext) {
|
||||
if (!context.activeThreadIds?.length) {
|
||||
return '暂无明确激活线程';
|
||||
}
|
||||
|
||||
const storyGraph = context.customWorldProfile?.storyGraph;
|
||||
const labels = context.activeThreadIds.map((threadId) =>
|
||||
[...(storyGraph?.visibleThreads ?? []), ...(storyGraph?.hiddenThreads ?? [])]
|
||||
.find((thread) => thread.id === threadId)?.title ?? threadId,
|
||||
);
|
||||
|
||||
return labels.join('、');
|
||||
}
|
||||
|
||||
function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) {
|
||||
const profile = context.issuerNarrativeProfile;
|
||||
if (!profile) {
|
||||
return '暂无额外叙事档案';
|
||||
}
|
||||
|
||||
return [
|
||||
`公开面:${profile.publicMask}`,
|
||||
`表层线:${profile.visibleLine}`,
|
||||
`当前压力:${profile.immediatePressure}`,
|
||||
profile.reactionHooks.length > 0
|
||||
? `反应钩子:${profile.reactionHooks.join('、')}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function summarizeQuestVisibility(context: QuestGenerationContext) {
|
||||
const slice = buildQuestVisibilitySlice({
|
||||
issuerNarrativeProfile: context.issuerNarrativeProfile,
|
||||
activeThreadIds: context.activeThreadIds,
|
||||
});
|
||||
|
||||
return [
|
||||
`可直接披露:${slice.sayableFactIds.join('、') || '无'}`,
|
||||
`只宜写成推测:${slice.inferredFactIds.join('、') || '无'}`,
|
||||
`禁止直接说破:${slice.forbiddenFactIds.join('、') || '无'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export const QUEST_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的任务导演。
|
||||
只返回 JSON,不要输出 Markdown。
|
||||
|
||||
输出结构:
|
||||
{
|
||||
"intent": {
|
||||
"title": "中文任务标题",
|
||||
"description": "中文任务描述",
|
||||
"summary": "中文短摘要",
|
||||
"narrativeType": "bounty|escort|investigation|retrieval|relationship|trial",
|
||||
"dramaticNeed": "string",
|
||||
"issuerGoal": "string",
|
||||
"playerHook": "string",
|
||||
"worldReason": "string",
|
||||
"recommendedObjectiveKinds": ["defeat_hostile_npc" | "inspect_treasure" | "spar_with_npc" | "talk_to_npc" | "reach_scene" | "deliver_item"],
|
||||
"urgency": "low|medium|high",
|
||||
"intimacy": "transactional|cooperative|trust_based",
|
||||
"rewardTheme": "currency|resource|relationship|intel|rare_item",
|
||||
"followupHooks": ["string"]
|
||||
}
|
||||
}
|
||||
|
||||
规则:
|
||||
- 所有自然语言字段都必须使用中文。
|
||||
- 任务必须扎根于当前场景、发布者和近期剧情。
|
||||
- 不要编造奖励、ID、数量、状态或不受支持的规则变化。
|
||||
- recommendedObjectiveKinds 只是语义建议,不是硬编码结果。
|
||||
- 优先给出简洁、可玩的任务框架,不要堆砌华丽辞藻。
|
||||
- title 必须是 4 到 10 个中文字符左右的短任务名,不要写成长句。
|
||||
- description 解释任务为什么在当前剧情里成立,避免纯规则说明。
|
||||
- summary 必须是清晰达成条件,优先使用“击败 / 调查 / 返回 / 前往 / 交付 / 切磋”等明确动词,不要写“处理一下”“看看情况”这类模糊表达。`;
|
||||
|
||||
export function buildQuestIntentPrompt(params: {
|
||||
context: QuestGenerationContext;
|
||||
scene: QuestSceneSnapshot | null;
|
||||
opportunity: QuestOpportunity;
|
||||
}) {
|
||||
const {context, scene, opportunity} = params;
|
||||
const customWorldSummary = context.customWorldProfile
|
||||
? `${context.customWorldProfile.name}: ${context.customWorldProfile.summary}`
|
||||
: '无';
|
||||
|
||||
return [
|
||||
`世界:${describeWorld(context.worldType)}`,
|
||||
`自定义世界摘要:${customWorldSummary}`,
|
||||
`发布角色:${context.issuerNpcName ?? '未知'}(${context.issuerNpcId ?? '未知'})`,
|
||||
`发布者身份:${context.issuerNpcContext ?? '暂无'}`,
|
||||
`发布者好感:${context.issuerAffinity ?? 0}`,
|
||||
`发布者信息揭示阶段:${context.issuerDisclosureStage ?? '未知'}`,
|
||||
`发布者亲疏阶段:${context.issuerWarmthStage ?? '未知'}`,
|
||||
`当前激活线程:${summarizeActiveThreads(context)}`,
|
||||
`发布者叙事档案:\n${summarizeIssuerNarrativeProfile(context)}`,
|
||||
`任务可见性切片:\n${summarizeQuestVisibility(context)}`,
|
||||
`当前遭遇类型:${context.encounterKind ?? '无'}`,
|
||||
summarizeScene(scene, context),
|
||||
summarizePlayerState(context),
|
||||
summarizeCompanions(context),
|
||||
`当前任务机会:${opportunity.reason}`,
|
||||
`当前任务列表:\n${summarizeCurrentQuests(context)}`,
|
||||
`近期剧情片段:\n${summarizeRecentStoryMoments(context)}`,
|
||||
'现在请基于这次具体局势,生成一个自然生长出来的任务意图。',
|
||||
].join('\n\n');
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import {
|
||||
buildRuntimeItemAiIntent,
|
||||
buildRuntimeItemAiPromptInput,
|
||||
} from '../data/runtimeItemNarrative';
|
||||
import type {
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
RuntimeRelationAnchor,
|
||||
} from '../types';
|
||||
import { buildRuntimeItemStoryFingerprint } from '../services/storyEngine/carrierNarrativeCompiler';
|
||||
import { buildCarrierVisibilitySlice } from '../services/storyEngine/visibilityEngine';
|
||||
|
||||
function describeRelationAnchor(anchor: RuntimeRelationAnchor) {
|
||||
switch (anchor.type) {
|
||||
case 'npc':
|
||||
return `NPC:${anchor.npcName}`;
|
||||
case 'scene':
|
||||
return `场景:${anchor.sceneName}`;
|
||||
case 'monster':
|
||||
return `怪物:${anchor.monsterName}`;
|
||||
case 'quest':
|
||||
return `任务:${anchor.questName}`;
|
||||
case 'faction':
|
||||
return `势力:${anchor.factionName}`;
|
||||
default:
|
||||
return `地标:${anchor.landmarkName}`;
|
||||
}
|
||||
}
|
||||
|
||||
function describeCarrierFactId(factId: string) {
|
||||
if (factId === 'visibleClue') return '可见线索';
|
||||
if (factId === 'currentAppearanceReason') return '当前出现理由';
|
||||
if (factId === 'witnessMark') return '见证痕';
|
||||
if (factId === 'unresolvedQuestion') return '未完成问题';
|
||||
if (factId.startsWith('thread:')) return `线程索引(${factId.slice('thread:'.length)})`;
|
||||
return factId;
|
||||
}
|
||||
|
||||
function describePlan(
|
||||
context: RuntimeItemGenerationContext,
|
||||
plan: RuntimeItemPlan,
|
||||
index: number,
|
||||
) {
|
||||
const promptInput = buildRuntimeItemAiPromptInput(context, plan);
|
||||
const fallbackIntent = buildRuntimeItemAiIntent(context, plan);
|
||||
const fallbackFingerprint = buildRuntimeItemStoryFingerprint({
|
||||
context,
|
||||
plan,
|
||||
intent: fallbackIntent,
|
||||
});
|
||||
const visibilitySlice = buildCarrierVisibilitySlice({
|
||||
activeThreadIds: context.activeThreadIds,
|
||||
storyFingerprint: fallbackFingerprint,
|
||||
});
|
||||
|
||||
return [
|
||||
`物品 ${index + 1}`,
|
||||
`- slot: ${plan.slot}`,
|
||||
`- 物品类型: ${promptInput.desiredItemKind}`,
|
||||
`- 持续性: ${promptInput.permanence}`,
|
||||
`- 关系锚点: ${describeRelationAnchor(plan.relationAnchor)}`,
|
||||
`- 世界摘要: ${promptInput.worldSummary}`,
|
||||
`- 场景摘要: ${promptInput.sceneSummary || '无'}`,
|
||||
`- 遭遇摘要: ${promptInput.encounterSummary || '无'}`,
|
||||
`- 相关人物: ${promptInput.relatedNpcSummary}`,
|
||||
`- 当前激活线程: ${promptInput.activeThreadSummary || '暂无'}`,
|
||||
`- 近期剧情: ${promptInput.recentStorySummary}`,
|
||||
`- 玩家当前 build: ${promptInput.playerBuildDirection.join('、') || '均衡'}`,
|
||||
`- 玩家待补缺口: ${promptInput.playerBuildGaps.join('、') || '无明显缺口'}`,
|
||||
`- 本次目标方向: ${plan.targetBuildDirection.join('、') || '均衡'}`,
|
||||
`- 物件可直写线索: ${visibilitySlice.sayableFactIds.map(describeCarrierFactId).join('、') || '无'}`,
|
||||
`- 物件只宜暗写: ${visibilitySlice.inferredFactIds.map(describeCarrierFactId).join('、') || '无'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。
|
||||
你只返回 JSON,不要输出 Markdown、解释或代码块。
|
||||
|
||||
输出结构:
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"shortNameSeed": "中文短种子",
|
||||
"sourcePhrase": "中文来源短语",
|
||||
"reasonToAppear": "中文出现理由",
|
||||
"relationHooks": ["中文关系钩子"],
|
||||
"desiredBuildTags": ["中文 build 标签"],
|
||||
"desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"],
|
||||
"tone": "grim|mysterious|martial|ritual|survival",
|
||||
"visibleClue": "玩家第一眼能抓到的痕迹",
|
||||
"witnessMark": "它见证过什么的使用痕",
|
||||
"unfinishedBusiness": "背后仍未结清的问题",
|
||||
"hiddenHook": "更深一层但别直接讲穿的钩子",
|
||||
"reactionHooks": ["以后谁会对它起反应"],
|
||||
"namingPattern": "命名范式建议"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
规则:
|
||||
- intents 数量必须与输入物品数量完全一致,顺序也必须一致。
|
||||
- 所有自然语言字段都必须使用中文。
|
||||
- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。
|
||||
- visibleClue / witnessMark / unfinishedBusiness / hiddenHook 要优先围绕当前激活线程、相关角色压力和旧痕来写。
|
||||
- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。
|
||||
- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。
|
||||
- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`;
|
||||
|
||||
export function buildRuntimeItemIntentPrompt(params: {
|
||||
context: RuntimeItemGenerationContext;
|
||||
plans: RuntimeItemPlan[];
|
||||
}) {
|
||||
return [
|
||||
`生成渠道:${params.context.generationChannel}`,
|
||||
`以下每个物品都需要给出一条可编译的运行时物品意图。`,
|
||||
...params.plans.map((plan, index) => describePlan(params.context, plan, index)),
|
||||
'请严格返回 JSON。',
|
||||
].join('\n\n');
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import {
|
||||
getNpcDisclosureStage,
|
||||
getNpcWarmthStage,
|
||||
} from '../data/npcInteractions';
|
||||
import { evaluateQuestOpportunity } from '../data/questFlow';
|
||||
import type { Encounter, GameState, QuestLogEntry } from '../types';
|
||||
import type { QuestGenerationContext } from './aiTypes';
|
||||
import { requestJson } from './apiClient';
|
||||
import type { QuestPreviewRequest } from './questTypes';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
} from './storyEngine/actorNarrativeProfile';
|
||||
import { buildThemePackFromWorldProfile } from './storyEngine/themePack';
|
||||
import { buildFallbackWorldStoryGraph } from './storyEngine/worldStoryGraph';
|
||||
|
||||
function resolveIssuerNarrativeProfile(state: GameState, encounter: Encounter) {
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildQuestGenerationContextFromState(params: {
|
||||
state: GameState;
|
||||
encounter: Encounter;
|
||||
}): QuestGenerationContext {
|
||||
const { state, encounter } = params;
|
||||
const issuerNpcId = encounter.id ?? encounter.npcName;
|
||||
const issuerState = state.npcStates[issuerNpcId];
|
||||
const issuerNarrativeProfile = resolveIssuerNarrativeProfile(
|
||||
state,
|
||||
encounter,
|
||||
);
|
||||
|
||||
return {
|
||||
worldType: state.worldType,
|
||||
customWorldProfile: state.customWorldProfile ?? null,
|
||||
actState: state.storyEngineMemory?.actState ?? null,
|
||||
currentSceneId: state.currentScenePreset?.id ?? null,
|
||||
currentSceneName: state.currentScenePreset?.name ?? null,
|
||||
currentSceneDescription: state.currentScenePreset?.description ?? null,
|
||||
issuerNpcId,
|
||||
issuerNpcName: encounter.npcName,
|
||||
issuerNpcContext: encounter.context,
|
||||
issuerAffinity: issuerState?.affinity ?? 0,
|
||||
issuerNarrativeProfile,
|
||||
issuerDisclosureStage: getNpcDisclosureStage(issuerState?.affinity ?? 0),
|
||||
issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0),
|
||||
activeThreadIds:
|
||||
state.storyEngineMemory?.activeThreadIds?.slice(0, 4) ??
|
||||
issuerNarrativeProfile?.relatedThreadIds?.slice(0, 4) ??
|
||||
[],
|
||||
encounterKind: encounter.kind ?? 'npc',
|
||||
currentSceneTreasureHintCount:
|
||||
state.currentScenePreset?.treasureHints?.length ?? 0,
|
||||
currentSceneHostileNpcIds: (state.currentScenePreset?.npcs ?? [])
|
||||
.filter((npc) => Boolean(npc.hostile || npc.monsterPresetId))
|
||||
.map((npc) => npc.id),
|
||||
recentStoryMoments: state.storyHistory.slice(-6),
|
||||
playerCharacter: state.playerCharacter,
|
||||
playerProgression: state.playerProgression ?? null,
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
playerMana: state.playerMana,
|
||||
playerMaxMana: state.playerMaxMana,
|
||||
playerInventory: state.playerInventory,
|
||||
playerEquipment: state.playerEquipment,
|
||||
activeCompanions: state.companions,
|
||||
rosterCompanions: state.roster,
|
||||
currentQuestSummary: state.quests.map((quest) => ({
|
||||
id: quest.id,
|
||||
title: quest.title,
|
||||
status: quest.status,
|
||||
issuerNpcId: quest.issuerNpcId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateQuestForNpcEncounter(params: {
|
||||
state: GameState;
|
||||
encounter: Encounter;
|
||||
}): Promise<QuestLogEntry | null> {
|
||||
const { state, encounter } = params;
|
||||
const issuerNpcId = encounter.id ?? encounter.npcName;
|
||||
const request: QuestPreviewRequest = {
|
||||
issuerNpcId,
|
||||
issuerNpcName: encounter.npcName,
|
||||
roleText: encounter.context,
|
||||
scene: state.currentScenePreset,
|
||||
worldType: state.worldType,
|
||||
currentQuests: state.quests.map((quest) => ({
|
||||
id: quest.id,
|
||||
issuerNpcId: quest.issuerNpcId,
|
||||
status: quest.status,
|
||||
})),
|
||||
context: buildQuestGenerationContextFromState({ state, encounter }),
|
||||
origin: 'ai_compiled',
|
||||
};
|
||||
const opportunity = evaluateQuestOpportunity(request);
|
||||
if (!opportunity.shouldOffer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return requestJson<QuestLogEntry | null>(
|
||||
'/api/runtime/quests/generate',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
},
|
||||
'任务生成失败',
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../prompts/questPrompts';
|
||||
@@ -1 +0,0 @@
|
||||
export * from '../prompts/runtimeItemPrompts';
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildContentDependencyGraph } from './contentDependencyGraph';
|
||||
|
||||
describe('contentDependencyGraph', () => {
|
||||
it('connects scenario, campaign, world, companions, and threads', () => {
|
||||
const graph = buildContentDependencyGraph({
|
||||
scenarioPack: {
|
||||
id: 'scenario-1',
|
||||
title: 'Scenario',
|
||||
version: '0.1.0',
|
||||
worldPackIds: ['world-1'],
|
||||
campaignIds: ['campaign-1'],
|
||||
sharedConstraintPackIds: [],
|
||||
},
|
||||
campaignPack: {
|
||||
id: 'campaign-1',
|
||||
scenarioPackId: 'scenario-1',
|
||||
title: 'Campaign',
|
||||
authoringStyle: 'classic',
|
||||
campaignStateSeed: {
|
||||
id: 'campaign-state',
|
||||
title: 'Campaign',
|
||||
currentActId: 'act-1',
|
||||
currentActIndex: 0,
|
||||
},
|
||||
actTemplates: [],
|
||||
requiredCompanionIds: [],
|
||||
},
|
||||
profile: {
|
||||
id: 'world-1',
|
||||
name: 'World',
|
||||
playableNpcs: [{ id: 'npc-1', name: 'A' }],
|
||||
storyGraph: {
|
||||
visibleThreads: [{ id: 'thread-1', title: 'T1' }],
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(graph.nodes.length).toBeGreaterThan(2);
|
||||
expect(graph.edges.some((edge) => edge.from === 'campaign-1' && edge.to === 'world-1')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import type {
|
||||
CampaignPack,
|
||||
CustomWorldProfile,
|
||||
ScenarioPack,
|
||||
} from '../../types';
|
||||
|
||||
export interface ContentDependencyNode {
|
||||
id: string;
|
||||
type: 'scenario' | 'campaign' | 'world' | 'thread' | 'companion' | 'constraint';
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ContentDependencyEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function buildContentDependencyGraph(params: {
|
||||
scenarioPack: ScenarioPack;
|
||||
campaignPack: CampaignPack;
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const nodes: ContentDependencyNode[] = [
|
||||
{
|
||||
id: params.scenarioPack.id,
|
||||
type: 'scenario',
|
||||
label: params.scenarioPack.title,
|
||||
},
|
||||
{
|
||||
id: params.campaignPack.id,
|
||||
type: 'campaign',
|
||||
label: params.campaignPack.title,
|
||||
},
|
||||
{
|
||||
id: params.profile.id,
|
||||
type: 'world',
|
||||
label: params.profile.name,
|
||||
},
|
||||
...params.profile.playableNpcs.map((npc) => ({
|
||||
id: npc.id,
|
||||
type: 'companion',
|
||||
label: npc.name,
|
||||
} as ContentDependencyNode)),
|
||||
...(params.profile.storyGraph?.visibleThreads ?? []).map((thread) => ({
|
||||
id: thread.id,
|
||||
type: 'thread',
|
||||
label: thread.title,
|
||||
} as ContentDependencyNode)),
|
||||
];
|
||||
|
||||
const edges: ContentDependencyEdge[] = [
|
||||
{
|
||||
from: params.scenarioPack.id,
|
||||
to: params.campaignPack.id,
|
||||
reason: 'scenario contains campaign',
|
||||
},
|
||||
{
|
||||
from: params.campaignPack.id,
|
||||
to: params.profile.id,
|
||||
reason: 'campaign depends on world profile',
|
||||
},
|
||||
...params.profile.playableNpcs.map((npc) => ({
|
||||
from: params.campaignPack.id,
|
||||
to: npc.id,
|
||||
reason: 'campaign references companion',
|
||||
})),
|
||||
...(params.profile.storyGraph?.visibleThreads ?? []).map((thread) => ({
|
||||
from: params.campaignPack.id,
|
||||
to: thread.id,
|
||||
reason: 'campaign advances thread',
|
||||
})),
|
||||
];
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
Reference in New Issue
Block a user