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[]; actNPCNames?: 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; 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(); 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>) { const lookup = new Map(); 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(); 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, landmarkIndex: number, ) { const references = compactUnique([ ...(landmark.sceneNpcIds ?? []), ...(landmark.actNPCNames ?? []), ...(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, ) { 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, 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 ?? [], })); }