This commit is contained in:
2026-04-21 19:18:26 +08:00
parent 4372ab5be1
commit 48957311bc
78 changed files with 643 additions and 3801 deletions

View File

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

View File

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

View File

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

View File

@@ -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('角色布局');
});
});

View File

@@ -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: '已保存共享角色布局配置。',
};
}

View File

@@ -1,4 +0,0 @@
export {
RpgCreationRoleAssetStudioModal,
type RpgCreationRoleAssetStudioModalProps,
} from './RpgCreationRoleAssetStudioModal';

View File

@@ -1 +0,0 @@
export { SectionPanel as default } from './RpgCreationEntityEditorShared';

View File

@@ -1,4 +0,0 @@
export {
RpgCreationEntityEditorModal,
type RpgCreationEntityEditorModalProps,
} from './RpgCreationEntityEditorModal';

View File

@@ -1,4 +0,0 @@
export {
RpgCreationResultView,
type RpgCreationResultViewProps,
} from './RpgCreationResultView';

View File

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

View File

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

View File

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

View File

@@ -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直接进入对白流。',

View File

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

View File

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

View File

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

View 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: '暂时不接',
},
};

View File

@@ -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直接进入战斗。',

View File

@@ -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直接获得帮助反馈。',

View File

@@ -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直接退出互动。',

View File

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

View File

@@ -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直接切磋。',

View 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: '直接攻击眼前敌人',
},
};

View 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: '释放一个指定技能',
},
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,7 @@ import {
type StoryOption,
WorldType,
} from '../../types';
import { createStoryNpcEncounterActions } from './npcEncounterActions';
import { createStoryNpcEncounterActions } from './useRpgRuntimeNpcInteraction';
function createCharacter(): Character {
return {

View File

@@ -1,6 +0,0 @@
export {
createRpgRuntimeNpcEncounterActions as createStoryNpcEncounterActions,
useRpgRuntimeNpcInteraction,
type RpgRuntimeNpcInteractionResult,
type UseRpgRuntimeNpcInteractionParams,
} from './useRpgRuntimeNpcInteraction';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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),
},
'任务生成失败',
);
}

View File

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

View File

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

View File

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

View File

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