This commit is contained in:
392
src/data/customWorldCharacterLoadout.ts
Normal file
392
src/data/customWorldCharacterLoadout.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user