fchat-rising/learn/matcher.ts
buttercheezii c539dba6a8 Fix insane bug where some profiles cause client hang
Some code was converting the customs object to an array with millions of elements, which caused iteration and ipc transport (json serialize) to hang the client for many minutes at a time.

This is probably because incorrect lodash functions were used on the customs. Lodash would convert it to array and then it would get stored and turn up as array every time, corrupting storage and ensuring client denial of service.

This fixes that bug by avoiding using incorrect lodash functions that convert things to arrays with millions of elements.

It also retroactively fixes profiles that were stored with array customs, so the client won't crash.
2021-12-06 05:00:44 -08:00

1299 lines
46 KiB
TypeScript

/* eslint-disable no-null-keyword, max-file-line-count */
import * as _ from 'lodash';
import { Character, CharacterInfotag, KinkChoice } from '../interfaces';
import log from 'electron-log'; //tslint:disable-line:match-default-export-name
// tslint:disable-next-line ban-ts-ignore
// @ts-ignore
import anyAscii from 'any-ascii';
import { Store } from '../site/character_page/data_store';
import {
BodyType,
fchatGenderMap,
FurryPreference,
Gender,
genderKinkMapping,
Kink,
KinkBucketScore,
kinkComparisonExclusionGroups,
kinkComparisonExclusions,
kinkComparisonSwaps,
kinkMapping,
kinkMatchScoreMap,
kinkMatchWeights,
KinkPreference,
likelyHuman,
mammalSpecies,
nonAnthroSpecies,
Orientation,
Position,
PostLengthPreference, postLengthPreferenceMapping, postLengthPreferenceScoreMapping, Scoring,
Species,
SpeciesMap,
speciesMapping,
SpeciesMappingCache,
speciesNames,
SubDomRole,
TagId
} from './matcher-types';
export interface MatchReport {
_isVue: true;
you: MatchResult;
them: MatchResult;
youMultiSpecies: boolean;
themMultiSpecies: boolean;
merged: MatchResultScores;
score: Scoring | null;
details: MatchScoreDetails;
}
export interface MatchResultCharacterInfo {
species: Species | null;
gender: Gender | null;
orientation: Orientation | null;
}
export interface MatchResultScores {
[key: number]: Score;
[TagId.Orientation]: Score;
[TagId.Gender]: Score;
[TagId.Age]: Score;
[TagId.FurryPreference]: Score;
[TagId.Species]: Score;
[TagId.Kinks]: Score;
}
export interface MatchScoreDetails {
totalScoreDimensions: number;
dimensionsAtScoreLevel: number;
}
export interface MatchResult {
you: Character,
them: Character,
scores: MatchResultScores;
info: MatchResultCharacterInfo;
total: number;
yourAnalysis: CharacterAnalysis;
theirAnalysis: CharacterAnalysis;
}
export interface ScoreClassMap {
[key: number]: string;
}
const scoreClasses: ScoreClassMap = {
[Scoring.MATCH]: 'match',
[Scoring.WEAK_MATCH]: 'weak-match',
[Scoring.NEUTRAL]: 'neutral',
[Scoring.WEAK_MISMATCH]: 'weak-mismatch',
[Scoring.MISMATCH]: 'mismatch'
};
const scoreIcons: ScoreClassMap = {
[Scoring.MATCH]: 'fas fa-heart',
[Scoring.WEAK_MATCH]: 'fas fa-thumbs-up',
[Scoring.NEUTRAL]: 'fas fa-meh',
[Scoring.WEAK_MISMATCH]: 'fas fa-question-circle',
[Scoring.MISMATCH]: 'fas fa-heart-broken'
};
export class Score {
readonly score: Scoring;
readonly description: string;
readonly shortDesc: string;
constructor(score: Scoring, description: string = '', shortDesc: string = '') {
if ((score !== Scoring.NEUTRAL) && (description === ''))
throw new Error('Description must be provided if score is not neutral');
this.score = score;
this.description = description;
this.shortDesc = shortDesc;
}
getRecommendedClass(): string {
return Score.getClasses(this.score);
}
getRecommendedIcon(): string {
return Score.getIcon(this.score);
}
static getClasses(score: Scoring): string {
return scoreClasses[score];
}
static getIcon(score: Scoring): string {
return scoreIcons[score];
}
}
export interface CharacterAnalysisVariation {
readonly character: Character;
readonly analysis: CharacterAnalysis;
}
export class CharacterAnalysis {
readonly character: Character;
readonly gender: Gender | null;
readonly orientation: Orientation | null;
readonly species: Species | null;
readonly furryPreference: FurryPreference | null;
readonly age: number | null;
readonly subDomRole: SubDomRole | null;
readonly position: Position | null;
readonly postLengthPreference: PostLengthPreference | null;
readonly isAnthro: boolean | null;
readonly isHuman: boolean | null;
readonly isMammal: boolean | null;
constructor(c: Character) {
this.character = c;
this.gender = Matcher.getTagValueList(TagId.Gender, c);
this.orientation = Matcher.getTagValueList(TagId.Orientation, c);
this.species = Matcher.species(c);
this.furryPreference = Matcher.getTagValueList(TagId.FurryPreference, c);
this.subDomRole = Matcher.getTagValueList(TagId.SubDomRole, c);
this.position = Matcher.getTagValueList(TagId.Position, c);
this.postLengthPreference = Matcher.getTagValueList(TagId.PostLength, c);
const ageTag = Matcher.getTagValue(TagId.Age, c);
this.age = ((ageTag) && (ageTag.string)) ? parseInt(ageTag.string, 10) : null;
this.isAnthro = Matcher.isAnthro(c);
this.isHuman = Matcher.isHuman(c);
this.isMammal = Matcher.isMammal(c);
}
}
/**
* Answers the question: What YOU think about THEM
* Never what THEY think about YOU
*
* So, when comparing two characters, you have to run it twice (you, them / them, you)
* to get the full picture
*/
export class Matcher {
readonly you: Character;
readonly them: Character;
readonly yourAnalysis: CharacterAnalysis;
readonly theirAnalysis: CharacterAnalysis;
constructor(you: Character, them: Character, yourAnalysis?: CharacterAnalysis, theirAnalysis?: CharacterAnalysis) {
this.you = you;
this.them = them;
this.yourAnalysis = yourAnalysis || new CharacterAnalysis(you);
this.theirAnalysis = theirAnalysis || new CharacterAnalysis(them);
}
static generateReport(you: Character, them: Character): MatchReport {
const yourAnalysis = new CharacterAnalysis(you);
const theirAnalysis = new CharacterAnalysis(them);
const youThem = new Matcher(you, them, yourAnalysis, theirAnalysis);
const themYou = new Matcher(them, you, theirAnalysis, yourAnalysis);
const youThemMatch = youThem.match('their');
const themYouMatch = themYou.match('your');
const report: MatchReport = {
_isVue: true,
you: youThemMatch,
them: themYouMatch,
youMultiSpecies: false,
themMultiSpecies: false,
merged: Matcher.mergeResults(youThemMatch, themYouMatch),
score: null,
details: {
totalScoreDimensions: 0,
dimensionsAtScoreLevel: 0
}
};
report.score = Matcher.calculateReportScore(report);
report.details.totalScoreDimensions = Matcher.countScoresTotal(report);
report.details.dimensionsAtScoreLevel = Matcher.countScoresAtLevel(report, report.score) || 0;
// log.debug('report.generate', report);
return report;
}
static identifyBestMatchReport(you: Character, them: Character): MatchReport {
const reportStartTime = Date.now();
const yourCharacterAnalyses = Matcher.generateAnalysisVariations(you);
const theirCharacterAnalyses = Matcher.generateAnalysisVariations(them);
let bestScore = null;
let bestScoreLevelCount = -10000;
let bestReport: MatchReport;
for(const yourAnalysis of yourCharacterAnalyses) {
for (const theirAnalysis of theirCharacterAnalyses) {
const youThem = new Matcher(yourAnalysis.character, theirAnalysis.character, yourAnalysis.analysis, theirAnalysis.analysis);
const themYou = new Matcher(theirAnalysis.character, yourAnalysis.character, theirAnalysis.analysis, yourAnalysis.analysis);
const youThemMatch = youThem.match('their');
const themYouMatch = themYou.match('your');
const report: MatchReport = {
_isVue: true,
you: youThemMatch,
them: themYouMatch,
youMultiSpecies: (yourCharacterAnalyses.length > 1),
themMultiSpecies: (theirCharacterAnalyses.length > 1),
merged: Matcher.mergeResults(youThemMatch, themYouMatch),
score: null,
details: {
totalScoreDimensions: 0,
dimensionsAtScoreLevel: 0
}
};
report.score = Matcher.calculateReportScore(report);
const scoreLevelCount = Matcher.countScoresAtLevel(report, report.score);
report.details.totalScoreDimensions = Matcher.countScoresTotal(report);
report.details.dimensionsAtScoreLevel = scoreLevelCount || 0;
if (
(bestScore === null)
|| (
(report.score !== null)
&& (report.score >= bestScore)
&& (scoreLevelCount !== null)
&& (report.score * scoreLevelCount > bestScoreLevelCount)
)
) {
bestScore = report.score;
bestScoreLevelCount = ((scoreLevelCount !== null) && (report.score !== null)) ? report.score * scoreLevelCount : -1000;
bestReport = report;
}
}
}
log.debug(
'report.identify.best',
{
buildTime: Date.now() - reportStartTime,
variations: yourCharacterAnalyses.length * theirCharacterAnalyses.length,
report: bestReport!
}
);
return bestReport!;
}
// tslint:disable-next-line
private static mergeResultScores(scores: MatchResultScores, results: MatchResultScores): void {
_.each(scores, (v: Score, k: any) => {
if (
// tslint:disable-next-line no-unsafe-any
((!(k in results)) || (v.score < results[k].score))
&& (v.score !== Scoring.NEUTRAL)
) {
results[k] = v;
}
}
);
}
static mergeResults(you: MatchResult, them: MatchResult): MatchResultScores {
const results: MatchResultScores = {} as any;
Matcher.mergeResultScores(you.scores, results);
Matcher.mergeResultScores(them.scores, results);
return results;
}
static generateAnalysisVariations(c: Character): CharacterAnalysisVariation[] {
const speciesOptions = Matcher.getAllSpeciesAsStr(c);
if (speciesOptions.length === 0) {
speciesOptions.push('');
}
return _.map(
speciesOptions,
(species) => {
// Avoid _.cloneDeep because it chokes on array-like objects with very large keys
// _.cloneDeep will happily make arrays with 41 million elements
const nc = {...c, infotags: {...c.infotags, [TagId.Species]: {string: species}}};
return { character: nc, analysis: new CharacterAnalysis(nc) };
}
);
}
static calculateReportScore(m: MatchReport): Scoring | null {
const yourScores = _.values(m.you.scores);
const theirScores = _.values(m.them.scores);
const finalScore = _.reduce(
_.concat(yourScores, theirScores),
(accum: Scoring | null, score: Score) => {
if (accum === null) {
return (score.score !== Scoring.NEUTRAL) ? score.score : null;
}
return (score.score === Scoring.NEUTRAL) ? accum : Math.min(accum, score.score);
},
null
);
if ((finalScore !== null) && (finalScore > 0)) {
// Manage edge cases where high score may not be ideal
// Nothing to score
if ((yourScores.length === 0) || (theirScores.length === 0)) {
// can't know
return Scoring.NEUTRAL;
}
// Only neutral scores given
if (
(_.every(yourScores, (n: Scoring) => n === Scoring.NEUTRAL)) ||
(_.every(theirScores, (n: Scoring) => n === Scoring.NEUTRAL))
) {
return Scoring.NEUTRAL;
}
}
// console.log('Profile score', c.character.name, score, m.you.total, m.them.total,
// m.you.total + m.them.total, m.you.total * m.them.total);
return (finalScore === null) ? Scoring.NEUTRAL : finalScore;
}
match(pronoun: string): MatchResult {
const data: MatchResult = {
you: this.you,
them: this.them,
yourAnalysis: this.yourAnalysis,
theirAnalysis: this.theirAnalysis,
total: 0,
scores: {
[TagId.Orientation]: this.resolveOrientationScore(),
[TagId.Gender]: this.resolveGenderScore(),
[TagId.Age]: this.resolveAgeScore(),
[TagId.FurryPreference]: this.resolveFurryPairingsScore(),
[TagId.Species]: this.resolveSpeciesScore(),
[TagId.SubDomRole]: this.resolveSubDomScore(),
[TagId.Kinks]: this.resolveKinkScore(pronoun),
[TagId.PostLength]: this.resolvePostLengthScore(),
[TagId.Position]: this.resolvePositionScore()
},
info: {
species: Matcher.species(this.you),
gender: Matcher.getTagValueList(TagId.Gender, this.you),
orientation: Matcher.getTagValueList(TagId.Orientation, this.you)
}
};
data.total = _.reduce(
data.scores,
(accum: number, s: Score) => (accum + s.score),
0
);
return data;
}
private resolveOrientationScore(): Score {
// Question: If someone identifies themselves as 'straight cuntboy', how should they be matched? like a straight female?
return Matcher.scoreOrientationByGender(this.yourAnalysis.gender, this.yourAnalysis.orientation, this.theirAnalysis.gender);
}
static scoreOrientationByGender(yourGender: Gender | null, yourOrientation: Orientation | null, theirGender: Gender | null): Score {
if ((yourGender === null) || (theirGender === null) || (yourOrientation === null))
return new Score(Scoring.NEUTRAL);
// CIS
// tslint:disable-next-line curly
if (Matcher.isCisGender(yourGender)) {
if (yourGender === theirGender) {
// same sex CIS
if (yourOrientation === Orientation.Straight)
return new Score(Scoring.MISMATCH, 'No <span>same sex</span>');
if (
(yourOrientation === Orientation.Gay)
|| (yourOrientation === Orientation.Bisexual)
|| (yourOrientation === Orientation.Pansexual)
|| ((yourOrientation === Orientation.BiFemalePreference) && (theirGender === Gender.Female))
|| ((yourOrientation === Orientation.BiMalePreference) && (theirGender === Gender.Male))
)
return new Score(Scoring.MATCH, 'Loves <span>same sex</span>');
if (
(yourOrientation === Orientation.BiCurious)
|| ((yourOrientation === Orientation.BiFemalePreference) && (theirGender === Gender.Male))
|| ((yourOrientation === Orientation.BiMalePreference) && (theirGender === Gender.Female))
)
return new Score(Scoring.WEAK_MATCH, 'Likes <span>same sex</span>');
} else if (Matcher.isCisGender(theirGender)) {
// straight CIS
if (yourOrientation === Orientation.Gay)
return new Score(Scoring.MISMATCH, 'No <span>opposite sex</span>');
if (
(yourOrientation === Orientation.Straight)
|| (yourOrientation === Orientation.Bisexual)
|| (yourOrientation === Orientation.BiCurious)
|| (yourOrientation === Orientation.Pansexual)
|| ((yourOrientation === Orientation.BiFemalePreference) && (theirGender === Gender.Female))
|| ((yourOrientation === Orientation.BiMalePreference) && (theirGender === Gender.Male))
)
return new Score(Scoring.MATCH, 'Loves <span>opposite sex</span>');
if (
((yourOrientation === Orientation.BiFemalePreference) && (theirGender === Gender.Male))
|| ((yourOrientation === Orientation.BiMalePreference) && (theirGender === Gender.Female))
)
return new Score(Scoring.WEAK_MATCH, 'Likes <span>opposite sex</span>');
}
}
return new Score(Scoring.NEUTRAL);
}
static formatKinkScore(score: KinkPreference, description: string): Score {
if (score === KinkPreference.No)
return new Score(Scoring.MISMATCH, `No <span>${description}</span>`);
if (score === KinkPreference.Maybe)
return new Score(Scoring.WEAK_MISMATCH, `Hesitant about <span>${description}</span>`);
if (score === KinkPreference.Yes)
return new Score(Scoring.WEAK_MATCH, `Likes <span>${description}</span>`);
if (score === KinkPreference.Favorite)
return new Score(Scoring.MATCH, `Loves <span>${description}</span>`);
return new Score(Scoring.NEUTRAL);
}
private resolvePostLengthScore(): Score {
const yourLength = this.yourAnalysis.postLengthPreference;
const theirLength = this.theirAnalysis.postLengthPreference;
if (
(!yourLength)
|| (!theirLength)
|| (yourLength === PostLengthPreference.NoPreference)
|| (theirLength === PostLengthPreference.NoPreference)
) {
return new Score(Scoring.NEUTRAL);
}
const score = postLengthPreferenceScoreMapping[yourLength][theirLength];
return this.formatScoring(score, postLengthPreferenceMapping[theirLength]);
}
private resolveSpeciesScore(): Score {
const you = this.you;
const theirAnalysis = this.theirAnalysis;
const theirSpecies = theirAnalysis.species;
if (theirSpecies === null)
return new Score(Scoring.NEUTRAL);
const speciesScore = Matcher.getKinkSpeciesPreference(you, theirSpecies);
if (speciesScore !== null) {
// console.log(this.them.name, speciesScore, theirSpecies);
const speciesName = speciesNames[theirSpecies] || `${Species[theirSpecies].toLowerCase()}s`;
return Matcher.formatKinkScore(speciesScore, speciesName);
}
if (theirAnalysis.isAnthro) {
const anthroScore = Matcher.getKinkPreference(you, Kink.AnthroCharacters);
if (anthroScore !== null)
return Matcher.formatKinkScore(anthroScore, 'anthros');
}
if (theirAnalysis.isMammal) {
const mammalScore = Matcher.getKinkPreference(you, Kink.Mammals);
if (mammalScore !== null)
return Matcher.formatKinkScore(mammalScore, 'mammals');
}
return new Score(Scoring.NEUTRAL);
}
formatScoring(score: Scoring, description: string): Score {
let type = '';
switch (score) {
case Scoring.MISMATCH:
type = 'No';
break;
case Scoring.WEAK_MISMATCH:
type = 'Hesitant about';
break;
case Scoring.WEAK_MATCH:
type = 'Likes';
break;
case Scoring.MATCH:
type = 'Loves';
break;
}
return new Score(score, `${type} <span>${description}</span>`);
}
private resolveFurryPairingsScore(): Score {
const you = this.you;
const theyAreAnthro = this.theirAnalysis.isAnthro;
const theyAreHuman = this.theirAnalysis.isHuman;
const score = theyAreAnthro
? Matcher.furryLikeabilityScore(you)
: (theyAreHuman ? Matcher.humanLikeabilityScore(you) : Scoring.NEUTRAL);
if (score === Scoring.WEAK_MATCH)
return new Score(
score,
theyAreAnthro
? 'Prefers <span>humans</span>, ok with anthros'
: 'Prefers <span>anthros</span>, ok with humans'
);
return this.formatScoring(score, theyAreAnthro ? 'furry pairings' : theyAreHuman ? 'human pairings' : '');
}
private resolveKinkScore(pronoun: string): Score {
// const kinkScore = this.resolveKinkBucketScore('all');
const scores = {
favorite: this.resolveKinkBucketScore('favorite'),
yes: this.resolveKinkBucketScore('yes'),
maybe: this.resolveKinkBucketScore('maybe'),
no: this.resolveKinkBucketScore('no')
};
const weighted = scores.favorite.weighted + scores.yes.weighted + scores.maybe.weighted + scores.no.weighted;
log.debug('report.score.kink', this.them.name, this.you.name, scores, weighted);
if (weighted === 0) {
return new Score(Scoring.NEUTRAL);
}
if (weighted < 0) {
if (Math.abs(weighted) < kinkMatchWeights.weakMismatchThreshold) {
return new Score(Scoring.WEAK_MISMATCH, `Hesitant about ${pronoun} <span>kinks</span>`);
}
return new Score(Scoring.MISMATCH, `Dislikes ${pronoun} <span>kinks</span>`);
}
if (Math.abs(weighted) < kinkMatchWeights.weakMatchThreshold) {
return new Score(Scoring.WEAK_MATCH, `Likes ${pronoun} <span>kinks</span>`);
}
return new Score(Scoring.MATCH, `Loves ${pronoun} <span>kinks</span>`);
}
static furryLikeabilityScore(c: Character): Scoring {
const furryPreference = Matcher.getTagValueList(TagId.FurryPreference, c);
if (
(furryPreference === FurryPreference.FursAndHumans) ||
(furryPreference === FurryPreference.FurriesPreferredHumansOk) ||
(furryPreference === FurryPreference.FurriesOnly)
)
return Scoring.MATCH;
if (furryPreference === FurryPreference.HumansPreferredFurriesOk)
return Scoring.WEAK_MATCH;
if (furryPreference === FurryPreference.HumansOnly)
return Scoring.MISMATCH;
return Scoring.NEUTRAL;
}
static humanLikeabilityScore(c: Character): Scoring {
const humanPreference = Matcher.getTagValueList(TagId.FurryPreference, c);
if (
(humanPreference === FurryPreference.FursAndHumans)
|| (humanPreference === FurryPreference.HumansPreferredFurriesOk)
|| (humanPreference === FurryPreference.HumansOnly)
)
return Scoring.MATCH;
if (humanPreference === FurryPreference.FurriesPreferredHumansOk)
return Scoring.WEAK_MATCH;
if (humanPreference === FurryPreference.FurriesOnly)
return Scoring.MISMATCH;
return Scoring.NEUTRAL;
}
private resolveAgeScore(): Score {
const you = this.you;
const theirAge = this.theirAnalysis.age;
if (theirAge === null)
return new Score(Scoring.NEUTRAL);
const ageplayScore = Matcher.getKinkPreference(you, Kink.Ageplay);
const underageScore = Matcher.getKinkPreference(you, Kink.UnderageCharacters);
if ((theirAge < 16) && (ageplayScore !== null))
return Matcher.formatKinkScore(ageplayScore, `ages of ${theirAge}`);
if ((theirAge < 16) && (ageplayScore === null))
return Matcher.formatKinkScore(KinkPreference.No, `ages of ${theirAge}`);
if ((theirAge < 18) && (theirAge >= 16) && (underageScore !== null))
return Matcher.formatKinkScore(underageScore, `ages of ${theirAge}`);
const yourAge = this.yourAnalysis.age;
if ((yourAge !== null) && (yourAge > 0) && (theirAge > 0) && (yourAge <= 80) && (theirAge <= 80)) {
const olderCharactersScore = Matcher.getKinkPreference(you, Kink.OlderCharacters);
const youngerCharactersScore = Matcher.getKinkPreference(you, Kink.YoungerCharacters);
const ageDifference = Math.abs(yourAge - theirAge);
if ((yourAge < theirAge) && (olderCharactersScore !== null) && (ageDifference >= 8))
return Matcher.formatKinkScore(olderCharactersScore, 'older characters');
if ((yourAge > theirAge) && (youngerCharactersScore !== null) && (ageDifference >= 8))
return Matcher.formatKinkScore(youngerCharactersScore, 'younger characters');
}
return new Score(Scoring.NEUTRAL);
}
private resolveGenderScore(): Score {
const you = this.you;
const theirGender = this.theirAnalysis.gender;
if (theirGender === null)
return new Score(Scoring.NEUTRAL);
const genderName = `${Gender[theirGender].toLowerCase()}s`;
const genderKinkScore = Matcher.getKinkGenderPreference(you, theirGender);
if (genderKinkScore !== null)
return Matcher.formatKinkScore(genderKinkScore, genderName);
return new Score(Scoring.NEUTRAL);
}
private resolveSubDomScore(): Score {
const you = this.you;
const yourSubDomRole = this.yourAnalysis.subDomRole;
const theirSubDomRole = this.theirAnalysis.subDomRole;
const yourRoleReversalPreference = Matcher.getKinkPreference(you, Kink.RoleReversal);
if ((!yourSubDomRole) || (!theirSubDomRole))
return new Score(Scoring.NEUTRAL);
if (yourSubDomRole === SubDomRole.UsuallyDominant) {
if (theirSubDomRole === SubDomRole.Switch)
return new Score(Scoring.MATCH, `Loves <span>switches</span> (role)`);
if ((theirSubDomRole === SubDomRole.AlwaysSubmissive) || (theirSubDomRole === SubDomRole.UsuallySubmissive))
return new Score(Scoring.MATCH, `Loves <span>submissives</span>`);
if (yourRoleReversalPreference === KinkPreference.Favorite)
return new Score(Scoring.MATCH, `Loves <span>role reversal</span>`);
if (yourRoleReversalPreference === KinkPreference.Yes)
return new Score(Scoring.MATCH, `Likes <span>role reversal</span>`);
return new Score(Scoring.WEAK_MISMATCH, 'Hesitant about <span>dominants</span>');
}
if (yourSubDomRole === SubDomRole.AlwaysDominant) {
if (theirSubDomRole === SubDomRole.Switch)
return new Score(Scoring.WEAK_MATCH, `Likes <span>switches</span> (role)`);
if ((theirSubDomRole === SubDomRole.AlwaysSubmissive) || (theirSubDomRole === SubDomRole.UsuallySubmissive))
return new Score(Scoring.MATCH, `Loves <span>submissives</span>`);
if (yourRoleReversalPreference === KinkPreference.Favorite)
return new Score(Scoring.MATCH, `Loves <span>role reversal</span>`);
if (yourRoleReversalPreference === KinkPreference.Yes)
return new Score(Scoring.MATCH, `Likes <span>role reversal</span>`);
if ((yourSubDomRole === SubDomRole.AlwaysDominant) && (theirSubDomRole === SubDomRole.AlwaysDominant))
return new Score(Scoring.MISMATCH, 'No <span>dominants</span>');
return new Score(Scoring.WEAK_MISMATCH, 'Hesitant about <span>dominants</span>');
}
if (yourSubDomRole === SubDomRole.UsuallySubmissive) {
if (theirSubDomRole === SubDomRole.Switch)
return new Score(Scoring.MATCH, `Loves <span>switches</span> (role)`);
if ((theirSubDomRole === SubDomRole.AlwaysDominant) || (theirSubDomRole === SubDomRole.UsuallyDominant))
return new Score(Scoring.MATCH, `Loves <span>dominants</span>`);
if (yourRoleReversalPreference === KinkPreference.Favorite)
return new Score(Scoring.MATCH, `Loves <span>role reversal</span>`);
if (yourRoleReversalPreference === KinkPreference.Yes)
return new Score(Scoring.MATCH, `Likes <span>role reversal</span>`);
return new Score(Scoring.WEAK_MISMATCH, 'Hesitant about <span>submissives</span>');
}
if (yourSubDomRole === SubDomRole.AlwaysSubmissive) {
if (theirSubDomRole === SubDomRole.Switch)
return new Score(Scoring.WEAK_MATCH, `Likes <span>switches</span> (role)`);
if ((theirSubDomRole === SubDomRole.AlwaysDominant) || (theirSubDomRole === SubDomRole.UsuallyDominant))
return new Score(Scoring.MATCH, `Loves <span>dominants</span>`);
if (yourRoleReversalPreference === KinkPreference.Favorite)
return new Score(Scoring.MATCH, `Loves <span>role reversal</span>`);
if (yourRoleReversalPreference === KinkPreference.Yes)
return new Score(Scoring.MATCH, `Likes <span>role reversal</span>`);
if ((yourSubDomRole === SubDomRole.AlwaysSubmissive) && (theirSubDomRole === SubDomRole.AlwaysSubmissive))
return new Score(Scoring.MISMATCH, 'No <span>submissives</span>');
return new Score(Scoring.WEAK_MISMATCH, 'Hesitant about <span>submissives</span>');
}
// You must be a switch
if (theirSubDomRole === SubDomRole.Switch)
return new Score(Scoring.MATCH, `Loves <span>switches</span> (role)`);
// if (yourRoleReversalPreference === KinkPreference.Favorite)
// return new Score(Scoring.MATCH, `Loves <span>role reversal</span>`);
//
// if (yourRoleReversalPreference === KinkPreference.Yes)
// return new Score(Scoring.MATCH, `Likes <span>role reversal</span>`);
if ((theirSubDomRole === SubDomRole.AlwaysDominant) || (theirSubDomRole === SubDomRole.UsuallyDominant))
return new Score(Scoring.MATCH, `Loves <span>dominants</span>`);
if ((theirSubDomRole === SubDomRole.AlwaysSubmissive) || (theirSubDomRole === SubDomRole.UsuallySubmissive))
return new Score(Scoring.MATCH, `Loves <span>submissives</span>`);
return new Score(Scoring.NEUTRAL);
}
private resolvePositionScore(): Score {
const yourPosition = this.yourAnalysis.position;
const theirPosition = this.theirAnalysis.position;
if ((!yourPosition) || (!theirPosition))
return new Score(Scoring.NEUTRAL);
if (yourPosition === Position.UsuallyTop) {
if (theirPosition === Position.Switch)
return new Score(Scoring.MATCH, `Loves <span>switches</span> (position)`);
if ((theirPosition === Position.AlwaysBottom) || (theirPosition === Position.UsuallyBottom))
return new Score(Scoring.MATCH, `Loves <span>bottoms</span>`);
return new Score(Scoring.WEAK_MISMATCH, 'Hesitant about <span>tops</span>');
}
if (yourPosition === Position.AlwaysTop) {
if (theirPosition === Position.Switch)
return new Score(Scoring.WEAK_MATCH, `Likes <span>switches</span> (position)`);
if ((theirPosition === Position.AlwaysBottom) || (theirPosition === Position.UsuallyBottom))
return new Score(Scoring.MATCH, `Loves <span>bottoms</span>`);
if ((yourPosition === Position.AlwaysTop) && (theirPosition === Position.AlwaysTop))
return new Score(Scoring.MISMATCH, 'No <span>tops</span>');
return new Score(Scoring.WEAK_MISMATCH, 'Hesitant about <span>tops</span>');
}
if (yourPosition === Position.UsuallyBottom) {
if (theirPosition === Position.Switch)
return new Score(Scoring.MATCH, `Loves <span>switches</span> (position)`);
if ((theirPosition === Position.AlwaysTop) || (theirPosition === Position.UsuallyTop))
return new Score(Scoring.MATCH, `Loves <span>tops</span>`);
return new Score(Scoring.WEAK_MISMATCH, 'Hesitant about <span>bottoms</span>');
}
if (yourPosition === Position.AlwaysBottom) {
if (theirPosition === Position.Switch)
return new Score(Scoring.WEAK_MATCH, `Likes <span>switches</span> (position)`);
if ((theirPosition === Position.AlwaysTop) || (theirPosition === Position.UsuallyTop))
return new Score(Scoring.MATCH, `Loves <span>tops</span>`);
if ((yourPosition === Position.AlwaysBottom) && (theirPosition === Position.AlwaysBottom))
return new Score(Scoring.MISMATCH, 'No <span>bottoms</span>');
return new Score(Scoring.WEAK_MISMATCH, 'Hesitant about <span>bottoms</span>');
}
// You must be a switch
if (theirPosition === Position.Switch)
return new Score(Scoring.MATCH, `Loves <span>switches</span> (position)`);
// if (yourRoleReversalPreference === KinkPreference.Favorite)
// return new Score(Scoring.MATCH, `Loves <span>role reversal</span>`);
//
// if (yourRoleReversalPreference === KinkPreference.Yes)
// return new Score(Scoring.MATCH, `Likes <span>role reversal</span>`);
if ((theirPosition === Position.AlwaysTop) || (theirPosition === Position.UsuallyTop))
return new Score(Scoring.MATCH, `Loves <span>tops</span>`);
if ((theirPosition === Position.AlwaysBottom) || (theirPosition === Position.UsuallyBottom))
return new Score(Scoring.MATCH, `Loves <span>bottoms</span>`);
return new Score(Scoring.NEUTRAL);
}
private resolveKinkBucketScore(bucket: 'all' | 'favorite' | 'yes' | 'maybe' | 'no' | 'positive' | 'negative'): KinkBucketScore {
const yourKinks = this.getAllStandardKinks(this.you);
const theirKinks = this.getAllStandardKinks(this.them);
// let missed = 0;
const result: any = _.reduce(
yourKinks,
(accum, yourKinkValue: any, yourKinkId: any) => {
const theirKinkId = (yourKinkId in kinkComparisonSwaps) ? kinkComparisonSwaps[yourKinkId] : yourKinkId;
const isExcluded = (yourKinkId in kinkComparisonExclusions)
|| ((Store.shared.kinks[yourKinkId]) && (Store.shared.kinks[yourKinkId].kink_group in kinkComparisonExclusionGroups));
const isBucketMatch = (yourKinkValue === bucket)
|| (bucket === 'all')
|| ((bucket === 'negative') && ((yourKinkValue === 'no') || (yourKinkValue === 'maybe')))
|| ((bucket === 'positive') && ((yourKinkValue === 'favorite') || (yourKinkValue === 'yes')));
if ((isBucketMatch) && (!isExcluded)) {
accum.total += 1;
}
if (
(!(theirKinkId in theirKinks))
|| (isExcluded)
) {
return accum;
}
const theirKinkValue = theirKinks[theirKinkId] as any;
if (isBucketMatch) {
return {
score: accum.score + this.getKinkMatchScore(yourKinkValue, theirKinkValue),
count: accum.count + 1,
total: accum.total
};
}
return accum;
},
{ score: 0, count: 0, total: 0 }
);
// const yourBucketCounts = this.countKinksByBucket(yourKinks);
// const theirBucketCounts = this.countKinksByBucket(theirKinks);
result.weighted = ((result.count === 0) || (Math.abs(result.score) < 1))
? 0
: (
Math.log(result.total) * Math.log(Math.abs(result.score)) * Math.sign(result.score)
// (Math.log(result.count) / Math.log(kinkMatchWeights.logBase)) // log 8 base
// * (result.score / result.count)
);
return result;
}
// private countKinksByBucket(kinks: { [key: number]: KinkChoice }): { favorite: number, yes: number, maybe: number, no: number } {
// return _.reduce(
// kinks,
// (accum, kinkValue) => {
// accum[kinkValue] += 1;
// return accum;
// },
// {
// favorite: 0,
// yes: 0,
// maybe: 0,
// no: 0
// }
// );
// }
private getAllStandardKinks(c: Character): { [key: number]: KinkChoice } {
const kinks = _.pickBy(c.kinks, _.isString);
// Avoid using _.forEach on c.customs because lodash thinks it is an array
for (const custom of Object.values(c.customs)) {
if (custom) {
const children = (custom as any).children ?? {};
_.each(children, (child) => kinks[child] = custom.choice);
}
}
return kinks as any;
}
private getKinkMatchScore(aValue: string, bValue: string): number {
return _.get(kinkMatchScoreMap, `${aValue}.${bValue}`, 0) * 7; // forces range above 1.0
}
static getTagValue(tagId: number, c: Character): CharacterInfotag | undefined {
return c.infotags[tagId];
}
static getTagValueList(tagId: number, c: Character): number | null {
const t = Matcher.getTagValue(tagId, c);
if ((!t) || (!t.list))
return null;
return t.list;
}
static isCisGender(...genders: Gender[] | null[]): boolean {
return _.every(genders, (g: Gender) => ((g === Gender.Female) || (g === Gender.Male)));
}
static getKinkPreference(c: Character, kinkId: number): KinkPreference | null {
if (!(kinkId in c.kinks))
return null;
const kinkVal = c.kinks[kinkId];
if (kinkVal === undefined) {
return null;
}
if (typeof kinkVal === 'string') {
return kinkMapping[c.kinks[kinkId] as string];
}
const custom = c.customs[kinkVal];
if (!custom) {
return null;
}
return kinkMapping[custom.choice];
}
static getKinkGenderPreference(c: Character, gender: Gender): KinkPreference | null {
if (!(gender in genderKinkMapping))
return null;
return Matcher.getKinkPreference(c, genderKinkMapping[gender]);
}
static getKinkSpeciesPreference(c: Character, species: Species): KinkPreference | null {
return Matcher.getKinkPreference(c, species);
}
static has(c: Character, kinkId: Kink): boolean {
const r = Matcher.getKinkPreference(c, kinkId);
return (r !== null);
}
static isMammal(c: Character): boolean | null {
const species = Matcher.species(c);
if (species === null)
return null;
return (mammalSpecies.indexOf(species) >= 0);
}
static isAnthro(c: Character): boolean | null {
const bodyTypeId = Matcher.getTagValueList(TagId.BodyType, c);
if (bodyTypeId === BodyType.Anthro)
return true;
const speciesId = Matcher.species(c);
if (!speciesId)
return null;
return (nonAnthroSpecies.indexOf(speciesId) < 0);
}
static isHuman(c: Character): boolean | null {
const bodyTypeId = Matcher.getTagValueList(TagId.BodyType, c);
if (bodyTypeId === BodyType.Human)
return true;
const speciesId = Matcher.species(c);
return (speciesId === Species.Human);
}
static species(c: Character): Species | null {
const mySpecies = Matcher.getTagValue(TagId.Species, c);
if ((!mySpecies) || (!mySpecies.string)) {
return Species.Human; // best guess
}
const s = Matcher.getMappedSpecies(mySpecies.string);
if (!s) {
log.debug('matcher.species.unknown', { character: c.name, species: mySpecies.string });
}
return s;
}
static generateSpeciesMappingCache(mapping: SpeciesMap): SpeciesMappingCache {
return _.mapValues(
mapping,
(keywords: string[]) => _.map(
keywords,
(keyword: string) => {
const keywordPlural = `${keyword}s`; // this is weak: elf -> elves doesn't occur
return {
keyword,
regexp: RegExp(`(^|\\b)(${keyword}|${keywordPlural})($|\\b)`)
};
}
)
);
}
private static speciesMappingCache?: SpeciesMappingCache;
private static likelyHumanCache?: SpeciesMappingCache;
private static matchMappedSpecies(species: string, mapping: SpeciesMappingCache, skipAscii: boolean = false): Species | null {
let foundSpeciesId: Species | null = null;
let match = '';
const finalSpecies = (skipAscii ? species : anyAscii(species)).toLowerCase().trim();
_.each(
mapping,
(matchers, speciesId: string) => {
_.each(
matchers,
(matcher) => {
// finalSpecies.indexOf(k) >= 0)
if ((matcher.keyword.length > match.length) && (matcher.regexp.test(finalSpecies))) {
match = matcher.keyword;
foundSpeciesId = parseInt(speciesId, 10);
}
}
);
}
);
return foundSpeciesId;
}
static getMappedSpecies(species: string): Species | null {
if (!Matcher.speciesMappingCache) {
Matcher.speciesMappingCache = Matcher.generateSpeciesMappingCache(speciesMapping);
}
if (!Matcher.likelyHumanCache) {
Matcher.likelyHumanCache = Matcher.generateSpeciesMappingCache(likelyHuman);
}
return Matcher.matchMappedSpecies(species, Matcher.speciesMappingCache)
|| Matcher.matchMappedSpecies(species, Matcher.speciesMappingCache, true)
|| Matcher.matchMappedSpecies(species, Matcher.likelyHumanCache)
|| Matcher.matchMappedSpecies(species, Matcher.likelyHumanCache, true);
}
static getAllSpecies(c: Character): Species[] {
const species = Matcher.getAllSpeciesAsStr(c);
return _.filter(_.map(species, (s) => Matcher.getMappedSpecies(s)), (s) => (s !== null)) as Species[];
}
static getAllSpeciesAsStr(c: Character): string[] {
const mySpecies = Matcher.getTagValue(TagId.Species, c);
if ((!mySpecies) || (!mySpecies.string)) {
return [];
}
const speciesStr = mySpecies.string.toLowerCase().replace(/optionally|alternatively/g, ',')
.replace(/[)(]/g, ' ').trim();
const matches = speciesStr.split(/[,]? or |,/);
return _.filter(_.map(matches, (m) => m.toLowerCase().trim()), (m) => (m !== ''));
}
static strToGender(fchatGenderStr: string | undefined): Gender | null {
if (fchatGenderStr === undefined) {
return null;
}
if (fchatGenderStr in fchatGenderMap) {
return fchatGenderMap[fchatGenderStr];
}
return null;
}
static countScoresAtLevel(
m: MatchReport, scoreLevel: Scoring | null,
skipYours: boolean = false,
skipTheirs: boolean = false
): number | null {
if (scoreLevel === null) {
return null;
}
const yourScores = skipYours ? [] : _.values(m.you.scores);
const theirScores = skipTheirs ? [] : _.values(m.them.scores);
return _.reduce(
_.concat(yourScores, theirScores),
(accum: number, score: Score) => accum + (score.score === scoreLevel ? 1 : 0),
0
);
}
static countScoresAboveLevel(
m: MatchReport, scoreLevel: Scoring | null,
skipYours: boolean = false,
skipTheirs: boolean = false
): number {
if (scoreLevel === null) {
return 0;
}
const yourScores = skipYours ? [] : _.values(m.you.scores);
const theirScores = skipTheirs ? [] : _.values(m.them.scores);
return _.reduce(
_.concat(yourScores, theirScores),
(accum: number, score: Score) => accum + ((score.score > scoreLevel) && (score.score !== Scoring.NEUTRAL) ? 1 : 0),
0
);
}
static countScoresTotal(m: MatchReport): number {
return _.values(m.you.scores).length
+ _.values(m.them.scores).length;
}
static calculateSearchScoreForMatch(
score: Scoring,
match: MatchReport
): number {
const totalScoreDimensions = match ? Matcher.countScoresTotal(match) : 0;
const dimensionsAtScoreLevel = match ? (Matcher.countScoresAtLevel(match, score) || 0) : 0;
const dimensionsAboveScoreLevel = match ? (Matcher.countScoresAboveLevel(match, Math.max(score, Scoring.WEAK_MATCH))) : 0;
let atLevelScore = 0;
let aboveLevelScore = 0;
let theirAtLevelDimensions = 0;
let atLevelMul = 0;
let theirAboveLevelDimensions = 0;
let aboveLevelMul = 0;
if ((dimensionsAtScoreLevel > 0) && (totalScoreDimensions > 0)) {
const matchRatio = dimensionsAtScoreLevel / totalScoreDimensions;
theirAtLevelDimensions = Matcher.countScoresAtLevel(match, score, true, false) || 0;
// 1.0 == bad balance; 0.0 == ideal balance
atLevelMul = Math.abs((theirAtLevelDimensions / (dimensionsAtScoreLevel)) - 0.5) * 2;
atLevelScore = (1 - (atLevelMul * 0.5)) * Math.pow(dimensionsAtScoreLevel, matchRatio);
}
if ((dimensionsAboveScoreLevel > 0) && (totalScoreDimensions > 0)) {
const matchRatio = dimensionsAboveScoreLevel / totalScoreDimensions;
theirAboveLevelDimensions = Matcher.countScoresAboveLevel(match, score, true, false) || 0;
// 1.0 == bad balance; 0.0 == ideal balance
aboveLevelMul = Math.abs((theirAboveLevelDimensions / (dimensionsAboveScoreLevel)) - 0.5) * 2;
aboveLevelScore = (1 - (aboveLevelMul * 0.5)) * Math.pow(dimensionsAboveScoreLevel, matchRatio);
}
// const kinkScore = match.you.kinkScore.weighted;
log.debug(
'report.score.search',
match.you.you.name,
match.them.you.name,
{
you: match.you.you.name,
them: match.them.you.name,
searchScore: (atLevelScore + aboveLevelScore),
atLevelScore,
aboveLevelScore,
atLevelMul,
aboveLevelMul,
dimensionsAboveScoreLevel,
dimensionsAtScoreLevel,
theirAtLevelDimensions,
theirAboveLevelDimensions
}
);
return (atLevelScore + aboveLevelScore);
}
}