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([
|
|
'这个世界',
|
|
'当前世界',
|
|
'玩家进入',
|
|
'玩家核心',
|
|
'世界设定',
|
|
'世界概述',
|
|
'世界基调',
|
|
'角色背景',
|
|
'角色描述',
|
|
'角色设定',
|
|
'角色故事',
|
|
'剧情关键',
|
|
'后续冒险',
|
|
'完整角色',
|
|
'当前局�?',
|
|
'进入世界',
|
|
'核心目标',
|
|
'可扮�?',
|
|
'主角候�?',
|
|
'主要角色',
|
|
'当前角色',
|
|
'这趟旅程',
|
|
'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<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));
|
|
}
|