Files
Genarrative/src/data/customWorldRuntime.ts
高物 0981d6ee1b
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-11 15:43:32 +08:00

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}`));
}