071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
478 lines
13 KiB
TypeScript
478 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}`),
|
|
);
|
|
}
|