import { Character, CustomWorldNpc, CustomWorldPlayableNpc, CustomWorldProfile, CustomWorldRoleInitialItem, EquipmentSlotId, InventoryItem, } from '../types'; import { buildRuntimeCustomWorldInventoryItems, getRuntimeCustomWorldProfile, type RuntimeCustomWorldItemQueryOptions, } from './customWorldRuntime'; const CATEGORY_ORDER = new Map([ ['武器', 0], ['护甲', 1], ['饰品', 2], ['消耗品', 3], ['材料', 4], ['稀有品', 5], ['专属物品', 6], ]); const STOP_PHRASES = new Set([ '这个世界', '当前世界', '玩家进入', '玩家核心', '世界设定', '世界概述', '世界基调', '角色背景', '角色描述', '角色设定', '角色故事', '剧情关键', '后续冒险', '完整角色', '当前局�?', '进入世界', '核心目标', '可扮�?', '主角候�?', '主要角色', '当前角色', '这趟旅程', 'No retreat', '真正起点', ]); const THEME_TAG_RULES: Array<{ pattern: RegExp; tags: string[] }> = [ { pattern: /range|bow|shot|sniper|scout/i, tags: ['range', 'mobility', 'explore', 'weapon'] }, { pattern: /blade|sword|slash|duel|charge/i, tags: ['melee', 'combat', 'weapon'] }, { pattern: /fist|hammer|burst|smash|impact/i, tags: ['burst', 'combat', 'weapon'] }, { pattern: /armor|shield|guard|wall|vanguard/i, tags: ['guard', 'defense', 'armor'] }, { pattern: /medic|herb|potion|heal|remedy/i, tags: ['alchemy', 'healing', 'supply'] }, { pattern: /rune|sigil|spell|mana|arcane|focus/i, tags: ['mana', 'arcane', 'glyph', 'focus'] }, { pattern: /rare|relic|archive|key|history/i, tags: ['rare', 'clue', 'history', 'secret'] }, { pattern: /travel|map|road|route|trail/i, tags: ['explore', 'route', 'supply'] }, { pattern: /forge|craft|tool|gear|metal/i, tags: ['craft', 'material', 'forge'] }, { pattern: /flora|seed|bloom|vine|root/i, tags: ['herb', 'alchemy', 'material'] }, ]; function normalizeExplicitItemCategory(category: string) { const normalized = category.trim(); return normalized === '专属物' ? '专属物品' : normalized; } function inferEquipmentSlotFromCategory(category: string): EquipmentSlotId | null { const normalized = normalizeExplicitItemCategory(category); if (normalized === '武器') return 'weapon'; if (normalized === '护甲') return 'armor'; if ( normalized === '饰品' || normalized === '稀有品' || normalized === '专属物品' ) { return 'relic'; } return null; } function buildExplicitRoleInventoryItem( role: CustomWorldPlayableNpc | CustomWorldNpc, item: CustomWorldRoleInitialItem, index: number, ): InventoryItem { const category = normalizeExplicitItemCategory(item.category); return { id: `custom-role-item:${role.id}:${index + 1}`, category, name: item.name, quantity: Math.max(1, item.quantity), rarity: item.rarity, tags: [...item.tags], description: item.description, equipmentSlotId: inferEquipmentSlotFromCategory(category), runtimeMetadata: { origin: 'ai_compiled', generationChannel: 'discovery', seedKey: `${role.id}:${index + 1}`, relationAnchor: { type: 'npc', npcId: role.id, npcName: role.name, roleText: role.role, }, sourceReason: `${role.name}在自定义世界开局时自带的初始物品。`, }, }; } function buildExplicitRoleInventoryItems( role: CustomWorldPlayableNpc | CustomWorldNpc | null, ) { if (!role) { return [] as InventoryItem[]; } return role.initialItems.map((item, index) => buildExplicitRoleInventoryItem(role, item, index), ); } function resolveCustomWorldRole( profile: CustomWorldProfile, character: Character, ) { return profile.playableNpcs.find(role => role.id === character.id) ?? profile.storyNpcs.find(role => role.id === character.id) ?? profile.playableNpcs.find(role => role.name === character.name) ?? profile.storyNpcs.find(role => role.name === character.name) ?? null; } function dedupeStrings(values: string[], max = 32) { return [...new Set(values.map(value => value.trim()).filter(Boolean))].slice(0, max); } function sortInventoryByCategory(items: InventoryItem[]) { return [...items].sort((left, right) => { const categoryDelta = (CATEGORY_ORDER.get(left.category) ?? 99) - (CATEGORY_ORDER.get(right.category) ?? 99); if (categoryDelta !== 0) { return categoryDelta; } return left.name.localeCompare(right.name, 'zh-Hans-CN'); }); } function collectPhrases(sourceTexts: string[]) { return sourceTexts.flatMap(text => text .split(/[[\]\s,。、“”‘’;:?�?.!?:()()【�?]+/u) .map(segment => segment.trim()) .filter(segment => segment.length >= 2 && segment.length <= 12) .filter(segment => !STOP_PHRASES.has(segment)), ); } function collectChineseNgrams(value: string, minSize = 2, maxSize = 4, limit = 16) { const source = value.replace(/[^\u4e00-\u9fa5]/g, ''); const grams: string[] = []; for (let size = minSize; size <= maxSize; size += 1) { for (let index = 0; index <= source.length - size; index += 1) { const gram = source.slice(index, index + size); if (STOP_PHRASES.has(gram)) { continue; } grams.push(gram); if (grams.length >= limit) { return grams; } } } return grams; } function buildKeywordBundle( profile: CustomWorldProfile, character: Character, role: CustomWorldPlayableNpc | CustomWorldNpc | null, ) { const roleTexts = [ role?.title ?? '', role?.description ?? '', role?.backstory ?? '', role?.backstoryReveal.publicSummary ?? '', role?.combatStyle ?? '', ...(role?.skills.map(skill => `${skill.name} ${skill.summary} ${skill.style}`) ?? []), ...(role?.initialItems.map(item => `${item.name} ${item.category} ${item.description}`) ?? []), ...(role?.tags ?? []), ]; const characterTexts = [ character.description, character.backstory, character.personality, ...(character.combatTags ?? []), ]; const worldTexts = [ profile.name, profile.settingText, profile.summary, profile.tone, profile.playerGoal, ]; const sourceTexts = [...roleTexts, ...characterTexts, ...worldTexts].filter(Boolean); const phrases = collectPhrases(sourceTexts); const ngrams = [ ...collectChineseNgrams(role?.title ?? '', 2, 4, 12), ...collectChineseNgrams(role?.combatStyle ?? '', 2, 4, 12), ...collectChineseNgrams((role?.tags ?? []).join(' '), 2, 4, 10), ...collectChineseNgrams(profile.name, 2, 4, 10), ]; const heuristics = THEME_TAG_RULES .filter(rule => rule.pattern.test(sourceTexts.join(' '))) .flatMap(rule => rule.tags); return { preferredTags: dedupeStrings([ ...(role?.tags ?? []), ...(role?.initialItems.flatMap(item => item.tags) ?? []), ...(character.combatTags ?? []), ...heuristics, ], 18), keywords: dedupeStrings([ ...phrases, ...ngrams, ...(role?.skills.map(skill => skill.name) ?? []), ...(role?.initialItems.map(item => item.name) ?? []), ...heuristics, ], 36), }; } function queryItems( seedKey: string, baseOptions: RuntimeCustomWorldItemQueryOptions, fallbackOptions?: RuntimeCustomWorldItemQueryOptions, ) { const items = buildRuntimeCustomWorldInventoryItems(seedKey, baseOptions); const categoryFallbackTriggered = Boolean( fallbackOptions && baseOptions.categories?.length && items.some(item => !baseOptions.categories!.includes(item.category)), ); if ((items.length > 0 && !categoryFallbackTriggered) || !fallbackOptions) { return items; } return buildRuntimeCustomWorldInventoryItems(seedKey, fallbackOptions); } function mergeUniqueItems(...groups: InventoryItem[][]) { const result: InventoryItem[] = []; const seen = new Set(); groups.flat().forEach(item => { const key = `${item.category}:${item.name}`; if (seen.has(key)) { return; } seen.add(key); result.push(item); }); return result; } export function buildCustomWorldStarterEquipmentItems( character: Character, explicitProfile?: CustomWorldProfile | null, ) { const profile = explicitProfile ?? getRuntimeCustomWorldProfile(); if (!profile) { return { weapon: null, armor: null, relic: null, } satisfies Record; } const role = resolveCustomWorldRole(profile, character); const explicitItems = buildExplicitRoleInventoryItems(role); const explicitWeapon = explicitItems.find(item => item.equipmentSlotId === 'weapon') ?? null; const explicitArmor = explicitItems.find(item => item.equipmentSlotId === 'armor') ?? null; const explicitRelic = explicitItems.find(item => item.equipmentSlotId === 'relic') ?? null; const bundle = buildKeywordBundle(profile, character, role); const baseTextKeywords = bundle.keywords; const baseTags = bundle.preferredTags; const [weapon] = queryItems(`equipment:${character.id}:weapon`, { count: 1, categories: ['武器'], rarityFloor: 'rare', preferredTags: dedupeStrings([...baseTags, 'weapon', '战斗']), keywords: dedupeStrings([...baseTextKeywords, role?.combatStyle ?? '', '武器', '战斗']), }); const [armor] = queryItems(`equipment:${character.id}:armor`, { count: 1, categories: ['护甲'], rarityFloor: 'rare', preferredTags: dedupeStrings([...baseTags, 'armor', '防护', '护体']), keywords: dedupeStrings([...baseTextKeywords, role?.personality ?? character.personality, '护甲', '守御']), }); const [relic] = queryItems(`equipment:${character.id}:relic`, { count: 1, categories: ['饰品'], rarityFloor: 'rare', preferredTags: dedupeStrings([...baseTags, 'relic', 'rare', 'mana', '线索']), keywords: dedupeStrings([...baseTextKeywords, profile.playerGoal, profile.summary, '信物', '关键']), }, { count: 1, categories: ['饰品', '稀有品', '专属物品'], rarityFloor: 'rare', preferredTags: dedupeStrings([...baseTags, 'relic', 'rare', 'mana', '线索']), keywords: dedupeStrings([...baseTextKeywords, profile.playerGoal, profile.summary, '信物', '关键']), }); return { weapon: explicitWeapon ?? weapon ?? null, armor: explicitArmor ?? armor ?? null, relic: explicitRelic ?? relic ?? null, } satisfies Record; } export function buildCustomWorldStarterInventoryItems( character: Character, explicitProfile?: CustomWorldProfile | null, ) { const profile = explicitProfile ?? getRuntimeCustomWorldProfile(); if (!profile) { return [] as InventoryItem[]; } const role = resolveCustomWorldRole(profile, character); const explicitItems = buildExplicitRoleInventoryItems(role); const bundle = buildKeywordBundle(profile, character, role); const consumables = queryItems(`inventory:${character.id}:consumables`, { count: 2, quantity: 2, categories: ['消耗品'], preferredTags: dedupeStrings([...bundle.preferredTags, 'healing', 'mana', '补给', '探索']), keywords: dedupeStrings([ ...bundle.keywords, role?.combatStyle ?? '', ...explicitItems.map(item => item.name), '调息', '续战', ]), }); const materials = queryItems(`inventory:${character.id}:materials`, { count: 1, quantity: 2, categories: ['材料'], preferredTags: dedupeStrings([...bundle.preferredTags, 'material', 'forge', 'alchemy']), keywords: dedupeStrings([...bundle.keywords, role?.backstory ?? character.backstory, '材料']), }); const rareUtility = queryItems(`inventory:${character.id}:rare-utility`, { count: 1, categories: ['饰品', '稀有品'], rarityFloor: 'uncommon', preferredTags: dedupeStrings([...bundle.preferredTags, 'relic', 'rare', '线索', '寻路']), keywords: dedupeStrings([...bundle.keywords, profile.settingText, profile.summary, '线索', '寻路']), }); const signature = queryItems(`inventory:${character.id}:signature`, { count: 1, categories: ['专属物品', '稀有品'], rarityFloor: 'rare', preferredTags: dedupeStrings([...bundle.preferredTags, '剧情关键', '异变', '旧史', 'rare']), keywords: dedupeStrings([...bundle.keywords, profile.playerGoal, profile.name, '信物', '关键']), }); const merged = mergeUniqueItems(explicitItems, consumables, materials, rareUtility, signature); if (merged.length >= 5) { return sortInventoryByCategory(merged.slice(0, 5)); } const filler = queryItems(`inventory:${character.id}:filler`, { count: 5 - merged.length, categories: ['消耗品', '材料', '饰品', '稀有品', '专属物品'], preferredTags: bundle.preferredTags, keywords: bundle.keywords, }); return sortInventoryByCategory(mergeUniqueItems(merged, filler).slice(0, 5)); }