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

388 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 '向前走';
}
}