import { collectCreatureArchetypeSignals, resolveCreatureArchetypeForSource, } from '../services/customWorldReferenceSignals'; import { type CustomWorldNpc, type CustomWorldPlayableNpc, type CustomWorldProfile, WorldType, } from '../types'; import { resolveCustomWorldCompatibilityTemplateWorldType } from '../services/customWorldTheme'; import { getMonsterPresetsByWorld, type HostileNpcPreset, } from './hostileNpcPresets'; type CustomWorldMonsterSource = Partial< Pick< CustomWorldNpc & CustomWorldPlayableNpc, | 'name' | 'title' | 'role' | 'description' | 'backstory' | 'personality' | 'motivation' | 'combatStyle' | 'initialAffinity' | 'relationshipHooks' | 'tags' > >; const MONSTER_SIGNAL_PATTERN = /妖|魔|鬼|怪|兽|灵|尸|蛛|蛇|虫|菇|傀|骸|骨|眼|蜗|藤|象|蝠|蛙|蛾|蟾|狼|狐|蛟|龙|祟/u; const MONSTER_SIGNAL_STOP_CHARS = new Set([ '妖', '魔', '鬼', '怪', '兽', '灵', '尸', '祟', '凶', '异', '夜', '古', ]); function hashText(value: string) { let hash = 0; for (let index = 0; index < value.length; index += 1) { hash = (hash * 31 + value.charCodeAt(index)) >>> 0; } return hash >>> 0; } function getMonsterPresetPool(worldType?: WorldType | null) { if (worldType) { return getMonsterPresetsByWorld(worldType); } const seen = new Set(); return [ ...getMonsterPresetsByWorld(WorldType.WUXIA), ...getMonsterPresetsByWorld(WorldType.XIANXIA), ].filter((preset) => { if (seen.has(preset.id)) { return false; } seen.add(preset.id); return true; }); } function getAllMonsterPresets() { return getMonsterPresetPool(null); } function uniqueText(values: Array) { return [ ...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean)), ]; } function buildMonsterSourceText(npc: CustomWorldMonsterSource) { return uniqueText([ npc.name, npc.title, npc.role, npc.description, npc.backstory, npc.personality, npc.motivation, npc.combatStyle, ...(npc.relationshipHooks ?? []), ...(npc.tags ?? []), ]).join(' '); } function buildSignalChars(label: string) { return [ ...new Set( label .replace(/[^\u4e00-\u9fa5]+/g, '') .split('') .filter((char) => char && !MONSTER_SIGNAL_STOP_CHARS.has(char)), ), ]; } function scoreMonsterPreset(preset: HostileNpcPreset, sourceText: string) { let score = 0; if (sourceText.includes(preset.name)) { score += 24; } for (const signalChar of buildSignalChars(preset.name)) { if (sourceText.includes(signalChar)) { score += 3; } } for (const tag of [...preset.habitatTags, ...preset.combatTags]) { if (tag && sourceText.includes(tag)) { score += 2; } } return score; } function scoreMonsterPresetWithArchetype( preset: HostileNpcPreset, sourceText: string, options: { archetypeSignals?: ReturnType | null; preferredWorldType?: WorldType | null; } = {}, ) { let score = scoreMonsterPreset(preset, sourceText); const { archetypeSignals, preferredWorldType } = options; if (archetypeSignals) { archetypeSignals.keywords.forEach((keyword) => { if (!keyword) { return; } if ( preset.name.includes(keyword) || preset.habitatTags.some((tag) => tag.includes(keyword) || keyword.includes(tag)) || preset.combatTags.some((tag) => tag.includes(keyword) || keyword.includes(tag)) ) { score += keyword.length >= 3 ? 6 : 4; } }); archetypeSignals.combatTags.forEach((tag) => { if (preset.combatTags.includes(tag)) { score += 8; } }); archetypeSignals.habitatTags.forEach((tag) => { if (preset.habitatTags.includes(tag)) { score += 6; } }); } if ( preferredWorldType && preferredWorldType !== WorldType.CUSTOM && preset.worldType === preferredWorldType ) { score += 3; } return score; } export function getCustomWorldMonsterPresetPool( profile?: Pick< CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType' > | null, ) { const presets = getAllMonsterPresets(); const creatureArchetypes = profile?.ownedSettingLayers?.referenceProfile.creatureArchetypes ?? []; if (creatureArchetypes.length === 0) { return presets; } const preferredWorldType = profile ? resolveCustomWorldCompatibilityTemplateWorldType(profile) : null; const scoredPresets = presets .map((preset) => { const archetypeScore = creatureArchetypes.reduce((bestScore, archetype) => { const nextScore = scoreMonsterPresetWithArchetype( preset, preset.name, { archetypeSignals: collectCreatureArchetypeSignals(archetype), preferredWorldType, }, ); return Math.max(bestScore, nextScore); }, 0); return { preset, score: archetypeScore, }; }) .sort((left, right) => right.score - left.score); const filtered = scoredPresets .filter((entry) => entry.score > 0) .map((entry) => entry.preset); return filtered.length > 0 ? filtered : presets; } export function resolveCustomWorldNpcMonsterPreset( npc: CustomWorldMonsterSource, worldType?: WorldType | null, profile?: Pick< CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType' > | null, ) { const sourceText = buildMonsterSourceText(npc); if (!sourceText || !MONSTER_SIGNAL_PATTERN.test(sourceText)) { return null; } const hostileBias = (npc.initialAffinity ?? 0) < 0; if (!hostileBias) { return null; } const preferredWorldType = profile ? resolveCustomWorldCompatibilityTemplateWorldType(profile) : worldType ?? null; const referenceArchetype = resolveCreatureArchetypeForSource( profile as CustomWorldProfile | null | undefined, npc, ); const archetypeSignals = referenceArchetype ? collectCreatureArchetypeSignals(referenceArchetype) : null; const candidates = profile && profile.ownedSettingLayers?.referenceProfile.creatureArchetypes.length ? getCustomWorldMonsterPresetPool(profile) : getMonsterPresetPool(worldType); if (candidates.length === 0) { return null; } const scoredCandidates = candidates .map((candidate) => ({ candidate, score: scoreMonsterPresetWithArchetype(candidate, sourceText, { archetypeSignals, preferredWorldType, }), })) .sort((left, right) => right.score - left.score); if ((scoredCandidates[0]?.score ?? 0) >= 3) { return scoredCandidates[0]?.candidate ?? null; } return candidates[hashText(sourceText) % candidates.length] ?? null; } export function resolveCustomWorldNpcMonsterPresetId( npc: CustomWorldMonsterSource, worldType?: WorldType | null, profile?: Pick< CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType' > | null, ) { return resolveCustomWorldNpcMonsterPreset(npc, worldType, profile)?.id ?? null; }