370 lines
10 KiB
TypeScript
370 lines
10 KiB
TypeScript
import type {
|
|
CreatureArchetypeProfile,
|
|
CustomWorldNpc,
|
|
CustomWorldPlayableNpc,
|
|
CustomWorldProfile,
|
|
RoleArchetypeProfile,
|
|
SceneArchetypeBucket,
|
|
} from '../types';
|
|
|
|
type SceneBucketSignalPreset = {
|
|
keywords: string[];
|
|
};
|
|
|
|
type CreatureArchetypeSignalPreset = {
|
|
keywords: string[];
|
|
combatTags: string[];
|
|
habitatTags: string[];
|
|
};
|
|
|
|
type RoleArchetypeSignalPreset = {
|
|
keywords: string[];
|
|
templateCharacterIds: string[];
|
|
};
|
|
|
|
const SCENE_BUCKET_SIGNAL_PRESETS: Record<string, SceneBucketSignalPreset> = {
|
|
高压入口区: {
|
|
keywords: ['入口', '关口', '哨站', '桥口', '门廊', '边关'],
|
|
},
|
|
临水渡口区: {
|
|
keywords: ['渡口', '码头', '港口', '岸线', '船坞', '水路'],
|
|
},
|
|
仪式神殿区: {
|
|
keywords: ['祭坛', '神殿', '仪式', '法坛', '庙宇', '圣所'],
|
|
},
|
|
高空通路区: {
|
|
keywords: ['高空', '悬桥', '云阶', '塔顶', '崖道', '飞桥'],
|
|
},
|
|
工业热区: {
|
|
keywords: ['工坊', '轨道', '机库', '熔炉', '工场', '锅炉'],
|
|
},
|
|
地底遗迹区: {
|
|
keywords: ['地宫', '矿道', '遗迹', '洞窟', '墓道', '地底'],
|
|
},
|
|
群落聚居区: {
|
|
keywords: ['街巷', '聚落', '城镇', '营地', '居所', '市集'],
|
|
},
|
|
高压交汇区: {
|
|
keywords: ['险地', '封锁', '交汇', '前线', '险关', '断层'],
|
|
},
|
|
叙事缓冲区: {
|
|
keywords: ['归处', '栖居', '缓冲', '休整', '据点', '落脚'],
|
|
},
|
|
};
|
|
|
|
const CREATURE_ARCHETYPE_SIGNAL_PRESETS: Record<
|
|
string,
|
|
CreatureArchetypeSignalPreset
|
|
> = {
|
|
潜伏袭击者: {
|
|
keywords: ['潜伏', '伏击', '突袭', '暗影', '贴身'],
|
|
combatTags: ['快袭', '突进', '机动'],
|
|
habitatTags: ['雾林', '断垣', '妖雾', '崖壁'],
|
|
},
|
|
重甲承压者: {
|
|
keywords: ['重甲', '承压', '守线', '堵截', '厚重'],
|
|
combatTags: ['重甲', '守御', '护体', '堡垒'],
|
|
habitatTags: ['矿道', '废城', '边关', '地宫'],
|
|
},
|
|
群居骚扰者: {
|
|
keywords: ['群居', '骚扰', '游窜', '围猎', '消耗'],
|
|
combatTags: ['机动', '追击', '控场'],
|
|
habitatTags: ['竹林', '雾林', '荒野', '月湖'],
|
|
},
|
|
远程威胁者: {
|
|
keywords: ['远程', '投射', '压制', '炮击', '凝视'],
|
|
combatTags: ['远射', '法修', '雷法'],
|
|
habitatTags: ['长街', '仙门', '星舟', '祭坛'],
|
|
},
|
|
异化污染体: {
|
|
keywords: ['异化', '污染', '腐化', '潮灾', '侵蚀'],
|
|
combatTags: ['法力', '回复', '重甲'],
|
|
habitatTags: ['洞天', '谷地', '秘境', '灵泉'],
|
|
},
|
|
灵体回响体: {
|
|
keywords: ['灵体', '回响', '残魂', '旧痕', '幽灵'],
|
|
combatTags: ['镇邪', '控场', '法修'],
|
|
habitatTags: ['遗迹', '祭坛', '古迹', '废寺'],
|
|
},
|
|
机关守卫体: {
|
|
keywords: ['机关', '守卫', '节点', '封印', '装置'],
|
|
combatTags: ['守御', '压制', '符阵'],
|
|
habitatTags: ['铸坊', '工场', '前哨', '长廊'],
|
|
},
|
|
回响追猎者: {
|
|
keywords: ['追猎', '回响', '索敌', '追索', '名单'],
|
|
combatTags: ['追击', '压制', '机动'],
|
|
habitatTags: ['前线', '断层', '渡口', '雾港'],
|
|
},
|
|
};
|
|
|
|
const ROLE_ARCHETYPE_SIGNAL_PRESETS: Record<string, RoleArchetypeSignalPreset> = {
|
|
正面推进型: {
|
|
keywords: ['推进', '压前', '正面', '先锋', '破阵'],
|
|
templateCharacterIds: ['sword-princess', 'punch-hero'],
|
|
},
|
|
远程压制型: {
|
|
keywords: ['远程', '弓', '射击', '投掷', '炮击'],
|
|
templateCharacterIds: ['archer-hero'],
|
|
},
|
|
控场解构型: {
|
|
keywords: ['控场', '阵', '法', '机关', '解构', '牵制'],
|
|
templateCharacterIds: ['fighter-4', 'girl-hero'],
|
|
},
|
|
续航承压型: {
|
|
keywords: ['承压', '护体', '守御', '续航', '稳阵'],
|
|
templateCharacterIds: ['fighter-4', 'punch-hero'],
|
|
},
|
|
潜行爆发型: {
|
|
keywords: ['潜行', '爆发', '影袭', '突进', '追击'],
|
|
templateCharacterIds: ['girl-hero', 'sword-princess'],
|
|
},
|
|
};
|
|
|
|
type ReferenceRoleSource = Pick<
|
|
CustomWorldPlayableNpc | CustomWorldNpc,
|
|
'id' | 'name' | 'title' | 'role' | 'description' | 'personality' | 'combatStyle' | 'tags'
|
|
>;
|
|
|
|
type ReferenceCreatureSource = Partial<
|
|
Pick<
|
|
CustomWorldPlayableNpc & CustomWorldNpc,
|
|
| 'id'
|
|
| 'name'
|
|
| 'title'
|
|
| 'role'
|
|
| 'description'
|
|
| 'backstory'
|
|
| 'personality'
|
|
| 'motivation'
|
|
| 'combatStyle'
|
|
| 'relationshipHooks'
|
|
| 'tags'
|
|
>
|
|
>;
|
|
|
|
function toText(value: unknown) {
|
|
return typeof value === 'string' ? value.trim() : '';
|
|
}
|
|
|
|
function dedupeStrings(
|
|
values: Array<string | null | undefined>,
|
|
max = 12,
|
|
) {
|
|
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))]
|
|
.slice(0, max);
|
|
}
|
|
|
|
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 buildRoleSourceText(role: ReferenceRoleSource) {
|
|
return dedupeStrings([
|
|
role.name,
|
|
role.title,
|
|
role.role,
|
|
role.description,
|
|
role.personality,
|
|
role.combatStyle,
|
|
...(role.tags ?? []),
|
|
]).join(' ');
|
|
}
|
|
|
|
function buildCreatureSourceText(source: ReferenceCreatureSource) {
|
|
return dedupeStrings([
|
|
source.name,
|
|
source.title,
|
|
source.role,
|
|
source.description,
|
|
source.backstory,
|
|
source.personality,
|
|
source.motivation,
|
|
source.combatStyle,
|
|
...(source.relationshipHooks ?? []),
|
|
...(source.tags ?? []),
|
|
]).join(' ');
|
|
}
|
|
|
|
function scoreTextMatches(sourceText: string, keywords: string[]) {
|
|
return keywords.reduce((score, keyword) => {
|
|
if (!keyword || !sourceText.includes(keyword)) {
|
|
return score;
|
|
}
|
|
|
|
if (keyword.length >= 4) {
|
|
return score + 8;
|
|
}
|
|
|
|
if (keyword.length === 3) {
|
|
return score + 6;
|
|
}
|
|
|
|
return score + 4;
|
|
}, 0);
|
|
}
|
|
|
|
function getReferenceProfile(profile: CustomWorldProfile | null | undefined) {
|
|
return profile?.ownedSettingLayers?.referenceProfile ?? null;
|
|
}
|
|
|
|
export function collectSceneBucketSignalKeywords(
|
|
bucket: Pick<SceneArchetypeBucket, 'label' | 'keywords' | 'moodTags'>,
|
|
) {
|
|
const preset = SCENE_BUCKET_SIGNAL_PRESETS[bucket.label];
|
|
return dedupeStrings([
|
|
bucket.label,
|
|
...bucket.keywords,
|
|
...bucket.moodTags,
|
|
...(preset?.keywords ?? []),
|
|
]);
|
|
}
|
|
|
|
export function resolveSceneBucketForLandmark(
|
|
profile: CustomWorldProfile | null | undefined,
|
|
landmark: Pick<CustomWorldProfile['landmarks'][number], 'id' | 'name' | 'description'>,
|
|
) {
|
|
const sceneBuckets = getReferenceProfile(profile)?.sceneBuckets ?? [];
|
|
if (sceneBuckets.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const explicitBucket = sceneBuckets.find((bucket) =>
|
|
bucket.referenceLandmarkIds.includes(landmark.id),
|
|
);
|
|
if (explicitBucket) {
|
|
return explicitBucket;
|
|
}
|
|
|
|
const sourceText = dedupeStrings([landmark.name, landmark.description]).join(' ');
|
|
|
|
const scoredBuckets = sceneBuckets
|
|
.map((bucket) => ({
|
|
bucket,
|
|
score: scoreTextMatches(sourceText, collectSceneBucketSignalKeywords(bucket)),
|
|
}))
|
|
.sort((left, right) => right.score - left.score);
|
|
|
|
return (scoredBuckets[0]?.score ?? 0) > 0 ? scoredBuckets[0]?.bucket ?? null : null;
|
|
}
|
|
|
|
export function collectCreatureArchetypeSignals(
|
|
archetype: Pick<CreatureArchetypeProfile, 'label' | 'threatStyle' | 'keywords'>,
|
|
) {
|
|
const preset = CREATURE_ARCHETYPE_SIGNAL_PRESETS[archetype.label];
|
|
|
|
return {
|
|
keywords: dedupeStrings([
|
|
archetype.label,
|
|
archetype.threatStyle,
|
|
...archetype.keywords,
|
|
...(preset?.keywords ?? []),
|
|
]),
|
|
combatTags: dedupeStrings(preset?.combatTags ?? [], 6),
|
|
habitatTags: dedupeStrings(preset?.habitatTags ?? [], 6),
|
|
};
|
|
}
|
|
|
|
export function resolveCreatureArchetypeForSource(
|
|
profile: CustomWorldProfile | null | undefined,
|
|
source: ReferenceCreatureSource,
|
|
) {
|
|
const creatureArchetypes = getReferenceProfile(profile)?.creatureArchetypes ?? [];
|
|
if (creatureArchetypes.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const sourceText = buildCreatureSourceText(source);
|
|
const scoredArchetypes = creatureArchetypes
|
|
.map((archetype) => ({
|
|
archetype,
|
|
score: scoreTextMatches(
|
|
sourceText,
|
|
collectCreatureArchetypeSignals(archetype).keywords,
|
|
),
|
|
}))
|
|
.sort((left, right) => right.score - left.score);
|
|
|
|
return (scoredArchetypes[0]?.score ?? 0) > 0
|
|
? scoredArchetypes[0]?.archetype ?? null
|
|
: creatureArchetypes[0] ?? null;
|
|
}
|
|
|
|
function collectRoleArchetypeSignals(
|
|
archetype: Pick<
|
|
RoleArchetypeProfile,
|
|
| 'label'
|
|
| 'combatFocus'
|
|
| 'narrativeFunction'
|
|
| 'tags'
|
|
| 'sourceTemplateCharacterIds'
|
|
>,
|
|
) {
|
|
const preset = ROLE_ARCHETYPE_SIGNAL_PRESETS[archetype.label];
|
|
|
|
return {
|
|
keywords: dedupeStrings([
|
|
archetype.label,
|
|
archetype.combatFocus,
|
|
archetype.narrativeFunction,
|
|
...archetype.tags,
|
|
...(preset?.keywords ?? []),
|
|
]),
|
|
templateCharacterIds:
|
|
archetype.sourceTemplateCharacterIds.length > 0
|
|
? archetype.sourceTemplateCharacterIds
|
|
: preset?.templateCharacterIds ?? [],
|
|
};
|
|
}
|
|
|
|
export function resolveRoleArchetypeForRole(
|
|
profile: CustomWorldProfile | null | undefined,
|
|
role: ReferenceRoleSource,
|
|
) {
|
|
const roleArchetypes = getReferenceProfile(profile)?.roleArchetypes ?? [];
|
|
if (roleArchetypes.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const explicitArchetype = roleArchetypes.find((archetype) =>
|
|
archetype.sourceRoleIds.includes(role.id),
|
|
);
|
|
if (explicitArchetype) {
|
|
return explicitArchetype;
|
|
}
|
|
|
|
const sourceText = buildRoleSourceText(role);
|
|
const scoredArchetypes = roleArchetypes
|
|
.map((archetype) => ({
|
|
archetype,
|
|
score: scoreTextMatches(sourceText, collectRoleArchetypeSignals(archetype).keywords),
|
|
}))
|
|
.sort((left, right) => right.score - left.score);
|
|
|
|
return (scoredArchetypes[0]?.score ?? 0) > 0
|
|
? scoredArchetypes[0]?.archetype ?? null
|
|
: roleArchetypes[0] ?? null;
|
|
}
|
|
|
|
export function resolveRoleTemplateCharacterIdFromReferenceProfile(
|
|
profile: CustomWorldProfile | null | undefined,
|
|
role: ReferenceRoleSource,
|
|
) {
|
|
const archetype = resolveRoleArchetypeForRole(profile, role);
|
|
if (!archetype) {
|
|
return null;
|
|
}
|
|
|
|
const templateCharacterIds = collectRoleArchetypeSignals(archetype).templateCharacterIds;
|
|
if (templateCharacterIds.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const seedSource = toText(role.id) || buildRoleSourceText(role);
|
|
return templateCharacterIds[hashText(seedSource) % templateCharacterIds.length] ?? null;
|
|
}
|