Files
Genarrative/src/data/customWorldCharacterLoadout.ts
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

393 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}