388 lines
11 KiB
TypeScript
388 lines
11 KiB
TypeScript
import type { NpcChatTurnDirective } from '../../packages/shared/src/contracts/rpgRuntimeChat';
|
|
import type {
|
|
CustomWorldProfile,
|
|
GameState,
|
|
SceneActBlueprint,
|
|
SceneActRuntimeState,
|
|
SceneChapterBlueprint,
|
|
SceneConnectionInfo,
|
|
StoryEngineMemoryState,
|
|
} from '../types';
|
|
import { resolveCustomWorldRoleIdReferences } from './customWorldRoleReferences';
|
|
|
|
function toSet(values: string[]) {
|
|
return new Set(values.map((value) => value.trim()).filter(Boolean));
|
|
}
|
|
|
|
function resolveCustomWorldRuntimeSceneAliases(
|
|
profile: CustomWorldProfile,
|
|
sceneId: string,
|
|
) {
|
|
const aliases = toSet([sceneId]);
|
|
const campId = profile.camp?.id?.trim() || 'custom-scene-camp';
|
|
if (sceneId === 'custom-scene-camp' || sceneId === campId) {
|
|
aliases.add(campId);
|
|
aliases.add('custom-scene-camp');
|
|
}
|
|
|
|
// 中文注释:部分单元测试和旧快照会传入精简 profile,运行态解析不能假设 landmarks 始终存在。
|
|
(profile.landmarks ?? []).forEach((landmark, index) => {
|
|
const runtimeSceneId = `custom-scene-landmark-${index + 1}`;
|
|
if (sceneId === runtimeSceneId || sceneId === landmark.id) {
|
|
aliases.add(runtimeSceneId);
|
|
aliases.add(landmark.id);
|
|
}
|
|
});
|
|
|
|
return aliases;
|
|
}
|
|
|
|
function doesSceneMatchChapter(
|
|
profile: CustomWorldProfile,
|
|
sceneId: string,
|
|
chapter: SceneChapterBlueprint,
|
|
) {
|
|
const sceneAliases = resolveCustomWorldRuntimeSceneAliases(profile, sceneId);
|
|
const chapterSceneIds = toSet([
|
|
chapter.sceneId,
|
|
...(chapter.linkedLandmarkIds ?? []),
|
|
...(chapter.acts ?? []).map((act) => act.sceneId),
|
|
]);
|
|
|
|
return [...sceneAliases].some((id) => chapterSceneIds.has(id));
|
|
}
|
|
|
|
export function resolveSceneChapterBlueprint(
|
|
profile: CustomWorldProfile | null | undefined,
|
|
sceneId: string | null | undefined,
|
|
): SceneChapterBlueprint | null {
|
|
if (!profile || !sceneId) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
profile.sceneChapterBlueprints?.find((entry) =>
|
|
doesSceneMatchChapter(profile, sceneId, entry),
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
export function resolveActiveSceneActBlueprint(params: {
|
|
profile: CustomWorldProfile | null | undefined;
|
|
sceneId: string | null | undefined;
|
|
storyEngineMemory?: StoryEngineMemoryState | null;
|
|
}): SceneActBlueprint | null {
|
|
const runtimeState = params.storyEngineMemory?.currentSceneActState;
|
|
const runtimeChapter =
|
|
params.profile && runtimeState?.chapterId
|
|
? params.profile.sceneChapterBlueprints?.find(
|
|
(entry) =>
|
|
entry.id === runtimeState.chapterId &&
|
|
Boolean(params.sceneId) &&
|
|
doesSceneMatchChapter(params.profile!, params.sceneId!, entry),
|
|
) ?? null
|
|
: null;
|
|
const chapter =
|
|
runtimeChapter ?? resolveSceneChapterBlueprint(params.profile, params.sceneId);
|
|
if (!chapter || chapter.acts.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
runtimeState &&
|
|
runtimeState.chapterId === chapter.id
|
|
) {
|
|
const matchedAct = chapter.acts.find((entry) => entry.id === runtimeState.currentActId);
|
|
if (matchedAct) {
|
|
return matchedAct;
|
|
}
|
|
}
|
|
|
|
return chapter.acts[0] ?? null;
|
|
}
|
|
|
|
export function resolveSceneActProgression(params: {
|
|
profile: CustomWorldProfile | null | undefined;
|
|
sceneId: string | null | undefined;
|
|
storyEngineMemory?: StoryEngineMemoryState | null;
|
|
}): {
|
|
chapter: SceneChapterBlueprint;
|
|
runtimeState: SceneActRuntimeState;
|
|
activeAct: SceneActBlueprint;
|
|
nextAct: SceneActBlueprint | null;
|
|
isLastAct: boolean;
|
|
} | null {
|
|
const chapter = resolveSceneChapterBlueprint(params.profile, params.sceneId);
|
|
if (!chapter || chapter.acts.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const runtimeState = buildInitialSceneActRuntimeState(params);
|
|
if (!runtimeState) {
|
|
return null;
|
|
}
|
|
|
|
const activeActIndex = chapter.acts.findIndex(
|
|
(entry) => entry.id === runtimeState.currentActId,
|
|
);
|
|
const resolvedActIndex =
|
|
activeActIndex >= 0
|
|
? activeActIndex
|
|
: Math.min(
|
|
Math.max(runtimeState.currentActIndex, 0),
|
|
chapter.acts.length - 1,
|
|
);
|
|
const activeAct = chapter.acts[resolvedActIndex] ?? chapter.acts[0]!;
|
|
const nextAct = chapter.acts[resolvedActIndex + 1] ?? null;
|
|
|
|
return {
|
|
chapter,
|
|
runtimeState: {
|
|
...runtimeState,
|
|
currentActId: activeAct.id,
|
|
currentActIndex: resolvedActIndex,
|
|
},
|
|
activeAct,
|
|
nextAct,
|
|
isLastAct: !nextAct,
|
|
};
|
|
}
|
|
|
|
export function advanceSceneActRuntimeState(params: {
|
|
progress: NonNullable<ReturnType<typeof resolveSceneActProgression>>;
|
|
}): SceneActRuntimeState | null {
|
|
const { progress } = params;
|
|
if (!progress.nextAct) {
|
|
return null;
|
|
}
|
|
|
|
const completedActIds = toSet([
|
|
...(progress.runtimeState.completedActIds ?? []),
|
|
progress.activeAct.id,
|
|
]);
|
|
const visitedActIds = toSet([
|
|
...(progress.runtimeState.visitedActIds ?? []),
|
|
progress.nextAct.id,
|
|
]);
|
|
|
|
return {
|
|
sceneId: progress.chapter.sceneId,
|
|
chapterId: progress.chapter.id,
|
|
currentActId: progress.nextAct.id,
|
|
currentActIndex: progress.runtimeState.currentActIndex + 1,
|
|
completedActIds: [...completedActIds],
|
|
visitedActIds: [...visitedActIds],
|
|
};
|
|
}
|
|
|
|
export function buildInitialSceneActRuntimeState(params: {
|
|
profile: CustomWorldProfile | null | undefined;
|
|
sceneId: string | null | undefined;
|
|
storyEngineMemory?: StoryEngineMemoryState | null;
|
|
}): SceneActRuntimeState | null {
|
|
const runtimeState = params.storyEngineMemory?.currentSceneActState;
|
|
const runtimeChapter =
|
|
params.profile && params.sceneId && runtimeState?.chapterId
|
|
? params.profile.sceneChapterBlueprints?.find(
|
|
(entry) =>
|
|
entry.id === runtimeState.chapterId &&
|
|
doesSceneMatchChapter(params.profile!, params.sceneId!, entry),
|
|
) ?? null
|
|
: null;
|
|
const chapter =
|
|
runtimeChapter ?? resolveSceneChapterBlueprint(params.profile, params.sceneId);
|
|
if (!chapter || chapter.acts.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
runtimeState &&
|
|
runtimeState.chapterId === chapter.id &&
|
|
chapter.acts.some((entry) => entry.id === runtimeState.currentActId)
|
|
) {
|
|
return {
|
|
...runtimeState,
|
|
completedActIds: [...toSet(runtimeState.completedActIds ?? [])],
|
|
visitedActIds: [...toSet(runtimeState.visitedActIds ?? [])],
|
|
};
|
|
}
|
|
|
|
const firstAct = chapter.acts[0]!;
|
|
return {
|
|
sceneId: chapter.sceneId,
|
|
chapterId: chapter.id,
|
|
currentActId: firstAct.id,
|
|
currentActIndex: 0,
|
|
completedActIds: [],
|
|
visitedActIds: [firstAct.id],
|
|
};
|
|
}
|
|
|
|
export function resolveActiveSceneActEncounterNpcIds(params: {
|
|
profile: CustomWorldProfile | null | undefined;
|
|
sceneId: string | null | undefined;
|
|
storyEngineMemory?: StoryEngineMemoryState | null;
|
|
}) {
|
|
const activeAct = resolveActiveSceneActBlueprint(params);
|
|
if (!activeAct) {
|
|
return [];
|
|
}
|
|
|
|
return resolveCustomWorldRoleIdReferences(params.profile, [
|
|
activeAct.primaryNpcId,
|
|
activeAct.oppositeNpcId,
|
|
...activeAct.encounterNpcIds,
|
|
]);
|
|
}
|
|
|
|
export function resolveActiveSceneActPrimaryNpcId(params: {
|
|
profile: CustomWorldProfile | null | undefined;
|
|
sceneId: string | null | undefined;
|
|
storyEngineMemory?: StoryEngineMemoryState | null;
|
|
}) {
|
|
return resolveCustomWorldRoleIdReferences(params.profile, [
|
|
resolveActiveSceneActBlueprint(params)?.primaryNpcId,
|
|
])[0] ?? null;
|
|
}
|
|
|
|
export function resolveActiveSceneActOppositeNpcId(params: {
|
|
profile: CustomWorldProfile | null | undefined;
|
|
sceneId: string | null | undefined;
|
|
storyEngineMemory?: StoryEngineMemoryState | null;
|
|
}) {
|
|
return resolveCustomWorldRoleIdReferences(params.profile, [
|
|
resolveActiveSceneActBlueprint(params)?.oppositeNpcId,
|
|
])[0] ?? null;
|
|
}
|
|
|
|
export function resolveActiveSceneActEncounterFocusNpcId(params: {
|
|
profile: CustomWorldProfile | null | undefined;
|
|
sceneId: string | null | undefined;
|
|
storyEngineMemory?: StoryEngineMemoryState | null;
|
|
}) {
|
|
const activeAct = resolveActiveSceneActBlueprint(params);
|
|
return resolveCustomWorldRoleIdReferences(params.profile, [
|
|
activeAct?.oppositeNpcId,
|
|
activeAct?.primaryNpcId,
|
|
activeAct?.encounterNpcIds[0],
|
|
])[0] ?? null;
|
|
}
|
|
|
|
export function resolveActiveSceneActBackgroundImage(params: {
|
|
profile: CustomWorldProfile | null | undefined;
|
|
sceneId: string | null | undefined;
|
|
storyEngineMemory?: StoryEngineMemoryState | null;
|
|
}) {
|
|
return resolveActiveSceneActBlueprint(params)?.backgroundImageSrc?.trim() || null;
|
|
}
|
|
|
|
export function canUseLimitedPrimaryNpcChat(params: {
|
|
profile: CustomWorldProfile | null | undefined;
|
|
sceneId: string | null | undefined;
|
|
storyEngineMemory?: StoryEngineMemoryState | null;
|
|
npcId: string | null | undefined;
|
|
affinity: number;
|
|
}) {
|
|
if (params.affinity >= 0 || !params.npcId) {
|
|
return false;
|
|
}
|
|
|
|
const activeAct = resolveActiveSceneActBlueprint({
|
|
profile: params.profile,
|
|
sceneId: params.sceneId,
|
|
storyEngineMemory: params.storyEngineMemory,
|
|
});
|
|
|
|
const limitedChatNpcIds = toSet(
|
|
resolveCustomWorldRoleIdReferences(params.profile, [
|
|
activeAct?.primaryNpcId,
|
|
activeAct?.oppositeNpcId,
|
|
]),
|
|
);
|
|
const normalizedNpcId =
|
|
resolveCustomWorldRoleIdReferences(params.profile, [params.npcId])[0] ??
|
|
params.npcId;
|
|
|
|
// 中文注释:第一幕对面角色即使是负好感,也必须先进入剧情对话;普通敌人仍按战斗处理。
|
|
if (limitedChatNpcIds.has(normalizedNpcId)) {
|
|
return true;
|
|
}
|
|
|
|
return (
|
|
resolveActiveSceneActPrimaryNpcId({
|
|
profile: params.profile,
|
|
sceneId: params.sceneId,
|
|
storyEngineMemory: params.storyEngineMemory,
|
|
}) === normalizedNpcId
|
|
);
|
|
}
|
|
|
|
export function resolveLimitedPrimaryNpcChatState(params: {
|
|
state: Pick<GameState, 'customWorldProfile' | 'currentScenePreset' | 'storyEngineMemory'>;
|
|
npcId: string | null | undefined;
|
|
affinity: number;
|
|
nextTurnCount: number;
|
|
}): NpcChatTurnDirective | null {
|
|
if (
|
|
!canUseLimitedPrimaryNpcChat({
|
|
profile: params.state.customWorldProfile,
|
|
sceneId: params.state.currentScenePreset?.id ?? null,
|
|
storyEngineMemory: params.state.storyEngineMemory,
|
|
npcId: params.npcId,
|
|
affinity: params.affinity,
|
|
})
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const activeAct = resolveActiveSceneActBlueprint({
|
|
profile: params.state.customWorldProfile,
|
|
sceneId: params.state.currentScenePreset?.id ?? null,
|
|
storyEngineMemory: params.state.storyEngineMemory,
|
|
});
|
|
return {
|
|
sceneActId: activeAct?.id ?? null,
|
|
turnLimit: null,
|
|
remainingTurns: null,
|
|
limitReason: 'negative_affinity' as const,
|
|
closingMode: 'free' as const,
|
|
forceExitAfterTurn: false,
|
|
terminationMode: 'hostile_model' as const,
|
|
isHostileChat: true,
|
|
};
|
|
}
|
|
|
|
export function getSceneConnectionDirectionText(
|
|
relativePosition: SceneConnectionInfo['relativePosition'],
|
|
) {
|
|
switch (relativePosition) {
|
|
case 'north':
|
|
return '向北走';
|
|
case 'south':
|
|
return '向南走';
|
|
case 'east':
|
|
return '向东走';
|
|
case 'west':
|
|
return '向西走';
|
|
case 'left':
|
|
return '向左走';
|
|
case 'right':
|
|
return '向右走';
|
|
case 'back':
|
|
return '往回走';
|
|
case 'up':
|
|
return '向上走';
|
|
case 'down':
|
|
return '向下走';
|
|
case 'inside':
|
|
return '向内走';
|
|
case 'outside':
|
|
return '向外走';
|
|
case 'portal':
|
|
return '穿过通路';
|
|
case 'forward':
|
|
default:
|
|
return '向前走';
|
|
}
|
|
}
|