Files
Genarrative/src/services/customWorldSceneActRuntime.ts
T
2026-04-27 22:50:18 +08:00

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