fchat-rising/learn/matcher.ts

1396 lines
49 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,
bodyTypeKinkMapping,
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 bodyType: BodyType | 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);
this.bodyType = Matcher.getTagValueList(TagId.BodyType, c);
this.age = Matcher.age(c);
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(),
[TagId.BodyType]: this.resolveBodyTypeScore()
},
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]);
}
static getSpeciesName(species: Species): string {
return speciesNames[species] || `${Species[species].toLowerCase()}s`;
}
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 (scores.favorite.count + scores.yes.count + scores.maybe.count + scores.no.count < 10) {
return new Score(Scoring.NEUTRAL);
}
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 resolveBodyTypeScore(): Score {
const theirBodyType = Matcher.getTagValueList(TagId.BodyType, this.them);
if (theirBodyType && theirBodyType in bodyTypeKinkMapping) {
const bodyTypePreference = Matcher.getKinkPreference(this.you, bodyTypeKinkMapping[theirBodyType]);
if (bodyTypePreference !== null) {
return Matcher.formatKinkScore(bodyTypePreference, `${BodyType[theirBodyType].toLowerCase()}s`);
}
}
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 age(c: Character): number | null {
const rawAge = Matcher.getTagValue(TagId.Age, c);
if (!rawAge || !rawAge.string) {
return null;
}
const ageStr = rawAge.string.toLowerCase().replace(/[,.]/g, '').trim();
if ((ageStr.indexOf('shota') >= 0) || (ageStr.indexOf('loli') >= 0)
|| (ageStr.indexOf('lolli') >= 0) || (ageStr.indexOf('pup') >= 0)) {
return 10;
}
let age: number | null = null;
const exactMatch = /^[0-9]+$/.exec(ageStr);
const rangeMatch = exactMatch ? null : ageStr.match(/^([0-9]+)-([0-9]+)$/);
if (exactMatch) {
// '18'
age = parseInt(rawAge.string, 10);
} else if (rangeMatch) {
// '18-22'
const v1 = parseInt(rangeMatch[1], 10);
const v2 = parseInt(rangeMatch[2], 10);
age = Math.min(v1, v2);
}
return age && !Number.isNaN(age) && Number.isFinite(age) ? age : null;
}
static apparentAge(c: Character): { min: number, max: number } | null {
const rawAge = Matcher.getTagValue(TagId.ApparentAge, c);
if ((!rawAge) || (!rawAge.string)) {
return null;
}
const ageStr = rawAge.string.toLowerCase().replace(/[,.]/g, '').trim();
if (ageStr === '') {
return null;
}
// '18'
if (/^[0-9]+$/.exec(ageStr)) {
const val = parseInt(rawAge.string, 10);
return { min: val, max: val };
}
// '18-22'
const rangeMatch = ageStr.match(/^([0-9]+)-([0-9]+)$/);
if (rangeMatch) {
const v1 = parseInt(rangeMatch[1], 10);
const v2 = parseInt(rangeMatch[2], 10);
return { min: Math.min(v1, v2), max: Math.max(v1, v2) };
}
if ((ageStr.indexOf('shota') >= 0) || (ageStr.indexOf('loli') >= 0)
|| (ageStr.indexOf('lolli') >= 0) || (ageStr.indexOf('pup') >= 0)) {
return { min: 10, max: 10 };
}
return null;
}
static calculateSearchScoreForMatch(
score: Scoring,
match: MatchReport,
penalty: number
): 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,
penalty
}
);
return (atLevelScore + aboveLevelScore + penalty);
}
}