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 '向前走';
|
||
}
|
||
}
|