This commit is contained in:
387
src/data/npcAttributeInsights.ts
Normal file
387
src/data/npcAttributeInsights.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import {
|
||||
type AttributeVector,
|
||||
type Character,
|
||||
type CustomWorldProfile,
|
||||
type Encounter,
|
||||
type InventoryItem,
|
||||
type NpcPersistentState,
|
||||
type RoleAttributeProfile,
|
||||
type WorldAttributeSchema,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import {
|
||||
buildItemAttributeResonance,
|
||||
buildRoleAttributeProfileFromLegacyData,
|
||||
} from './attributeProfileGenerator';
|
||||
import {
|
||||
buildDefaultAxisVector,
|
||||
getSortedAttributeEntries,
|
||||
resolveAttributeSchema,
|
||||
resolveCharacterAttributeProfile,
|
||||
scoreAttributeFit,
|
||||
} from './attributeResolver';
|
||||
import {normalizeAttributeVector} from './attributeValidation';
|
||||
import {getCharacterById} from './characterPresets';
|
||||
import {getRuntimeCustomWorldProfile} from './customWorldRuntime';
|
||||
import {getMonsterPresetById} from './hostileNpcPresets';
|
||||
|
||||
export type GiftAffinityInsight = {
|
||||
resonanceScore: number;
|
||||
resonanceBonus: number;
|
||||
matchedAttributes: string[];
|
||||
reasonText: string;
|
||||
};
|
||||
|
||||
export type ChatAffinityOutcome = {
|
||||
affinityGain: number;
|
||||
matchedAttributes: string[];
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type RecruitReadinessInsight = {
|
||||
readinessScore: number;
|
||||
resonanceBonus: number;
|
||||
tabooPenalty: number;
|
||||
matchedAttributes: string[];
|
||||
summary: string;
|
||||
};
|
||||
|
||||
function resolveCustomWorldProfile(profile?: CustomWorldProfile | null) {
|
||||
return profile ?? getRuntimeCustomWorldProfile();
|
||||
}
|
||||
|
||||
function resolveInteractionWorldType(
|
||||
worldType?: WorldType | null,
|
||||
customWorldProfile?: CustomWorldProfile | null,
|
||||
encounter?: Encounter | null,
|
||||
) {
|
||||
if (worldType) {
|
||||
return worldType;
|
||||
}
|
||||
|
||||
if (customWorldProfile || getRuntimeCustomWorldProfile()) {
|
||||
return WorldType.CUSTOM;
|
||||
}
|
||||
|
||||
const schemaId = encounter?.attributeProfile?.schemaId ?? '';
|
||||
if (schemaId.includes('xianxia')) {
|
||||
return WorldType.XIANXIA;
|
||||
}
|
||||
|
||||
return WorldType.WUXIA;
|
||||
}
|
||||
|
||||
function buildSchemaContext(
|
||||
worldType?: WorldType | null,
|
||||
customWorldProfile?: CustomWorldProfile | null,
|
||||
encounter?: Encounter | null,
|
||||
) {
|
||||
const resolvedCustomWorldProfile = resolveCustomWorldProfile(customWorldProfile);
|
||||
const resolvedWorldType = resolveInteractionWorldType(
|
||||
worldType,
|
||||
resolvedCustomWorldProfile,
|
||||
encounter,
|
||||
);
|
||||
|
||||
return {
|
||||
worldType: resolvedWorldType,
|
||||
customWorldProfile: resolvedCustomWorldProfile,
|
||||
schema: resolveAttributeSchema(resolvedWorldType, resolvedCustomWorldProfile),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMonsterProfile(
|
||||
encounter: Encounter,
|
||||
worldType: WorldType,
|
||||
) {
|
||||
if (!encounter.monsterPresetId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getMonsterPresetById(worldType, encounter.monsterPresetId)?.attributeProfile
|
||||
?? getMonsterPresetById(WorldType.WUXIA, encounter.monsterPresetId)?.attributeProfile
|
||||
?? getMonsterPresetById(WorldType.XIANXIA, encounter.monsterPresetId)?.attributeProfile
|
||||
?? getMonsterPresetById(WorldType.CUSTOM, encounter.monsterPresetId)?.attributeProfile
|
||||
?? null;
|
||||
}
|
||||
|
||||
export function resolveEncounterAttributeProfile(
|
||||
encounter: Encounter,
|
||||
options: {
|
||||
worldType?: WorldType | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
} = {},
|
||||
) {
|
||||
const schemaContext = buildSchemaContext(
|
||||
options.worldType,
|
||||
options.customWorldProfile,
|
||||
encounter,
|
||||
);
|
||||
|
||||
if (encounter.characterId) {
|
||||
const character = getCharacterById(encounter.characterId);
|
||||
if (character) {
|
||||
return resolveCharacterAttributeProfile(
|
||||
character,
|
||||
schemaContext.worldType,
|
||||
schemaContext.customWorldProfile,
|
||||
) ?? encounter.attributeProfile ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
if (encounter.attributeProfile) {
|
||||
return encounter.attributeProfile;
|
||||
}
|
||||
|
||||
const monsterProfile = resolveMonsterProfile(encounter, schemaContext.worldType);
|
||||
if (monsterProfile) {
|
||||
return monsterProfile;
|
||||
}
|
||||
|
||||
return buildRoleAttributeProfileFromLegacyData({
|
||||
entityId: encounter.id ?? encounter.npcName,
|
||||
schema: schemaContext.schema,
|
||||
textBlocks: [
|
||||
encounter.npcName,
|
||||
encounter.context,
|
||||
encounter.npcDescription,
|
||||
],
|
||||
}).profile;
|
||||
}
|
||||
|
||||
function buildFocusVector(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
limit: number,
|
||||
direction: 'top' | 'bottom' = 'top',
|
||||
) {
|
||||
const slotIds = schema.slots.map(slot => slot.slotId);
|
||||
const sourceEntries = getSortedAttributeEntries(profile, schema);
|
||||
const orderedEntries = direction === 'bottom' ? [...sourceEntries].reverse() : sourceEntries;
|
||||
const selectedEntries = orderedEntries.slice(0, limit);
|
||||
const seed = Object.fromEntries(slotIds.map(slotId => [slotId, 0])) as AttributeVector;
|
||||
|
||||
selectedEntries.forEach(entry => {
|
||||
seed[entry.slot.slotId] = entry.value;
|
||||
});
|
||||
|
||||
return normalizeAttributeVector(seed, slotIds);
|
||||
}
|
||||
|
||||
function getTopContributionAttributeNames(
|
||||
profile: RoleAttributeProfile | null | undefined,
|
||||
vector: AttributeVector,
|
||||
schema: WorldAttributeSchema,
|
||||
limit = 2,
|
||||
) {
|
||||
const slotIds = schema.slots.map(slot => slot.slotId);
|
||||
const profileWeights = normalizeAttributeVector(profile?.values ?? {}, slotIds);
|
||||
const vectorWeights = normalizeAttributeVector(vector, slotIds);
|
||||
|
||||
return schema.slots
|
||||
.map(slot => ({
|
||||
name: slot.name,
|
||||
contribution: (profileWeights[slot.slotId] ?? 0) * (vectorWeights[slot.slotId] ?? 0),
|
||||
}))
|
||||
.sort((left, right) => right.contribution - left.contribution)
|
||||
.filter(entry => entry.contribution > 0)
|
||||
.slice(0, limit)
|
||||
.map(entry => entry.name);
|
||||
}
|
||||
|
||||
function uniqueNames(names: string[]) {
|
||||
return [...new Set(names.filter(Boolean))];
|
||||
}
|
||||
|
||||
function buildDialogueIntentVector(actionText: string, schema: WorldAttributeSchema) {
|
||||
const slotIds = schema.slots.map(slot => slot.slotId);
|
||||
const source = actionText.trim();
|
||||
const seed = buildDefaultAxisVector({});
|
||||
|
||||
const addWeight = (slotId: string, weight: number, pattern: RegExp) => {
|
||||
if (pattern.test(source)) {
|
||||
seed[slotId] = (seed[slotId] ?? 0) + weight;
|
||||
}
|
||||
};
|
||||
|
||||
addWeight('axis_a', 0.24, /顶|扛|硬闯|守住|挡住/u);
|
||||
addWeight('axis_b', 0.2, /快|抢|追|赶在|绕开|先一步/u);
|
||||
addWeight('axis_c', 0.34, /试探|追问|看穿|真假|真相|线索|判断|观察/u);
|
||||
addWeight('axis_d', 0.3, /直说|表态|逼|压住|摊开|决断|邀你同行|邀请/u);
|
||||
addWeight('axis_e', 0.34, /安抚|诚意|求助|关心|陪|同行|信|礼物|约定/u);
|
||||
addWeight('axis_f', 0.22, /稳|缓一缓|耐心|慢慢|调息|先稳住/u);
|
||||
|
||||
const total = slotIds.reduce((sum, slotId) => sum + (seed[slotId] ?? 0), 0);
|
||||
if (total <= 0) {
|
||||
return normalizeAttributeVector(
|
||||
buildDefaultAxisVector({
|
||||
axis_c: 0.34,
|
||||
axis_e: 0.38,
|
||||
axis_f: 0.28,
|
||||
}),
|
||||
slotIds,
|
||||
);
|
||||
}
|
||||
|
||||
return normalizeAttributeVector(seed, slotIds);
|
||||
}
|
||||
|
||||
export function buildEncounterAttributeRumors(
|
||||
encounter: Encounter,
|
||||
options: {
|
||||
worldType?: WorldType | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
limit?: number;
|
||||
} = {},
|
||||
) {
|
||||
const schemaContext = buildSchemaContext(
|
||||
options.worldType,
|
||||
options.customWorldProfile,
|
||||
encounter,
|
||||
);
|
||||
const profile = resolveEncounterAttributeProfile(encounter, options);
|
||||
const evidence = profile?.evidence?.slice(0, options.limit ?? 2).map(entry => entry.reason) ?? [];
|
||||
|
||||
if (evidence.length > 0) {
|
||||
return evidence;
|
||||
}
|
||||
|
||||
return getSortedAttributeEntries(profile, schemaContext.schema)
|
||||
.slice(0, options.limit ?? 2)
|
||||
.map(entry => `${entry.slot.name}:${entry.slot.definition}`);
|
||||
}
|
||||
|
||||
export function buildGiftAffinityInsight(
|
||||
item: InventoryItem,
|
||||
encounter: Encounter,
|
||||
options: {
|
||||
worldType?: WorldType | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
} = {},
|
||||
): GiftAffinityInsight {
|
||||
const schemaContext = buildSchemaContext(
|
||||
options.worldType,
|
||||
options.customWorldProfile,
|
||||
encounter,
|
||||
);
|
||||
const npcProfile = resolveEncounterAttributeProfile(encounter, options);
|
||||
const attributeResonance = item.attributeResonance ?? buildItemAttributeResonance(item);
|
||||
const resonanceScore = scoreAttributeFit(
|
||||
npcProfile,
|
||||
attributeResonance.resonanceVector,
|
||||
schemaContext.schema,
|
||||
);
|
||||
const resonanceBonus = Math.max(0, Math.min(6, Math.round(resonanceScore * 18) - 1));
|
||||
const matchedAttributes = getTopContributionAttributeNames(
|
||||
npcProfile,
|
||||
attributeResonance.resonanceVector,
|
||||
schemaContext.schema,
|
||||
);
|
||||
|
||||
return {
|
||||
resonanceScore,
|
||||
resonanceBonus,
|
||||
matchedAttributes,
|
||||
reasonText: matchedAttributes.length > 0
|
||||
? `${item.name}更对上对方看重的${matchedAttributes.join('、')}。`
|
||||
: attributeResonance.explanation ?? `${item.name}带来的属性共振暂时不够明显。`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildChatAffinityOutcome(params: {
|
||||
playerCharacter: Character;
|
||||
encounter: Encounter;
|
||||
npcState: NpcPersistentState;
|
||||
actionText: string;
|
||||
worldType?: WorldType | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
}): ChatAffinityOutcome {
|
||||
const schemaContext = buildSchemaContext(
|
||||
params.worldType,
|
||||
params.customWorldProfile,
|
||||
params.encounter,
|
||||
);
|
||||
const playerProfile = resolveCharacterAttributeProfile(
|
||||
params.playerCharacter,
|
||||
schemaContext.worldType,
|
||||
schemaContext.customWorldProfile,
|
||||
);
|
||||
const npcProfile = resolveEncounterAttributeProfile(params.encounter, {
|
||||
worldType: schemaContext.worldType,
|
||||
customWorldProfile: schemaContext.customWorldProfile,
|
||||
});
|
||||
const intentVector = buildDialogueIntentVector(params.actionText, schemaContext.schema);
|
||||
const actorFit = scoreAttributeFit(playerProfile, intentVector, schemaContext.schema);
|
||||
const targetFit = scoreAttributeFit(npcProfile, intentVector, schemaContext.schema);
|
||||
const sharedFocusVector = buildFocusVector(npcProfile, schemaContext.schema, 2);
|
||||
const sharedResonance = scoreAttributeFit(playerProfile, sharedFocusVector, schemaContext.schema);
|
||||
const baseGain = Math.max(4, 8 - params.npcState.chattedCount);
|
||||
const bonusGain = Math.max(
|
||||
0,
|
||||
Math.min(4, Math.round(actorFit * 8 + targetFit * 6 + sharedResonance * 8) - 2),
|
||||
);
|
||||
const matchedAttributes = uniqueNames([
|
||||
...getTopContributionAttributeNames(playerProfile, intentVector, schemaContext.schema),
|
||||
...getTopContributionAttributeNames(npcProfile, intentVector, schemaContext.schema),
|
||||
]).slice(0, 2);
|
||||
|
||||
return {
|
||||
affinityGain: baseGain + bonusGain,
|
||||
matchedAttributes,
|
||||
summary: matchedAttributes.length > 0
|
||||
? `你这番说法更对上了对方在${matchedAttributes.join('、')}上的判断方式。`
|
||||
: '这轮交谈让对方更愿意继续观察你的来意。',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRecruitmentInsight(params: {
|
||||
encounter: Encounter;
|
||||
npcState: NpcPersistentState;
|
||||
playerCharacter: Character;
|
||||
worldType?: WorldType | null;
|
||||
customWorldProfile?: CustomWorldProfile | null;
|
||||
}): RecruitReadinessInsight {
|
||||
const schemaContext = buildSchemaContext(
|
||||
params.worldType,
|
||||
params.customWorldProfile,
|
||||
params.encounter,
|
||||
);
|
||||
const playerProfile = resolveCharacterAttributeProfile(
|
||||
params.playerCharacter,
|
||||
schemaContext.worldType,
|
||||
schemaContext.customWorldProfile,
|
||||
);
|
||||
const npcProfile = resolveEncounterAttributeProfile(params.encounter, {
|
||||
worldType: schemaContext.worldType,
|
||||
customWorldProfile: schemaContext.customWorldProfile,
|
||||
});
|
||||
const preferenceVector = buildFocusVector(npcProfile, schemaContext.schema, 2);
|
||||
const tabooVector = buildFocusVector(npcProfile, schemaContext.schema, 1, 'bottom');
|
||||
const resonanceScore = scoreAttributeFit(playerProfile, preferenceVector, schemaContext.schema);
|
||||
const tabooScore = scoreAttributeFit(playerProfile, tabooVector, schemaContext.schema);
|
||||
const resonanceBonus = Math.max(0, Math.min(8, Math.round(resonanceScore * 24) - 2));
|
||||
const tabooPenalty = Math.max(0, Math.min(4, Math.round(tabooScore * 12) - 5));
|
||||
const matchedAttributes = getTopContributionAttributeNames(
|
||||
playerProfile,
|
||||
preferenceVector,
|
||||
schemaContext.schema,
|
||||
);
|
||||
const tabooAttributeName = getTopContributionAttributeNames(
|
||||
playerProfile,
|
||||
tabooVector,
|
||||
schemaContext.schema,
|
||||
1,
|
||||
)[0];
|
||||
|
||||
return {
|
||||
readinessScore: params.npcState.affinity + resonanceBonus - tabooPenalty,
|
||||
resonanceBonus,
|
||||
tabooPenalty,
|
||||
matchedAttributes,
|
||||
summary: matchedAttributes.length > 0
|
||||
? tabooPenalty > 0 && tabooAttributeName
|
||||
? `你在${matchedAttributes.join('、')}上的做派更容易被对方视为可同行,但${tabooAttributeName}这一路数仍让他保留几分。`
|
||||
: `你在${matchedAttributes.join('、')}上的做派,正是对方愿意并肩同行的那类信号。`
|
||||
: '对方主要还是在看你们当前积累下来的信任。',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user