Files
Genarrative/src/data/customWorldSceneGraph.ts
2026-04-18 13:05:29 +08:00

426 lines
12 KiB
TypeScript

import type {
CustomWorldLandmark,
CustomWorldNpc,
CustomWorldSceneConnection,
CustomWorldSceneRelativePosition,
} from '../types';
export type CustomWorldSceneConnectionDraft = {
targetLandmarkId?: string;
targetLandmarkName?: string;
relativePosition?: unknown;
summary?: string;
};
export type CustomWorldLandmarkDraft = Omit<
CustomWorldLandmark,
'sceneNpcIds' | 'connections'
> & {
sceneNpcIds?: string[];
sceneNpcNames?: string[];
connections?: CustomWorldSceneConnectionDraft[];
};
export const CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS: Array<{
value: CustomWorldSceneRelativePosition;
label: string;
}> = [
{ value: 'forward', label: '前方' },
{ value: 'back', label: '后方' },
{ value: 'left', label: '左侧' },
{ value: 'right', label: '右侧' },
{ value: 'north', label: '北侧' },
{ value: 'south', label: '南侧' },
{ value: 'east', label: '东侧' },
{ value: 'west', label: '西侧' },
{ value: 'up', label: '上方' },
{ value: 'down', label: '下方' },
{ value: 'inside', label: '内部' },
{ value: 'outside', label: '外部' },
{ value: 'portal', label: '传送节点' },
] as const;
const RELATIVE_POSITION_ALIASES: Record<
CustomWorldSceneRelativePosition,
string[]
> = {
forward: ['forward', 'front', 'ahead', '前方', '前面', '前侧', '向前'],
back: ['back', 'rear', 'behind', '后方', '后面', '后侧', '回程'],
left: ['left', '左侧', '左边', '左方'],
right: ['right', '右侧', '右边', '右方'],
north: ['north', '北侧', '北边', '北方', '上北'],
south: ['south', '南侧', '南边', '南方', '下南'],
east: ['east', '东侧', '东边', '东方'],
west: ['west', '西侧', '西边', '西方'],
up: ['up', 'upper', 'above', '上方', '上层', '高处', '顶部'],
down: ['down', 'lower', 'below', '下方', '下层', '低处', '底部'],
inside: ['inside', 'inner', 'indoors', '内部', '内侧', '内里', '室内'],
outside: ['outside', 'outer', 'outdoors', '外部', '外侧', '外围', '室外'],
portal: ['portal', 'gate', 'path', 'junction', '传送', '门', '入口', '通道'],
};
const RELATIVE_POSITION_LABELS = Object.fromEntries(
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS.map((option) => [
option.value,
option.label,
]),
) as Record<CustomWorldSceneRelativePosition, string>;
const RELATIVE_POSITION_DISPLAY_ORDER: CustomWorldSceneRelativePosition[] = [
'forward',
'north',
'east',
'right',
'up',
'outside',
'portal',
'left',
'west',
'south',
'down',
'inside',
'back',
];
function normalizeKey(value: string) {
return value.trim().toLowerCase();
}
function buildSceneNpcLookup(storyNpcs: CustomWorldNpc[]) {
const lookup = new Map<string, string>();
storyNpcs.forEach((npc) => {
const normalizedId = normalizeKey(npc.id);
const normalizedName = normalizeKey(npc.name);
if (normalizedId) {
lookup.set(normalizedId, npc.id);
}
if (normalizedName) {
lookup.set(normalizedName, npc.id);
}
});
return lookup;
}
function buildLandmarkLookup(landmarks: Array<Pick<CustomWorldLandmarkDraft, 'id' | 'name'>>) {
const lookup = new Map<string, string>();
landmarks.forEach((landmark) => {
const normalizedId = normalizeKey(landmark.id);
const normalizedName = normalizeKey(landmark.name);
if (normalizedId) {
lookup.set(normalizedId, landmark.id);
}
if (normalizedName) {
lookup.set(normalizedName, landmark.id);
}
});
return lookup;
}
function compactUnique(values: string[]) {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
}
function sortConnections(connections: CustomWorldSceneConnection[]) {
return [...connections].sort((left, right) => {
const leftOrder = RELATIVE_POSITION_DISPLAY_ORDER.indexOf(
left.relativePosition,
);
const rightOrder = RELATIVE_POSITION_DISPLAY_ORDER.indexOf(
right.relativePosition,
);
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
return left.targetLandmarkId.localeCompare(right.targetLandmarkId);
});
}
function dedupeConnections(connections: CustomWorldSceneConnection[]) {
const deduped = new Map<string, CustomWorldSceneConnection>();
connections.forEach((connection) => {
const key = [
connection.targetLandmarkId.trim(),
connection.relativePosition,
connection.summary.trim(),
].join('::');
if (!deduped.has(key)) {
deduped.set(key, {
targetLandmarkId: connection.targetLandmarkId,
relativePosition: connection.relativePosition,
summary: connection.summary,
});
}
});
return [...deduped.values()];
}
export function getCustomWorldSceneRelativePositionLabel(
value: CustomWorldSceneRelativePosition,
) {
return RELATIVE_POSITION_LABELS[value] ?? value;
}
export function normalizeCustomWorldSceneRelativePosition(
value: unknown,
): CustomWorldSceneRelativePosition {
const normalizedValue =
typeof value === 'string' ? normalizeKey(value) : '';
for (const option of CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS) {
if (option.value === normalizedValue) {
return option.value;
}
if (RELATIVE_POSITION_ALIASES[option.value].includes(normalizedValue)) {
return option.value;
}
}
return 'forward';
}
export function invertCustomWorldSceneRelativePosition(
value: CustomWorldSceneRelativePosition,
): CustomWorldSceneRelativePosition {
switch (value) {
case 'forward':
return 'back';
case 'back':
return 'forward';
case 'left':
return 'right';
case 'right':
return 'left';
case 'north':
return 'south';
case 'south':
return 'north';
case 'east':
return 'west';
case 'west':
return 'east';
case 'up':
return 'down';
case 'down':
return 'up';
case 'inside':
return 'outside';
case 'outside':
return 'inside';
default:
return 'portal';
}
}
function buildFallbackSceneNpcIds(
storyNpcs: CustomWorldNpc[],
currentNpcIds: string[],
landmarkIndex: number,
) {
const targetCount = Math.min(3, storyNpcs.length);
if (targetCount <= currentNpcIds.length) {
return currentNpcIds.slice(0, targetCount);
}
const resolved = [...currentNpcIds];
for (
let offset = 0;
offset < storyNpcs.length && resolved.length < targetCount;
offset += 1
) {
const nextNpc = storyNpcs[(landmarkIndex + offset) % storyNpcs.length];
if (!nextNpc || resolved.includes(nextNpc.id)) {
continue;
}
resolved.push(nextNpc.id);
}
return resolved;
}
function resolveSceneNpcIdsForLandmark(
landmark: CustomWorldLandmarkDraft,
storyNpcs: CustomWorldNpc[],
lookup: Map<string, string>,
landmarkIndex: number,
) {
const references = compactUnique([
...(landmark.sceneNpcIds ?? []),
...(landmark.sceneNpcNames ?? []),
]);
const resolvedIds = compactUnique(
references
.map((reference) => lookup.get(normalizeKey(reference)) ?? '')
.filter(Boolean),
);
return buildFallbackSceneNpcIds(storyNpcs, resolvedIds, landmarkIndex);
}
function resolveConnectionsForLandmark(
landmark: CustomWorldLandmarkDraft,
landmarkLookup: Map<string, string>,
) {
return (landmark.connections ?? [])
.map((connection) => {
const targetReference =
connection.targetLandmarkId ?? connection.targetLandmarkName ?? '';
const targetLandmarkId =
landmarkLookup.get(normalizeKey(targetReference)) ?? '';
if (!targetLandmarkId || targetLandmarkId === landmark.id) {
return null;
}
return {
targetLandmarkId,
relativePosition: normalizeCustomWorldSceneRelativePosition(
connection.relativePosition,
),
summary: typeof connection.summary === 'string'
? connection.summary.trim()
: '',
} satisfies CustomWorldSceneConnection;
})
.filter((connection): connection is CustomWorldSceneConnection =>
Boolean(connection),
);
}
function ensureReverseConnections(landmarks: CustomWorldLandmark[]) {
const connectionMap = new Map(
landmarks.map((landmark) => [landmark.id, [...landmark.connections]]),
);
const nameMap = new Map(landmarks.map((landmark) => [landmark.id, landmark.name]));
landmarks.forEach((landmark) => {
landmark.connections.forEach((connection) => {
const reverseConnections = connectionMap.get(connection.targetLandmarkId);
if (!reverseConnections) {
return;
}
const hasReverseConnection = reverseConnections.some(
(item) => item.targetLandmarkId === landmark.id,
);
if (hasReverseConnection) {
return;
}
reverseConnections.push({
targetLandmarkId: landmark.id,
relativePosition: invertCustomWorldSceneRelativePosition(
connection.relativePosition,
),
summary: nameMap.get(landmark.id)
? `可通往${nameMap.get(landmark.id)}`
: '',
});
});
});
return landmarks.map((landmark) => ({
...landmark,
connections: sortConnections(
dedupeConnections(connectionMap.get(landmark.id) ?? []),
),
}));
}
function ensureFallbackLandmarkConnections(landmarks: CustomWorldLandmark[]) {
if (landmarks.length <= 1) {
return landmarks;
}
const connectionMap = new Map(
landmarks.map((landmark) => [landmark.id, [...landmark.connections]]),
);
landmarks.forEach((landmark, index) => {
const nextLandmark = landmarks[(index + 1) % landmarks.length];
if (!nextLandmark || nextLandmark.id === landmark.id) {
return;
}
const existingConnections = connectionMap.get(landmark.id) ?? [];
if (
existingConnections.some(
(connection) => connection.targetLandmarkId === nextLandmark.id,
)
) {
return;
}
existingConnections.push({
targetLandmarkId: nextLandmark.id,
relativePosition: 'forward',
summary: `沿主路可继续前往${nextLandmark.name}`,
});
connectionMap.set(landmark.id, existingConnections);
});
return landmarks.map((landmark) => ({
...landmark,
connections: sortConnections(connectionMap.get(landmark.id) ?? []),
}));
}
export function normalizeCustomWorldLandmarks(params: {
landmarks: CustomWorldLandmarkDraft[];
storyNpcs: CustomWorldNpc[];
}) {
const { landmarks, storyNpcs } = params;
const npcLookup = buildSceneNpcLookup(storyNpcs);
const landmarkLookup = buildLandmarkLookup(landmarks);
const resolvedLandmarks = landmarks.map((landmark, index) => ({
id: landmark.id,
name: landmark.name,
description: landmark.description,
visualDescription: landmark.visualDescription,
dangerLevel: landmark.dangerLevel,
imageSrc: landmark.imageSrc,
narrativeResidues: landmark.narrativeResidues,
sceneNpcIds: resolveSceneNpcIdsForLandmark(
landmark,
storyNpcs,
npcLookup,
index,
),
connections: sortConnections(
resolveConnectionsForLandmark(landmark, landmarkLookup),
),
}));
return ensureReverseConnections(
ensureFallbackLandmarkConnections(resolvedLandmarks),
);
}
export function syncCustomWorldLandmarkConnections(
landmarks: CustomWorldLandmark[],
) {
return normalizeCustomWorldLandmarks({
landmarks: landmarks.map((landmark) => ({
...landmark,
narrativeResidues: landmark.narrativeResidues,
sceneNpcIds: landmark.sceneNpcIds,
connections: landmark.connections.map((connection) => ({
targetLandmarkId: connection.targetLandmarkId,
relativePosition: connection.relativePosition,
summary: connection.summary,
})),
})),
storyNpcs: [],
}).map((landmark, index) => ({
...landmark,
sceneNpcIds: landmarks[index]?.sceneNpcIds ?? [],
}));
}