393 lines
13 KiB
TypeScript
393 lines
13 KiB
TypeScript
import {
|
||
Character,
|
||
CustomWorldNpc,
|
||
CustomWorldPlayableNpc,
|
||
CustomWorldProfile,
|
||
CustomWorldRoleInitialItem,
|
||
EquipmentSlotId,
|
||
InventoryItem,
|
||
} from '../types';
|
||
import {
|
||
buildRuntimeCustomWorldInventoryItems,
|
||
getRuntimeCustomWorldProfile,
|
||
type RuntimeCustomWorldItemQueryOptions,
|
||
} from './customWorldRuntime';
|
||
|
||
const CATEGORY_ORDER = new Map<string, number>([
|
||
['武器', 0],
|
||
['护甲', 1],
|
||
['饰品', 2],
|
||
['消耗品', 3],
|
||
['材料', 4],
|
||
['稀有品', 5],
|
||
['专属物品', 6],
|
||
]);
|
||
|
||
const STOP_PHRASES = new Set([
|
||
'这个世界',
|
||
'当前世界',
|
||
'玩家进入',
|
||
'玩家核心',
|
||
'世界设定',
|
||
'世界概述',
|
||
'世界基调',
|
||
'角色背景',
|
||
'角色描述',
|
||
'角色设定',
|
||
'角色故事',
|
||
'剧情关键',
|
||
'后续冒险',
|
||
'完整角色',
|
||
'当前局<E5898D>?',
|
||
'进入世界',
|
||
'核心目标',
|
||
'可扮<E58FAF>?',
|
||
'主角候<E8A792>?',
|
||
'主要角色',
|
||
'当前角色',
|
||
'这趟旅程',
|
||
'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,。、“”‘’;:?<EFBFBD>?.!?:()()【<EFBFBD>?]+/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<string>();
|
||
|
||
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<EquipmentSlotId, InventoryItem | null>;
|
||
}
|
||
|
||
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<EquipmentSlotId, InventoryItem | null>;
|
||
}
|
||
|
||
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));
|
||
}
|