This commit is contained in:
291
src/data/customWorldNpcMonsters.ts
Normal file
291
src/data/customWorldNpcMonsters.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user