Files
Genarrative/src/services/customWorldReferenceSignals.ts
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

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