374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
import {
|
|
type CustomWorldThemeMode,
|
|
detectCustomWorldThemeMode,
|
|
resolveCustomWorldCompatibilityTemplateWorldType,
|
|
} from '../services/customWorldTheme';
|
|
import { CustomWorldItem, CustomWorldProfile, InventoryItem, WorldTemplateType, WorldType } from '../types';
|
|
|
|
let runtimeCustomWorldProfile: CustomWorldProfile | null = null;
|
|
|
|
export function setRuntimeCustomWorldProfile(profile: CustomWorldProfile | null) {
|
|
runtimeCustomWorldProfile = profile;
|
|
}
|
|
|
|
export function getRuntimeCustomWorldProfile() {
|
|
return runtimeCustomWorldProfile;
|
|
}
|
|
|
|
export function resolveCompatibilityTemplateWorldType(
|
|
worldType: WorldType | null | undefined,
|
|
customWorldProfile: CustomWorldProfile | null | undefined = runtimeCustomWorldProfile,
|
|
): WorldTemplateType | null {
|
|
if (!worldType) return null;
|
|
if (worldType === WorldType.CUSTOM) {
|
|
return customWorldProfile
|
|
? resolveCustomWorldCompatibilityTemplateWorldType(customWorldProfile)
|
|
: WorldType.WUXIA;
|
|
}
|
|
return worldType;
|
|
}
|
|
|
|
export function isCustomWorldType(worldType: WorldType | null | undefined) {
|
|
return worldType === WorldType.CUSTOM;
|
|
}
|
|
|
|
function hashText(value: string) {
|
|
let hash = 0;
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
|
}
|
|
return hash >>> 0;
|
|
}
|
|
|
|
function compactStrings(values: Array<string | null | undefined | false>) {
|
|
return [...new Set(
|
|
values
|
|
.map(value => typeof value === 'string' ? value.trim() : '')
|
|
.filter(Boolean),
|
|
)];
|
|
}
|
|
|
|
function pickCyclic<T>(items: readonly T[], index: number, fallback: T): T {
|
|
return items[index % items.length] ?? fallback;
|
|
}
|
|
|
|
function normalizeInventoryItemId(item: CustomWorldItem, quantity: number, seedKey: string) {
|
|
return `custom:${item.id}:${quantity}:${hashText(seedKey).toString(36)}`;
|
|
}
|
|
|
|
function toInventoryItem(item: CustomWorldItem, quantity: number, seedKey: string): InventoryItem {
|
|
return {
|
|
id: normalizeInventoryItemId(item, quantity, seedKey),
|
|
category: item.category,
|
|
name: item.name,
|
|
quantity,
|
|
rarity: item.rarity,
|
|
tags: [...item.tags],
|
|
iconSrc: item.iconSrc,
|
|
description: item.description,
|
|
equipmentSlotId: item.equipmentSlotId ?? null,
|
|
statProfile: item.statProfile ?? null,
|
|
useProfile: item.useProfile ?? null,
|
|
value: item.value,
|
|
runtimeMetadata: {
|
|
origin: 'procedural',
|
|
generationChannel: 'discovery',
|
|
seedKey,
|
|
sourceReason: `围绕自定义世界 ${runtimeCustomWorldProfile?.name ?? '未知世界'} 的主题即时生成。`,
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface RuntimeCustomWorldItemQueryOptions {
|
|
categories?: string[];
|
|
tags?: string[];
|
|
preferredTags?: string[];
|
|
keywords?: string[];
|
|
count?: number;
|
|
quantity?: number;
|
|
rarityFloor?: CustomWorldItem['rarity'];
|
|
}
|
|
|
|
const RARITY_ORDER: CustomWorldItem['rarity'][] = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
|
|
const DEFAULT_RUNTIME_CATEGORIES = ['武器', '护甲', '饰品', '消耗品', '材料', '稀有品', '专属物'] as const;
|
|
const CATEGORY_DEFAULT_TAGS: Record<string, string[]> = {
|
|
武器: ['weapon', '战斗'],
|
|
护甲: ['armor', '防护'],
|
|
饰品: ['relic', 'mana'],
|
|
消耗品: ['healing', '补给'],
|
|
材料: ['material', '采集'],
|
|
稀有品: ['rare', '线索'],
|
|
专属物: ['rare', '剧情关键'],
|
|
};
|
|
const WORLD_ITEM_PREFIXES: Record<CustomWorldThemeMode, string[]> = {
|
|
mythic: ['远岸', '回声', '曙迹', '长旅', '微光', '新铭'],
|
|
martial: ['风雨', '断桥', '青锋', '旧案', '夜行', '残影'],
|
|
arcane: ['灵纹', '道痕', '云篆', '星芒', '界辉', '玉简'],
|
|
machina: ['铁脊', '脉冲', '新星', '等离', '钢律', '核列'],
|
|
tide: ['潮纹', '霜浪', '天澜', '海晕', '潮歌', '沧流'],
|
|
rift: ['裂痕', '灰域', '界桥', '断层', '回响', '前哨'],
|
|
};
|
|
const WORLD_ITEM_NOUNS: Record<string, string[]> = {
|
|
武器: ['刃', '剑', '弓', '枪', '印', '锤'],
|
|
护甲: ['甲', '衣', '护符', '披风', '战铠', '护腕'],
|
|
饰品: ['坠', '环', '佩', '珠', '印记', '信物'],
|
|
消耗品: ['药', '露', '符', '瓶', '包', '散'],
|
|
材料: ['砂', '石', '铁', '木', '羽', '晶'],
|
|
稀有品: ['残页', '密卷', '古钥', '图录', '印匣', '秘函'],
|
|
专属物: ['遗物', '核心', '母印', '真符', '遗钥', '界核'],
|
|
};
|
|
|
|
function normalizeLookupText(value: string) {
|
|
return value.trim().toLowerCase();
|
|
}
|
|
|
|
function getRarityFloorValue(rarityFloor?: CustomWorldItem['rarity']) {
|
|
return rarityFloor ? RARITY_ORDER.indexOf(rarityFloor) : -1;
|
|
}
|
|
|
|
function sanitizeNameFragment(value: string) {
|
|
return value.replace(/[^\u4e00-\u9fa5a-z0-9]/giu, '').slice(0, 4);
|
|
}
|
|
|
|
function getWorldSeedLabel(profile: CustomWorldProfile) {
|
|
const fromName = sanitizeNameFragment(profile.name);
|
|
if (fromName) return fromName;
|
|
|
|
const fromSetting = sanitizeNameFragment(profile.settingText);
|
|
if (fromSetting) return fromSetting;
|
|
|
|
return '旅境';
|
|
}
|
|
|
|
function buildRuntimeItemTags(
|
|
category: string,
|
|
options: RuntimeCustomWorldItemQueryOptions,
|
|
seed: number,
|
|
) {
|
|
const baseTags = [...(CATEGORY_DEFAULT_TAGS[category] ?? ['world-item'])];
|
|
const preferredTags = [...new Set((options.preferredTags ?? []).map(tag => tag.trim()).filter(Boolean))];
|
|
const keywordTags = [...new Set((options.keywords ?? []).map(tag => tag.trim()).filter(Boolean))];
|
|
const selectedPreferredTag = preferredTags.length > 0
|
|
? preferredTags[seed % preferredTags.length]
|
|
: undefined;
|
|
const selectedKeywordTag = keywordTags.length > 0
|
|
? keywordTags[(seed >>> 3) % keywordTags.length]
|
|
: undefined;
|
|
|
|
if (category === '消耗品' && preferredTags.some(tag => /mana|法力|灵气|内息/u.test(tag))) {
|
|
baseTags.push('mana');
|
|
}
|
|
if (category === '消耗品' && preferredTags.some(tag => /heal|疗|血|恢复/u.test(tag))) {
|
|
baseTags.push('healing');
|
|
}
|
|
|
|
return compactStrings([...baseTags, selectedPreferredTag, selectedKeywordTag]).slice(0, 5);
|
|
}
|
|
|
|
function inferRuntimeItemRarity(seed: number, rarityFloorValue: number): CustomWorldItem['rarity'] {
|
|
const rolledRarity = [0, 1, 1, 2, 2, 2, 3, 3, 4][seed % 9] ?? 0;
|
|
return RARITY_ORDER[Math.max(rarityFloorValue, rolledRarity)] ?? 'common';
|
|
}
|
|
|
|
function inferRuntimeItemMechanics(
|
|
category: string,
|
|
rarity: CustomWorldItem['rarity'],
|
|
tags: string[],
|
|
seed: number,
|
|
) {
|
|
const rarityTier = Math.max(1, RARITY_ORDER.indexOf(rarity) + 1);
|
|
|
|
if (category === '武器') {
|
|
return {
|
|
equipmentSlotId: 'weapon' as const,
|
|
statProfile: {
|
|
outgoingDamageBonus: 2 * rarityTier + (seed % 3),
|
|
},
|
|
useProfile: null,
|
|
value: 28 * rarityTier,
|
|
};
|
|
}
|
|
|
|
if (category === '护甲') {
|
|
return {
|
|
equipmentSlotId: 'armor' as const,
|
|
statProfile: {
|
|
maxHpBonus: 10 * rarityTier + (seed % 8),
|
|
incomingDamageMultiplier: Math.max(0.72, Number((1 - rarityTier * 0.04).toFixed(2))),
|
|
},
|
|
useProfile: null,
|
|
value: 26 * rarityTier,
|
|
};
|
|
}
|
|
|
|
if (category === '饰品' || category === '稀有品' || category === '专属物') {
|
|
return {
|
|
equipmentSlotId: 'relic' as const,
|
|
statProfile: {
|
|
maxManaBonus: 8 * rarityTier + (seed % 7),
|
|
outgoingDamageBonus: rarityTier >= 3 ? rarityTier : undefined,
|
|
},
|
|
useProfile: null,
|
|
value: 32 * rarityTier,
|
|
};
|
|
}
|
|
|
|
if (category === '消耗品') {
|
|
return {
|
|
equipmentSlotId: null,
|
|
statProfile: null,
|
|
useProfile: tags.includes('mana')
|
|
? { manaRestore: 12 * rarityTier, cooldownReduction: rarityTier >= 3 ? 1 : 0 }
|
|
: { hpRestore: 16 * rarityTier },
|
|
value: 18 * rarityTier,
|
|
};
|
|
}
|
|
|
|
return {
|
|
equipmentSlotId: null,
|
|
statProfile: null,
|
|
useProfile: null,
|
|
value: 10 * rarityTier,
|
|
};
|
|
}
|
|
|
|
function buildProceduralRuntimeItem(
|
|
profile: CustomWorldProfile,
|
|
seedKey: string,
|
|
options: RuntimeCustomWorldItemQueryOptions,
|
|
index: number,
|
|
) {
|
|
const themeMode = detectCustomWorldThemeMode(profile);
|
|
const seed = hashText(`${profile.id}:${seedKey}:${index}`);
|
|
const defaultCategory = DEFAULT_RUNTIME_CATEGORIES[0] ?? 'world-item';
|
|
const categories = compactStrings(options.categories?.length ? options.categories : [...DEFAULT_RUNTIME_CATEGORIES]);
|
|
const category = pickCyclic(categories, seed, defaultCategory);
|
|
const rarityFloorValue = getRarityFloorValue(options.rarityFloor);
|
|
const rarity = inferRuntimeItemRarity(seed, rarityFloorValue);
|
|
const tags = buildRuntimeItemTags(category, options, seed);
|
|
const prefixPool = WORLD_ITEM_PREFIXES[themeMode];
|
|
const nounPool = WORLD_ITEM_NOUNS[category] ?? WORLD_ITEM_NOUNS.稀有品;
|
|
const fallbackNounPool = ['sigil', 'relic', 'token', 'seal', 'core', 'mark'];
|
|
const resolvedNounPool = nounPool ?? fallbackNounPool;
|
|
const worldSeed = getWorldSeedLabel(profile);
|
|
const optionSeed = sanitizeNameFragment((options.preferredTags ?? [])[0] ?? '') || sanitizeNameFragment((options.keywords ?? [])[0] ?? '');
|
|
const prefix = pickCyclic(prefixPool, seed >>> 2, prefixPool[0] ?? 'world');
|
|
const noun = pickCyclic(resolvedNounPool, seed >>> 5, fallbackNounPool[0]);
|
|
const name = `${prefix}${optionSeed || worldSeed}${noun}${index + 1}`;
|
|
const mechanics = inferRuntimeItemMechanics(category, rarity, tags, seed);
|
|
|
|
return {
|
|
id: `runtime-item:${hashText(`${seedKey}:${index}`).toString(36)}`,
|
|
name,
|
|
category,
|
|
rarity,
|
|
description: `围绕“${profile.playerGoal}”即时生成的${category},适合在 ${profile.name} 中作为掉落、交易或补给资源。`,
|
|
tags,
|
|
origin: 'generated' as const,
|
|
equipmentSlotId: mechanics.equipmentSlotId,
|
|
statProfile: mechanics.statProfile,
|
|
useProfile: mechanics.useProfile,
|
|
value: mechanics.value,
|
|
} satisfies CustomWorldItem;
|
|
}
|
|
|
|
function matchesRuntimeQuery(
|
|
item: CustomWorldItem,
|
|
options: RuntimeCustomWorldItemQueryOptions,
|
|
rarityFloorValue: number,
|
|
) {
|
|
if (options.categories?.length && !options.categories.includes(item.category)) {
|
|
return false;
|
|
}
|
|
if (options.tags?.length && !options.tags.some(tag => item.tags.includes(tag))) {
|
|
return false;
|
|
}
|
|
if (rarityFloorValue >= 0) {
|
|
const itemRarityValue = RARITY_ORDER.indexOf(item.rarity);
|
|
if (itemRarityValue < rarityFloorValue) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function scoreItemRelevance(item: CustomWorldItem, options: RuntimeCustomWorldItemQueryOptions) {
|
|
const haystack = normalizeLookupText([
|
|
item.name,
|
|
item.category,
|
|
item.description,
|
|
...(item.tags ?? []),
|
|
].join(' '));
|
|
const itemTags = new Set((item.tags ?? []).map(tag => normalizeLookupText(tag)));
|
|
let score = 0;
|
|
|
|
const preferredTags = [...new Set((options.preferredTags ?? []).map(normalizeLookupText).filter(Boolean))];
|
|
preferredTags.forEach(tag => {
|
|
if (itemTags.has(tag)) {
|
|
score += 10;
|
|
return;
|
|
}
|
|
if (haystack.includes(tag)) {
|
|
score += 4;
|
|
}
|
|
});
|
|
|
|
const keywords = [...new Set((options.keywords ?? []).map(normalizeLookupText).filter(keyword => keyword.length >= 2))];
|
|
keywords.forEach(keyword => {
|
|
if (!haystack.includes(keyword)) {
|
|
return;
|
|
}
|
|
score += keyword.length >= 4 ? 7 : keyword.length === 3 ? 5 : 3;
|
|
});
|
|
|
|
if (options.categories?.includes(item.category)) {
|
|
score += 2;
|
|
}
|
|
if (item.origin === 'generated') {
|
|
score += 1;
|
|
}
|
|
|
|
return score;
|
|
}
|
|
|
|
function rankItems(items: CustomWorldItem[], seedKey: string, options: RuntimeCustomWorldItemQueryOptions = {}) {
|
|
const seed = hashText(seedKey);
|
|
return [...items].sort((left, right) => {
|
|
const relevanceDelta = scoreItemRelevance(right, options) - scoreItemRelevance(left, options);
|
|
if (relevanceDelta !== 0) {
|
|
return relevanceDelta;
|
|
}
|
|
|
|
const leftScore = hashText(`${left.id}:${seed}`) % 997;
|
|
const rightScore = hashText(`${right.id}:${seed}`) % 997;
|
|
return leftScore - rightScore;
|
|
});
|
|
}
|
|
|
|
export function pickRuntimeCustomWorldItems(
|
|
seedKey: string,
|
|
options: RuntimeCustomWorldItemQueryOptions = {},
|
|
) {
|
|
const profile = runtimeCustomWorldProfile;
|
|
if (!profile) return [] as CustomWorldItem[];
|
|
|
|
const rarityFloorValue = getRarityFloorValue(options.rarityFloor);
|
|
const sourceItems = Array.from({ length: Math.max(16, (options.count ?? 1) * 10) }, (_, index) =>
|
|
buildProceduralRuntimeItem(profile, seedKey, options, index),
|
|
);
|
|
|
|
const filtered = sourceItems.filter(item => matchesRuntimeQuery(item, options, rarityFloorValue));
|
|
|
|
return rankItems(filtered.length > 0 ? filtered : sourceItems, seedKey, options).slice(0, options.count ?? 1);
|
|
}
|
|
|
|
export function buildRuntimeCustomWorldInventoryItems(
|
|
seedKey: string,
|
|
options: RuntimeCustomWorldItemQueryOptions = {},
|
|
) {
|
|
const count = options.count ?? 1;
|
|
return pickRuntimeCustomWorldItems(seedKey, options)
|
|
.slice(0, count)
|
|
.map((item, index) => toInventoryItem(item, options.quantity ?? 1, `${seedKey}:${index}`));
|
|
}
|