292 lines
7.1 KiB
TypeScript
292 lines
7.1 KiB
TypeScript
import {
|
|
collectCreatureArchetypeSignals,
|
|
resolveCreatureArchetypeForSource,
|
|
} from '../services/customWorldReferenceSignals';
|
|
import {
|
|
type CustomWorldNpc,
|
|
type CustomWorldPlayableNpc,
|
|
type CustomWorldProfile,
|
|
WorldType,
|
|
} from '../types';
|
|
import { resolveCustomWorldCompatibilityTemplateWorldType } from '../services/customWorldTheme';
|
|
import {
|
|
getMonsterPresetsByWorld,
|
|
type HostileNpcPreset,
|
|
} from './hostileNpcPresets';
|
|
|
|
type CustomWorldMonsterSource = Partial<
|
|
Pick<
|
|
CustomWorldNpc & CustomWorldPlayableNpc,
|
|
| 'name'
|
|
| 'title'
|
|
| 'role'
|
|
| 'description'
|
|
| 'backstory'
|
|
| 'personality'
|
|
| 'motivation'
|
|
| 'combatStyle'
|
|
| 'initialAffinity'
|
|
| 'relationshipHooks'
|
|
| 'tags'
|
|
>
|
|
>;
|
|
|
|
const MONSTER_SIGNAL_PATTERN =
|
|
/妖|魔|鬼|怪|兽|灵|尸|蛛|蛇|虫|菇|傀|骸|骨|眼|蜗|藤|象|蝠|蛙|蛾|蟾|狼|狐|蛟|龙|祟/u;
|
|
const MONSTER_SIGNAL_STOP_CHARS = new Set([
|
|
'妖',
|
|
'魔',
|
|
'鬼',
|
|
'怪',
|
|
'兽',
|
|
'灵',
|
|
'尸',
|
|
'祟',
|
|
'凶',
|
|
'异',
|
|
'夜',
|
|
'古',
|
|
]);
|
|
|
|
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 getMonsterPresetPool(worldType?: WorldType | null) {
|
|
if (worldType) {
|
|
return getMonsterPresetsByWorld(worldType);
|
|
}
|
|
|
|
const seen = new Set<string>();
|
|
return [
|
|
...getMonsterPresetsByWorld(WorldType.WUXIA),
|
|
...getMonsterPresetsByWorld(WorldType.XIANXIA),
|
|
].filter((preset) => {
|
|
if (seen.has(preset.id)) {
|
|
return false;
|
|
}
|
|
seen.add(preset.id);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function getAllMonsterPresets() {
|
|
return getMonsterPresetPool(null);
|
|
}
|
|
|
|
function uniqueText(values: Array<string | null | undefined>) {
|
|
return [
|
|
...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean)),
|
|
];
|
|
}
|
|
|
|
function buildMonsterSourceText(npc: CustomWorldMonsterSource) {
|
|
return uniqueText([
|
|
npc.name,
|
|
npc.title,
|
|
npc.role,
|
|
npc.description,
|
|
npc.backstory,
|
|
npc.personality,
|
|
npc.motivation,
|
|
npc.combatStyle,
|
|
...(npc.relationshipHooks ?? []),
|
|
...(npc.tags ?? []),
|
|
]).join(' ');
|
|
}
|
|
|
|
function buildSignalChars(label: string) {
|
|
return [
|
|
...new Set(
|
|
label
|
|
.replace(/[^\u4e00-\u9fa5]+/g, '')
|
|
.split('')
|
|
.filter((char) => char && !MONSTER_SIGNAL_STOP_CHARS.has(char)),
|
|
),
|
|
];
|
|
}
|
|
|
|
function scoreMonsterPreset(preset: HostileNpcPreset, sourceText: string) {
|
|
let score = 0;
|
|
|
|
if (sourceText.includes(preset.name)) {
|
|
score += 24;
|
|
}
|
|
|
|
for (const signalChar of buildSignalChars(preset.name)) {
|
|
if (sourceText.includes(signalChar)) {
|
|
score += 3;
|
|
}
|
|
}
|
|
|
|
for (const tag of [...preset.habitatTags, ...preset.combatTags]) {
|
|
if (tag && sourceText.includes(tag)) {
|
|
score += 2;
|
|
}
|
|
}
|
|
|
|
return score;
|
|
}
|
|
|
|
function scoreMonsterPresetWithArchetype(
|
|
preset: HostileNpcPreset,
|
|
sourceText: string,
|
|
options: {
|
|
archetypeSignals?: ReturnType<typeof collectCreatureArchetypeSignals> | null;
|
|
preferredWorldType?: WorldType | null;
|
|
} = {},
|
|
) {
|
|
let score = scoreMonsterPreset(preset, sourceText);
|
|
const { archetypeSignals, preferredWorldType } = options;
|
|
|
|
if (archetypeSignals) {
|
|
archetypeSignals.keywords.forEach((keyword) => {
|
|
if (!keyword) {
|
|
return;
|
|
}
|
|
if (
|
|
preset.name.includes(keyword)
|
|
|| preset.habitatTags.some((tag) => tag.includes(keyword) || keyword.includes(tag))
|
|
|| preset.combatTags.some((tag) => tag.includes(keyword) || keyword.includes(tag))
|
|
) {
|
|
score += keyword.length >= 3 ? 6 : 4;
|
|
}
|
|
});
|
|
|
|
archetypeSignals.combatTags.forEach((tag) => {
|
|
if (preset.combatTags.includes(tag)) {
|
|
score += 8;
|
|
}
|
|
});
|
|
|
|
archetypeSignals.habitatTags.forEach((tag) => {
|
|
if (preset.habitatTags.includes(tag)) {
|
|
score += 6;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (
|
|
preferredWorldType
|
|
&& preferredWorldType !== WorldType.CUSTOM
|
|
&& preset.worldType === preferredWorldType
|
|
) {
|
|
score += 3;
|
|
}
|
|
|
|
return score;
|
|
}
|
|
|
|
export function getCustomWorldMonsterPresetPool(
|
|
profile?: Pick<
|
|
CustomWorldProfile,
|
|
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
|
|
> | null,
|
|
) {
|
|
const presets = getAllMonsterPresets();
|
|
const creatureArchetypes =
|
|
profile?.ownedSettingLayers?.referenceProfile.creatureArchetypes ?? [];
|
|
|
|
if (creatureArchetypes.length === 0) {
|
|
return presets;
|
|
}
|
|
|
|
const preferredWorldType = profile
|
|
? resolveCustomWorldCompatibilityTemplateWorldType(profile)
|
|
: null;
|
|
const scoredPresets = presets
|
|
.map((preset) => {
|
|
const archetypeScore = creatureArchetypes.reduce((bestScore, archetype) => {
|
|
const nextScore = scoreMonsterPresetWithArchetype(
|
|
preset,
|
|
preset.name,
|
|
{
|
|
archetypeSignals: collectCreatureArchetypeSignals(archetype),
|
|
preferredWorldType,
|
|
},
|
|
);
|
|
return Math.max(bestScore, nextScore);
|
|
}, 0);
|
|
|
|
return {
|
|
preset,
|
|
score: archetypeScore,
|
|
};
|
|
})
|
|
.sort((left, right) => right.score - left.score);
|
|
|
|
const filtered = scoredPresets
|
|
.filter((entry) => entry.score > 0)
|
|
.map((entry) => entry.preset);
|
|
|
|
return filtered.length > 0 ? filtered : presets;
|
|
}
|
|
|
|
export function resolveCustomWorldNpcMonsterPreset(
|
|
npc: CustomWorldMonsterSource,
|
|
worldType?: WorldType | null,
|
|
profile?: Pick<
|
|
CustomWorldProfile,
|
|
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
|
|
> | null,
|
|
) {
|
|
const sourceText = buildMonsterSourceText(npc);
|
|
if (!sourceText || !MONSTER_SIGNAL_PATTERN.test(sourceText)) {
|
|
return null;
|
|
}
|
|
|
|
const hostileBias = (npc.initialAffinity ?? 0) < 0;
|
|
if (!hostileBias) {
|
|
return null;
|
|
}
|
|
|
|
const preferredWorldType = profile
|
|
? resolveCustomWorldCompatibilityTemplateWorldType(profile)
|
|
: worldType ?? null;
|
|
const referenceArchetype = resolveCreatureArchetypeForSource(
|
|
profile as CustomWorldProfile | null | undefined,
|
|
npc,
|
|
);
|
|
const archetypeSignals = referenceArchetype
|
|
? collectCreatureArchetypeSignals(referenceArchetype)
|
|
: null;
|
|
const candidates =
|
|
profile && profile.ownedSettingLayers?.referenceProfile.creatureArchetypes.length
|
|
? getCustomWorldMonsterPresetPool(profile)
|
|
: getMonsterPresetPool(worldType);
|
|
if (candidates.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const scoredCandidates = candidates
|
|
.map((candidate) => ({
|
|
candidate,
|
|
score: scoreMonsterPresetWithArchetype(candidate, sourceText, {
|
|
archetypeSignals,
|
|
preferredWorldType,
|
|
}),
|
|
}))
|
|
.sort((left, right) => right.score - left.score);
|
|
|
|
if ((scoredCandidates[0]?.score ?? 0) >= 3) {
|
|
return scoredCandidates[0]?.candidate ?? null;
|
|
}
|
|
|
|
return candidates[hashText(sourceText) % candidates.length] ?? null;
|
|
}
|
|
|
|
export function resolveCustomWorldNpcMonsterPresetId(
|
|
npc: CustomWorldMonsterSource,
|
|
worldType?: WorldType | null,
|
|
profile?: Pick<
|
|
CustomWorldProfile,
|
|
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
|
|
> | null,
|
|
) {
|
|
return resolveCustomWorldNpcMonsterPreset(npc, worldType, profile)?.id ?? null;
|
|
}
|